From ef1ab420c614db39084d5b779bbf0694521ab8f7 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sun, 25 Oct 2026 11:48:29 +0000 Subject: [PATCH] fix(docx): cut a panel at a page break in a section or panel inside it - DocxPanelPieces cuts a container's children at page breaks among them and, at any depth, among those of the sections and panels its cell writes them in; Word and LibreOffice break no page inside a table cell. - writePanel cuts a panel in the flow by those pieces, a panel inside it written in the part each table holds; writeContainerBody writes a section's part, its edges where it starts and ends; what follows a break stands its container's spacing below the page's top. - A table after the first has no top padding, as the page sets the text there; a cut container's anchor is marked, and its notes said, once. - Tests: a section and a card inside a card cut there; spacing and edges at the cut; one bookmark and one note; nothing written twice; a card in a row's column not cut. --- CHANGELOG.md | 18 +++ docs/recipes/docx-export.md | 12 +- .../semantic/docx/DocxPanelPieces.java | 108 ++++++++++++++ .../semantic/docx/DocxSemanticBackend.java | 126 ++++++++++++++--- .../semantic/docx/DocxPanelEdgeCasesTest.java | 132 ++++++++++++++++++ .../semantic/docx/DocxPanelPiecesTest.java | 105 ++++++++++++++ 6 files changed, 476 insertions(+), 25 deletions(-) create mode 100644 render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelPieces.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelPiecesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b4de5c52a..c6e58c812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export breaks a page inside a section or a panel of a panel where the page does.** + - Word breaks no page inside a table cell (measured in Word and LibreOffice 24.2: a run's page + break and a paragraph's break before it, in a table of the body or one nested in a cell, left + what follows on the same page). A panel was cut into two tables at a page break among its own + children only; one in a section or a panel inside it was written after the panel's table, so + the panel's text after the break stood on the page before, in the panel, and the page broke + after it. + - The panel's table is now cut at a page break among the children of any section or panel its + cell writes them in, at any depth, each table holding its part of every one the break runs + through: a section's top edge where it starts and its bottom edge where it ends, a panel's top + padding in its first table. A cut section's anchor marks where it starts, and its notes are + said once. + - The page goes on with a panel a break runs through at the top of the next page, with no + padding above its text there, its own or that of any panel round it: a table after the first + has none now, where it had the panel's top padding, the text 8pt lower in Word for a card + padded 8pt. What follows a break stands its container's spacing below that top. + - No document of the corpus has a page break inside a panel; its export is unchanged. + - **A DOCX export's lines of runs of several depths grow with their text where every line is as deep as the deepest.** - A paragraph whose runs reach to different depths below the baseline — a bold title beside diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 042659225..daac8e70c 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -715,8 +715,16 @@ How it lands: 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. +- **Page breaks.** Word breaks no page inside a table cell (measured in Word and LibreOffice + 24.2: a run's page break and a paragraph's break before it, in a table of the body or one + nested in a cell, left what follows on the same page), so a page break among a panel's + children, or among those of a section or a panel inside it, at any depth, closes the panel + there and opens it again after the break, each table holding its part of every section and + panel the break runs through. The page goes on with them at the top of the next page, with + no padding above, its own or theirs, so the tables after the first have none; what follows + the break stands the spacing of the container it is in below that top, as the page sets it. + A break in a panel composed in a table cell, a row's column or a stack's column is not cut, + nor one in an alignment, an anchor's wrapper or a stack of one layer inside a panel. - **Measured.** Cards stacked in a row's columns, panels nested in its table, and a card set in from a filled panel's text stand where the page sets them in Word 16 and LibreOffice on Windows, as far as the left edge and the right end above say. diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelPieces.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelPieces.java new file mode 100644 index 000000000..ffb11d6f5 --- /dev/null +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelPieces.java @@ -0,0 +1,108 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.PageBreakNode; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +/** + * Where a panel the page breaks over pages is cut into tables: at each page break among its + * children, and among the children of each section or panel its cell writes them in, at any depth. + * + *

Word breaks no page inside a table cell: measured in Word and LibreOffice 24.2, a page break + * in a cell — a run's break or a paragraph's break before it, in a table of the body or one + * nested in a cell — left the text after it on the same page. The page breaks a card a break runs + * through, the card going on at the top of the next page, as a section or a card inside it does. + * So the panel's table ends at the break and a new one opens after it, on the next page, each + * holding its part of every container the break runs through.

+ * + * @author Artem Demchyshyn + */ +final class DocxPanelPieces { + + private DocxPanelPieces() { + } + + /** + * What one piece of a panel holds of one of a container's children: the child whole, or the + * run of its own children from one break to the next ({@code parts}), the child opening in + * that piece where the run starts its children and closing where it ends them. + * + * @param node the child + * @param parts what the piece holds of the child's children, or null where it holds the child + * whole + * @param opens whether the piece holds the child's start: its top edge + * @param closes whether the piece holds the child's end: its bottom edge + */ + record Part(DocumentNode node, List parts, boolean opens, boolean closes) { + + static Part whole(DocumentNode node) { + return new Part(node, null, true, true); + } + + boolean isWhole() { + return parts == null; + } + } + + /** + * A container's children cut at the page breaks among them and among the children of those + * of them its cell writes them in, at any depth. + * + * @param container the container + * @param writtenInItsCell whether a child is a container its parent's cell writes in it, its own + * children after one another: a section or a panel there + * @return the pieces, one more than the breaks found, each the parts it holds in order; a + * piece is empty where a break ends or opens the container + */ + static List> of(DocumentNode container, Predicate writtenInItsCell) { + List> pieces = new ArrayList<>(); + pieces.add(new ArrayList<>()); + for (DocumentNode child : container.children()) { + if (child instanceof PageBreakNode) { + pieces.add(new ArrayList<>()); + continue; + } + List> inner = writtenInItsCell.test(child) ? of(child, writtenInItsCell) : List.of(); + if (inner.size() < 2) { + pieces.get(pieces.size() - 1).add(Part.whole(child)); + continue; + } + for (int index = 0; index < inner.size(); index++) { + if (index > 0) { + pieces.add(new ArrayList<>()); + } + pieces.get(pieces.size() - 1).add(new Part(child, inner.get(index), index == 0, + index == inner.size() - 1)); + } + } + List> written = new ArrayList<>(pieces.size()); + for (List piece : pieces) { + written.add(List.copyOf(piece)); + } + return List.copyOf(written); + } + + /** The children a run of parts holds, whole or in part, in order. */ + static List nodesOf(List parts) { + List nodes = new ArrayList<>(parts.size()); + for (Part part : parts) { + nodes.add(part.node()); + } + return nodes; + } + + /** + * Whether the first part of a piece after the first follows a page break among the container's + * own children, so that the container's spacing stands above it: a part holding a child's + * start, not one going on from a break inside that child. + * + * @param piece the piece's parts + * @param firstPiece whether the piece is the container's first + */ + static boolean followsABreak(List piece, boolean firstPiece) { + return !firstPiece && !piece.isEmpty() && piece.get(0).opens(); + } +} 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 98699b17d..ef04f65b8 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 @@ -432,6 +432,9 @@ public final class DocxSemanticBackend implements SemanticBackend { // How tall Word stands each written paragraph's line, and where its text stands in it, by the // faces the export ships (DocxGrownLines). private DocxGrownLines grownLines = DocxGrownLines.NONE; + // The part a panel's table being written holds of each container a page break cuts it at + // (DocxPanelPieces), by node, taken by the container's writer. + private final java.util.Map panelWindows = new java.util.IdentityHashMap<>(); // Where the shapes this export draws are anchored — see DocxDrawingAnchors. private final DocxDrawingAnchors anchors = new DocxDrawingAnchors(() -> nextDrawingId++, paragraph -> grownLines.lineOf(paragraph)); @@ -1220,6 +1223,7 @@ private byte[] write(List sections, Path outputFile) throws Exc markdownWritten.clear(); fittedWritten.clear(); listNumbering.clear(); + panelWindows.clear(); measuredColumns.clear(); prefixColumns.clear(); report = new DocxExportReport.Builder(); @@ -3740,7 +3744,11 @@ private void writePlacedNode(XWPFDocument document, DocumentNode node) throws Ex && layout.onOnePage(node); PanelBreak panelBreak = keepThePanelMayBreak(node); boolean keepWithNext = node.keepWithNext() && layout.onOnePage(node); - String anchor = blockAnchorOf(node, inTheFlow(node, overlayDepth, oneLayerDepth)); + // A container a page break cuts is written once a table of its panel, its anchor marked + // where it starts: a bookmark round the part after the break would be the one kept. + DocxPanelPieces.Part part = panelWindows.get(node); + String anchor = part != null && !part.opens() ? null + : blockAnchorOf(node, inTheFlow(node, overlayDepth, oneLayerDepth)); if (!keepTogether && !keepWithNext && anchor == null && panelBreak == null) { writeNodeContent(document, node); return; @@ -6996,12 +7004,20 @@ && known && holdsWrappedText(node) alsoLost.add("its bleed is not in the file, so its fill and borders stop at its box, not at " + "the page's edge"); } - reportWrittenWithout(node, paint.isEmpty() ? "written as its contents" : "written as a panel", alsoLost); + // A container a page break cuts is written once a table of its panel (DocxPanelPieces), and + // named once, where it starts. + DocxPanelPieces.Part part = panelWindows.get(node); + boolean itsStart = part == null || part.opens(); + if (itsStart) { + reportWrittenWithout(node, paint.isEmpty() ? "written as its contents" : "written as a panel", alsoLost); + } if (paint.isEmpty()) { writeContainerBody(document, node); return; } - warnContainerRadiusDropped(node); + if (itsStart) { + warnContainerRadiusDropped(node); + } writePanel(document, node, paint); } @@ -7035,20 +7051,22 @@ && known && holdsWrappedText(node) * margins are the space around the table, owed like any block's. A container kept * together that the layout held on one page is a row that may not break.

* - *

Word breaks no page inside a table cell, so a page break among the panel's children - * closes the panel there and opens it again after the break, on the next page — the way - * the page draws a card a break runs through. Inside a cell there is no page to break, - * and the panel is written whole.

+ *

Word breaks no page inside a table cell, so a page break among the panel's children, or + * among those of a section or panel its cell writes them in, at any depth, closes the panel + * there and opens it again after the break, on the next page — the way the page draws a card a + * break runs through ({@link DocxPanelPieces}). A panel inside one so cut is written in the + * piece of it each table holds. Inside a cell the break is not cut otherwise, and the panel is + * written whole.

*/ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint paint) throws Exception { - List> pieces = new ArrayList<>(); - pieces.add(new ArrayList<>()); - for (DocumentNode child : node.children()) { - if (child instanceof PageBreakNode && currentCell == null) { - pieces.add(new ArrayList<>()); - } else { - pieces.get(pieces.size() - 1).add(child); - } + DocxPanelPieces.Part window = panelWindows.remove(node); + List> pieces; + if (window != null) { + pieces = List.of(window.parts()); + } else if (currentCell == null) { + pieces = DocxPanelPieces.of(node, DocxSemanticBackend::writtenInItsCell); + } else { + pieces = List.of(node.children().stream().map(DocxPanelPieces.Part::whole).toList()); } for (int index = 0; index < pieces.size(); index++) { boolean onANewPage = false; @@ -7066,11 +7084,54 @@ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint } onANewPage = true; } - List piece = pieces.get(index); + List piece = pieces.get(index); ShapeContainerNode shape = node instanceof ShapeContainerNode container ? container : null; - writePanelPiece(document, node, paint, () -> writeChildren(document, piece, spacingOf(node), false, shape), - index == 0, index == pieces.size() - 1, onANewPage); + boolean first = window == null ? index == 0 : window.opens(); + boolean last = window == null ? index == pieces.size() - 1 : window.closes(); + writePanelPiece(document, node, paint, () -> writeParts(document, piece, spacingOf(node), false, shape, + DocxPanelPieces.followsABreak(piece, first)), first, last, onANewPage); + } + } + + /** + * Writes what a piece of a panel holds of a container's children ({@link DocxPanelPieces}), + * each one cut by a break written in the part of it the piece holds ({@link #panelWindows}). + * + * @param lastFollowed whether something is written after them in the flow, as + * {@link #writeChildren(XWPFDocument, List, double, boolean)} + * @param afterABreak whether the first of them follows a page break among the container's own + * children, the container's spacing above it + */ + private void writeParts(XWPFDocument document, List parts, double spacing, + boolean lastFollowed, ShapeContainerNode shape, boolean afterABreak) throws Exception { + for (DocxPanelPieces.Part part : parts) { + if (!part.isWhole()) { + panelWindows.put(part.node(), part); + } } + if (afterABreak && spacing > 0) { + // The page sets what follows a break the container's spacing below the page's top. + owePendingSpacingAfter(spacing); + } + try { + writeChildren(document, DocxPanelPieces.nodesOf(parts), spacing, lastFollowed, shape); + } finally { + // A part not written through its container's writer, one an earlier layer wrote in + // its place (movedIntoAStandIn), leaves no window for anything after it. + for (DocxPanelPieces.Part part : parts) { + panelWindows.remove(part.node()); + } + } + } + + /** + * Whether a panel's cell writes a node in it as its children after one another, its own top + * and bottom edges around them: a section or a container, painted or not — the containers a + * page break inside is cut through ({@link DocxPanelPieces}). An alignment, an anchor's + * wrapper and a stack of one layer, written so too, are not cut through. + */ + private static boolean writtenInItsCell(DocumentNode node) { + return node instanceof SectionNode || node instanceof ContainerNode; } /** @@ -7554,7 +7615,11 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container // Below zero — a padding thinner than what of the half it does not keep — written as none // (setCellMargin). double marginLeft = inTheBody ? padding.left() : padding.left() - halfLeft + setIn; - applyCellPadding(cell, new DocumentInsets(margins.top(), Math.max(0, marginHeld - slackTaken), + // The page goes on with a panel a break runs through at the top of the next page, its text + // there with no padding above it, its own or any panel's round it (measured: a card's and a + // card's in it, padded 8 and 6pt, set their text at the page's top edge after the break): + // a piece after the first has none. + applyCellPadding(cell, new DocumentInsets(first ? margins.top() : 0, Math.max(0, marginHeld - slackTaken), margins.bottom(), marginLeft)); // A row is laid out as one piece, never across a page break (writeRow): so is its panel. if ((node.keepTogether() || node instanceof RowNode) && layout.onOnePage(node)) { @@ -8136,10 +8201,17 @@ private static double spacingOf(DocumentNode node) { } private void writeContainerBody(XWPFDocument document, DocumentNode node) throws Exception { + // Cut by a page break its panel's tables are cut at, it writes the part of it this table + // holds, and its top and bottom edges where that part holds them (DocxPanelPieces). + DocxPanelPieces.Part window = panelWindows.remove(node); + boolean opens = window == null || window.opens(); + boolean closes = window == null || window.closes(); double carriedFromOutside = carriedSpacingBefore; long blocksBefore = blocksWritten; - carriedSpacingBefore += node.margin().top() + node.padding().top(); - riseIntoItsLine(node); + if (opens) { + carriedSpacingBefore += node.margin().top() + node.padding().top(); + riseIntoItsLine(node); + } // The sides are carried the same way, as the indent of every paragraph inside: a // container's content starts inside its margin and its padding on the page, and was // written flush with the page margin, the card's text touching the card's edge. @@ -8164,14 +8236,22 @@ private void writeContainerBody(XWPFDocument document, DocumentNode node) throws if (isOneLayer(node)) { layerOffsetNotWritten += layerOffsetDown(node); } + boolean followed = followedInFlow.remove(node); try { - writeChildren(document, node.children(), spacingOf(node), followedInFlow.remove(node)); + if (window == null) { + writeChildren(document, node.children(), spacingOf(node), followed); + } else { + writeParts(document, window.parts(), spacingOf(node), closes && followed, null, + DocxPanelPieces.followsABreak(window.parts(), opens)); + } } finally { insetLeft = outerLeft; insetRight = outerRight; layerOffsetNotWritten = outerOffset; } - owePendingSpacingAfter(node.margin().bottom() + node.padding().bottom()); + if (closes) { + owePendingSpacingAfter(node.margin().bottom() + node.padding().bottom()); + } // A container that wrote something has had its top edge taken — by its first // paragraph, or owed above its first table (see newTable) — and none of it is left // waiting. A container that wrote nothing at all — empty, or holding nothing but a diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java index 8cc595ccd..f28a56253 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java @@ -111,9 +111,141 @@ void aPageBreakInsideACardClosesItAndOpensItAgainAfterTheBreak() throws Exceptio XWPFTableCell second = ((XWPFTable) body.get(2)).getRow(0).getCell(0); assertThat(second.getText()).isEqualTo("After the break"); assertThat(second.getCTTc().getTcPr().getShd()).as("still the card").isNotNull(); + assertThat(topMargin(second)).as("the page goes on with the card at its top, padding none").isZero(); } } + @Test + void aPageBreakInASectionInsideACardCutsTheCardThere() throws Exception { + // The page goes on with the card on the next page, its section with it: Word, which breaks + // no page inside a cell, set both parts in the card on the first page, the break after it. + try (XWPFDocument document = export(page -> page + .addSection("Card", card -> card.fillColor(SURFACE).padding(DocumentInsets.of(8)) + .addSection(in -> in.addParagraph("Part A").addPageBreak(b -> { }).addParagraph("Part B"))) + .addParagraph("After the card"))) { + List body = document.getBodyElements(); + + assertThat(body).as("two tables of the card, the break between them, and what follows").hasSize(4); + XWPFTableCell first = ((XWPFTable) body.get(0)).getRow(0).getCell(0); + XWPFTableCell second = ((XWPFTable) body.get(2)).getRow(0).getCell(0); + assertThat(first.getText()).isEqualTo("Part A"); + assertThat(((XWPFParagraph) body.get(1)).getRuns().get(0).getCTR().getBrArray(0).getType()) + .isEqualTo(STBrType.PAGE); + assertThat(second.getText()).isEqualTo("Part B"); + assertThat(((XWPFParagraph) body.get(3)).getText()).isEqualTo("After the card"); + assertThat(topMargin(first)).as("the card's padding where it starts").isEqualTo(8 * 20); + assertThat(topMargin(second)).as("none where the page goes on with it").isZero(); + } + } + + @Test + void aPageBreakInACardInsideACardCutsBoth() throws Exception { + try (XWPFDocument document = export(page -> page + .addSection("Card", card -> card.fillColor(SURFACE).padding(DocumentInsets.of(8)) + .addSection(in -> in.fillColor(DocumentColor.rgb(220, 230, 240)).padding(DocumentInsets.of(6)) + .addParagraph("Part A").addPageBreak(b -> { }).addParagraph("Part B")) + .addParagraph("Card tail")) + .addParagraph("After the card"))) { + List body = document.getBodyElements(); + + assertThat(body).hasSize(4); + XWPFTableCell first = ((XWPFTable) body.get(0)).getRow(0).getCell(0); + XWPFTableCell second = ((XWPFTable) body.get(2)).getRow(0).getCell(0); + assertThat(first.getTables()).as("the inner card's first part").hasSize(1); + assertThat(first.getTables().get(0).getRow(0).getCell(0).getText()).isEqualTo("Part A"); + assertThat(second.getTables()).as("its second, in the card's second table").hasSize(1); + XWPFTableCell innerAfter = second.getTables().get(0).getRow(0).getCell(0); + assertThat(innerAfter.getText()).isEqualTo("Part B"); + assertThat(topMargin(innerAfter)).as("none where the page goes on with it").isZero(); + assertThat(second.getParagraphs()).extracting(XWPFParagraph::getText).contains("Card tail"); + String xml = document.getDocument().xmlText(); + assertThat(xml.split("Part A", -1)).as("written once").hasSize(2); + assertThat(xml.split("Part B", -1)).as("written once").hasSize(2); + assertThat(xml.split("Card tail", -1)).as("written once").hasSize(2); + } + } + + @Test + void whatFollowsABreakInASectionStandsItsSpacingBelowThePagesTop() throws Exception { + // The section's top edge stands above its first part and its bottom edge below its last, + // not at the break. + try (XWPFDocument document = export(page -> page + .addSection("Card", card -> card.fillColor(SURFACE) + .addSection(in -> in.spacing(4).padding(DocumentInsets.of(6)).addParagraph("Part A") + .addPageBreak(b -> { }).addParagraph("Part B"))))) { + XWPFTableCell first = ((XWPFTable) document.getBodyElements().get(0)).getRow(0).getCell(0); + XWPFTableCell second = ((XWPFTable) document.getBodyElements().get(2)).getRow(0).getCell(0); + XWPFParagraph before = first.getParagraphs().get(first.getParagraphs().size() - 1); + XWPFParagraph after = second.getParagraphs().get(0); + + assertThat(after.getText()).isEqualTo("Part B"); + assertThat(DocxTwips.of(after.getCTP().getPPr().getSpacing().getBefore())) + .as("the section's spacing, as the page sets it after the break, less what the line takes " + + "of it to stand on the page's baseline") + .isBetween(4 * 20 - 10L, 4 * 20L); + assertThat(before.getText()).isEqualTo("Part A"); + var spacing = before.getCTP().getPPr().getSpacing(); + assertThat(spacing.isSetAfter() ? DocxTwips.of(spacing.getAfter()) : 0) + .as("no bottom edge of the section at the break").isLessThan(6 * 20); + } + } + + @Test + void aCutSectionsAnchorMarksWhereItStartsAndItsNotesAreSaidOnce() throws Exception { + java.util.function.Consumer content = page -> page + .addSection("Card", card -> card.fillColor(SURFACE) + .addSection(in -> in.anchor("terms").fillColor(DocumentColor.rgb(220, 230, 240)).cornerRadius(6) + .addParagraph("Part A").addPageBreak(b -> { }).addParagraph("Part B"))); + try (XWPFDocument document = export(content)) { + String xml = document.getDocument().xmlText(); + XWPFTableCell first = ((XWPFTable) document.getBodyElements().get(0)).getRow(0).getCell(0); + + assertThat(xml.split("w:name=\"terms\"", -1)).as("one bookmark").hasSize(2); + assertThat(paragraphIn(first.getTables().get(0).getRow(0).getCell(0), "Part A").getCTP() + .getBookmarkStartList()).extracting(CTBookmark::getName).containsExactly("terms"); + } + DocxExportReport report = DocxExports.report(595, 842, 36, content); + assertThat(report.notes()).filteredOn(note -> note.subject().equals("corner radius")) + .as("said once, of the panel the break cuts").hasSize(1); + } + + @Test + void aBreakOpeningASectionInACardLeavesNothingWrittenTwice() throws Exception { + try (XWPFDocument document = export(page -> page + .addSection("Card", card -> card.fillColor(SURFACE).addParagraph("Head") + .addSection(in -> in.addPageBreak(b -> { }).addParagraph("Part B") + .addPageBreak(b -> { }).addPageBreak(b -> { }).addParagraph("Part D"))) + .addParagraph("After the card"))) { + String xml = document.getDocument().xmlText(); + + for (String text : List.of("Head", "Part B", "Part D", "After the card")) { + assertThat(xml.split(text, -1)).as(text + ", written once").hasSize(2); + } + assertThat(((XWPFTable) document.getBodyElements().get(0)).getRow(0).getCell(0).getText()).isEqualTo("Head"); + } + } + + @Test + void aBreakInACardInARowsColumnIsNotCutThere() throws Exception { + // A row's columns are a table of their own, laid out as one piece: a card there is written + // whole, as it was. + try (XWPFDocument document = export(page -> page.addRow(row -> row + .addSection(card -> card.fillColor(SURFACE) + .addSection(in -> in.addParagraph("Part A").addPageBreak(b -> { }).addParagraph("Part B"))) + .addParagraph("Beside")))) { + XWPFTable rowTable = (XWPFTable) document.getBodyElements().get(0); + XWPFTableCell column = rowTable.getRow(0).getCell(0); + + assertThat(column.getTables()).as("one card").hasSize(1); + assertThat(column.getTables().get(0).getRow(0).getCell(0).getText()).contains("Part A").contains("Part B"); + } + } + + private static long topMargin(XWPFTableCell cell) { + var margins = cell.getCTTc().getTcPr().getTcMar(); + return margins == null || !margins.isSetTop() ? 0 : DocxTwips.of(margins.getTop().getW()); + } + @Test void theSpaceAboveACardWithNothingAboveItIsHeldByAHairline() throws Exception { try (XWPFDocument document = export(page -> page.addSection("Card", card -> card diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelPiecesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelPiecesTest.java new file mode 100644 index 000000000..5744a2037 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelPiecesTest.java @@ -0,0 +1,105 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.node.ContainerNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.node.SectionNode; +import com.demcha.compose.document.style.DocumentColor; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Predicate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A panel a page break runs through is cut into tables at the break, wherever among the sections + * and panels its cell writes the break is. + * + * @author Artem Demchyshyn + */ +class DocxPanelPiecesTest { + + private static final Predicate CONTAINERS = + node -> node instanceof SectionNode || node instanceof ContainerNode; + private static final DocumentColor SURFACE = DocumentColor.rgb(238, 242, 247); + + @Test + void aBreakAmongAPanelsChildrenCutsItInTwo() throws Exception { + DocumentNode card = card(page -> page.addSection(section -> section.fillColor(SURFACE) + .addParagraph("One").addPageBreak(b -> { }).addParagraph("Two"))); + + List> pieces = DocxPanelPieces.of(card, CONTAINERS); + + assertThat(pieces).hasSize(2); + assertThat(texts(pieces.get(0))).containsExactly("One"); + assertThat(texts(pieces.get(1))).containsExactly("Two"); + assertThat(pieces.get(1).get(0).isWhole()).isTrue(); + assertThat(DocxPanelPieces.followsABreak(pieces.get(1), false)).as("right after the break").isTrue(); + assertThat(DocxPanelPieces.followsABreak(pieces.get(0), true)).isFalse(); + } + + @Test + void aBreakInsideASectionOrAPanelInsideCutsThemWithIt() throws Exception { + DocumentNode card = card(page -> page.addSection(section -> section.fillColor(SURFACE) + .addParagraph("Head") + .addSection(mid -> mid.addSection(in -> in.fillColor(SURFACE) + .addParagraph("One").addPageBreak(b -> { }).addParagraph("Two"))) + .addParagraph("Tail"))); + + List> pieces = DocxPanelPieces.of(card, CONTAINERS); + + assertThat(pieces).hasSize(2); + DocxPanelPieces.Part mid = pieces.get(0).get(1); + assertThat(mid.isWhole()).isFalse(); + assertThat(mid.opens()).isTrue(); + assertThat(mid.closes()).isFalse(); + DocxPanelPieces.Part in = mid.parts().get(0); + assertThat(texts(in.parts())).containsExactly("One"); + DocxPanelPieces.Part midAfter = pieces.get(1).get(0); + assertThat(midAfter.node()).isSameAs(mid.node()); + assertThat(midAfter.opens()).isFalse(); + assertThat(midAfter.closes()).isTrue(); + assertThat(texts(midAfter.parts().get(0).parts())).containsExactly("Two"); + assertThat(pieces.get(1).get(1).node()).isInstanceOf(ParagraphNode.class); + assertThat(DocxPanelPieces.followsABreak(pieces.get(1), false)) + .as("going on from a break inside its first child, not among its own").isFalse(); + assertThat(DocxPanelPieces.followsABreak(midAfter.parts().get(0).parts(), false)).isTrue(); + } + + @Test + void aBreakInANodeItsCellDoesNotWriteAsAContainerIsNotCut() throws Exception { + DocumentNode card = card(page -> page.addSection(section -> section.fillColor(SURFACE) + .addSection(in -> in.addParagraph("One").addPageBreak(b -> { }).addParagraph("Two")))); + + List> pieces = DocxPanelPieces.of(card, node -> node == card); + + assertThat(pieces).as("its section written some other way, whole").hasSize(1); + assertThat(pieces.get(0).get(0).isWhole()).isTrue(); + assertThat(DocxPanelPieces.of(card, CONTAINERS)).as("cut through its section").hasSize(2); + } + + @Test + void aBreakEndingAPanelLeavesAnEmptyPieceAfterIt() throws Exception { + DocumentNode card = card(page -> page.addSection(section -> section.fillColor(SURFACE) + .addParagraph("One").addPageBreak(b -> { }))); + + List> pieces = DocxPanelPieces.of(card, CONTAINERS); + + assertThat(pieces).hasSize(2); + assertThat(pieces.get(1)).isEmpty(); + assertThat(DocxPanelPieces.followsABreak(pieces.get(1), false)).isFalse(); + } + + /** The first block of a page flow built by {@code content}. */ + private static DocumentNode card(java.util.function.Consumer content) + throws Exception { + return DocxExports.graphOf(content).roots().get(0).children().get(0); + } + + private static List texts(List parts) { + return parts.stream().map(DocxPanelPieces.Part::node) + .map(node -> node instanceof ParagraphNode paragraph ? paragraph.text() : node.nodeKind()) + .toList(); + } +}