From e84ab8802e5cd8fb95eac8a599e7061122533f46 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 30 Sep 2026 18:40:45 +0100
Subject: [PATCH 1/2] 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
---
CHANGELOG.md | 15 +++
docs/recipes/docx-export.md | 12 +-
.../semantic/docx/DocxSemanticBackend.java | 52 ++++++--
.../docx/DocxContainerSpacingTest.java | 20 ++--
.../semantic/docx/DocxOverTheFlowTest.java | 20 ++++
.../semantic/docx/DocxSpaceAboveTest.java | 112 ++++++++++++++++++
6 files changed, 214 insertions(+), 17 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ac7b580f..7dd3b9f9f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,21 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A block pulled up by a negative top edge, and a row padded down its column, stand where the
+ page puts them in Word.** Word has no negative space above a paragraph, so a paragraph's, a
+ page reference's or a rule's negative top `margin` or `padding` was dropped:
+ `PaymentsInvoice`'s header rule, pulled 16.4pt up out of the space its header stack leaves
+ below its content, stood that far low with the page under it. The pull now comes out of the
+ space owed above the block, with any pull of the containers around it. Word has no space above
+ a table either, and a row opening a row's column lost the padding above it:
+ `PaymentsInvoice`'s metadata grid, padded 6.2pt down its column, stood that much above the
+ issuer beside it, as did `MerchantInvoice`'s. A paragraph a tenth of a point tall now carries
+ that space, the containers' padding included, in any cell but a table's — as it already
+ carried the space a row owed above itself at the top of a panel; a row opening a table's cell
+ is left to the row height the table holds. In Word `PaymentsInvoice`'s median drift falls from 10.3pt to 0.5,
+ and across the 62 templates lines more than 2pt off fall from 705 to 572; in LibreOffice from
+ 1066 to 933.
+
- **A row a drawing makes tall, and a title pulled above its row, keep the page's height in
Word.** A row whose tallest child is drawn where the page puts it — a badge beside a heading,
its cell holding nothing in Word — was only as tall as its text there: `WorkspaceInvoice`'s
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 99ae1f70d..6fcebb2ae 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -193,8 +193,16 @@ paragraph is held to a tenth of a point, so it adds no line of text to the gap i
A table holds its own space the same way. Word has no space above a table and none below
one, so a table's or a row's `margin` and `padding` travel to the paragraphs around it —
-the space above a table is the space below the paragraph before it. A table with no
-paragraph above it loses that edge, which is the one gap Word has nowhere to put.
+the space above a table is the space below the paragraph before it. Where no paragraph
+stands above it — a table opening the body, or a row opening any cell but a table's, padded
+down it — a paragraph a tenth of a point tall carries the space instead. A row opening a
+table's cell is left to the row height the table holds, which has that space in it already;
+a table (not a row) opening a cell still loses that edge.
+
+An edge pulling a block up — a paragraph's, a page reference's or a rule's negative top
+`margin` or `padding` — comes out of the space owed above it, as the page takes it from
+there: Word has no negative space above a paragraph. What that space cannot give is not
+written. Text laid over the flow in text boxes owes no space, so its edges move nothing.
A paragraph's or a list's `lineSpacing` — the gap the page puts between two wrapped lines —
goes into the line: Word has one line height for a paragraph and no gap between its lines,
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index f3a268849..cffd8452e 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -236,6 +236,10 @@ public final class DocxSemanticBackend implements SemanticBackend {
// How far each paragraph a container pulled above its cell rises inside its own line: see
// riseIntoItsLine.
private final java.util.Map risenLines = new java.util.IdentityHashMap<>();
+ // The cells of a table's grid that hold a composed node, whose row the table holds at the
+ // page's height: see writeTableWithItsOwnSpacing.
+ private final java.util.Set tablesCells =
+ java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
// How far above the page's first line a paragraph's Word line starts, in points, where its
// lines took the space between them from the space above it: see applyLineGap.
private final java.util.Map lineTopsTakenIn =
@@ -608,6 +612,7 @@ private byte[] write(List sections, Path outputFile) throws Exc
stackedLineHeights.clear();
lineTopsTakenIn.clear();
risenLines.clear();
+ tablesCells.clear();
picturesDrawnBeside.clear();
listNumbering.clear();
report = new DocxExportReport.Builder();
@@ -2040,6 +2045,10 @@ private DocxRules.Rule ruleOf(DocumentNode node) {
private void dispatchNode(XWPFDocument document, DocumentNode node) throws Exception {
DocxRules.Rule rule = ruleOf(node);
+ if (overTheFlowDepth == 0 && (node instanceof ParagraphNode
+ || node instanceof com.demcha.compose.document.node.PageReferenceNode || rule != null)) {
+ standsIntoTheSpaceAbove(node);
+ }
if (node instanceof ParagraphNode paragraph) {
writeParagraph(document, paragraph);
} else if (node instanceof com.demcha.compose.document.node.PageReferenceNode reference) {
@@ -2979,7 +2988,9 @@ private void holdTheSpaceAboveATable(XWPFDocument document) {
boolean tableAbove = currentCell == null
? endsWithATable(document.getBodyElements())
: cellEndsWithItsTableCloser();
- if (pendingSpacingAfter > 0 && lastBodyParagraph == null && !tableAbove) {
+ // The space owed is the block's own edge and the edges of the containers opened
+ // around it, which are carried down to it rather than owed.
+ if (pendingSpacingAfter + carriedSpacingBefore - borderBelow > 0 && lastBodyParagraph == null && !tableAbove) {
holdToHairline(newBodyParagraph(document));
}
}
@@ -3719,6 +3730,27 @@ private static boolean drawn(CTBorder border) {
return border != null && border.getVal() != STBorder.NONE && border.getVal() != STBorder.NIL;
}
+ /**
+ * Takes a paragraph's, page reference's or rule's negative top edge out of the space owed
+ * above it.
+ *
+ * Word has no negative space above a paragraph, and {@link #applyVerticalSpacing}
+ * writes an edge only where it is positive, so an edge pulling the block up was dropped
+ * and the block stood that much low, with everything after it. {@code PaymentsInvoice}'s
+ * header rule is pulled 16.4pt up from the foot of the stack above it, whose band runs
+ * past its content: the rule and the page under it stood 16.4pt low. The pull is added
+ * to the edges carried down to the block, as a container's negative edge already is, so
+ * {@link #newBodyParagraph} nets it against everything owed above, as the page sums it;
+ * what that cannot give stays unwritten, as before. Text laid over the flow owes no
+ * space ({@link #writeOverTheFlow}), so its edges move nothing.
+ */
+ private void standsIntoTheSpaceAbove(DocumentNode node) {
+ double edge = node.margin().top() + node.padding().top();
+ if (edge < 0) {
+ carriedSpacingBefore += edge;
+ }
+ }
+
/**
* Gives back space already owed above the next block, most recent first: text standing
* above its band took it on the page.
@@ -5982,18 +6014,21 @@ private static boolean startsWith(byte[] bytes, int... signature) {
* below the paragraph before it, and the space below one is the space above the
* paragraph after. Both go through the debt every other gap goes through
* ({@link #owePendingSpacingAfter}), so a table between two paragraphs reads the same as
- * two paragraphs with a gap between them — and a table with nothing above it loses that
- * edge, which is the one thing Word genuinely cannot hold.
+ * two paragraphs with a gap between them. With nothing above it — opening the body, or a
+ * row opening a cell — a paragraph a tenth of a point tall holds that edge
+ * ({@link #holdTheSpaceAboveATable}); a table opening a cell still loses it.
*/
private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node)
throws Exception {
owePendingSpacingAfter(node.margin().top() + node.padding().top());
- if (node instanceof RowNode && currentCell != null && currentCell == panelCell) {
- // At the top of a panel nothing above holds that space: MerchantInvoice's due-date
+ if (node instanceof RowNode && currentCell != null && !tablesCells.contains(currentCell.getCTTc())) {
+ // At the top of a cell nothing above holds that space: MerchantInvoice's due-date
// row lost its 16.7pt of top padding and stood against the card's top edge, once the
- // card held the page's height. A row in a table's cell keeps to the row height the
- // table holds (holdRowHeight). A table is left as it was: holding its space moved
- // ObsidianInvoice's line items 6pt below the page's.
+ // card held the page's height, and PaymentsInvoice's metadata grid the 6.2pt its
+ // column is padded down by, standing that much above the issuer beside it. A row in
+ // a table's cell keeps to the row height the table holds (holdRowHeight): held
+ // again, ObsidianInvoice's line items stood 6pt low. A table is left as it was too:
+ // holding its space moved ObsidianInvoice's line items 6pt below the page's.
holdTheSpaceAboveATable(document);
}
if (node instanceof RowNode row) {
@@ -6592,6 +6627,7 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement,
// exported it as an empty cell.
double previous = currentCellWidth;
currentCellWidth = usableWidthOf(cell, placement);
+ tablesCells.add(cell.getCTTc());
try {
writeCellBody(cell, source.content());
} finally {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java
index bedc1bc7a..7bad47389 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java
@@ -224,14 +224,20 @@ void aTableAfterAPageBreakKeepsTheSpaceAboveIt() throws Exception {
}
@Test
- void aTableOpeningACellIsWrittenAsBefore() throws Exception {
- // Only the body takes the paragraph: in a cell it was measured to set content lower
- // than the page does — each of ObsidianInvoice's line items 8.5pt lower.
+ void aTableOpeningATablesCellIsWrittenAsBefore() throws Exception {
+ // A table's cell takes no paragraph: the table holds its row at the page's height, and
+ // one there was measured to set content lower than the page does — each of
+ // ObsidianInvoice's line items 8.5pt lower. A row's column does (DocxSpaceAboveTest).
+ com.demcha.compose.document.dsl.SectionBuilder composed = new com.demcha.compose.document.dsl.SectionBuilder();
+ composed.padding(new DocumentInsets(12, 0, 0, 0))
+ .addRow(inner -> inner.addParagraph("Qty").addParagraph("1"));
+ com.demcha.compose.document.node.DocumentNode item = composed.build();
try (XWPFDocument document = DocxExports.withLayout(400, 600, 0, page -> page
- .addRow(outer -> outer
- .addSection(cell -> cell.padding(new DocumentInsets(12, 0, 0, 0))
- .addRow(inner -> inner.addParagraph("Qty").addParagraph("1")))
- .addParagraph("Notes")))) {
+ .addTable(t -> t
+ .columns(com.demcha.compose.document.table.DocumentTableColumn.auto(),
+ com.demcha.compose.document.table.DocumentTableColumn.auto())
+ .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(item),
+ com.demcha.compose.document.table.DocumentTableCell.text("Notes"))))) {
XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0);
assertThat(cell.getBodyElements().get(0)).as("the nested table opens the cell")
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverTheFlowTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverTheFlowTest.java
index 8dad6feea..eeb046332 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverTheFlowTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverTheFlowTest.java
@@ -59,6 +59,26 @@ void theFlowAfterItStandsAsIfItWereNotThere() throws Exception {
}
}
+ @Test
+ void aLinePulledUpInItTakesNothingFromTheFlowAfterIt() throws Exception {
+ // A negative top edge takes its pull out of the space owed above a block in the flow;
+ // a line in a text box owes none, and the masthead keeps the 6pt its section holds
+ // above it.
+ DocumentNode pulled = new ShapeContainerBuilder().name("Sidebar")
+ .rectangle(100, STUB).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .margin(new DocumentInsets(-40, 0, 40 - STUB, -40))
+ .position(new ParagraphBuilder().name("Monogram").text("L")
+ .margin(DocumentInsets.top(-5)).build(), 10, 10, LayerAlign.TOP_LEFT, 0)
+ .build();
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 40, page -> page
+ .add(pulled)
+ .addSection("Head", head -> head.padding(DocumentInsets.top(6)).addParagraph("Masthead")))) {
+ assertThat(document.getDocument().getBody().xmlText()).as("the line is in a text box")
+ .contains("");
+ assertThat(before(masthead(document))).as("the section's space above it").isEqualTo(6 * 20L);
+ }
+ }
+
@Test
void aLineIsGivenRoomOnTheSideItsTextDoesNotLeanOn() throws Exception {
// A right-to-left line is set against the page's right side as a right-aligned one is,
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java
new file mode 100644
index 000000000..8990f61ee
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java
@@ -0,0 +1,112 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentRowColumn;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+import org.apache.poi.xwpf.usermodel.IBodyElement;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.apache.poi.xwpf.usermodel.XWPFTableCell;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * The space above a block reaches Word where Word has nowhere to write it as the page does.
+ *
+ * {@code PaymentsInvoice}'s header rule is pulled 16.4pt up out of the space its header
+ * stack leaves below itself, and its metadata grid is padded 6.2pt down its column. Word has
+ * no negative space above a paragraph and no space above a table: the rule and the page under
+ * it stood 16.4pt low, and the grid 6.2pt high.
+ */
+class DocxSpaceAboveTest {
+
+ private static final DocumentTextStyle BODY = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build();
+
+ @Test
+ void aRulePulledUpTakesItsPullOutOfTheSpaceOwedAboveIt() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addParagraph(p -> p.text("Header").textStyle(BODY).margin(new DocumentInsets(0, 0, 20, 0)))
+ .addLine(line -> line.horizontal(200).thickness(1).color(DocumentColor.BLACK)
+ .margin(new DocumentInsets(-8, 0, 0, 0))))) {
+ XWPFParagraph rule = document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getCTP().getPPr() != null && paragraph.getCTP().getPPr().isSetPBdr())
+ .findFirst().orElseThrow();
+
+ assertThat(DocxTwips.of(rule.getCTP().getPPr().getSpacing().getBefore()) / 20.0)
+ .as("20pt owed less the 8pt it is pulled up").isCloseTo(12, within(0.1));
+ }
+ }
+
+ @Test
+ void aParagraphPulledUpFurtherThanTheSpaceAboveItHasNoSpaceAbove() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addParagraph(p -> p.text("Header").textStyle(BODY).margin(new DocumentInsets(0, 0, 4, 0)))
+ .addParagraph(p -> p.text("Pulled").textStyle(BODY).margin(new DocumentInsets(-10, 0, 0, 0))))) {
+ XWPFParagraph pulled = document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getText().contains("Pulled")).findFirst().orElseThrow();
+ var spacing = pulled.getCTP().getPPr().getSpacing();
+
+ assertThat(spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0L).isZero();
+ }
+ }
+
+ @Test
+ void aParagraphPulledUpInsideASectionPulledUpAddsToItsPull() throws Exception {
+ // 10pt owed below the header, the section pulls up 4pt and its paragraph 3pt more: the
+ // page sets the paragraph 3pt below the header's box.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addParagraph(p -> p.text("Header").textStyle(BODY).margin(new DocumentInsets(0, 0, 10, 0)))
+ .addSection("Pulled", section -> section
+ .margin(new DocumentInsets(-4, 0, 0, 0))
+ .addParagraph(p -> p.text("Pulled").textStyle(BODY).margin(new DocumentInsets(-3, 0, 0, 0)))))) {
+ XWPFParagraph pulled = document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getText().contains("Pulled")).findFirst().orElseThrow();
+
+ assertThat(DocxTwips.of(pulled.getCTP().getPPr().getSpacing().getBefore()) / 20.0)
+ .as("10 less 4 less 3").isCloseTo(3, within(0.1));
+ }
+ }
+
+ @Test
+ void aRowOpeningAPaddedColumnKeepsThePaddingAboveIt() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addRow("Split", row -> row
+ .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1))
+ .addParagraph(p -> p.text("Issuer").textStyle(BODY))
+ .addSection("Meta", cell -> cell
+ .padding(new DocumentInsets(6, 0, 0, 0))
+ .addRow("Grid", grid -> grid
+ .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1))
+ .addParagraph(p -> p.text("Label").textStyle(BODY))
+ .addParagraph(p -> p.text("Value").textStyle(BODY))))))) {
+ XWPFTableCell meta = document.getTables().get(0).getRow(0).getCell(1);
+ List content = meta.getBodyElements();
+ int grid = indexOfTheFirstTable(content);
+
+ assertThat(grid).as("something holds the space above the grid").isPositive();
+ double above = 0;
+ for (int i = 0; i < grid; i++) {
+ var spacing = ((XWPFParagraph) content.get(i)).getCTP().getPPr().getSpacing();
+ above += (spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0) / 20.0
+ + DocxTwips.of(spacing.getLine()) / 20.0;
+ }
+ assertThat(above).as("the column's 6pt, and a hairline").isCloseTo(6, within(0.2));
+ }
+ }
+
+ private static int indexOfTheFirstTable(List content) {
+ for (int i = 0; i < content.size(); i++) {
+ if (content.get(i) instanceof XWPFTable) {
+ return i;
+ }
+ }
+ return -1;
+ }
+}
From 678d7d353b78420c6fea6133b968efde371b0db6 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 30 Sep 2026 19:11:42 +0100
Subject: [PATCH 2/2] fix(docx): take a page reference's pull out of the space
above it too, and state the drift as measured
---
CHANGELOG.md | 25 +++++++++----------
docs/recipes/docx-export.md | 2 +-
.../semantic/docx/DocxSemanticBackend.java | 20 ++++++++-------
.../docx/DocxContainerSpacingTest.java | 8 +++---
.../semantic/docx/DocxSpaceAboveTest.java | 21 ++++++++++++++--
5 files changed, 47 insertions(+), 29 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7dd3b9f9f..e5dcc0715 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,18 +11,17 @@ follow semantic versioning; release dates are ISO 8601.
- **A block pulled up by a negative top edge, and a row padded down its column, stand where the
page puts them in Word.** Word has no negative space above a paragraph, so a paragraph's, a
page reference's or a rule's negative top `margin` or `padding` was dropped:
- `PaymentsInvoice`'s header rule, pulled 16.4pt up out of the space its header stack leaves
- below its content, stood that far low with the page under it. The pull now comes out of the
- space owed above the block, with any pull of the containers around it. Word has no space above
- a table either, and a row opening a row's column lost the padding above it:
- `PaymentsInvoice`'s metadata grid, padded 6.2pt down its column, stood that much above the
- issuer beside it, as did `MerchantInvoice`'s. A paragraph a tenth of a point tall now carries
- that space, the containers' padding included, in any cell but a table's — as it already
- carried the space a row owed above itself at the top of a panel; a row opening a table's cell
- is left to the row height the table holds. In Word `PaymentsInvoice`'s median drift falls from 10.3pt to 0.5,
- and across the 62 templates lines more than 2pt off fall from 705 to 572; in LibreOffice from
- 1066 to 933.
-
+ `PaymentsInvoice`'s header rule is pulled 16.4pt up out of the space its header stack leaves
+ below its content. The pull now comes out of the space owed above the block, with any pull of
+ the containers around it. Word has no space above a table either, and a row opening a row's
+ column lost the padding above it: `PaymentsInvoice`'s metadata grid, padded 6.2pt down its
+ column, stood 6.3pt above the issuer beside it, as `MerchantInvoice`'s stood 6pt, and
+ `PaymentsInvoice`'s rule and the page under it stood 10pt low, the pull less that lost
+ padding. A paragraph a tenth of a point tall now carries that space, the containers' edges
+ included, in any cell but a table's — as it already carried the space a row owed above itself
+ at the top of a panel; a row opening a table's cell is left to the row height the table
+ holds. In Word `PaymentsInvoice`'s median drift falls from 10.3pt to 0.5, and across the 62
+ templates lines more than 2pt off fall from 705 to 572; in LibreOffice from 1066 to 933.
- **A row a drawing makes tall, and a title pulled above its row, keep the page's height in
Word.** A row whose tallest child is drawn where the page puts it — a badge beside a heading,
its cell holding nothing in Word — was only as tall as its text there: `WorkspaceInvoice`'s
@@ -34,7 +33,7 @@ follow semantic versioning; release dates are ISO 8601.
line start at the cell's top in Word, and the page under it 4pt low; a one-line first paragraph
is now written as much shorter, its text seated where the page sets it — no more than the room
above its letters, as Word draws an exact line's text only inside the line. The paragraph's own
- top edge is written as before. In Word `WorkspaceInvoice`'s median drift
+ top edge is not counted in the rise. In Word `WorkspaceInvoice`'s median drift
falls from 4.8pt to 0.4 and `SubscriptionInvoice`'s from 8.8 to 0.5; across the 62 templates,
lines more than 2pt off fall from 858 to 705. `PaymentsInvoice`'s parties now stand level with
each other and its median rises from 3.7 to 10.3: the collapsed heading row had been hiding a
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 6fcebb2ae..27abfe708 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -196,7 +196,7 @@ one, so a table's or a row's `margin` and `padding` travel to the paragraphs aro
the space above a table is the space below the paragraph before it. Where no paragraph
stands above it — a table opening the body, or a row opening any cell but a table's, padded
down it — a paragraph a tenth of a point tall carries the space instead. A row opening a
-table's cell is left to the row height the table holds, which has that space in it already;
+table's cell is left to the row height the table holds, at least as tall as the page makes it;
a table (not a row) opening a cell still loses that edge.
An edge pulling a block up — a paragraph's, a page reference's or a rule's negative top
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index cffd8452e..fbaaf5c5a 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -3183,9 +3183,10 @@ private void riseIntoItsLine(DocumentNode node) {
return;
}
// What the paragraph would have above it before its own edge, which is written apart
- // (applyVerticalSpacing): the edges carried down to it and the space the block before it
- // owes, less a border standing below that block. Only what that comes short of zero is
- // a rise; the rest is written as space.
+ // (applyVerticalSpacing, or standsIntoTheSpaceAbove for a negative one, after this): the
+ // edges carried down to it and the space the block before it owes, less a border
+ // standing below that block. Only what that comes short of zero is a rise; the rest is
+ // written as space.
double rise = -(carriedSpacingBefore + pendingSpacingAfter - borderBelow);
if (!(rise > 0.01) || layout.lineCount(first) != 1 || stackedLineHeights.containsKey(first)) {
return;
@@ -3738,7 +3739,8 @@ private static boolean drawn(CTBorder border) {
* writes an edge only where it is positive, so an edge pulling the block up was dropped
* and the block stood that much low, with everything after it. {@code PaymentsInvoice}'s
* header rule is pulled 16.4pt up from the foot of the stack above it, whose band runs
- * past its content: the rule and the page under it stood 16.4pt low. The pull is added
+ * past its content: the pull was lost, and the rule and the page under it stood 10pt low,
+ * the 16.4pt less the 6.3pt its metadata grid had lost above it. The pull is added
* to the edges carried down to the block, as a container's negative edge already is, so
* {@link #newBodyParagraph} nets it against everything owed above, as the page sums it;
* what that cannot give stays unwritten, as before. Text laid over the flow owes no
@@ -6015,7 +6017,7 @@ private static boolean startsWith(byte[] bytes, int... signature) {
* paragraph after. Both go through the debt every other gap goes through
* ({@link #owePendingSpacingAfter}), so a table between two paragraphs reads the same as
* two paragraphs with a gap between them. With nothing above it — opening the body, or a
- * row opening a cell — a paragraph a tenth of a point tall holds that edge
+ * row opening any cell but a table's — a paragraph a tenth of a point tall holds that edge
* ({@link #holdTheSpaceAboveATable}); a table opening a cell still loses it.
*/
private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node)
@@ -6025,10 +6027,10 @@ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode nod
// At the top of a cell nothing above holds that space: MerchantInvoice's due-date
// row lost its 16.7pt of top padding and stood against the card's top edge, once the
// card held the page's height, and PaymentsInvoice's metadata grid the 6.2pt its
- // column is padded down by, standing that much above the issuer beside it. A row in
- // a table's cell keeps to the row height the table holds (holdRowHeight): held
- // again, ObsidianInvoice's line items stood 6pt low. A table is left as it was too:
- // holding its space moved ObsidianInvoice's line items 6pt below the page's.
+ // column is padded down by, standing 6.3pt above the issuer beside it. A row in a
+ // table's cell keeps to the row height the table holds (holdRowHeight), and a table
+ // is left as it was: holding the space of either moved ObsidianInvoice's line items
+ // 6pt below the page's.
holdTheSpaceAboveATable(document);
}
if (node instanceof RowNode row) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java
index 7bad47389..4ffbd56b3 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java
@@ -224,10 +224,10 @@ void aTableAfterAPageBreakKeepsTheSpaceAboveIt() throws Exception {
}
@Test
- void aTableOpeningATablesCellIsWrittenAsBefore() throws Exception {
- // A table's cell takes no paragraph: the table holds its row at the page's height, and
- // one there was measured to set content lower than the page does — each of
- // ObsidianInvoice's line items 8.5pt lower. A row's column does (DocxSpaceAboveTest).
+ void aRowOpeningATablesCellIsWrittenAsBefore() throws Exception {
+ // A table's cell takes no paragraph: the table holds its row at least as tall as the
+ // page makes it, and one there was measured to set content lower than the page does —
+ // each of ObsidianInvoice's line items 6pt lower. A row's column does (DocxSpaceAboveTest).
com.demcha.compose.document.dsl.SectionBuilder composed = new com.demcha.compose.document.dsl.SectionBuilder();
composed.padding(new DocumentInsets(12, 0, 0, 0))
.addRow(inner -> inner.addParagraph("Qty").addParagraph("1"));
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java
index 8990f61ee..523a70618 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java
@@ -22,8 +22,9 @@
*
* {@code PaymentsInvoice}'s header rule is pulled 16.4pt up out of the space its header
* stack leaves below itself, and its metadata grid is padded 6.2pt down its column. Word has
- * no negative space above a paragraph and no space above a table: the rule and the page under
- * it stood 16.4pt low, and the grid 6.2pt high.
+ * no negative space above a paragraph and no space above a table: the pull was dropped and the
+ * padding lost, and in Word the grid stood 6.3pt high and the rule and the page under it 10pt
+ * low.
*/
class DocxSpaceAboveTest {
@@ -57,6 +58,22 @@ void aParagraphPulledUpFurtherThanTheSpaceAboveItHasNoSpaceAbove() throws Except
}
}
+ @Test
+ void aPageReferencePulledUpTakesItsPullOutOfTheSpaceOwedAboveIt() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addParagraph(p -> p.text("Header").textStyle(BODY).margin(new DocumentInsets(0, 0, 20, 0)))
+ .add(new com.demcha.compose.document.node.PageReferenceNode("Ref", "terms", BODY,
+ com.demcha.compose.document.node.TextAlign.LEFT, "", DocumentInsets.zero(),
+ new DocumentInsets(-8, 0, 0, 0)))
+ .addSection(s -> s.anchor("terms").addParagraph(p -> p.text("Terms").textStyle(BODY))))) {
+ XWPFParagraph reference = document.getParagraphs().get(1);
+
+ assertThat(reference.getCTP().xmlText()).as("the page reference's paragraph").contains("PAGEREF");
+ assertThat(DocxTwips.of(reference.getCTP().getPPr().getSpacing().getBefore()) / 20.0)
+ .as("20pt owed less the 8pt it is pulled up").isCloseTo(12, within(0.1));
+ }
+ }
+
@Test
void aParagraphPulledUpInsideASectionPulledUpAddsToItsPull() throws Exception {
// 10pt owed below the header, the section pulls up 4pt and its paragraph 3pt more: the