From e04ef6383e9f1ffc9c10e5a93751ea1fb521382f Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 20 Oct 2026 19:11:12 +0100 Subject: [PATCH 1/2] fix(docx): name a block Word may break inside a panel it moves down in Inside a panel the layout runs over a page break, a block it keeps whole or with the next moves to the next page, but Word and LibreOffice break the panel's row where its lines run out and hold no keeps across that break. Measured in both, a stack of three lines or a section kept together moved whole to the next page stood with two of its lines at the foot of the page above, and a heading kept with the line under it stood there alone. Nothing was named. The report now names such a block. A filled section kept together and a row, which Word moves whole, are not named. --- CHANGELOG.md | 13 ++ docs/recipes/docx-export.md | 11 +- .../semantic/docx/DocxSemanticBackend.java | 48 +++++ .../docx/DocxPanelKeepReportTest.java | 167 ++++++++++++++++++ 4 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelKeepReportTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 03c43e3bf..55d0f5aed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export names a block Word may break inside a panel the layout moves it down in.** The + layout moves a block kept together, a layer stack, or a block kept with the next to the next + page as the page requires. Inside a panel the layout runs on there, Word and LibreOffice break + the panel's row where its lines run out and hold no keeps across that break. + - Measured in both on Windows, inside a filled section, with nothing named: + - a stack of three lines, or a section kept together, moved whole to the next page, stood + with two of its lines at the foot of the page above; + - a heading kept with the line under it stood there too, the line on the next page. + - The report now names such a block (`APPROXIMATED`), including one opening a panel inside the + panel, as the outer row breaks before it. It is named once, not again for what it holds. + - Measured as moving whole, as the page sets them, and not named: a filled section kept + together, whose own row may not split, and a row holding a card. + - None of the corpus's 62 DOCX changes, and none of its 1246 notes. - **A DOCX export keeps a stack of one layer on the page the layout puts it on.** The layout moves a layer stack to a new page whole, but Word was told nothing and broke between its paragraphs. Measured in Word and LibreOffice on Windows, a stack of three lines that the page moved to the diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index fcedb2d8e..21e09b9dc 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -588,8 +588,15 @@ How it lands: whole right border plus half a point short of that edge, the points taken from its right margin and no more than it holds, so its text keeps its place and width. - **Keeping together.** A `keepTogether()` panel the layout placed on one page is a row - Word may not split. Anchors and keeps on the blocks inside a panel carry as they do - anywhere else. + Word may not split. Anchors on the blocks inside a panel carry as they do anywhere else, and + so do their keeps, but Word and LibreOffice do not hold them where the panel's row breaks over + a page: they break it where its lines run out (measured: a stack of three lines, or a section + kept together, that the layout moved whole to the next page inside a panel stood with two of + its lines at the foot of the page above, in both, and a heading kept with the line under it + stood there with that line on the next page). The report names a block kept together, a layer + stack or a block kept with the next that the layout moves to a new page inside a panel that + runs on there. A filled section kept together, whose own row may not split, and a row in the + panel move whole, as the page sets them, and are not named. - **Page breaks.** Word breaks no page inside a table cell, so a page break among a panel's children closes the panel there and opens it again after the break. - **Measured.** Cards stacked in a row's columns, panels nested in its table, stand where the 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 86e0293f3..01bbdb07a 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 @@ -483,6 +483,10 @@ public final class DocxSemanticBackend implements SemanticBackend { private String badgeText; // The cell of the panel being written, painted or framed; null outside a panel. private XWPFTableCell panelCell; + // The page the last block written straight into panelCell ended on, or into the panel holding + // it, -1 before the first: a block the layout keeps whole and moves to a new page inside the + // panel, Word may break (reportAKeepThePanelBreaks). + private int panelLastEndPage = -1; // How much of the space above the first paragraph of panelCell its top border takes, in twips // (writePanelPiece): no room to raise that paragraph's line into (raiseIntoTheSpaceAbove). private long panelsFirstLineKeeps; @@ -3578,6 +3582,9 @@ private void writeNode(XWPFDocument document, DocumentNode node) throws Exceptio if (placed != null && inTheBody) { lastEndPage = Math.max(lastEndPage, placed.endPage()); } + if (placed != null && panelCell != null && currentCell == panelCell) { + panelLastEndPage = Math.max(panelLastEndPage, placed.endPage()); + } } } @@ -3588,6 +3595,7 @@ private void writePlacedNode(XWPFDocument document, DocumentNode node) throws Ex // In a cell its paragraphs are its table's, which a row keeps or breaks on its own terms. boolean keepTogether = (node.keepTogether() || currentCell == null && isOneLayer(node)) && layout.onOnePage(node); + reportAKeepThePanelBreaks(node); boolean keepWithNext = node.keepWithNext() && layout.onOnePage(node); String anchor = blockAnchorOf(node, inTheFlow(node, overlayDepth, oneLayerDepth)); if (!keepTogether && !keepWithNext && anchor == null) { @@ -3617,6 +3625,39 @@ private void writePlacedNode(XWPFDocument document, DocumentNode node) throws Ex } } + /** + * Names a block the layout moves to a new page inside a panel that runs on there, its row + * breaking with it: a block kept together, a layer stack, or a block kept with the next. + * + *

Word breaks a table's row where its lines run out and holds no keeps across that break: + * told to, it still set the first two lines of a stack of three, moved whole to the next page + * inside a panel, at the foot of the page above, and a heading kept with the line under it + * there with that line on the next page, in Word and LibreOffice alike (measured). What Word + * keeps whole moves whole: a panel kept together — its own row may not split — and a row in + * the panel's cell. A panel whose row Word keeps whole is on one page, and nothing in it opens + * another. A panel inside a panel is in the outer one's row, which breaks as well: a block + * opening it on a new page is named too, once, not again for what it holds.

+ */ + private void reportAKeepThePanelBreaks(DocumentNode node) { + boolean whole = node.keepTogether() || node instanceof com.demcha.compose.document.node.LayerStackNode; + if (panelCell == null || currentCell != panelCell || panelLastEndPage < 0 + || !(whole || node.keepWithNext()) || !layout.onOnePage(node) + || node.keepTogether() && !paintOf(node).isEmpty()) { + return; + } + com.demcha.compose.document.layout.PlacedNode placed = layout.placement(node); + if (placed != null && placed.startPage() > panelLastEndPage) { + report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node), whole + ? "the layout moves it to the next page whole, inside a panel that runs on there; Word breaks " + + "the panel's row where its lines run out and holds no keeps across that break, so it may set " + + "its first lines at the foot of the page above" + : "the layout moves it to the next page with the block after it, inside a panel that runs on " + + "there; Word breaks the panel's row where its lines run out and holds no keeps across that " + + "break, so it may leave it at the foot of the page above"); + panelLastEndPage = Math.max(panelLastEndPage, placed.startPage()); + } + } + /** * The anchor of a block that writes more than one paragraph, or none. * @@ -6931,6 +6972,12 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container long outerMarginKept = panelsMarginKeeps; boolean outerGives = panelsPaddingGives; long outerRaised = panelRaisedFromItsPadding; + int outerLastEndPage = panelLastEndPage; + if (currentCell != outerPanelCell || table.getRow(0).isCantSplitRow()) { + // In a row's cell, or a column's, the panel moves with its row, and a panel kept + // together moves whole: what opens its page is theirs, not the outer panel's. + panelLastEndPage = -1; + } panelsFirstLineKeeps = Math.max(0, toTwips(topNotTaken) - cellMargin(cell, true)); // What the padding takes of it is no room either (paddingAbove). A shape's layers are // set inside the outline it holds whole, and its first line rises into nothing of it. @@ -6949,6 +6996,7 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container panelsMarginKeeps = outerMarginKept; panelsPaddingGives = outerGives; panelRaisedFromItsPadding = outerRaised; + panelLastEndPage = outerLastEndPage; } if (cell.getParagraphs().isEmpty()) { // A panel with nothing Word can hold inside is its padding tall on the page, not a diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelKeepReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelKeepReportTest.java new file mode 100644 index 000000000..893000de0 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelKeepReportTest.java @@ -0,0 +1,167 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.LayerStackBuilder; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A block the layout moves to a new page inside a panel that runs on there is named when Word + * may break it there: Word breaks the panel's row where its own lines run out. + * + *

Measured in Word 16 and LibreOffice on Windows, inside a filled section the layout breaks + * over a page:

+ * + * + * @author Artem Demchyshyn + */ +class DocxPanelKeepReportTest { + + private static final String WHOLE = "the layout moves it to the next page whole, inside a panel that runs on there"; + private static final String WITH_NEXT = "the layout moves it to the next page with the block after it"; + + @Test + void aStackMovedToTheNextPageInsideAPanelIsNamed() throws Exception { + assertThat(notes(inAPanel(10, panel -> panel.addLayerStack(stack -> stack.name("Entry").topLeft(threeLines()))))) + .singleElement().satisfies(note -> { + assertThat(note.path()).contains("Entry"); + assertThat(note.detail()).startsWith(WHOLE); + }); + } + + @Test + void aBlockKeptTogetherMovedThereIsNamedToo() throws Exception { + assertThat(notes(inAPanel(10, panel -> panel.addSection("Kept", kept -> kept.keepTogether() + .addParagraph("Opening").addParagraph("Body").addParagraph("Tail"))))) + .singleElement().satisfies(note -> assertThat(note.path()).contains("Kept")); + } + + @Test + void aBandMovedThereIsNamedToo() throws Exception { + assertThat(notes(inAPanel(10, panel -> panel.addLayerStack(stack -> stack.name("Band").topLeft(threeLines()) + .bottomRight(new ParagraphBuilder().text("Caption").build()))))) + .singleElement().satisfies(note -> assertThat(note.path()).contains("Band")); + } + + @Test + void aBlockKeptWithTheNextMovedThereIsNamed() throws Exception { + // Ten lines leave 24pt: the heading and the line under it, 26pt, move down together. + assertThat(notes(inAPanel(10, panel -> panel.addSection("Heading", heading -> heading.keepWithNext() + .addParagraph("Opening")).addParagraph("Body")))) + .singleElement().satisfies(note -> { + assertThat(note.path()).contains("Heading"); + assertThat(note.detail()).startsWith(WITH_NEXT); + }); + } + + @Test + void aBlockOpeningAPanelInsideThePanelIsNamedOnce() throws Exception { + // The inner panel is in the outer one's row, which breaks before the stack too; the stack + // holds a section kept together, named with it, not again. + assertThat(notes(inAPanel(10, panel -> panel.addSection(inner -> inner + .fillColor(DocumentColor.rgb(250, 240, 230)) + .addLayerStack(stack -> stack.name("Entry").topLeft(new SectionBuilder().keepTogether() + .addParagraph("Opening").addParagraph("Body").addParagraph("Tail").build())))))) + .singleElement().satisfies(note -> assertThat(note.path()).contains("Entry")); + } + + @Test + void aPanelKeptTogetherInsideThePanelIsNot() throws Exception { + // Its own row may not split: Word moves it whole, a stack in it with it. + assertThat(notes(inAPanel(10, panel -> panel.addSection(kept -> kept.keepTogether() + .fillColor(DocumentColor.rgb(250, 240, 230)) + .addLayerStack(stack -> stack.topLeft(threeLines())))))).isEmpty(); + } + + @Test + void aCardInARowInsideThePanelIsNot() throws Exception { + // The row may not split, and moves with the card in it. + assertThat(notes(inAPanel(10, panel -> panel.addRow(row -> row + .add(new SectionBuilder().fillColor(DocumentColor.rgb(250, 240, 230)) + .addLayerStack(stack -> stack.topLeft(threeLines())).build()) + .addParagraph("Beside"))))).isEmpty(); + } + + @Test + void aStackInARowsCellInsideThePanelIsNot() throws Exception { + assertThat(notes(inAPanel(10, panel -> panel.addRow(row -> row + .add(new LayerStackBuilder().topLeft(threeLines()).build()) + .addParagraph("Beside"))))).isEmpty(); + } + + @Test + void oneOnThePanelsFirstPageIsNot() throws Exception { + assertThat(notes(inAPanel(2, panel -> panel.addLayerStack(stack -> stack.topLeft(threeLines()))))).isEmpty(); + } + + @Test + void oneOpeningThePanelsPieceAfterABreakIsNot() throws Exception { + // The break closes the panel there and opens it again on the next page. + assertThat(notes(inAPanel(2, panel -> panel.addPageBreak(pageBreak -> { }) + .addLayerStack(stack -> stack.topLeft(threeLines()))))).isEmpty(); + } + + @Test + void oneOpeningTheNextPanelIsNot() throws Exception { + // The panel before it ends on the first page; this one opens on the second with the stack. + Consumer content = page -> { + inAPanel(9, panel -> { }).accept(page); + page.addSection(next -> next.fillColor(DocumentColor.rgb(230, 236, 245)).padding(DocumentInsets.of(6)) + .addLayerStack(stack -> stack.topLeft(threeLines()))); + }; + assertThat(notes(content)).isEmpty(); + } + + @Test + void oneInTheFlowIsNot() throws Exception { + // Kept on its page there, as Word keeps it. + Consumer content = page -> { + for (int i = 0; i < 10; i++) { + page.addParagraph("Filler line " + i); + } + page.addLayerStack(stack -> stack.topLeft(threeLines())); + }; + assertThat(notes(content)).isEmpty(); + } + + /** + * A filled section on a 300 x 200 page with a 20pt margin, {@code lines} lines of filler, then + * the block; ten lines leave no room for three more under them. + */ + private static Consumer inAPanel(int lines, Consumer block) { + return page -> page.addSection(panel -> { + panel.fillColor(DocumentColor.rgb(230, 236, 245)).padding(DocumentInsets.of(6)); + for (int i = 0; i < lines; i++) { + panel.addParagraph("Filler line " + i); + } + block.accept(panel); + panel.addParagraph("Closing"); + }); + } + + private static com.demcha.compose.document.node.DocumentNode threeLines() { + return new SectionBuilder().addParagraph("Opening").addParagraph("Body").addParagraph("Tail").build(); + } + + private static List notes(Consumer content) throws Exception { + return DocxExports.report(300, 200, 20, content).notes().stream() + .filter(note -> note.detail().startsWith(WHOLE) || note.detail().startsWith(WITH_NEXT)) + .toList(); + } +} From 27ca04d0298fcfd1ccee8424bfa9f15dc13bbbf9 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 20 Oct 2026 22:29:00 +0100 Subject: [PATCH 2/2] fix(docx): name only what Word can split inside a panel, and shapes too A block moved whole inside a panel was named even where Word cannot split it: one line, one picture, a drawing, a row that may not split. It is now named only when it wrote at least two pieces Word may set on different pages, counted after it is written; a painted section kept together, whose own row may not split, is one such piece. A shape container the layout moves whole is named as a stack is, and a painted section kept together and with the next is named as kept with the next, which Word may leave behind. --- CHANGELOG.md | 12 +- docs/recipes/docx-export.md | 7 +- .../semantic/docx/DocxSemanticBackend.java | 138 ++++++++++++++---- .../docx/DocxPanelKeepReportTest.java | 85 +++++++++-- 4 files changed, 195 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55d0f5aed..99c2571ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,19 @@ follow semantic versioning; release dates are ISO 8601. ### Public API - **A DOCX export names a block Word may break inside a panel the layout moves it down in.** The - layout moves a block kept together, a layer stack, or a block kept with the next to the next - page as the page requires. Inside a panel the layout runs on there, Word and LibreOffice break - the panel's row where its lines run out and hold no keeps across that break. + layout moves a block kept together, a layer stack or a shape container whole, or a block kept + with the next with it, to the next page as the page requires. Inside a panel the layout runs on + there, Word and LibreOffice break the panel's row where its lines run out and hold no keeps + across that break. - Measured in both on Windows, inside a filled section, with nothing named: - a stack of three lines, or a section kept together, moved whole to the next page, stood with two of its lines at the foot of the page above; - a heading kept with the line under it stood there too, the line on the next page. - The report now names such a block (`APPROXIMATED`), including one opening a panel inside the panel, as the outer row breaks before it. It is named once, not again for what it holds. - - Measured as moving whole, as the page sets them, and not named: a filled section kept - together, whose own row may not split, and a row holding a card. + - A block moved whole that Word cannot split is not named: one line, one picture, a drawing, or + a row that may not split. A painted section kept together and a row holding a card were + measured moving whole, as the page sets them. One kept with the next is named all the same. - None of the corpus's 62 DOCX changes, and none of its 1246 notes. - **A DOCX export keeps a stack of one layer on the page the layout puts it on.** The layout moves a layer stack to a new page whole, but Word was told nothing and broke between its paragraphs. diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 21e09b9dc..a4251066c 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -594,9 +594,10 @@ How it lands: kept together, that the layout moved whole to the next page inside a panel stood with two of its lines at the foot of the page above, in both, and a heading kept with the line under it stood there with that line on the next page). The report names a block kept together, a layer - stack or a block kept with the next that the layout moves to a new page inside a panel that - runs on there. A filled section kept together, whose own row may not split, and a row in the - panel move whole, as the page sets them, and are not named. + stack, a shape container or a block kept with the next that the layout moves to a new page + inside a panel that runs on there. One moved whole that Word cannot split — one line, one + picture, a drawing, a row that may not split — is not named: a painted section kept together, + whose own row may not split, and a row in the panel move whole, as the page sets them. - **Page breaks.** Word breaks no page inside a table cell, so a page break among a panel's children closes the panel there and opens it again after the break. - **Measured.** Cards stacked in a row's columns, panels nested in its table, stand where the 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 01bbdb07a..02a0f3315 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 @@ -1101,6 +1101,7 @@ private byte[] write(List sections, Path outputFile) throws Exc overTheFlowDepth = 0; oneLayerDepth = 0; layerOffsetNotWritten = 0; + panelLastEndPage = -1; roomHeldAround = 0; openOverlays.clear(); // An earlier export's lowerings are its own document's. @@ -3595,10 +3596,10 @@ private void writePlacedNode(XWPFDocument document, DocumentNode node) throws Ex // In a cell its paragraphs are its table's, which a row keeps or breaks on its own terms. boolean keepTogether = (node.keepTogether() || currentCell == null && isOneLayer(node)) && layout.onOnePage(node); - reportAKeepThePanelBreaks(node); + PanelBreak panelBreak = keepThePanelMayBreak(node); boolean keepWithNext = node.keepWithNext() && layout.onOnePage(node); String anchor = blockAnchorOf(node, inTheFlow(node, overlayDepth, oneLayerDepth)); - if (!keepTogether && !keepWithNext && anchor == null) { + if (!keepTogether && !keepWithNext && anchor == null && panelBreak == null) { writeNodeContent(document, node); return; } @@ -3623,39 +3624,118 @@ private void writePlacedNode(XWPFDocument document, DocumentNode node) throws Ex if (anchor != null) { bookmarkAround(written, anchor); } + if (panelBreak != null) { + reportAKeepThePanelBreaks(node, panelBreak, written); + } + } + + /** How a block the layout moves to a new page inside a panel may be broken there in Word. */ + private enum PanelBreak { + /** Moved whole, which Word may split over the panel's row break. */ + WHOLE, + /** Moved with the block after it, which Word may leave behind. */ + WITH_NEXT } /** - * Names a block the layout moves to a new page inside a panel that runs on there, its row - * breaking with it: a block kept together, a layer stack, or a block kept with the next. + * How Word may break a block the layout moves to a new page inside a panel that runs on + * there, its row breaking with it, or {@code null} where it moves as the page moves it. * *

Word breaks a table's row where its lines run out and holds no keeps across that break: * told to, it still set the first two lines of a stack of three, moved whole to the next page * inside a panel, at the foot of the page above, and a heading kept with the line under it - * there with that line on the next page, in Word and LibreOffice alike (measured). What Word - * keeps whole moves whole: a panel kept together — its own row may not split — and a row in - * the panel's cell. A panel whose row Word keeps whole is on one page, and nothing in it opens - * another. A panel inside a panel is in the outer one's row, which breaks as well: a block - * opening it on a new page is named too, once, not again for what it holds.

- */ - private void reportAKeepThePanelBreaks(DocumentNode node) { - boolean whole = node.keepTogether() || node instanceof com.demcha.compose.document.node.LayerStackNode; - if (panelCell == null || currentCell != panelCell || panelLastEndPage < 0 - || !(whole || node.keepWithNext()) || !layout.onOnePage(node) - || node.keepTogether() && !paintOf(node).isEmpty()) { - return; + * there with that line on the next page, in Word and LibreOffice alike (measured). So a block + * the layout keeps whole — kept together, a layer stack, a shape container — may be split, and + * one kept with the next left behind. What Word cannot split moves whole, and is not named + * ({@link #reportAKeepThePanelBreaks}): a painted section kept together, its own row one that + * may not split, as measured. A row in the panel's cell is the row's. A panel whose row Word + * keeps whole is on one page, and nothing in it opens another. A panel inside a panel is in + * the outer one's row, which breaks as well: a block opening it on a new page is a candidate + * too.

+ * + *

A candidate stops what it holds from being one again: the block is named once.

+ */ + private PanelBreak keepThePanelMayBreak(DocumentNode node) { + if (panelCell == null || currentCell != panelCell || panelLastEndPage < 0 || !layout.onOnePage(node)) { + return null; + } + boolean whole = node.keepTogether() + || node instanceof com.demcha.compose.document.node.LayerStackNode + || node instanceof ShapeContainerNode; + if (!whole && !node.keepWithNext()) { + return null; } com.demcha.compose.document.layout.PlacedNode placed = layout.placement(node); - if (placed != null && placed.startPage() > panelLastEndPage) { - report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node), whole - ? "the layout moves it to the next page whole, inside a panel that runs on there; Word breaks " - + "the panel's row where its lines run out and holds no keeps across that break, so it may set " - + "its first lines at the foot of the page above" - : "the layout moves it to the next page with the block after it, inside a panel that runs on " - + "there; Word breaks the panel's row where its lines run out and holds no keeps across that " - + "break, so it may leave it at the foot of the page above"); - panelLastEndPage = Math.max(panelLastEndPage, placed.startPage()); + if (placed == null || placed.startPage() <= panelLastEndPage) { + return null; + } + panelLastEndPage = placed.startPage(); + return whole ? PanelBreak.WHOLE : PanelBreak.WITH_NEXT; + } + + /** + * Names a block Word may break inside a panel ({@link #keepThePanelMayBreak}). One moved whole + * is named where it wrote more than one piece Word may set on another page — a paragraph, or a + * row that may split, by its tallest cell — and not where it is one line, one picture, a row + * that may not split, or nothing but a drawing: Word cannot split those. Whether Word keeps + * the lines of a single paragraph there, told to, is not measured, and one is not named. + */ + private void reportAKeepThePanelBreaks(DocumentNode node, PanelBreak panelBreak, List written) { + if (panelBreak == PanelBreak.WHOLE && piecesWordMaySplit(written) < 2) { + if (!node.keepWithNext()) { + return; + } + // Whole all the same, and kept with the next: Word may still leave it behind. + panelBreak = PanelBreak.WITH_NEXT; + } + report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node), + panelBreak == PanelBreak.WHOLE + ? "the layout moves it to the next page whole, inside a panel that runs on there; Word breaks " + + "the panel's row where its lines run out and holds no keeps across that break, so it may " + + "set its first lines at the foot of the page above" + : "the layout moves it to the next page with the block after it, inside a panel that runs on " + + "there; Word breaks the panel's row where its lines run out and holds no keeps across that " + + "break, so it may leave it at the foot of the page above"); + } + + /** + * How many pieces of what a block wrote Word may set on different pages: a paragraph that + * shows something each — not the empty one closing a table in a cell, or a spacer's — a row + * that may not split one, and a row that may as many as its tallest cell holds paragraphs. + */ + private static int piecesWordMaySplit(List written) { + int pieces = 0; + for (IBodyElement element : written) { + if (element instanceof XWPFParagraph paragraph) { + if (showsSomething(paragraph)) { + pieces++; + } + } else if (element instanceof XWPFTable table) { + for (XWPFTableRow row : table.getRows()) { + int tallest = 1; + if (!row.isCantSplitRow()) { + for (XWPFTableCell cell : row.getTableCells()) { + tallest = Math.max(tallest, cell.getParagraphs().size()); + } + } + pieces += tallest; + } + } } + return pieces; + } + + /** Whether a paragraph shows text, or a picture or a drawing in one of its runs. */ + private static boolean showsSomething(XWPFParagraph paragraph) { + if (!paragraph.getText().isBlank()) { + return true; + } + for (XWPFRun run : paragraph.getRuns()) { + if (run.getCTR().sizeOfDrawingArray() > 0 || !run.getEmbeddedPictures().isEmpty()) { + return true; + } + } + return false; } /** @@ -6973,9 +7053,11 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container boolean outerGives = panelsPaddingGives; long outerRaised = panelRaisedFromItsPadding; int outerLastEndPage = panelLastEndPage; - if (currentCell != outerPanelCell || table.getRow(0).isCantSplitRow()) { - // In a row's cell, or a column's, the panel moves with its row, and a panel kept - // together moves whole: what opens its page is theirs, not the outer panel's. + if (currentCell != outerPanelCell) { + // In any other cell than the outer panel's what opens its page is that cell's — a + // row's moves whole, and a column's stack is named as one — not the outer panel's. A + // panel kept together straight in the outer one is a candidate itself, which stops + // what it holds from being one (keepThePanelMayBreak). panelLastEndPage = -1; } panelsFirstLineKeeps = Math.max(0, toTwips(topNotTaken) - cellMargin(cell, true)); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelKeepReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelKeepReportTest.java index 893000de0..299ab0301 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelKeepReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelKeepReportTest.java @@ -1,9 +1,13 @@ package com.demcha.compose.document.backend.semantic.docx; +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.LayerStackBuilder; import com.demcha.compose.document.dsl.PageFlowBuilder; import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.RowBuilder; import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; import org.junit.jupiter.api.Test; @@ -59,6 +63,13 @@ void aBandMovedThereIsNamedToo() throws Exception { .singleElement().satisfies(note -> assertThat(note.path()).contains("Band")); } + @Test + void aShapeContainerMovedThereIsNamedToo() throws Exception { + assertThat(notes(inAPanel(10, panel -> panel.add(new ShapeContainerBuilder().name("Shape") + .rectangle(200, 45).fillColor(DocumentColor.rgb(250, 240, 230)).topLeft(threeLines()).build())))) + .singleElement().satisfies(note -> assertThat(note.path()).contains("Shape")); + } + @Test void aBlockKeptWithTheNextMovedThereIsNamed() throws Exception { // Ten lines leave 24pt: the heading and the line under it, 26pt, move down together. @@ -70,6 +81,17 @@ void aBlockKeptWithTheNextMovedThereIsNamed() throws Exception { }); } + @Test + void aPaintedPanelKeptTogetherAndWithTheNextIsNamedAsKeptWithTheNext() throws Exception { + // Its own row may not split, but Word may leave it at the foot of the page above all the same. + assertThat(notes(inAPanel(10, panel -> panel.addSection("Title", title -> title.keepTogether().keepWithNext() + .fillColor(DocumentColor.rgb(250, 240, 230)).addParagraph("Opening")).addParagraph("Body")))) + .singleElement().satisfies(note -> { + assertThat(note.path()).contains("Title"); + assertThat(note.detail()).startsWith(WITH_NEXT); + }); + } + @Test void aBlockOpeningAPanelInsideThePanelIsNamedOnce() throws Exception { // The inner panel is in the outer one's row, which breaks before the stack too; the stack @@ -84,37 +106,68 @@ void aBlockOpeningAPanelInsideThePanelIsNamedOnce() throws Exception { @Test void aPanelKeptTogetherInsideThePanelIsNot() throws Exception { // Its own row may not split: Word moves it whole, a stack in it with it. - assertThat(notes(inAPanel(10, panel -> panel.addSection(kept -> kept.keepTogether() + Consumer content = inAPanel(10, panel -> panel.addSection("Kept", kept -> kept.keepTogether() .fillColor(DocumentColor.rgb(250, 240, 230)) - .addLayerStack(stack -> stack.topLeft(threeLines())))))).isEmpty(); + .addLayerStack(stack -> stack.topLeft(threeLines())))); + assertThat(startPage(content, "Kept")).as("the premise: it moves to the second page").isEqualTo(1); + assertThat(notes(content)).isEmpty(); } @Test void aCardInARowInsideThePanelIsNot() throws Exception { // The row may not split, and moves with the card in it. - assertThat(notes(inAPanel(10, panel -> panel.addRow(row -> row + Consumer content = inAPanel(10, panel -> panel.addRow("Cards", row -> row .add(new SectionBuilder().fillColor(DocumentColor.rgb(250, 240, 230)) .addLayerStack(stack -> stack.topLeft(threeLines())).build()) - .addParagraph("Beside"))))).isEmpty(); + .addParagraph("Beside"))); + assertThat(startPage(content, "Cards")).as("the premise: it moves to the second page").isEqualTo(1); + assertThat(notes(content)).isEmpty(); } @Test void aStackInARowsCellInsideThePanelIsNot() throws Exception { - assertThat(notes(inAPanel(10, panel -> panel.addRow(row -> row + Consumer content = inAPanel(10, panel -> panel.addRow("Cards", row -> row .add(new LayerStackBuilder().topLeft(threeLines()).build()) - .addParagraph("Beside"))))).isEmpty(); + .addParagraph("Beside"))); + assertThat(startPage(content, "Cards")).as("the premise: it moves to the second page").isEqualTo(1); + assertThat(notes(content)).isEmpty(); + } + + @Test + void aStackHoldingARowIsNot() throws Exception { + // One row that may not split is one piece, which Word moves whole. + Consumer content = inAPanel(10, panel -> panel.addLayerStack(stack -> stack.name("Entry") + .topLeft(new RowBuilder().addParagraph("Icon") + .addSection(lines -> lines.addParagraph("Label").addParagraph("More").addParagraph("Most")) + .build()))); + assertThat(startPage(content, "Entry")).as("the premise: it moves to the second page").isEqualTo(1); + assertThat(notes(content)).isEmpty(); + } + + @Test + void aSingleLineKeptTogetherIsNot() throws Exception { + // Ten lines and a bit leave less than a line: the one line moves, and Word cannot split it. + Consumer content = inAPanel(11, panel -> panel.addSection("Kept", kept -> kept.keepTogether() + .addParagraph("Opening"))); + assertThat(startPage(content, "Kept")).as("the premise: it moves to the second page").isEqualTo(1); + assertThat(notes(content)).isEmpty(); } @Test void oneOnThePanelsFirstPageIsNot() throws Exception { - assertThat(notes(inAPanel(2, panel -> panel.addLayerStack(stack -> stack.topLeft(threeLines()))))).isEmpty(); + Consumer content = inAPanel(2, panel -> panel.addLayerStack(stack -> stack.name("Entry") + .topLeft(threeLines()))); + assertThat(startPage(content, "Entry")).as("the premise: it stays on the first page").isEqualTo(0); + assertThat(notes(content)).isEmpty(); } @Test void oneOpeningThePanelsPieceAfterABreakIsNot() throws Exception { // The break closes the panel there and opens it again on the next page. - assertThat(notes(inAPanel(2, panel -> panel.addPageBreak(pageBreak -> { }) - .addLayerStack(stack -> stack.topLeft(threeLines()))))).isEmpty(); + Consumer content = inAPanel(2, panel -> panel.addPageBreak(pageBreak -> { }) + .addLayerStack(stack -> stack.name("Entry").topLeft(threeLines()))); + assertThat(startPage(content, "Entry")).as("the premise: it opens the second page").isEqualTo(1); + assertThat(notes(content)).isEmpty(); } @Test @@ -123,8 +176,9 @@ void oneOpeningTheNextPanelIsNot() throws Exception { Consumer content = page -> { inAPanel(9, panel -> { }).accept(page); page.addSection(next -> next.fillColor(DocumentColor.rgb(230, 236, 245)).padding(DocumentInsets.of(6)) - .addLayerStack(stack -> stack.topLeft(threeLines()))); + .addLayerStack(stack -> stack.name("Entry").topLeft(threeLines()))); }; + assertThat(startPage(content, "Entry")).as("the premise: it opens the second page").isEqualTo(1); assertThat(notes(content)).isEmpty(); } @@ -135,8 +189,9 @@ void oneInTheFlowIsNot() throws Exception { for (int i = 0; i < 10; i++) { page.addParagraph("Filler line " + i); } - page.addLayerStack(stack -> stack.topLeft(threeLines())); + page.addLayerStack(stack -> stack.name("Entry").topLeft(threeLines())); }; + assertThat(startPage(content, "Entry")).as("the premise: it moves to the second page").isEqualTo(1); assertThat(notes(content)).isEmpty(); } @@ -159,6 +214,14 @@ private static com.demcha.compose.document.node.DocumentNode threeLines() { return new SectionBuilder().addParagraph("Opening").addParagraph("Body").addParagraph("Tail").build(); } + private static int startPage(Consumer content, String name) { + try (DocumentSession session = GraphCompose.document().pageSize(300, 200).margin(DocumentInsets.of(20)).create()) { + session.pageFlow(content::accept); + return session.layoutGraph().nodes().stream() + .filter(node -> name.equals(node.semanticName())).findFirst().orElseThrow().startPage(); + } + } + private static List notes(Consumer content) throws Exception { return DocxExports.report(300, 200, 20, content).notes().stream() .filter(note -> note.detail().startsWith(WHOLE) || note.detail().startsWith(WITH_NEXT))