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();
+ }
+}