From 0bb0a1fc23048455b8123cfea87cc8fd83de39fe Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 3 Oct 2026 16:42:05 +0100 Subject: [PATCH 1/2] fix(docx): take text hanging past a block out of the space above a band that follows --- CHANGELOG.md | 7 +++ docs/recipes/docx-export.md | 5 +- .../libreoffice-windows-lines.tsv | 12 ++--- .../docx-fidelity/libreoffice-windows.tsv | 4 +- .../docx-fidelity/word-windows-lines.tsv | 12 ++--- .../resources/docx-fidelity/word-windows.tsv | 4 +- .../semantic/docx/DocxSemanticBackend.java | 10 +++- .../semantic/docx/DocxOverlayBandTest.java | 52 +++++++++++++++++++ 8 files changed, 88 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95554b5b5..a3e8725b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **Text hanging past a block takes its place out of the space above a stack that follows in + DOCX.** A stack written as a band opens at the space the page measures above its first block, + and that block forgot what the text above it already hung into that space: a shape + container's last line run past its foot, a row's icon line. `ConsultingInvoice`'s address + runs 2.3pt past its band, and the contact bands under it stood 2.4pt low in Word. Lines more + than 2pt off fall from 3 to none for `ConsultingInvoice` and its long version in both editors, + and from 11 to 5 across the corpus in Word. - **A ruled table's first and last rows keep the page's height in DOCX.** A row the page holds at a height is written that height less what its cells take around their content, since Word, as LibreOffice, reads a row's written height as its content. Word gives a rule between diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 5b7a43637..c9b37abf8 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -224,7 +224,10 @@ pulls the next paragraph up into it the same way: it comes out of the space abov paragraph, its own top edge included, even where the two stand in different containers. What that space cannot give is not written. Before a table, a page break or the end of a cell it comes out of the space owed below instead. Layers that overlap on the page take none of it -from one another, as the space between them is measured from their boxes. +from one another, as the space between them is measured from their boxes. Text that runs past +the foot of the block it stands in — a stack's or a shape container's last line, a row cell's +icon line — comes out of the space above what follows the same way, a stack written as a band +included. 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/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv index 4365539f7..697cb65ca 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv @@ -1918,9 +1918,9 @@ invoice-classic_long 1:8822ea375315 0.22 remittancedetails. invoice-classic_long 1:e3337beaa171 0.20 thankyouforchoosinggraphcomposef invoice-consulting 0:d44ceaea97d1 0.27 level8,1collinsstreet invoice-consulting 0:78a0bb33347f 0.29 melbournevic3000australia -invoice-consulting 0:3b45ab243edb 2.81 +61398765432 -invoice-consulting 0:a17a9fc6d4a8 2.81 hello@northpoint.com.au -invoice-consulting 0:ef0664c1d352 2.81 northpoint.com.au +invoice-consulting 0:3b45ab243edb 0.51 +61398765432 +invoice-consulting 0:a17a9fc6d4a8 0.51 hello@northpoint.com.au +invoice-consulting 0:ef0664c1d352 0.51 northpoint.com.au invoice-consulting 0:52d6e3de4fa0 -0.44 invoice invoice-consulting 0:6282391a4cc1 -0.63 invoicenumber: invoice-consulting 0:7d66eb3c1070 -0.19 inv-2025-0478 @@ -2010,9 +2010,9 @@ invoice-consulting 0:55ee5fe62c33 0.01 northpointconsultingptyltd|abn12 invoice-consulting 0:bdbada284e80 0.01 page1/1 invoice-consulting_long 0:d44ceaea97d1 0.27 level8,1collinsstreet invoice-consulting_long 0:78a0bb33347f 0.29 melbournevic3000australia -invoice-consulting_long 0:3b45ab243edb 2.81 +61398765432 -invoice-consulting_long 0:a17a9fc6d4a8 2.81 hello@northpoint.com.au -invoice-consulting_long 0:ef0664c1d352 2.81 northpoint.com.au +invoice-consulting_long 0:3b45ab243edb 0.51 +61398765432 +invoice-consulting_long 0:a17a9fc6d4a8 0.51 hello@northpoint.com.au +invoice-consulting_long 0:ef0664c1d352 0.51 northpoint.com.au invoice-consulting_long 0:52d6e3de4fa0 -0.44 invoice invoice-consulting_long 0:6282391a4cc1 -0.63 invoicenumber: invoice-consulting_long 0:7d66eb3c1070 -0.19 inv-2025-0478 diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv index 7dce57d40..f84de8629 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv @@ -28,8 +28,8 @@ cv-timeline_minimal 2 2 89 86 0.45 0.50 0 cv-violet_grid 1 2 80 76 5.21 21.31 39 invoice-classic 1 1 56 56 0.17 0.32 0 invoice-classic_long 2 2 166 166 0.22 0.27 0 -invoice-consulting 1 1 92 92 0.68 1.01 3 -invoice-consulting_long 2 2 236 236 0.71 1.06 3 +invoice-consulting 1 1 92 92 0.67 1.01 0 +invoice-consulting_long 2 2 236 236 0.69 1.04 0 invoice-luma_studio 1 1 97 97 0.12 0.52 2 invoice-luma_studio_long 3 3 192 192 0.20 0.41 2 invoice-merchant 1 2 94 90 0.12 0.23 0 diff --git a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv index e66d12cc0..0cafde84c 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv @@ -2012,9 +2012,9 @@ invoice-classic_long 1:8822ea375315 0.33 remittancedetails. invoice-classic_long 1:e3337beaa171 0.29 thankyouforchoosinggraphcomposef invoice-consulting 0:d44ceaea97d1 0.26 level8,1collinsstreet invoice-consulting 0:78a0bb33347f 0.33 melbournevic3000australia -invoice-consulting 0:3b45ab243edb 2.91 +61398765432 -invoice-consulting 0:a17a9fc6d4a8 2.91 hello@northpoint.com.au -invoice-consulting 0:ef0664c1d352 2.91 northpoint.com.au +invoice-consulting 0:3b45ab243edb 0.51 +61398765432 +invoice-consulting 0:a17a9fc6d4a8 0.51 hello@northpoint.com.au +invoice-consulting 0:ef0664c1d352 0.51 northpoint.com.au invoice-consulting 0:52d6e3de4fa0 0.08 invoice invoice-consulting 0:6282391a4cc1 -0.10 invoicenumber: invoice-consulting 0:7d66eb3c1070 0.12 inv-2025-0478 @@ -2104,9 +2104,9 @@ invoice-consulting 0:55ee5fe62c33 0.15 northpointconsultingptyltd|abn12 invoice-consulting 0:bdbada284e80 0.15 page1/1 invoice-consulting_long 0:d44ceaea97d1 0.26 level8,1collinsstreet invoice-consulting_long 0:78a0bb33347f 0.33 melbournevic3000australia -invoice-consulting_long 0:3b45ab243edb 2.91 +61398765432 -invoice-consulting_long 0:a17a9fc6d4a8 2.91 hello@northpoint.com.au -invoice-consulting_long 0:ef0664c1d352 2.91 northpoint.com.au +invoice-consulting_long 0:3b45ab243edb 0.51 +61398765432 +invoice-consulting_long 0:a17a9fc6d4a8 0.51 hello@northpoint.com.au +invoice-consulting_long 0:ef0664c1d352 0.51 northpoint.com.au invoice-consulting_long 0:52d6e3de4fa0 0.08 invoice invoice-consulting_long 0:6282391a4cc1 -0.10 invoicenumber: invoice-consulting_long 0:7d66eb3c1070 0.12 inv-2025-0478 diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv index 63757ee70..ca33ebb8e 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv @@ -28,8 +28,8 @@ cv-timeline_minimal 2 2 89 86 0.36 0.48 0 cv-violet_grid 1 1 80 77 0.23 1.30 5 invoice-classic 1 1 56 56 0.22 0.36 0 invoice-classic_long 2 2 166 166 0.27 0.34 0 -invoice-consulting 1 1 92 92 0.73 1.05 3 -invoice-consulting_long 2 2 236 236 0.74 1.10 3 +invoice-consulting 1 1 92 92 0.70 1.02 0 +invoice-consulting_long 2 2 236 236 0.74 0.97 0 invoice-luma_studio 1 1 97 97 0.13 0.42 0 invoice-luma_studio_long 3 3 192 192 0.22 0.38 0 invoice-merchant 1 1 94 94 0.14 0.35 0 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 1a8ce0a28..75b29ac4d 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 @@ -8991,7 +8991,15 @@ private void writeOverlayBand(XWPFDocument document, DocumentNode stack, DocxLay // A band inside a band that has written nothing yet: the outer band measured its space // to the first block written, which is this one's first too, so that space stands. if (Double.isNaN(resumeSpacing)) { - resumeSpacing = carriedSpacingBefore + pendingSpacingAfter + stack.margin().top() + band.above(); + double above = carriedSpacingBefore + pendingSpacingAfter + stack.margin().top() + band.above(); + // Text hanging below the block before — a band's last line past its foot, a row's + // icon line — takes its place out of that space, as out of the gap above a paragraph + // (newBodyParagraph); the band's first block forgets it (resumeHere). ConsultingInvoice's + // address runs 2.3pt past its band, and the contact bands under it stood 2.4pt low. + // A band the layout starts on a new page leaves the hang on the page above, as a + // paragraph or a table there does. + double hang = currentCell == null && overlayDepth == 0 && startsAPageOfItsOwn(stack) ? 0 : hangingBelow; + resumeSpacing = above - Math.min(Math.max(0, above), hang); } carriedSpacingBefore = 0; pendingSpacingAfter = 0; diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverlayBandTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverlayBandTest.java index 0dfd04619..5c8fd6055 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverlayBandTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverlayBandTest.java @@ -295,6 +295,58 @@ void theLastLayersNegativeBottomEdgeIsTakenOnceBelowTheBand() throws Exception { } } + @Test + void textHangingBelowABandTakesItsPlaceOutOfTheSpaceAboveTheNextBand() throws Exception { + // ConsultingInvoice's address runs past its band, and the contact band under it opened at + // the gap whole: the band's first block resumes the space the page measures, and the + // text hanging into it from above already stands in it. + DocumentSession session = GraphCompose.document().pageSize(300, 500).margin(DocumentInsets.of(20)).create(); + session.pageFlow(page -> page + .spacing(10) + .addLayerStack(stack -> stack.name("First") + .back(new SpacerNode("Space", 200, BADGE, DocumentInsets.zero(), DocumentInsets.zero())) + .layer(new ParagraphBuilder().name("Low").text("Low") + .margin(new DocumentInsets(0, 0, -4, 0)).build(), LayerAlign.BOTTOM_LEFT)) + .addLayerStack(stack -> stack.name("Second") + .back(new SpacerNode("Room", 200, 40, DocumentInsets.zero(), DocumentInsets.zero())) + .layer(new ParagraphBuilder().name("Next").text("Next").build(), LayerAlign.TOP_LEFT))); + try (session; XWPFDocument document = new XWPFDocument(new ByteArrayInputStream( + session.export(new DocxSemanticBackend())))) { + XWPFParagraph next = document.getParagraphs().stream() + .filter(paragraph -> "Next".equals(paragraph.getText())).findFirst().orElseThrow(); + + assertThat(before(next)).as("the 10pt gap, less the 4pt the text above hangs into it") + .isEqualTo((10 - 4) * 20L); + } + } + + @Test + void aBandOpeningTheNextPageLeavesTheHangAboveOnItsPage() throws Exception { + // 360pt, the gap and the first stack leave the second no room: it opens the next page, + // and what the first stack's text hangs below it stays on the page above. + DocumentSession session = GraphCompose.document().pageSize(300, 500).margin(DocumentInsets.of(20)).create(); + session.pageFlow(page -> page + .spacing(10) + .spacer(0, 360) + .addLayerStack(stack -> stack.name("First") + .back(new SpacerNode("Space", 200, BADGE, DocumentInsets.zero(), DocumentInsets.zero())) + .layer(new ParagraphBuilder().name("Low").text("Low") + .margin(new DocumentInsets(0, 0, -4, 0)).build(), LayerAlign.BOTTOM_LEFT)) + .addLayerStack(stack -> stack.name("Second") + .back(new SpacerNode("Room", 200, 40, DocumentInsets.zero(), DocumentInsets.zero())) + .layer(new ParagraphBuilder().name("Next").text("Next").build(), LayerAlign.TOP_LEFT))); + assertThat(session.layoutGraph().nodes().stream() + .filter(node -> "Second".equals(node.semanticName())).findFirst().orElseThrow().startPage()) + .as("the second stack opens the next page").isEqualTo(1); + try (session; XWPFDocument document = new XWPFDocument(new ByteArrayInputStream( + session.export(new DocxSemanticBackend())))) { + XWPFParagraph next = document.getParagraphs().stream() + .filter(paragraph -> "Next".equals(paragraph.getText())).findFirst().orElseThrow(); + + assertThat(before(next)).as("the gap whole, the hang left above").isEqualTo(10 * 20L); + } + } + @Test void aLayersNegativeBottomEdgeInAShapeContainerMovesNothingBelowIt() throws Exception { // The outline is its own size; the layer's shorter box is already in the space below it. From 10b07234649d9c89f42f256035d98ff81b4349a7 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 3 Oct 2026 16:53:14 +0100 Subject: [PATCH 2/2] fix(docx): take the Linux LibreOffice baseline from CI and state what the band test pins --- CHANGELOG.md | 7 ++++--- .../docx-fidelity/libreoffice-linux-lines.tsv | 12 ++++++------ .../resources/docx-fidelity/libreoffice-linux.tsv | 4 ++-- .../backend/semantic/docx/DocxSemanticBackend.java | 2 +- .../backend/semantic/docx/DocxOverlayBandTest.java | 2 +- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e8725b4..d7104f735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,10 @@ follow semantic versioning; release dates are ISO 8601. - **Text hanging past a block takes its place out of the space above a stack that follows in DOCX.** A stack written as a band opens at the space the page measures above its first block, - and that block forgot what the text above it already hung into that space: a shape - container's last line run past its foot, a row's icon line. `ConsultingInvoice`'s address - runs 2.3pt past its band, and the contact bands under it stood 2.4pt low in Word. Lines more + and that block forgot what the text above it already hung into that space: a band's or a + shape container's last line run past its foot, a row's icon line. `ConsultingInvoice`'s + address runs 2.3pt past its band, and the contact bands under it stood 2.9pt low in Word, 0.5pt + now. Lines more than 2pt off fall from 3 to none for `ConsultingInvoice` and its long version in both editors, and from 11 to 5 across the corpus in Word. - **A ruled table's first and last rows keep the page's height in DOCX.** A row the page holds diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv index 0409cda8f..2e1447fb2 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv @@ -2012,9 +2012,9 @@ invoice-classic_long 1:8822ea375315 0.22 remittancedetails. invoice-classic_long 1:e3337beaa171 0.20 thankyouforchoosinggraphcomposef invoice-consulting 0:d44ceaea97d1 0.27 level8,1collinsstreet invoice-consulting 0:78a0bb33347f 0.29 melbournevic3000australia -invoice-consulting 0:3b45ab243edb 2.81 +61398765432 -invoice-consulting 0:a17a9fc6d4a8 2.81 hello@northpoint.com.au -invoice-consulting 0:ef0664c1d352 2.81 northpoint.com.au +invoice-consulting 0:3b45ab243edb 0.51 +61398765432 +invoice-consulting 0:a17a9fc6d4a8 0.51 hello@northpoint.com.au +invoice-consulting 0:ef0664c1d352 0.51 northpoint.com.au invoice-consulting 0:52d6e3de4fa0 -0.44 invoice invoice-consulting 0:6282391a4cc1 -0.63 invoicenumber: invoice-consulting 0:7d66eb3c1070 -0.19 inv-2025-0478 @@ -2104,9 +2104,9 @@ invoice-consulting 0:55ee5fe62c33 0.01 northpointconsultingptyltd|abn12 invoice-consulting 0:bdbada284e80 0.01 page1/1 invoice-consulting_long 0:d44ceaea97d1 0.27 level8,1collinsstreet invoice-consulting_long 0:78a0bb33347f 0.29 melbournevic3000australia -invoice-consulting_long 0:3b45ab243edb 2.81 +61398765432 -invoice-consulting_long 0:a17a9fc6d4a8 2.81 hello@northpoint.com.au -invoice-consulting_long 0:ef0664c1d352 2.81 northpoint.com.au +invoice-consulting_long 0:3b45ab243edb 0.51 +61398765432 +invoice-consulting_long 0:a17a9fc6d4a8 0.51 hello@northpoint.com.au +invoice-consulting_long 0:ef0664c1d352 0.51 northpoint.com.au invoice-consulting_long 0:52d6e3de4fa0 -0.44 invoice invoice-consulting_long 0:6282391a4cc1 -0.63 invoicenumber: invoice-consulting_long 0:7d66eb3c1070 -0.19 inv-2025-0478 diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv index a72370fa8..8130fb274 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv @@ -28,8 +28,8 @@ cv-timeline_minimal 2 2 89 86 0.45 0.50 0 cv-violet_grid 1 2 80 76 5.21 21.31 39 invoice-classic 1 1 56 56 0.12 0.30 0 invoice-classic_long 2 2 166 166 0.20 0.27 0 -invoice-consulting 1 1 92 92 0.67 0.96 3 -invoice-consulting_long 2 2 236 236 0.71 1.06 3 +invoice-consulting 1 1 92 92 0.67 0.96 0 +invoice-consulting_long 2 2 236 236 0.69 1.04 0 invoice-luma_studio 1 1 97 97 0.12 0.52 2 invoice-luma_studio_long 3 3 192 192 0.20 0.41 2 invoice-merchant 1 1 94 94 0.73 1.91 5 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 75b29ac4d..94c825bdc 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 @@ -8995,7 +8995,7 @@ private void writeOverlayBand(XWPFDocument document, DocumentNode stack, DocxLay // Text hanging below the block before — a band's last line past its foot, a row's // icon line — takes its place out of that space, as out of the gap above a paragraph // (newBodyParagraph); the band's first block forgets it (resumeHere). ConsultingInvoice's - // address runs 2.3pt past its band, and the contact bands under it stood 2.4pt low. + // address runs 2.3pt past its band, and the contact bands under it stood 2.9pt low. // A band the layout starts on a new page leaves the hang on the page above, as a // paragraph or a table there does. double hang = currentCell == null && overlayDepth == 0 && startsAPageOfItsOwn(stack) ? 0 : hangingBelow; diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverlayBandTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverlayBandTest.java index 5c8fd6055..bfe8f6714 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverlayBandTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverlayBandTest.java @@ -343,7 +343,7 @@ void aBandOpeningTheNextPageLeavesTheHangAboveOnItsPage() throws Exception { XWPFParagraph next = document.getParagraphs().stream() .filter(paragraph -> "Next".equals(paragraph.getText())).findFirst().orElseThrow(); - assertThat(before(next)).as("the gap whole, the hang left above").isEqualTo(10 * 20L); + assertThat(before(next)).as("the space above it, written whole: Word drops it at the page top, and the hang is not taken from it").isEqualTo(10 * 20L); } }