rows = new HashMap<>();
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 06dc61a89..6e2a47269 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
@@ -7163,8 +7163,14 @@ private void noteATableOverPages(DocumentNode node, XWPFTable outer) {
* header missing, and over three one row stood a page early, in LibreOffice 24.2 too. Each part
* after a cut opens with copies of the table's header rows and has no top padding, and each part
* before one no bottom padding: the page sets the panel's last rows on a page down to its foot,
- * and closes the panel only on its last page. A table that cannot be split at a page's row
- * ({@link DocxTableSplit#rowStarting}) is left whole there.
+ * and closes the panel only on its last page.
+ *
+ * A page is left to Word, and named: one the panel runs onto outside its table's pages; one
+ * whose first row the table cannot be split at ({@link DocxTableSplit#rowStarting}); and one
+ * whose page above ends the panel's rows nearer its foot than the borders Word sets inside the
+ * part there take, its bottom border and, where that part opens the page, its top border and
+ * the paragraph before it, and a point for the editor's lines: Word would set the last row on
+ * a page of its own, the part after it on the next.
*
* Done once the document is written — its drawings anchored, its page references settled —
* so that a part's copy carries what the writing put in it. A panel's own section's layout
@@ -7173,21 +7179,37 @@ private void noteATableOverPages(DocumentNode node, XWPFTable outer) {
private void cutThePanelsAtTheirPages(XWPFDocument document) {
for (PanelOverPages over : panelsOverPages) {
DocxLayoutMetrics itsLayout = over.layout();
+ com.demcha.compose.document.layout.PlacedNode panel = itsLayout.placement(over.panel());
com.demcha.compose.document.layout.PlacedNode placed = itsLayout.placement(over.table());
+ DocumentBorders borders = paintOf(over.panel()).borders();
+ double top = strokeWidth(borders == null ? null : borders.top());
+ double bottom = strokeWidth(borders == null ? null : borders.bottom());
XWPFTable piece = over.outer();
XWPFTable inner = over.inner();
boolean header = DocxTableSplit.headerRows(inner) > 0;
// The table's rows the parts before the current one hold, after its header rows.
int before = 0;
List cut = new ArrayList<>();
- List left = new ArrayList<>();
- for (int page = placed.startPage() + 1; page <= placed.endPage(); page++) {
+ java.util.Map left = new java.util.TreeMap<>();
+ for (int page = panel.startPage() + 1; page <= panel.endPage(); page++) {
+ if (page <= placed.startPage() || page > placed.endPage()) {
+ left.put(page, "outside its table");
+ continue;
+ }
int headerRows = DocxTableSplit.headerRows(inner);
int skipped = before;
int row = DocxTableSplit.rowStarting(inner, page,
index -> itsLayout.rowPage(over.table(), index < headerRows ? index : index + skipped));
if (row < 0) {
- left.add(page);
+ left.put(page, "at a row its table cannot be split at");
+ continue;
+ }
+ var margin = itsLayout.marginOf(page - 1);
+ double room = itsLayout.lowestRowOn(over.table(), page - 1) - (margin == null ? 0 : margin.bottom());
+ double needed = bottom + (cut.contains(page - 1) ? top + SEPARATOR_POINTS : 0) + ROOM_FOR_THE_LINES;
+ if (!(room >= needed)) {
+ left.put(page, "the page above setting its rows too near its foot for the borders Word sets inside "
+ + "the part");
continue;
}
DocxTableSplit.PanelPart part = DocxTableSplit.splitInItsPanel(document, piece, inner, row);
@@ -7203,35 +7225,51 @@ private void cutThePanelsAtTheirPages(XWPFDocument document) {
inner = next.getRow(0).getCell(0).getTables().get(0);
cut.add(page);
}
- if (!cut.isEmpty()) {
- reportTheCut(over.panel(), itsLayout.pathOf(over.panel()), header, cut, left);
- }
+ reportTheCut(over.panel(), itsLayout.pathOf(over.panel()), header, top, cut, left);
}
}
/**
- * Names a panel cut at its pages ({@link #cutThePanelsAtTheirPages}): the pages it is cut at,
- * those it is not, its fixed page breaks, and the top border a part after a cut stands lower by.
+ * Room a part before a cut keeps under its page's last row for the editor's lines, in points,
+ * besides the borders it sets there: Word's lines stand within a few tenths of the page's by a
+ * page's foot (measured: within 0.33pt in cards padded 6 and 12pt, 0.72pt in
+ * {@code ObsidianInvoice}).
+ */
+ private static final double ROOM_FOR_THE_LINES = 1.0;
+
+ /**
+ * Names a panel the layout breaks over pages inside its table ({@link #cutThePanelsAtTheirPages}):
+ * the pages it is cut at, its fixed page breaks and the top border a part after a cut stands
+ * lower by, or that it is written whole; and each page left to Word, and why.
*
* @param header whether its table has header rows a part after a cut opens with
+ * @param top the width of its top border, in points
* @param cut the pages, from 0, it is cut at
- * @param left the pages, from 0, it runs onto whole, its table not split there
- */
- private void reportTheCut(DocumentNode panel, String path, boolean header, List cut, List left) {
- DocumentBorders borders = paintOf(panel).borders();
- double border = strokeWidth(borders == null ? null : borders.top());
- report.add(DocxExportReport.Severity.APPROXIMATED, panel.nodeKind(), path,
- "the layout breaks it over pages inside its table: written as a table a page, cut at the "
- + (cut.size() == 1 ? "row " : "rows ") + pagesNamed(cut) + (cut.size() == 1 ? " starts" : " start")
- + " with"
- + (header ? ", the part after a cut opening with the table's header rows" : "")
- + ", and its page breaks fixed there, so rows an edit adds above a cut no longer run onto the page "
- + "after it"
- + (left.isEmpty() ? "" : "; " + pagesNamed(left) + ", whose first row its table cannot be split at, "
- + (left.size() == 1 ? "is" : "are") + " left to Word")
- + (border < 0.1 ? "" : "; in Word a part after a cut stands about its top border's width, "
- + pointsOf(border) + "pt, lower than the page sets it: Word sets what a cell holds the whole border "
- + "inside the row, where the page strokes it on the panel's edge, at the page's top"));
+ * @param left the pages, from 0, left to Word, each with why
+ */
+ private void reportTheCut(DocumentNode panel, String path, boolean header, double top, List cut,
+ java.util.Map left) {
+ StringBuilder detail = new StringBuilder("the layout breaks it over pages inside its table: ");
+ if (cut.isEmpty()) {
+ detail.append("written as one table, whose row Word breaks where its lines run out, repeating no header "
+ + "row of the table nested in its cell");
+ } else {
+ detail.append("written as a table a page, cut at the ").append(cut.size() == 1 ? "row " : "rows ")
+ .append(pagesNamed(cut)).append(cut.size() == 1 ? " starts" : " start").append(" with")
+ .append(header ? ", the part after a cut opening with the table's header rows" : "")
+ .append(", and its page breaks fixed there, so rows an edit adds above a cut no longer run onto "
+ + "the page after it");
+ }
+ java.util.Map> why = new java.util.LinkedHashMap<>();
+ left.forEach((page, reason) -> why.computeIfAbsent(reason, key -> new ArrayList<>()).add(page));
+ why.forEach((reason, pages) -> detail.append("; it is not cut where ").append(pagesNamed(pages))
+ .append(pages.size() == 1 ? " starts, " : " start, ").append(reason));
+ if (!cut.isEmpty() && top >= 0.1) {
+ detail.append("; in Word a part after a cut stands about its top border's width, ").append(pointsOf(top))
+ .append("pt, lower than the page sets it: Word sets what a cell holds the whole border inside the "
+ + "row, where the page strokes it on the panel's edge, at the page's top");
+ }
+ report.add(DocxExportReport.Severity.APPROXIMATED, panel.nodeKind(), path, detail.toString());
}
/** Pages counted from 0, named as the page counts them: "page 2", "pages 2 and 3", "pages 2, 3 and 4". */
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
index 8eddf4ef1..6c237bacc 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
@@ -51,7 +51,7 @@ class DocxPanelOverPagesTest {
private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249);
private static final DocumentColor INK = DocumentColor.rgb(26, 86, 148);
- private static final String CUT = "written as a table a page";
+ private static final String NOTE = "the layout breaks it over pages inside its table";
@Test
void aPanelWhoseTableRunsOverAPageIsATableAPageCutAtTheRowsThePagesStartWith() throws Exception {
@@ -136,8 +136,8 @@ void theCutIsNamedWithItsPagesItsFixedBreaksAndTheTopBorderItsPartsStandLowerBy(
@Test
void aPageWhoseRowsHoldAFieldIsLeftWholeAndTheNextOneIsStillCut() throws Exception {
- // A page reference in a row on page 2: the table is not split where page 2 starts, a
- // field's copy being one the export does not settle, and is where page 3 does.
+ // A page reference in a row on page 2: the table is not split where a row from page 2's
+ // first on holds a field, as a section's split of a table is not, and is where page 3 starts.
Exported exported = export(page -> page.addSection(card -> card.fillColor(SURFACE)
.stroke(DocumentStroke.of(INK, 1)).padding(DocumentInsets.of(6))
.addParagraph(p -> p.text("Heading").anchor("top"))
@@ -156,7 +156,82 @@ void aPageWhoseRowsHoldAFieldIsLeftWholeAndTheNextOneIsStillCut() throws Excepti
assertThat(exported.pages().get(1)).as("the premise: row 15 on page 2").contains("Row 15");
assertThat(parts(exported.document())).hasSize(2);
assertThat(cutNote(exported.report())).contains("cut at the row page 3 starts with")
- .contains("page 2, whose first row its table cannot be split at, is left to Word");
+ .contains("it is not cut where page 2 starts, at a row its table cannot be split at");
+ }
+
+ @Test
+ void aCardThatCannotBeCutAtAnyPageIsNamedAsWrittenWhole() throws Exception {
+ // An anchored card's bookmark opens in its table's header row, which a part's copy would
+ // hold again: the table is split nowhere.
+ Exported exported = export(page -> page.addSection(card -> card.anchor("items").fillColor(SURFACE)
+ .stroke(DocumentStroke.of(INK, 1)).padding(DocumentInsets.of(6))
+ .addTable(table -> rows(table, 20, true))));
+
+ assertThat(exported.pages()).as("the premise: the card runs onto a second page").hasSize(2);
+ assertThat(exported.document().getParagraphs()).noneMatch(XWPFParagraph::isPageBreak);
+ assertThat(cutNote(exported.report())).contains("written as one table")
+ .contains("it is not cut where page 2 starts, at a row its table cannot be split at")
+ .doesNotContain("page breaks fixed");
+ }
+
+ @Test
+ void aPageTheCardStartsOutsideItsTableIsNamedAndTheTableStillCut() throws Exception {
+ // A heading and a gap fill page 1: the table starts on page 2 and runs onto page 3.
+ Exported exported = export(card(20, true, above -> above.addParagraph("Heading")
+ .addSpacer(spacer -> spacer.height(225)), null));
+
+ assertThat(exported.cardPages()).as("the premise: the card runs over three pages").isEqualTo(3);
+ assertThat(exported.pages().get(0)).as("the premise: no row of the table on page 1").containsExactly("Item");
+ assertThat(parts(exported.document())).hasSize(2);
+ assertThat(cutNote(exported.report())).contains("it is not cut where page 2 starts, outside its table");
+ }
+
+ @Test
+ void aPageAboveWhichTheRowsEndTooNearTheFootIsNotCut() throws Exception {
+ // Word sets the part's bottom border inside its row: where the page's last row on page 1
+ // leaves less room than that above the foot, Word would set that row on a page of its own.
+ double gap = gapLeavingTheLastRowNearTheFoot();
+ Exported exported = export(page -> {
+ page.addSpacer(spacer -> spacer.height(gap));
+ card(20, true, null, null).accept(page);
+ });
+
+ assertThat(parts(exported.document())).as("not cut where page 2 starts").hasSize(1);
+ assertThat(cutNote(exported.report())).contains("it is not cut where page 2 starts, the page above setting "
+ + "its rows too near its foot");
+ }
+
+ @Test
+ void aTableDeeperThanThePanelsOwnChildrenIsNotCut() throws Exception {
+ Exported exported = export(page -> page.addSection(card -> card.fillColor(SURFACE)
+ .stroke(DocumentStroke.of(INK, 1)).padding(DocumentInsets.of(6))
+ .addSection(inner -> inner.addTable(table -> rows(table, 20, true)))));
+
+ assertThat(exported.cardPages()).as("the premise: the card runs onto a second page").isEqualTo(2);
+ assertThat(exported.document().getParagraphs()).noneMatch(XWPFParagraph::isPageBreak);
+ assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains(NOTE));
+ }
+
+ /**
+ * A gap above the card that leaves its table's last row on page 1 less than its 1pt bottom
+ * border and a point above the page's foot, found by laying the page out.
+ */
+ private static double gapLeavingTheLastRowNearTheFoot() {
+ for (double gap = 0; gap < 40; gap += 0.25) {
+ double height = gap;
+ try (DocumentSession session = session(page -> {
+ page.addSpacer(spacer -> spacer.height(height));
+ card(20, true, null, null).accept(page);
+ })) {
+ DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph());
+ TableNode table = first(session.documentGraph().roots(), TableNode.class);
+ double room = layout.lowestRowOn(table, 0) - layout.marginOf(0).bottom();
+ if (room < 1.5 && layout.placement(table).endPage() == 1) {
+ return gap;
+ }
+ }
+ }
+ throw new AssertionError("no gap leaves the last row near the foot");
}
@Test
@@ -181,7 +256,7 @@ void aPanelWhoseTableFitsItsPageIsOneTable() throws Exception {
assertThat(exported.pages()).hasSize(1);
assertThat(parts(exported.document())).hasSize(1);
- assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains(CUT));
+ assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains(NOTE));
}
@Test
@@ -207,7 +282,7 @@ void aPanelWithAnotherBlockOverPagesBesideItsTableIsNotCut() throws Exception {
assertThat(exported.cardPages()).as("the premise: the card runs over pages").isGreaterThan(2);
assertThat(parts(exported.document())).hasSize(1);
- assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains(CUT));
+ assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains(NOTE));
}
@Test
@@ -308,7 +383,7 @@ private static T first(List nodes, Class<
}
private static String cutNote(DocxExportReport report) {
- return report.notes().stream().filter(note -> note.detail().contains(CUT)).findFirst().orElseThrow().detail();
+ return report.notes().stream().filter(note -> note.detail().contains(NOTE)).findFirst().orElseThrow().detail();
}
/** The panel's tables in the body, those that hold a table in their one cell. */
From 67437532d2f963c22b145eaeb42d1a2251e307bd Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 25 Oct 2026 16:34:06 +0000
Subject: [PATCH 3/3] fix(docx): name a cut panel's pages as its section counts
them
A panel in a later section names the pages it is cut at, and those it
is not, with its section, as other notes naming a section's pages do.
A panel cut at no page says Word repeats no header row only where its
table has one.
---
.../semantic/docx/DocxLayoutMetrics.java | 12 +-
.../semantic/docx/DocxSemanticBackend.java | 45 +++++---
.../semantic/docx/DocxPanelOverPagesTest.java | 103 ++++++++++++++++--
3 files changed, 129 insertions(+), 31 deletions(-)
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
index 7c3656678..3a3a02a57 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
@@ -484,12 +484,6 @@ private Map rowHeightsOf(DocumentNode table) {
});
}
- /**
- * The index part of each placed row's name, kept as the text the layout wrote rather than
- * parsed: a row is looked up by writing its index the same way, so a name that only
- * resembles the pattern matches nothing instead of failing the export. Each is kept with
- * the page it was placed on.
- */
/**
* Where the lowest of a table's own rows on a page ends, measured up from the page's foot as
* {@link #lowestContentOn} measures.
@@ -508,6 +502,12 @@ private Map rowHeightsOf(DocumentNode table) {
return lowest;
}
+ /**
+ * The index part of each placed row's name, kept as the text the layout wrote rather than
+ * parsed: a row is looked up by writing its index the same way, so a name that only
+ * resembles the pattern matches nothing instead of failing the export. Each is kept with
+ * the page it was placed on.
+ */
private Map placedRowsOf(DocumentNode table) {
return placedRows.computeIfAbsent(table, node -> {
Map rows = new HashMap<>();
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 6e2a47269..9ea0bf646 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
@@ -7121,10 +7121,11 @@ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint
* @param outer the table of one cell the panel is written as
* @param table the table among its children the layout breaks over pages
* @param inner the table that one is written as, in the panel's cell
- * @param layout the layout of the section the panel is in
+ * @param layout the layout of the section the panel is in, which counts its pages
+ * @param section what the report calls that section, as "section 2", or null for a document of one
*/
private record PanelOverPages(DocumentNode panel, XWPFTable outer, TableNode table, XWPFTable inner,
- DocxLayoutMetrics layout) {
+ DocxLayoutMetrics layout, String section) {
}
/**
@@ -7147,7 +7148,8 @@ private void noteATableOverPages(DocumentNode node, XWPFTable outer) {
}
XWPFTable inner = over == null ? null : tablesWritten.get(over);
if (inner != null && outer.getRow(0).getCell(0).getBodyElements().contains(inner)) {
- panelsOverPages.add(new PanelOverPages(node, outer, over, inner, layout));
+ panelsOverPages.add(new PanelOverPages(node, outer, over, inner, layout,
+ sectioned ? "section " + (sectionIndex + 1) : null));
}
}
@@ -7225,7 +7227,7 @@ private void cutThePanelsAtTheirPages(XWPFDocument document) {
inner = next.getRow(0).getCell(0).getTables().get(0);
cut.add(page);
}
- reportTheCut(over.panel(), itsLayout.pathOf(over.panel()), header, top, cut, left);
+ reportTheCut(over, header, top, cut, left);
}
}
@@ -7242,40 +7244,49 @@ private void cutThePanelsAtTheirPages(XWPFDocument document) {
* the pages it is cut at, its fixed page breaks and the top border a part after a cut stands
* lower by, or that it is written whole; and each page left to Word, and why.
*
+ * @param over the panel
* @param header whether its table has header rows a part after a cut opens with
* @param top the width of its top border, in points
- * @param cut the pages, from 0, it is cut at
- * @param left the pages, from 0, left to Word, each with why
+ * @param cut the pages, from 0 in its section, it is cut at
+ * @param left the pages, from 0 in its section, left to Word, each with why
*/
- private void reportTheCut(DocumentNode panel, String path, boolean header, double top, List cut,
+ private void reportTheCut(PanelOverPages over, boolean header, double top, List cut,
java.util.Map left) {
StringBuilder detail = new StringBuilder("the layout breaks it over pages inside its table: ");
if (cut.isEmpty()) {
- detail.append("written as one table, whose row Word breaks where its lines run out, repeating no header "
- + "row of the table nested in its cell");
+ detail.append("written as one table, whose row Word breaks where its lines run out")
+ .append(header ? ", repeating no header row of the table nested in its cell" : "");
} else {
- detail.append("written as a table a page, cut at the ").append(cut.size() == 1 ? "row " : "rows ")
- .append(pagesNamed(cut)).append(cut.size() == 1 ? " starts" : " start").append(" with")
+ detail.append("written as tables cut at the ").append(cut.size() == 1 ? "row " : "rows ")
+ .append(pagesNamed(over.section(), cut)).append(cut.size() == 1 ? " starts" : " start")
+ .append(" with")
.append(header ? ", the part after a cut opening with the table's header rows" : "")
.append(", and its page breaks fixed there, so rows an edit adds above a cut no longer run onto "
+ "the page after it");
}
java.util.Map> why = new java.util.LinkedHashMap<>();
left.forEach((page, reason) -> why.computeIfAbsent(reason, key -> new ArrayList<>()).add(page));
- why.forEach((reason, pages) -> detail.append("; it is not cut where ").append(pagesNamed(pages))
- .append(pages.size() == 1 ? " starts, " : " start, ").append(reason));
+ why.forEach((reason, pages) -> detail.append("; it is not cut where ")
+ .append(pagesNamed(over.section(), pages)).append(pages.size() == 1 ? " starts, " : " start, ")
+ .append(reason));
if (!cut.isEmpty() && top >= 0.1) {
detail.append("; in Word a part after a cut stands about its top border's width, ").append(pointsOf(top))
.append("pt, lower than the page sets it: Word sets what a cell holds the whole border inside the "
+ "row, where the page strokes it on the panel's edge, at the page's top");
}
- report.add(DocxExportReport.Severity.APPROXIMATED, panel.nodeKind(), path, detail.toString());
+ report.add(DocxExportReport.Severity.APPROXIMATED, over.panel().nodeKind(), over.layout().pathOf(over.panel()),
+ detail.toString());
}
- /** Pages counted from 0, named as the page counts them: "page 2", "pages 2 and 3", "pages 2, 3 and 4". */
- private static String pagesNamed(List pages) {
+ /**
+ * Pages counted from 0 in a section, named as the section counts them: "page 2", "section 2,
+ * pages 2 and 3", "pages 2, 3 and 4".
+ *
+ * @param section what the report calls the section, or null for a document of one
+ */
+ private static String pagesNamed(String section, List pages) {
List numbers = pages.stream().map(page -> String.valueOf(page + 1)).toList();
- return (numbers.size() == 1 ? "page " : "pages ") + listed(numbers);
+ return (section == null ? "" : section + ", ") + (numbers.size() == 1 ? "page " : "pages ") + listed(numbers);
}
/**
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
index 6c237bacc..cde94f8bc 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
@@ -154,7 +154,10 @@ void aPageWhoseRowsHoldAFieldIsLeftWholeAndTheNextOneIsStillCut() throws Excepti
assertThat(exported.pages()).as("the premise: three pages").hasSize(3);
assertThat(exported.pages().get(1)).as("the premise: row 15 on page 2").contains("Row 15");
- assertThat(parts(exported.document())).hasSize(2);
+ List first = new ArrayList<>(exported.pages().get(0));
+ first.addAll(exported.pages().get(1).subList(1, exported.pages().get(1).size()));
+ assertThat(rowsOf(parts(exported.document()))).as("pages 1 and 2 one part, page 3 another")
+ .containsExactly(first, exported.pages().get(2));
assertThat(cutNote(exported.report())).contains("cut at the row page 3 starts with")
.contains("it is not cut where page 2 starts, at a row its table cannot be split at");
}
@@ -190,7 +193,7 @@ void aPageTheCardStartsOutsideItsTableIsNamedAndTheTableStillCut() throws Except
void aPageAboveWhichTheRowsEndTooNearTheFootIsNotCut() throws Exception {
// Word sets the part's bottom border inside its row: where the page's last row on page 1
// leaves less room than that above the foot, Word would set that row on a page of its own.
- double gap = gapLeavingTheLastRowNearTheFoot();
+ double gap = gapLeavingTheLastRowNearTheFoot(true);
Exported exported = export(page -> {
page.addSpacer(spacer -> spacer.height(gap));
card(20, true, null, null).accept(page);
@@ -198,7 +201,43 @@ void aPageAboveWhichTheRowsEndTooNearTheFootIsNotCut() throws Exception {
assertThat(parts(exported.document())).as("not cut where page 2 starts").hasSize(1);
assertThat(cutNote(exported.report())).contains("it is not cut where page 2 starts, the page above setting "
- + "its rows too near its foot");
+ + "its rows too near its foot")
+ .contains("repeating no header row of the table nested in its cell");
+ }
+
+ @Test
+ void aTableWithNoHeaderRowsCutNowhereIsNamedWithNoWordOfAHeader() throws Exception {
+ double gap = gapLeavingTheLastRowNearTheFoot(false);
+ Exported exported = export(page -> {
+ page.addSpacer(spacer -> spacer.height(gap));
+ card(20, false, null, null).accept(page);
+ });
+
+ assertThat(cutNote(exported.report())).contains("written as one table").doesNotContain("header");
+ }
+
+ @Test
+ void aPartOpeningItsPageNeedsRoomForItsTopBorderAndTheParagraphBeforeItToo() throws Exception {
+ // A page height at which page 2's last row leaves room for a bottom border and a point,
+ // and not for the top border and the hairline paragraph the part opening page 2 sets too.
+ double height = heightLeavingTheMiddlePartTooLittleRoom();
+ Exported exported = export(height, card(30, true, null, null));
+
+ assertThat(parts(exported.document())).as("cut where page 2 starts, not where page 3 does").hasSize(2);
+ assertThat(cutNote(exported.report())).contains("cut at the row page 2 starts with")
+ .contains("it is not cut where page 3 starts, the page above setting its rows too near its foot");
+ }
+
+ @Test
+ void aPageTheCardRunsOntoAfterItsTableIsNamed() throws Exception {
+ // A gap after the table moves the card's closing line to page 3.
+ Exported exported = export(card(20, true, null, below -> below.addSpacer(spacer -> spacer.height(240))
+ .addParagraph("Closing")));
+
+ assertThat(exported.cardPages()).as("the premise: the card runs over three pages").isEqualTo(3);
+ assertThat(exported.pages()).as("the premise: its table over two").hasSize(2);
+ assertThat(parts(exported.document())).hasSize(2);
+ assertThat(cutNote(exported.report())).contains("it is not cut where page 3 starts, outside its table");
}
@Test
@@ -216,12 +255,12 @@ void aTableDeeperThanThePanelsOwnChildrenIsNotCut() throws Exception {
* A gap above the card that leaves its table's last row on page 1 less than its 1pt bottom
* border and a point above the page's foot, found by laying the page out.
*/
- private static double gapLeavingTheLastRowNearTheFoot() {
+ private static double gapLeavingTheLastRowNearTheFoot(boolean header) {
for (double gap = 0; gap < 40; gap += 0.25) {
double height = gap;
try (DocumentSession session = session(page -> {
page.addSpacer(spacer -> spacer.height(height));
- card(20, true, null, null).accept(page);
+ card(20, header, null, null).accept(page);
})) {
DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph());
TableNode table = first(session.documentGraph().roots(), TableNode.class);
@@ -234,6 +273,30 @@ private static double gapLeavingTheLastRowNearTheFoot() {
throw new AssertionError("no gap leaves the last row near the foot");
}
+ /**
+ * A page height at which a 30-row card runs over three pages, its rows on page 1 ending far
+ * enough above the foot for the first part's 1pt bottom border and a point, and those on page 2
+ * ending room enough for that but not for the second part's 1pt top border and 0.1pt paragraph
+ * too, found by laying the pages out.
+ */
+ private static double heightLeavingTheMiddlePartTooLittleRoom() {
+ for (double height = 280; height < 340; height += 0.25) {
+ try (DocumentSession session = session(height, card(30, true, null, null))) {
+ DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph());
+ TableNode table = first(session.documentGraph().roots(), TableNode.class);
+ if (layout.placement(table).startPage() != 0 || layout.placement(table).endPage() != 2) {
+ continue;
+ }
+ double first = layout.lowestRowOn(table, 0) - layout.marginOf(0).bottom();
+ double second = layout.lowestRowOn(table, 1) - layout.marginOf(1).bottom();
+ if (first >= 2.05 && second >= 2.05 && second < 3.05) {
+ return height;
+ }
+ }
+ }
+ throw new AssertionError("no page height leaves the middle part too little room");
+ }
+
@Test
void aPanelInAnEarlierSectionIsCutByItsOwnSectionsLayout() throws Exception {
// Each section is laid out on its own: the cut reads the card's pages off its section's.
@@ -247,7 +310,22 @@ void aPanelInAnEarlierSectionIsCutByItsOwnSectionsLayout() throws Exception {
}
assertThat(parts(new XWPFDocument(new ByteArrayInputStream(docx)))).hasSize(2);
- assertThat(cutNote(report.get())).contains("cut at the row page 2 starts with");
+ assertThat(cutNote(report.get())).contains("cut at the row section 1, page 2 starts with");
+ }
+
+ @Test
+ void aPanelInALaterSectionNamesItsPagesAsItsSectionCountsThem() throws Exception {
+ AtomicReference report = new AtomicReference<>();
+ byte[] docx;
+ try (MultiSectionDocument sections = GraphCompose.documents()
+ .section(session(page -> page.addParagraph("First section")))
+ .section(session(card(20, true, null, null)))
+ .create()) {
+ docx = sections.export(DocxSemanticBackend.builder().reportSink(report::set).build());
+ }
+
+ assertThat(parts(new XWPFDocument(new ByteArrayInputStream(docx)))).hasSize(2);
+ assertThat(cutNote(report.get())).contains("cut at the row section 2, page 2 starts with");
}
@Test
@@ -340,14 +418,23 @@ private record Exported(XWPFDocument document, DocxExportReport report, List content) {
- DocumentSession session = GraphCompose.document().pageSize(400, 300).margin(DocumentInsets.of(20)).create();
+ return session(300, content);
+ }
+
+ private static DocumentSession session(double pageHeight, Consumer content) {
+ DocumentSession session = GraphCompose.document().pageSize(400, pageHeight).margin(DocumentInsets.of(20))
+ .create();
session.pageFlow(content::accept);
return session;
}
private static Exported export(Consumer content) throws Exception {
+ return export(300, content);
+ }
+
+ private static Exported export(double pageHeight, Consumer content) throws Exception {
AtomicReference report = new AtomicReference<>();
- try (DocumentSession session = session(content)) {
+ try (DocumentSession session = session(pageHeight, content)) {
byte[] docx = session.export(new DocxSemanticBackend(report::set));
DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph());
List> pages = new ArrayList<>();