diff --git a/CHANGELOG.md b/CHANGELOG.md index 03c43e3bf..99c2571ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ 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 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. + - 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. 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..a4251066c 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -588,8 +588,16 @@ 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, 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 86e0293f3..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 @@ -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; @@ -1097,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. @@ -3578,6 +3583,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,9 +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); + 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; } @@ -3615,6 +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 + } + + /** + * 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). 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) { + 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; } /** @@ -6931,6 +7052,14 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container long outerMarginKept = panelsMarginKeeps; boolean outerGives = panelsPaddingGives; long outerRaised = panelRaisedFromItsPadding; + int outerLastEndPage = panelLastEndPage; + 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)); // 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 +7078,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..299ab0301 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelKeepReportTest.java @@ -0,0 +1,230 @@ +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; + +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:

+ *
    + *
  • a stack of three lines, or a section of three kept together, moved whole to the next page + * stood with two of its lines at the foot of the page above in both, its paragraphs told to + * keep with the next or not;
  • + *
  • a heading kept with the line under it, moved down with it, stood at the foot of the page + * above, the line on the next page;
  • + *
  • a filled section kept together, and a row holding a card, moved there whole, as the page + * sets them.
  • + *
+ * + * @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 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. + 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 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 + // 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. + Consumer content = inAPanel(10, panel -> panel.addSection("Kept", kept -> kept.keepTogether() + .fillColor(DocumentColor.rgb(250, 240, 230)) + .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. + 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"))); + assertThat(startPage(content, "Cards")).as("the premise: it moves to the second page").isEqualTo(1); + assertThat(notes(content)).isEmpty(); + } + + @Test + void aStackInARowsCellInsideThePanelIsNot() throws Exception { + Consumer content = inAPanel(10, panel -> panel.addRow("Cards", row -> row + .add(new LayerStackBuilder().topLeft(threeLines()).build()) + .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 { + 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. + 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 + 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.name("Entry").topLeft(threeLines()))); + }; + assertThat(startPage(content, "Entry")).as("the premise: it opens the second page").isEqualTo(1); + 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.name("Entry").topLeft(threeLines())); + }; + assertThat(startPage(content, "Entry")).as("the premise: it moves to the second page").isEqualTo(1); + 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 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)) + .toList(); + } +}