Skip to content

Commit 4e243a8

Browse files
authored
fix(docx): take a block's upward pull out of the space above it, and hold a row's padding at the top of its column (#793)
* fix(docx): take a block's upward pull out of the space above it, and hold a row's padding at the top of its column * fix(docx): take a page reference's pull out of the space above it too, and state the drift as measured
1 parent ec7a2eb commit 4e243a8

6 files changed

Lines changed: 236 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ follow semantic versioning; release dates are ISO 8601.
88

99
### Public API
1010

11+
- **A block pulled up by a negative top edge, and a row padded down its column, stand where the
12+
page puts them in Word.** Word has no negative space above a paragraph, so a paragraph's, a
13+
page reference's or a rule's negative top `margin` or `padding` was dropped:
14+
`PaymentsInvoice`'s header rule is pulled 16.4pt up out of the space its header stack leaves
15+
below its content. The pull now comes out of the space owed above the block, with any pull of
16+
the containers around it. Word has no space above a table either, and a row opening a row's
17+
column lost the padding above it: `PaymentsInvoice`'s metadata grid, padded 6.2pt down its
18+
column, stood 6.3pt above the issuer beside it, as `MerchantInvoice`'s stood 6pt, and
19+
`PaymentsInvoice`'s rule and the page under it stood 10pt low, the pull less that lost
20+
padding. A paragraph a tenth of a point tall now carries that space, the containers' edges
21+
included, in any cell but a table's — as it already carried the space a row owed above itself
22+
at the top of a panel; a row opening a table's cell is left to the row height the table
23+
holds. In Word `PaymentsInvoice`'s median drift falls from 10.3pt to 0.5, and across the 62
24+
templates lines more than 2pt off fall from 705 to 572; in LibreOffice from 1066 to 933.
1125
- **A row a drawing makes tall, and a title pulled above its row, keep the page's height in
1226
Word.** A row whose tallest child is drawn where the page puts it — a badge beside a heading,
1327
its cell holding nothing in Word — was only as tall as its text there: `WorkspaceInvoice`'s
@@ -19,7 +33,7 @@ follow semantic versioning; release dates are ISO 8601.
1933
line start at the cell's top in Word, and the page under it 4pt low; a one-line first paragraph
2034
is now written as much shorter, its text seated where the page sets it — no more than the room
2135
above its letters, as Word draws an exact line's text only inside the line. The paragraph's own
22-
top edge is written as before. In Word `WorkspaceInvoice`'s median drift
36+
top edge is not counted in the rise. In Word `WorkspaceInvoice`'s median drift
2337
falls from 4.8pt to 0.4 and `SubscriptionInvoice`'s from 8.8 to 0.5; across the 62 templates,
2438
lines more than 2pt off fall from 858 to 705. `PaymentsInvoice`'s parties now stand level with
2539
each other and its median rises from 3.7 to 10.3: the collapsed heading row had been hiding a

‎docs/recipes/docx-export.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,16 @@ paragraph is held to a tenth of a point, so it adds no line of text to the gap i
193193

194194
A table holds its own space the same way. Word has no space above a table and none below
195195
one, so a table's or a row's `margin` and `padding` travel to the paragraphs around it —
196-
the space above a table is the space below the paragraph before it. A table with no
197-
paragraph above it loses that edge, which is the one gap Word has nowhere to put.
196+
the space above a table is the space below the paragraph before it. Where no paragraph
197+
stands above it — a table opening the body, or a row opening any cell but a table's, padded
198+
down it — a paragraph a tenth of a point tall carries the space instead. A row opening a
199+
table's cell is left to the row height the table holds, at least as tall as the page makes it;
200+
a table (not a row) opening a cell still loses that edge.
201+
202+
An edge pulling a block up — a paragraph's, a page reference's or a rule's negative top
203+
`margin` or `padding` — comes out of the space owed above it, as the page takes it from
204+
there: Word has no negative space above a paragraph. What that space cannot give is not
205+
written. Text laid over the flow in text boxes owes no space, so its edges move nothing.
198206

199207
A paragraph's or a list's `lineSpacing` — the gap the page puts between two wrapped lines —
200208
goes into the line: Word has one line height for a paragraph and no gap between its lines,

‎render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java‎

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ public final class DocxSemanticBackend implements SemanticBackend<byte[]> {
236236
// How far each paragraph a container pulled above its cell rises inside its own line: see
237237
// riseIntoItsLine.
238238
private final java.util.Map<ParagraphNode, Double> risenLines = new java.util.IdentityHashMap<>();
239+
// The cells of a table's grid that hold a composed node, whose row the table holds at the
240+
// page's height: see writeTableWithItsOwnSpacing.
241+
private final java.util.Set<org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTc> tablesCells =
242+
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
239243
// How far above the page's first line a paragraph's Word line starts, in points, where its
240244
// lines took the space between them from the space above it: see applyLineGap.
241245
private final java.util.Map<org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP, Double> lineTopsTakenIn =
@@ -608,6 +612,7 @@ private byte[] write(List<SemanticSection> sections, Path outputFile) throws Exc
608612
stackedLineHeights.clear();
609613
lineTopsTakenIn.clear();
610614
risenLines.clear();
615+
tablesCells.clear();
611616
picturesDrawnBeside.clear();
612617
listNumbering.clear();
613618
report = new DocxExportReport.Builder();
@@ -2040,6 +2045,10 @@ private DocxRules.Rule ruleOf(DocumentNode node) {
20402045

20412046
private void dispatchNode(XWPFDocument document, DocumentNode node) throws Exception {
20422047
DocxRules.Rule rule = ruleOf(node);
2048+
if (overTheFlowDepth == 0 && (node instanceof ParagraphNode
2049+
|| node instanceof com.demcha.compose.document.node.PageReferenceNode || rule != null)) {
2050+
standsIntoTheSpaceAbove(node);
2051+
}
20432052
if (node instanceof ParagraphNode paragraph) {
20442053
writeParagraph(document, paragraph);
20452054
} else if (node instanceof com.demcha.compose.document.node.PageReferenceNode reference) {
@@ -2979,7 +2988,9 @@ private void holdTheSpaceAboveATable(XWPFDocument document) {
29792988
boolean tableAbove = currentCell == null
29802989
? endsWithATable(document.getBodyElements())
29812990
: cellEndsWithItsTableCloser();
2982-
if (pendingSpacingAfter > 0 && lastBodyParagraph == null && !tableAbove) {
2991+
// The space owed is the block's own edge and the edges of the containers opened
2992+
// around it, which are carried down to it rather than owed.
2993+
if (pendingSpacingAfter + carriedSpacingBefore - borderBelow > 0 && lastBodyParagraph == null && !tableAbove) {
29832994
holdToHairline(newBodyParagraph(document));
29842995
}
29852996
}
@@ -3172,9 +3183,10 @@ private void riseIntoItsLine(DocumentNode node) {
31723183
return;
31733184
}
31743185
// What the paragraph would have above it before its own edge, which is written apart
3175-
// (applyVerticalSpacing): the edges carried down to it and the space the block before it
3176-
// owes, less a border standing below that block. Only what that comes short of zero is
3177-
// a rise; the rest is written as space.
3186+
// (applyVerticalSpacing, or standsIntoTheSpaceAbove for a negative one, after this): the
3187+
// edges carried down to it and the space the block before it owes, less a border
3188+
// standing below that block. Only what that comes short of zero is a rise; the rest is
3189+
// written as space.
31783190
double rise = -(carriedSpacingBefore + pendingSpacingAfter - borderBelow);
31793191
if (!(rise > 0.01) || layout.lineCount(first) != 1 || stackedLineHeights.containsKey(first)) {
31803192
return;
@@ -3719,6 +3731,28 @@ private static boolean drawn(CTBorder border) {
37193731
return border != null && border.getVal() != STBorder.NONE && border.getVal() != STBorder.NIL;
37203732
}
37213733

3734+
/**
3735+
* Takes a paragraph's, page reference's or rule's negative top edge out of the space owed
3736+
* above it.
3737+
*
3738+
* <p>Word has no negative space above a paragraph, and {@link #applyVerticalSpacing}
3739+
* writes an edge only where it is positive, so an edge pulling the block up was dropped
3740+
* and the block stood that much low, with everything after it. {@code PaymentsInvoice}'s
3741+
* header rule is pulled 16.4pt up from the foot of the stack above it, whose band runs
3742+
* past its content: the pull was lost, and the rule and the page under it stood 10pt low,
3743+
* the 16.4pt less the 6.3pt its metadata grid had lost above it. The pull is added
3744+
* to the edges carried down to the block, as a container's negative edge already is, so
3745+
* {@link #newBodyParagraph} nets it against everything owed above, as the page sums it;
3746+
* what that cannot give stays unwritten, as before. Text laid over the flow owes no
3747+
* space ({@link #writeOverTheFlow}), so its edges move nothing.</p>
3748+
*/
3749+
private void standsIntoTheSpaceAbove(DocumentNode node) {
3750+
double edge = node.margin().top() + node.padding().top();
3751+
if (edge < 0) {
3752+
carriedSpacingBefore += edge;
3753+
}
3754+
}
3755+
37223756
/**
37233757
* Gives back space already owed above the next block, most recent first: text standing
37243758
* above its band took it on the page.
@@ -5982,18 +6016,21 @@ private static boolean startsWith(byte[] bytes, int... signature) {
59826016
* below the paragraph before it, and the space below one is the space above the
59836017
* paragraph after. Both go through the debt every other gap goes through
59846018
* ({@link #owePendingSpacingAfter}), so a table between two paragraphs reads the same as
5985-
* two paragraphs with a gap between them — and a table with nothing above it loses that
5986-
* edge, which is the one thing Word genuinely cannot hold.</p>
6019+
* two paragraphs with a gap between them. With nothing above it — opening the body, or a
6020+
* row opening any cell but a table's — a paragraph a tenth of a point tall holds that edge
6021+
* ({@link #holdTheSpaceAboveATable}); a table opening a cell still loses it.</p>
59876022
*/
59886023
private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node)
59896024
throws Exception {
59906025
owePendingSpacingAfter(node.margin().top() + node.padding().top());
5991-
if (node instanceof RowNode && currentCell != null && currentCell == panelCell) {
5992-
// At the top of a panel nothing above holds that space: MerchantInvoice's due-date
6026+
if (node instanceof RowNode && currentCell != null && !tablesCells.contains(currentCell.getCTTc())) {
6027+
// At the top of a cell nothing above holds that space: MerchantInvoice's due-date
59936028
// row lost its 16.7pt of top padding and stood against the card's top edge, once the
5994-
// card held the page's height. A row in a table's cell keeps to the row height the
5995-
// table holds (holdRowHeight). A table is left as it was: holding its space moved
5996-
// ObsidianInvoice's line items 6pt below the page's.
6029+
// card held the page's height, and PaymentsInvoice's metadata grid the 6.2pt its
6030+
// column is padded down by, standing 6.3pt above the issuer beside it. A row in a
6031+
// table's cell keeps to the row height the table holds (holdRowHeight), and a table
6032+
// is left as it was: holding the space of either moved ObsidianInvoice's line items
6033+
// 6pt below the page's.
59976034
holdTheSpaceAboveATable(document);
59986035
}
59996036
if (node instanceof RowNode row) {
@@ -6592,6 +6629,7 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement,
65926629
// exported it as an empty cell.
65936630
double previous = currentCellWidth;
65946631
currentCellWidth = usableWidthOf(cell, placement);
6632+
tablesCells.add(cell.getCTTc());
65956633
try {
65966634
writeCellBody(cell, source.content());
65976635
} finally {

‎render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -224,14 +224,20 @@ void aTableAfterAPageBreakKeepsTheSpaceAboveIt() throws Exception {
224224
}
225225

226226
@Test
227-
void aTableOpeningACellIsWrittenAsBefore() throws Exception {
228-
// Only the body takes the paragraph: in a cell it was measured to set content lower
229-
// than the page does — each of ObsidianInvoice's line items 8.5pt lower.
227+
void aRowOpeningATablesCellIsWrittenAsBefore() throws Exception {
228+
// A table's cell takes no paragraph: the table holds its row at least as tall as the
229+
// page makes it, and one there was measured to set content lower than the page does —
230+
// each of ObsidianInvoice's line items 6pt lower. A row's column does (DocxSpaceAboveTest).
231+
com.demcha.compose.document.dsl.SectionBuilder composed = new com.demcha.compose.document.dsl.SectionBuilder();
232+
composed.padding(new DocumentInsets(12, 0, 0, 0))
233+
.addRow(inner -> inner.addParagraph("Qty").addParagraph("1"));
234+
com.demcha.compose.document.node.DocumentNode item = composed.build();
230235
try (XWPFDocument document = DocxExports.withLayout(400, 600, 0, page -> page
231-
.addRow(outer -> outer
232-
.addSection(cell -> cell.padding(new DocumentInsets(12, 0, 0, 0))
233-
.addRow(inner -> inner.addParagraph("Qty").addParagraph("1")))
234-
.addParagraph("Notes")))) {
236+
.addTable(t -> t
237+
.columns(com.demcha.compose.document.table.DocumentTableColumn.auto(),
238+
com.demcha.compose.document.table.DocumentTableColumn.auto())
239+
.rowCells(com.demcha.compose.document.table.DocumentTableCell.node(item),
240+
com.demcha.compose.document.table.DocumentTableCell.text("Notes"))))) {
235241
XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0);
236242

237243
assertThat(cell.getBodyElements().get(0)).as("the nested table opens the cell")

‎render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverTheFlowTest.java‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ void theFlowAfterItStandsAsIfItWereNotThere() throws Exception {
5959
}
6060
}
6161

62+
@Test
63+
void aLinePulledUpInItTakesNothingFromTheFlowAfterIt() throws Exception {
64+
// A negative top edge takes its pull out of the space owed above a block in the flow;
65+
// a line in a text box owes none, and the masthead keeps the 6pt its section holds
66+
// above it.
67+
DocumentNode pulled = new ShapeContainerBuilder().name("Sidebar")
68+
.rectangle(100, STUB).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
69+
.margin(new DocumentInsets(-40, 0, 40 - STUB, -40))
70+
.position(new ParagraphBuilder().name("Monogram").text("L")
71+
.margin(DocumentInsets.top(-5)).build(), 10, 10, LayerAlign.TOP_LEFT, 0)
72+
.build();
73+
try (XWPFDocument document = DocxExports.withLayout(400, 600, 40, page -> page
74+
.add(pulled)
75+
.addSection("Head", head -> head.padding(DocumentInsets.top(6)).addParagraph("Masthead")))) {
76+
assertThat(document.getDocument().getBody().xmlText()).as("the line is in a text box")
77+
.contains("<w:txbxContent>");
78+
assertThat(before(masthead(document))).as("the section's space above it").isEqualTo(6 * 20L);
79+
}
80+
}
81+
6282
@Test
6383
void aLineIsGivenRoomOnTheSideItsTextDoesNotLeanOn() throws Exception {
6484
// A right-to-left line is set against the page's right side as a right-aligned one is,

0 commit comments

Comments
 (0)