From e8609fd21fab59f00478d56cf6a021613059f8d5 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 23 Sep 2026 13:29:16 +0100 Subject: [PATCH] fix(docx): keep a block on one page in Word when the layout does keepTogether() and keepWithNext() are resolved by the layout, and Word re-paginates on its own, so the export has to say them. A block the layout placed on one page gives each of its paragraphs w:keepLines and every one but the last w:keepNext, the last as well for keepWithNext; a table inside it is chained row by row. A block that ran over a page break in the layout is left to flow. --- CHANGELOG.md | 11 ++ .../architecture/backend-capability-matrix.md | 1 + docs/recipes/docx-export.md | 2 +- .../semantic/docx/DocxLayoutMetrics.java | 11 ++ .../semantic/docx/DocxSemanticBackend.java | 66 +++++++++ .../semantic/docx/DocxKeepTogetherTest.java | 138 ++++++++++++++++++ 6 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxKeepTogetherTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 058541186..faa733f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A block kept together in the layout is kept together in Word.** `keepTogether()` moves a + block to the next page whole, and `keepWithNext()` moves a block down with the first line + of the one after it. The DOCX export said neither, and Word re-paginates on its own, so a + card split across Word's page break and a heading could be left at the foot of the page + above its body. A block the layout placed on one page now gives each of its paragraphs + `w:keepLines` and every one but the last `w:keepNext` — the last as well when it is kept + with the next — and a table inside it takes part row by row. A block taller than a page, + which the layout lets flow, is left to flow. Converted in LibreOffice, a six-line card that + the page moves whole split 4 + 2 across the break before and lands whole on the next page + after. + - **A landscape page exports as landscape.** Word draws a page from its width and height but reads the orientation from `w:orient` — for Page Setup, for printing, for the paper tray — and the DOCX export wrote portrait for every page. A landscape document opened the right diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 4e2cac0ee..581bd98e0 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -108,6 +108,7 @@ honour an option ignores it (documented contract). | Protection / encryption | ✅ `PdfDocumentPostProcessor` | ❌ (ignored with a one-time warning — no OOXML encryption support planned) | ❌ | | Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a | | Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a | +| Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it | ## Output surface and lifecycle diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index e09b2c78e..9d2ba6df2 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -74,7 +74,7 @@ creation date is real metadata. | Images | Embedded pictures at the node's declared size | | Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is | | Rows | A one-row table spanning the content width, so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin (cell content limited to atomic children). The row is kept whole across a page break, as the layout keeps it | -| Sections / containers | Children written in order; a fill, per-side borders or a uniform stroke travel to each paragraph inside as `w:shd` and `w:pBdr`, so a card keeps its panel — see "What a panel keeps and loses" below | +| Sections / containers | Children written in order; a fill, per-side borders or a uniform stroke travel to each paragraph inside as `w:shd` and `w:pBdr`, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`) | | Spacers | Empty paragraphs carrying the vertical gap as spacing-after | | Page breaks | Explicit Word page breaks | diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index 2cfe3bb59..425bc9ef1 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -217,6 +217,17 @@ boolean placed(DocumentNode node) { return placedFor(node) != null; } + /** + * Whether the layout placed a node, and placed all of it on one page. + * + * @param node any authored node + * @return true when the node starts and ends on the same page + */ + boolean onOnePage(DocumentNode node) { + PlacedNode placedNode = placedFor(node); + return placedNode != null && placedNode.startPage() == placedNode.endPage(); + } + /** * How far a page zone's content sits from the page edge it belongs to, as laid out on * the first page. 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 5c3f1f9c1..29200ec4f 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 @@ -56,6 +56,7 @@ import com.demcha.compose.font.FontName; import org.apache.poi.util.Units; import org.apache.poi.xwpf.usermodel.BreakType; +import org.apache.poi.xwpf.usermodel.IBodyElement; import org.apache.poi.xwpf.usermodel.IRunBody; import org.apache.poi.xwpf.usermodel.ParagraphAlignment; import com.demcha.compose.document.node.PageFieldKind; @@ -627,6 +628,71 @@ private void warnUnsupportedZoneNode(DocumentNode node) { } private void writeNode(XWPFDocument document, DocumentNode node) throws Exception { + boolean keepTogether = node.keepTogether() && layout.onOnePage(node); + boolean keepWithNext = node.keepWithNext() && layout.onOnePage(node); + if (!keepTogether && !keepWithNext) { + writeNodeContent(document, node); + return; + } + int first = document.getBodyElements().size(); + writeNodeContent(document, node); + keepOnOnePage(document.getBodyElements().subList(first, document.getBodyElements().size()), + keepWithNext); + } + + /** + * Tells Word to keep together what the layout kept together. + * + *

{@code keepTogether()} moves a block to the next page whole rather than letting it + * run over the break, and {@code keepWithNext()} does the same for a block and the first + * line of the one after it. Word re-paginates on its own and was told neither, so a card + * the page held together split across Word's break, and a heading the page moved down + * with its body was left at the foot of the page above it.

+ * + *

Word says both with two paragraph properties: {@code w:keepLines} keeps a paragraph's + * own lines on one page, and {@code w:keepNext} keeps it on the page of whatever follows. + * Every paragraph the block wrote gets the first and every one but the last the second, + * which chains the block into one unit; a block kept with the next gives its last + * paragraph {@code w:keepNext} as well. A table inside the block takes part row by row, + * because Word reads keep-with-next on a row's paragraphs as keeping the row with the + * next one.

+ * + *

Only a block the layout placed on one page is kept. The layout keeps a block together + * only when a page can hold it and lets a taller one flow, and a block that ran over a + * page break is exactly that; without a layout there is nothing to say either way.

+ */ + private static void keepOnOnePage(List written, boolean withNext) { + List> units = new ArrayList<>(); + for (IBodyElement element : written) { + if (element instanceof XWPFParagraph paragraph) { + units.add(List.of(paragraph)); + } else if (element instanceof XWPFTable table) { + for (XWPFTableRow row : table.getRows()) { + List paragraphs = new ArrayList<>(); + for (XWPFTableCell cell : row.getTableCells()) { + paragraphs.addAll(cell.getParagraphs()); + } + units.add(paragraphs); + } + } + } + for (int index = 0; index < units.size(); index++) { + boolean last = index == units.size() - 1; + for (XWPFParagraph paragraph : units.get(index)) { + CTPPr properties = paragraph.getCTP().isSetPPr() + ? paragraph.getCTP().getPPr() + : paragraph.getCTP().addNewPPr(); + if (!properties.isSetKeepLines()) { + properties.addNewKeepLines(); + } + if ((!last || withNext) && !properties.isSetKeepNext()) { + properties.addNewKeepNext(); + } + } + } + } + + private void writeNodeContent(XWPFDocument document, DocumentNode node) throws Exception { if (node instanceof ParagraphNode paragraph) { writeParagraph(document, paragraph); } else if (node instanceof ImageNode image) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxKeepTogetherTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxKeepTogetherTest.java new file mode 100644 index 000000000..6157c960d --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxKeepTogetherTest.java @@ -0,0 +1,138 @@ +package com.demcha.compose.document.backend.semantic.docx; + +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.apache.poi.xwpf.usermodel.XWPFTableRow; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * What the layout keeps on one page, Word keeps on one page. + * + *

{@code keepTogether()} moves a block to the next page whole, and {@code keepWithNext()} + * moves a block down with the first line of the one after it. Word re-paginates on its own, + * so it has to be told the same thing — {@code w:keepLines} and {@code w:keepNext} — or a + * card splits across its break and a heading is left behind at the foot of a page.

+ * + * @author Artem Demchyshyn + */ +class DocxKeepTogetherTest { + + @Test + void aBlockKeptTogetherIsChainedIntoOneUnit() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addSection(s -> s.keepTogether() + .addParagraph(p -> p.text("Invoice")) + .addParagraph(p -> p.text("Due in 30 days")) + .addParagraph(p -> p.text("Thank you"))) + .addParagraph(p -> p.text("After")))) { + XWPFParagraph first = paragraph(document, "Invoice"); + XWPFParagraph middle = paragraph(document, "Due in 30 days"); + XWPFParagraph last = paragraph(document, "Thank you"); + + assertThat(keepsNext(first)).isTrue(); + assertThat(keepsNext(middle)).isTrue(); + assertThat(keepsNext(last)) + .as("the block ends here, and is not tied to what follows it") + .isFalse(); + assertThat(keepsLines(first) && keepsLines(middle) && keepsLines(last)).isTrue(); + assertThat(keepsNext(paragraph(document, "After")) || keepsLines(paragraph(document, "After"))) + .isFalse(); + } + } + + @Test + void aBlockKeptWithTheNextHoldsOntoIt() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addSection(s -> s.keepWithNext().addParagraph(p -> p.text("Experience"))) + .addParagraph(p -> p.text("Senior engineer, 2019 to now")))) { + assertThat(keepsNext(paragraph(document, "Experience"))).isTrue(); + assertThat(keepsNext(paragraph(document, "Senior engineer, 2019 to now"))).isFalse(); + } + } + + @Test + void aBlockThatAsksForNothingIsFreeToBreak() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addSection(s -> s + .addParagraph(p -> p.text("One")) + .addParagraph(p -> p.text("Two"))))) { + for (XWPFParagraph paragraph : document.getParagraphs()) { + assertThat(keepsNext(paragraph) || keepsLines(paragraph)).isFalse(); + } + } + } + + @Test + void aBlockTallerThanAPageIsLeftToFlowAsTheLayoutLetsIt() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(300, 200, 20, page -> page + .addSection(s -> { + s.keepTogether(); + for (int index = 0; index < 20; index++) { + int line = index; + s.addParagraph(p -> p.text("Line " + line)); + } + }))) { + for (XWPFParagraph paragraph : document.getParagraphs()) { + assertThat(keepsNext(paragraph) || keepsLines(paragraph)).isFalse(); + } + } + } + + @Test + void aTableInsideAKeptBlockIsChainedRowByRow() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addSection(s -> s.keepTogether() + .addParagraph(p -> p.text("Totals")) + .addTable(t -> t.autoColumns(2).row("Net", "100").row("Tax", "20"))))) { + XWPFTable table = document.getTables().get(0); + + assertThat(keepsNext(paragraph(document, "Totals"))).isTrue(); + assertThat(rowKeepsNext(table.getRow(0))).isTrue(); + assertThat(rowKeepsNext(table.getRow(1))) + .as("the table's last row ends the block") + .isFalse(); + } + } + + @Test + void withoutALayoutNothingIsKnownToBeOnOnePage() throws Exception { + try (XWPFDocument document = DocxExports.withoutLayout(595, 842, 36, page -> page + .addSection(s -> s.keepTogether() + .addParagraph(p -> p.text("One")) + .addParagraph(p -> p.text("Two"))))) { + for (XWPFParagraph paragraph : document.getParagraphs()) { + assertThat(keepsNext(paragraph) || keepsLines(paragraph)).isFalse(); + } + } + } + + private static XWPFParagraph paragraph(XWPFDocument document, String text) { + return document.getParagraphs().stream() + .filter(paragraph -> paragraph.getText().equals(text)) + .findFirst() + .orElseThrow(() -> new AssertionError("no paragraph reads " + text)); + } + + private static boolean keepsNext(XWPFParagraph paragraph) { + return paragraph.getCTP().isSetPPr() && paragraph.getCTP().getPPr().isSetKeepNext(); + } + + private static boolean keepsLines(XWPFParagraph paragraph) { + return paragraph.getCTP().isSetPPr() && paragraph.getCTP().getPPr().isSetKeepLines(); + } + + private static boolean rowKeepsNext(XWPFTableRow row) { + for (XWPFTableCell cell : row.getTableCells()) { + for (XWPFParagraph paragraph : cell.getParagraphs()) { + if (!keepsNext(paragraph)) { + return false; + } + } + } + return true; + } +}