It cannot be where a cell spans rows across that row, where only the header rows stand
+ * above it, or where a header row holds what the file can hold only once — a bookmark or a
+ * drawing — as the part after it would hold that row again. Nor where a header row or a row
+ * from that one on holds a field, a page reference's: the export settles it once the body is
+ * written, in the paragraph it wrote it in.
+ *
+ * @param table the table as written
+ * @param page the page, from 0
+ * @param pageOf the page the layout placed each of its rows on, by index; -1 for one it did not
+ * @return the row's index, or -1 where no row starts the page or the table cannot be split there
+ */
+ static int rowStarting(XWPFTable table, int page, IntUnaryOperator pageOf) {
+ int rows = table.getNumberOfRows();
+ int header = headerRows(table);
+ if (holds(table, 0, header, HELD_ONCE) || holds(table, 0, header, FIELDS)) {
+ return -1;
+ }
+ for (int row = header; row < rows; row++) {
+ int on = pageOf.applyAsInt(row);
+ if (on < 0) {
+ return -1;
+ }
+ if (on == page) {
+ return row > header && !continuesAMerge(table.getRow(row)) && !holds(table, row, rows, FIELDS)
+ ? row : -1;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * How many of a table's leading rows Word repeats at the top of each page it runs onto.
+ *
+ * @param table the table
+ * @return the number of its header rows
+ */
+ static int headerRows(XWPFTable table) {
+ int header = 0;
+ while (header < table.getNumberOfRows() && table.getRow(header).isRepeatHeader()) {
+ header++;
+ }
+ return header;
+ }
+
+ /**
+ * Splits a table of the body before one of its rows: that row and the rows after it go to a
+ * table of their own after it, under copies of its header rows, and a paragraph is put between
+ * the two.
+ *
+ * @param document the document the table is in the body of
+ * @param table the table
+ * @param row the row the second part starts with, after the header rows
+ * @return the paragraph between the two parts
+ */
+ static XWPFParagraph split(XWPFDocument document, XWPFTable table, int row) {
+ int header = headerRows(table);
+ CTBody body = document.getDocument().getBody();
+ int written = List.of(body.getTblArray()).indexOf(table.getCTTbl());
+ try (XmlCursor from = table.getCTTbl().newCursor(); XmlCursor after = table.getCTTbl().newCursor()) {
+ after.toEndToken();
+ after.toNextToken();
+ from.copyXml(after);
+ }
+ CTTbl copied = body.getTblArray(written + 1);
+ XWPFTable second = new XWPFTable(copied, document);
+ document.insertTable(document.getBodyElements().indexOf(table) + 1, second);
+ for (int index = row - 1; index >= header; index--) {
+ second.removeRow(index);
+ }
+ for (int index = table.getNumberOfRows() - 1; index >= row; index--) {
+ table.removeRow(index);
+ }
+ try (XmlCursor before = copied.newCursor()) {
+ return document.insertNewParagraph(before);
+ }
+ }
+
+ /** Whether a table's rows from one to another, that one left out, hold any of some elements. */
+ private static boolean holds(XWPFTable table, int from, int to, List elements) {
+ for (int row = from; row < to; row++) {
+ for (String element : elements) {
+ if (table.getRow(row).getCtRow().selectPath(W + ".//w:" + element).length > 0) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /** Whether a cell of the row continues one merged down from the row above. */
+ private static boolean continuesAMerge(XWPFTableRow row) {
+ for (XWPFTableCell cell : row.getTableCells()) {
+ CTTcPr properties = cell.getCTTc().getTcPr();
+ if (properties != null && properties.isSetVMerge()
+ && properties.getVMerge().getVal() != STMerge.RESTART) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java
index ea672d526..d1a4cab37 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java
@@ -5,11 +5,17 @@
import com.demcha.compose.document.api.PageMarginRule;
import com.demcha.compose.document.dsl.PageFlowBuilder;
import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.layout.payloads.TableRowFragmentPayload;
+import com.demcha.compose.document.node.PageReferenceNode;
+import com.demcha.compose.document.node.TextAlign;
import com.demcha.compose.document.output.DocumentPageZone;
import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.table.DocumentTableCell;
import com.demcha.compose.document.table.DocumentTableColumn;
+import org.apache.poi.xwpf.usermodel.IBodyElement;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.junit.jupiter.api.Test;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr;
@@ -23,7 +29,8 @@
/**
* A page the layout sets against other margins than the page before it starts a Word section with
- * them, where it starts between blocks of the body, and is named where it does not.
+ * them, where it starts between blocks of the body or among a table's rows, and is named where it
+ * does not.
*
* Measured in Word and LibreOffice on Windows: one set of margins for the whole document ran a
* page with a larger bottom margin on past its foot, 7 lines of 33 further; a section starting
@@ -66,27 +73,199 @@ void aPageBreakIntoOtherMarginsBecomesTheSectionsBreak() throws Exception {
}
@Test
- void aPageStartingInsideATableIsNamed() throws Exception {
- // The total after the table starts on page 2 too: a section starting there would break
- // the page under the table's last rows.
+ void aPageStartingAmongATablesRowsSplitsTheTableAtTheRowItStartsWith() throws Exception {
+ // The second part opens with the header row, which the layout repeats at the top of page 2.
Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> page
- .addParagraph("Items")
+ .addTable(table -> itemsUnderAHeader(table, 40))
+ .addParagraph("Total"));
+
+ List tables = exported.document().getTables();
+ assertThat(tables).hasSize(2);
+ XWPFTable first = tables.get(0);
+ XWPFTable second = tables.get(1);
+ int onPage1 = exported.rowsOn(0);
+ assertThat(first.getNumberOfRows()).as("the header and the rows of page 1").isEqualTo(onPage1);
+ assertThat(second.getRow(0).getCell(0).getText()).isEqualTo("Item");
+ assertThat(second.getRow(0).isRepeatHeader()).isTrue();
+ assertThat(second.getRow(1).getCell(0).getText()).as("page 2's first row").isEqualTo("Item " + (onPage1 - 1));
+ assertThat(first.getNumberOfRows() + second.getNumberOfRows()).isEqualTo(1 + 40 + 1);
+ XWPFParagraph ending = exported.paragraphsEndingASection().get(0);
+ assertThat(exported.paragraphsEndingASection()).singleElement();
+ List body = exported.document().getBodyElements();
+ assertThat(body.indexOf(ending)).as("between the two parts").isEqualTo(body.indexOf(first) + 1)
+ .isEqualTo(body.indexOf(second) - 1);
+ assertThat(((java.math.BigInteger) ending.getCTP().getPPr().getSpacing().getLine()).longValue())
+ .as("a hairline, which page 1 has room for").isEqualTo(2);
+ assertThat(bottom(ending.getCTP().getPPr().getSectPr())).isEqualTo(36 * 20);
+ assertThat(bottom(exported.body())).isEqualTo(136 * 20);
+ assertThat(exported.report().notes()).filteredOn(note -> note.subject().equals("page margin"))
+ .singleElement().satisfies(note -> {
+ assertThat(note.path()).endsWith("TableNode[0]");
+ assertThat(note.detail()).isEqualTo("page 2's top and bottom margins differ from page 1's: written "
+ + "as a Word section that starts at the row page 2 starts with, "
+ + "the table written as two there, the second part opening with "
+ + "its header rows, and its page break fixed there, so rows an "
+ + "edit adds above it no longer run onto that page");
+ });
+ }
+
+ @Test
+ void aTableRunningOntoPagesOfTwoOtherSetsOfMarginsIsSplitAtTheRowEachStartsWith() throws Exception {
+ DocumentInsets middle = new DocumentInsets(36, 36, 86, 36);
+ Exported exported = export(List.of(PageMarginRule.page(2, middle), PageMarginRule.from(3, LATER)),
+ page -> page.addTable(table -> itemsUnderAHeader(table, 50)));
+
+ List tables = exported.document().getTables();
+ assertThat(tables).hasSize(3);
+ assertThat(tables.get(0).getNumberOfRows()).isEqualTo(exported.rowsOn(0));
+ assertThat(tables.get(1).getNumberOfRows()).as("the header and the rows of page 2").isEqualTo(exported.rowsOn(1));
+ assertThat(tables.get(2).getRow(0).getCell(0).getText()).isEqualTo("Item");
+ assertThat(tables.get(2).getRow(1).getCell(0).getText())
+ .isEqualTo("Item " + (exported.rowsOn(0) - 1 + exported.rowsOn(1) - 1));
+ List ending = exported.paragraphsEndingASection();
+ assertThat(ending).hasSize(2);
+ List body = exported.document().getBodyElements();
+ assertThat(body.indexOf(ending.get(0))).isEqualTo(body.indexOf(tables.get(0)) + 1);
+ assertThat(body.indexOf(ending.get(1))).isEqualTo(body.indexOf(tables.get(1)) + 1);
+ assertThat(bottom(ending.get(0).getCTP().getPPr().getSectPr())).isEqualTo(36 * 20);
+ assertThat(bottom(ending.get(1).getCTP().getPPr().getSectPr())).isEqualTo(86 * 20);
+ assertThat(bottom(exported.body())).isEqualTo(136 * 20);
+ }
+
+ @Test
+ void aTableWhoseRowsReachThePagesFootIsNotSplitThere() throws Exception {
+ // Page 1's rows end a point above its foot: the hairline between the two parts would not
+ // fit, and would open a page of its own before the section's.
+ double row = rowHeight();
+ Exported exported = export(List.of(PageMarginRule.page(1, new DocumentInsets(36, 36, 400 - 36 - 10 * row - 1, 36)),
+ PageMarginRule.from(2, LATER)), page -> page.addTable(table -> rows(table, "Item", 20)));
+
+ assertThat(exported.rowsOn(0)).as("ten rows on page 1").isEqualTo(10);
+ assertThat(exported.paragraphsEndingASection()).isEmpty();
+ assertThat(exported.document().getTables()).hasSize(1);
+ assertThat(exported.report().notes()).filteredOn(note -> note.subject().equals("page margin"))
+ .singleElement().satisfies(note -> {
+ assertThat(note.path()).endsWith("TableNode[0]");
+ assertThat(note.detail()).endsWith("page 1's content reaches its foot, where the paragraph that "
+ + "would end a Word section before page 2 would open a page of "
+ + "its own");
+ });
+ }
+
+ @Test
+ void aTableWithoutHeaderRowsIsSplitIntoPartsWithNone() throws Exception {
+ Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> page
+ .addTable(table -> rows(table, "Item", 30)));
+
+ List tables = exported.document().getTables();
+ assertThat(tables).hasSize(2);
+ assertThat(tables.get(0).getNumberOfRows()).isEqualTo(exported.rowsOn(0));
+ assertThat(tables.get(1).getRow(0).getCell(0).getText()).as("page 2's first row")
+ .isEqualTo("Item " + exported.rowsOn(0));
+ assertThat(tables.get(1).getRow(0).isRepeatHeader()).isFalse();
+ assertThat(exported.notes()).singleElement().asString().isEqualTo("page 2's top and bottom margins differ from "
+ + "page 1's: written as a Word section that starts at the row page 2 starts with, the table written "
+ + "as two there, and its page break fixed there, so rows an edit adds above it no longer run onto "
+ + "that page");
+ }
+
+ @Test
+ void aBookmarkInARowMovedToTheSecondPartIsWrittenOnceThere() throws Exception {
+ Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> page.addTable(table -> {
+ table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100));
+ for (int row = 0; row < 30; row++) {
+ table.rowCells(row == 25
+ ? DocumentTableCell.node(new ParagraphBuilder().text("Item " + row).anchor("late").build())
+ : DocumentTableCell.text("Item " + row),
+ DocumentTableCell.text("$" + row));
+ }
+ }));
+
+ List tables = exported.document().getTables();
+ assertThat(tables).hasSize(2);
+ assertThat(bookmarks(tables.get(0))).isEmpty();
+ assertThat(bookmarks(tables.get(1))).singleElement().isEqualTo("late");
+ }
+
+ @Test
+ void aPageReferenceInARowThatWouldMoveKeepsTheTableWhole() throws Exception {
+ // The export settles a page reference once the body is written, in the paragraph it wrote
+ // it in: moved to the second part, that paragraph is gone.
+ Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> page
+ .addParagraph(p -> p.text("Intro").anchor("intro"))
.addTable(table -> {
table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100));
- for (int row = 0; row < 40; row++) {
- table.row("Item " + row, "$" + row);
+ for (int row = 0; row < 30; row++) {
+ table.rowCells(DocumentTableCell.text("Item " + row), row == 25
+ ? DocumentTableCell.node(new PageReferenceNode("intro", null, TextAlign.LEFT))
+ : DocumentTableCell.text("$" + row));
}
- })
- .addParagraph("Total"));
+ }));
- assertThat(exported.paragraphsEndingASection()).isEmpty();
- assertThat(bottom(exported.body())).as("page 1's, the only ones written").isEqualTo(36 * 20);
+ assertThat(exported.document().getTables()).hasSize(1);
+ assertThat(exported.document().getDocument().xmlText()).contains("PAGEREF");
assertThat(exported.report().notes()).filteredOn(note -> note.subject().equals("page margin"))
.singleElement().satisfies(note -> {
assertThat(note.path()).endsWith("TableNode[1]");
- assertThat(note.detail()).isEqualTo("page 2's top and bottom margins differ from page 1's, which Word sets it "
- + "with: page 2 starts inside this block, where the export "
- + "starts no Word section");
+ assertThat(note.detail()).endsWith("page 2 starts inside this block, where the export starts no "
+ + "Word section");
+ });
+ }
+
+ @Test
+ void aTableNamedForAPageBeforeASectionItStartsIsNamedForAPageAfterItToo() throws Exception {
+ // Page 2 starts inside a cell spanning two rows, page 3 at a row of its own, and page 4
+ // inside a span again: named for page 2 and split for page 3, the table is named for page 4.
+ List rules = List.of(PageMarginRule.page(2, new DocumentInsets(36, 36, 136, 36)),
+ PageMarginRule.page(3, new DocumentInsets(36, 36, 126, 36)),
+ PageMarginRule.from(4, new DocumentInsets(36, 36, 116, 36)));
+ List counts = export(rules, page -> page.addTable(table -> rows(table, "Item", 50))).rows();
+ int page2 = counts.get(0).intValue();
+ int page4 = page2 + counts.get(1).intValue() + counts.get(2).intValue();
+ Exported exported = export(rules, page -> page.addTable(table -> {
+ table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100));
+ for (int row = 0; row < 50; row++) {
+ if (row + 1 == page2 || row + 1 == page4) {
+ table.rowCells(DocumentTableCell.text("Span " + row).rowSpan(2), DocumentTableCell.text("$" + row));
+ } else if (row == page2 || row == page4) {
+ table.rowCells(DocumentTableCell.text("$" + row));
+ } else {
+ table.row("Item " + row, "$" + row);
+ }
+ }
+ }));
+
+ assertThat(exported.notes()).filteredOn(note -> note.contains("starts inside this block"))
+ .extracting(note -> note.substring(0, 6)).containsExactly("page 2", "page 4");
+ assertThat(exported.notes()).anyMatch(note -> note.startsWith("page 3's top and bottom margins differ from "
+ + "page 1's: written"));
+ }
+
+ private static List bookmarks(XWPFTable table) {
+ List names = new ArrayList<>();
+ for (var found : table.getCTTbl().selectPath("declare namespace w='http://schemas.openxmlformats.org/"
+ + "wordprocessingml/2006/main' .//w:bookmarkStart")) {
+ names.add(((org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBookmark) found).getName());
+ }
+ return names;
+ }
+
+ @Test
+ void aPageStartingInsideAPanelIsNamed() throws Exception {
+ Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> page.addSection(panel -> {
+ panel.fillColor(com.demcha.compose.document.style.DocumentColor.rgb(238, 242, 247));
+ for (int line = 0; line < 40; line++) {
+ panel.addParagraph("Line " + line);
+ }
+ }));
+
+ assertThat(exported.paragraphsEndingASection()).isEmpty();
+ assertThat(exported.report().notes()).filteredOn(note -> note.subject().equals("page margin"))
+ .singleElement().satisfies(note -> {
+ assertThat(note.path()).endsWith("SectionNode[0]");
+ assertThat(note.detail()).isEqualTo("page 2's top and bottom margins differ from page 1's, which "
+ + "Word sets it with: page 2 starts inside this block, where "
+ + "the export starts no Word section");
});
}
@@ -155,16 +334,11 @@ void eachChangeOfMarginsStartsASectionOfItsOwn() throws Exception {
@Test
void aPageAfterOneStartingInsideABlockStartsTheSectionItsMarginsNeed() throws Exception {
- // Page 2 starts inside the table, and Word sets it with page 1's margins; page 3, which
+ // Page 2 starts inside the paragraph, and Word sets it with page 1's margins; page 3, which
// starts between paragraphs, has margins Word has not been given yet, and starts a section.
Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> {
- lines(page, "Intro", 10);
- page.addTable(table -> {
- table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100));
- for (int row = 0; row < 15; row++) {
- table.row("Item " + row, "$" + row);
- }
- });
+ lines(page, "Intro", 18);
+ page.addParagraph("Long " + "running on over the foot of the first page and onto the next one, ".repeat(6));
lines(page, "Line", 60);
});
@@ -406,6 +580,25 @@ private static void rows(com.demcha.compose.document.dsl.TableBuilder table, Str
}
}
+ /** Rows "Item 0" on under a header row the layout repeats on each page. */
+ private static void itemsUnderAHeader(com.demcha.compose.document.dsl.TableBuilder table, int count) {
+ table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100));
+ table.header("Item", "Amount").repeatHeader();
+ for (int row = 0; row < count; row++) {
+ table.row("Item " + row, "$" + row);
+ }
+ }
+
+ /** How tall the layout sets a row of {@link #rows}. */
+ private static double rowHeight() throws Exception {
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 400).margin(FIRST).create()) {
+ session.pageFlow(page -> page.addTable(table -> rows(table, "Item", 1)));
+ return session.layoutGraph().fragments().stream()
+ .filter(fragment -> fragment.payload() instanceof TableRowFragmentPayload)
+ .findFirst().orElseThrow().height();
+ }
+ }
+
/** The most rows one table holds on a page, laid out alone. */
private static int rowsFilling(List rules, int pages) throws Exception {
int rows = 1;
@@ -452,6 +645,7 @@ private static Exported export(List rules, Consumer lastLines = new ArrayList<>();
+ List rows = new ArrayList<>();
for (int page = 0; page < session.layoutGraph().totalPages(); page++) {
int at = page;
lastLines.add(session.layoutGraph().nodes().stream()
@@ -459,12 +653,21 @@ private static Exported export(List rules, Consumer after)
.map(node -> node.semanticName())
.orElse(""));
+ rows.add(session.layoutGraph().fragments().stream()
+ .filter(fragment -> fragment.pageIndex() == at
+ && fragment.payload() instanceof TableRowFragmentPayload)
+ .count());
}
- return new Exported(new XWPFDocument(new ByteArrayInputStream(bytes)), report.get(), lastLines);
+ return new Exported(new XWPFDocument(new ByteArrayInputStream(bytes)), report.get(), lastLines, rows);
}
}
- private record Exported(XWPFDocument document, DocxExportReport report, List lastNames) {
+ private record Exported(XWPFDocument document, DocxExportReport report, List lastNames, List rows) {
+
+ /** How many table rows the layout sets on a page, a header it repeats there included. */
+ int rowsOn(int page) {
+ return rows.get(page).intValue();
+ }
CTSectPr body() {
return document.getDocument().getBody().getSectPr();
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java
new file mode 100644
index 000000000..6aad9360f
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java
@@ -0,0 +1,175 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import org.apache.poi.xwpf.usermodel.IBodyElement;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.apache.poi.xwpf.usermodel.XWPFTableRow;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A body table is split in two at the row a page starts with: the rows from it on go to a table of
+ * their own, under copies of the header rows, a paragraph between the two.
+ */
+class DocxTableSplitTest {
+
+ @Test
+ void theRowsFromTheSplitRowOnGoToATableOfTheirOwnUnderTheHeaderRows() throws Exception {
+ try (XWPFDocument document = new XWPFDocument()) {
+ document.createParagraph().createRun().setText("Before");
+ XWPFTable table = table(document, 1, 5);
+ document.createParagraph().createRun().setText("After");
+
+ XWPFParagraph between = DocxTableSplit.split(document, table, 3);
+
+ List body = document.getBodyElements();
+ assertThat(body).hasSize(5);
+ assertThat(body.get(1)).isSameAs(table);
+ assertThat(body.get(2)).isSameAs(between);
+ assertThat(texts(table)).containsExactly("Header", "Row 1", "Row 2");
+ XWPFTable second = (XWPFTable) body.get(3);
+ assertThat(texts(second)).containsExactly("Header", "Row 3", "Row 4", "Row 5");
+ assertThat(second.getRow(0).isRepeatHeader()).as("repeated on the pages it runs onto").isTrue();
+ // The passes after it read the document's lists: the second part and the paragraph are in them.
+ assertThat(document.getTables()).containsExactly(table, second);
+ assertThat(document.getParagraphs()).element(1).isSameAs(between);
+
+ try (XWPFDocument read = new XWPFDocument(new ByteArrayInputStream(bytes(document)))) {
+ assertThat(read.getTables()).hasSize(2);
+ assertThat(texts(read.getTables().get(1))).containsExactly("Header", "Row 3", "Row 4", "Row 5");
+ }
+ }
+ }
+
+ @Test
+ void theRowAPageStartsWithIsTheFirstTheLayoutPlacedOnIt() throws Exception {
+ try (XWPFDocument document = new XWPFDocument()) {
+ XWPFTable table = table(document, 1, 5);
+
+ assertThat(DocxTableSplit.rowStarting(table, 1, row -> row < 4 ? 0 : 1)).isEqualTo(4);
+ assertThat(DocxTableSplit.rowStarting(table, 2, row -> row < 4 ? 0 : 1)).as("no row on page 3")
+ .isEqualTo(-1);
+ }
+ }
+
+ @Test
+ void aTableIsNotSplitWhereOnlyItsHeaderRowsStandAboveThatRow() throws Exception {
+ // The first part would hold the header alone at the foot of the page.
+ try (XWPFDocument document = new XWPFDocument()) {
+ XWPFTable table = table(document, 1, 5);
+
+ assertThat(DocxTableSplit.rowStarting(table, 1, row -> row == 0 ? 0 : 1)).isEqualTo(-1);
+ }
+ }
+
+ @Test
+ void aTableIsNotSplitWhereACellSpansRowsAcrossThatRow() throws Exception {
+ try (XWPFDocument document = new XWPFDocument()) {
+ XWPFTable table = table(document, 1, 5);
+ table.getRow(3).getCell(0).getCTTc().addNewTcPr().addNewVMerge().setVal(STMerge.RESTART);
+ table.getRow(4).getCell(0).getCTTc().addNewTcPr().addNewVMerge().setVal(STMerge.CONTINUE);
+
+ assertThat(DocxTableSplit.rowStarting(table, 1, row -> row < 4 ? 0 : 1)).isEqualTo(-1);
+ assertThat(DocxTableSplit.rowStarting(table, 1, row -> row < 3 ? 0 : 1)).as("where the merge starts")
+ .isEqualTo(3);
+ }
+ }
+
+ @Test
+ void aTableIsNotSplitWhereAHeaderRowHoldsWhatTheFileHoldsOnce() throws Exception {
+ // A bookmark's name marks one place, and a drawing's id one drawing: the copy would hold them twice.
+ try (XWPFDocument document = new XWPFDocument()) {
+ XWPFTable marked = table(document, 1, 5);
+ marked.getRow(0).getCell(0).getParagraphs().get(0).getCTP().addNewBookmarkStart().setName("top");
+ XWPFTable drawn = table(document, 1, 5);
+ drawn.getRow(0).getCell(0).getParagraphs().get(0).createRun().getCTR().addNewDrawing();
+
+ assertThat(DocxTableSplit.rowStarting(marked, 1, row -> row < 4 ? 0 : 1)).isEqualTo(-1);
+ assertThat(DocxTableSplit.rowStarting(drawn, 1, row -> row < 4 ? 0 : 1)).isEqualTo(-1);
+ }
+ }
+
+ @Test
+ void aTableIsNotSplitWhereARowItMovesOrCopiesHoldsAField() throws Exception {
+ // The export settles a page reference in the paragraph it wrote it in, once the body is written.
+ try (XWPFDocument document = new XWPFDocument()) {
+ XWPFTable moved = table(document, 1, 5);
+ field(moved, 5);
+ XWPFTable copied = table(document, 1, 5);
+ field(copied, 0);
+ XWPFTable kept = table(document, 1, 5);
+ field(kept, 2);
+
+ assertThat(DocxTableSplit.rowStarting(moved, 1, row -> row < 4 ? 0 : 1)).isEqualTo(-1);
+ assertThat(DocxTableSplit.rowStarting(copied, 1, row -> row < 4 ? 0 : 1)).isEqualTo(-1);
+ assertThat(DocxTableSplit.rowStarting(kept, 1, row -> row < 4 ? 0 : 1)).as("above the split")
+ .isEqualTo(4);
+ }
+ }
+
+ @Test
+ void aTableWithNoHeaderRowsIsSplitIntoTwoPartsOfItsOwnRows() throws Exception {
+ try (XWPFDocument document = new XWPFDocument()) {
+ XWPFTable table = table(document, 0, 5);
+
+ assertThat(DocxTableSplit.rowStarting(table, 1, row -> row < 2 ? 0 : 1)).isEqualTo(2);
+ DocxTableSplit.split(document, table, 2);
+
+ assertThat(texts(table)).containsExactly("Row 1", "Row 2");
+ assertThat(texts(document.getTables().get(1))).containsExactly("Row 3", "Row 4", "Row 5");
+ }
+ }
+
+ private static void field(XWPFTable table, int row) {
+ table.getRow(row).getCell(0).getParagraphs().get(0).createRun().getCTR().addNewInstrText()
+ .setStringValue(" PAGEREF ");
+ }
+
+ @Test
+ void aTableIsNotSplitAtARowAfterOneTheLayoutDidNotPlace() throws Exception {
+ try (XWPFDocument document = new XWPFDocument()) {
+ XWPFTable table = table(document, 1, 5);
+
+ assertThat(DocxTableSplit.rowStarting(table, 1, row -> row == 2 ? -1 : row < 4 ? 0 : 1)).isEqualTo(-1);
+ }
+ }
+
+ @Test
+ void theHeaderRowsAreTheLeadingRowsWordRepeats() throws Exception {
+ try (XWPFDocument document = new XWPFDocument()) {
+ XWPFTable table = table(document, 2, 4);
+ table.getRow(4).setRepeatHeader(true);
+
+ assertThat(DocxTableSplit.headerRows(table)).isEqualTo(2);
+ }
+ }
+
+ /** A table of so many header rows, then rows "Row 1" on. */
+ private static XWPFTable table(XWPFDocument document, int header, int rows) {
+ XWPFTable table = document.createTable(header + rows, 1);
+ for (int index = 0; index < header + rows; index++) {
+ XWPFTableRow row = table.getRow(index);
+ row.getCell(0).setText(index < header ? "Header" : "Row " + (index - header + 1));
+ row.setRepeatHeader(index < header);
+ }
+ return table;
+ }
+
+ private static List texts(XWPFTable table) {
+ return table.getRows().stream().map(row -> row.getCell(0).getText()).toList();
+ }
+
+ private static byte[] bytes(XWPFDocument document) throws Exception {
+ try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ document.write(out);
+ return out.toByteArray();
+ }
+ }
+}
From 4bca4fefc39f2bd4fa9d8cfcd8e62221558609d8 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 24 Oct 2026 07:35:58 +0100
Subject: [PATCH 2/2] fix(docx): keep a table whole under a numbered header row
and name its split plainly
- A numbered list item in a header row is copied into the second part
under the same list, where Word numbers it on from the original: the
page's "1." would read "2." there. A table whose header row holds one
is not split, as one holding a bookmark or a drawing is not.
- The split's note says the table is split there, the part after it
opening with its header rows: "written as two" read wrong on a table
split twice.
- Tests: a split before a page break into a third set of margins ends
two sections; a table of a later section of a document of several is
split in it; a numbered header row keeps the table whole.
- CHANGELOG: the margins in force rather than the page before's, the
drift of a table without header rows as measured (0.4 to 0.8pt), what
stays the same in MerchantInvoice and PaymentsInvoice, and Obsidian's
one note.
---
CHANGELOG.md | 19 +++---
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 2 +-
.../semantic/docx/DocxPageMarginSections.java | 10 ++--
.../backend/semantic/docx/DocxTableSplit.java | 12 ++--
.../docx/DocxPageMarginSectionsTest.java | 60 +++++++++++++++++--
.../semantic/docx/DocxTableSplitTest.java | 11 ++++
7 files changed, 89 insertions(+), 27 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11efda866..9792b94a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,24 +11,25 @@ follow semantic versioning; release dates are ISO 8601.
- **A DOCX export splits a body table at the row where a page with other top and bottom margins
starts.**
- A Word section starts only between blocks. So a page that the layout started among a table's
- rows was named, and Word set it with the margins of the page before. `WorkspaceInvoice` with
- its line items run onto three pages set 58 words of its third page on its second.
+ rows was named, and Word set it with the margins in force. `WorkspaceInvoice` with its line
+ items run onto three pages set 58 words of its third page on its second.
- The table is written as two at the row the page starts with, and the hairline between the two
parts ends the section. The second part opens with copies of the table's header rows, which
the layout repeats at the top of each page.
- Measured in Word and LibreOffice on Windows:
- On tables with and without header rows, and across three sets of margins, every page held
the lines the layout sets on it. With header rows, each line stood within 0.4pt. Without
- them, a continued table's pages stand about half a point lower in both editors, as they do
- in the same table written whole.
+ them, a continued table's pages stand 0.4 to 0.8pt lower in both editors, as they do in the
+ same table written whole.
- In `WorkspaceInvoice`, every word stood on its page, within 0.5pt. `MerchantInvoice` and
- `PaymentsInvoice` are split too, and both editors set every word where they did before,
- which was already its page.
+ `PaymentsInvoice` are split too. Both editors set every word of them on the same page as
+ before, the layout's, and at the same height.
- The report names the split on the table (`page margin`). A table is still not split, and its
page is still named on it, where:
- a cell spans rows across that row;
- only its header rows stand above that row;
- - a header row holds a bookmark or a drawing, which the file can hold only once;
+ - a header row holds a bookmark or a drawing, which the file can hold only once, or a
+ numbered list item, which its copy would number on;
- a header row or a row from that one on holds a page reference, which the export settles in
the paragraph it wrote it in once the body is written;
- the rows above that row reach the foot of their page, leaving no room for the hairline.
@@ -59,8 +60,8 @@ follow semantic versioning; release dates are ISO 8601.
the lines of a paragraph, a panel — or after a page whose content reaches its foot under a
table, and a page of a document of several sections whose header or footer counts its
section's pages, which Word would count apart. `ObsidianInvoice`, which sets another bottom
- margin from page 2, starts its second page inside the panel holding its line items, so its
- later pages are named, not written.
+ margin from page 2, starts its second page inside the panel holding its line items, so that
+ page is named on the panel and its margins are not written.
- The corpus is unchanged: none of its documents has margins that change from page to page.
- **A DOCX export names a document's timeline rails once.**
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 6c7367157..81f708708 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -133,7 +133,7 @@ honour an option ignores it (documented contract).
| Render to images (`renderToImages`) | ✅ PDFBox `PDFRenderer` | ❌ (throws with a pointer to the PDF backend — POI's slide rasterizer cannot honour embedded fonts, and the PDF raster of the same graph is the canonical image output) | n/a (a `FixedLayoutRenderer` surface; the semantic SPI has no raster output) |
| Raster-slide mode — every page as one full-slide picture, pixel-exact to the PDF/PNG output (`Builder.rasterSlides(dpi)`) | n/a (the PDF raster is the source) | ✅ `PptxFixedLayoutBackend` | n/a (a fixed-layout backend builder option) |
| Multi-section documents (`renderSections`, per-section chrome, cross-section links) | ✅ `buildSectionsDocument` in `PdfFixedLayoutBackend` | ⚠️ `renderSections` in `PptxFixedLayoutBackend` (a deck carries one slide size, so every section must share the same page canvas — differing sizes throw) | ✅ `DocxSemanticBackend.exportSections`, reached through `MultiSectionDocument.export` / `toDocxBytes` — a Word section per section, each with its own page size, orientation, margins and page-zone header and footer (the text header and footer slots are written per section, their page total as `SECTIONPAGES`). Page numbers restart at 1 in every section and a zone's total is `SECTIONPAGES`, as the PDF counts; a section with no zone of its own gets an empty header or footer rather than Word's inherited one; styles, fonts and bookmark names are shared, so links cross sections. With different even and odd pages stated, Word starts every section on an odd page and adds a blank page before a section whose section before it runs to an odd number of pages, which the report names (LibreOffice adds none) |
-| Per-page margins (`DocumentSession.pageMargins`) | ✅ laid out page by page (`PageGeometry`) | ✅ drawn from the layout | ⚠️ `DocxPageMarginSections` — the first page's top and bottom margins are the section's; a page set against other top and bottom margins than Word sets it with starts a Word section with them where it starts between blocks of the body, an explicit page break there becoming the section's break and a hairline between two tables carrying its end, the title-page part kept to the first section, as is the page count from 1 in a document of several sections — measured in Word and LibreOffice on Windows, on pages of paragraphs, after a page break, after a table, between two tables and under a card's held margin, every page where the layout sets it, line for line, where one set of margins ran a page with a larger bottom margin on past its foot. Each section is named (`page margin`): its page break is fixed when the text above it is edited. Side margins stay the document's, which the body is written against and the page sets its header and footer from; a page whose side margins differ is named. A page that starts among a body table's rows splits the table at that row, the second part opening with copies of the header rows the layout repeats, the hairline between the parts ending the section — measured in both editors, every page holds the lines the layout sets on it; the split is named on the table. A page that starts inside another block (a paragraph's lines, a panel), at a table's row a cell spans rows across or only the header rows stand above, under a header row holding a bookmark or a drawing, where a header row or a row that would move holds a page reference, after a page whose content reaches its foot under a table or in its rows, or in a document of sections whose header or footer counts its section's pages starts none and is named, Word setting it with the margins in force |
+| Per-page margins (`DocumentSession.pageMargins`) | ✅ laid out page by page (`PageGeometry`) | ✅ drawn from the layout | ⚠️ `DocxPageMarginSections` — the first page's top and bottom margins are the section's; a page set against other top and bottom margins than Word sets it with starts a Word section with them where it starts between blocks of the body, an explicit page break there becoming the section's break and a hairline between two tables carrying its end, the title-page part kept to the first section, as is the page count from 1 in a document of several sections — measured in Word and LibreOffice on Windows, on pages of paragraphs, after a page break, after a table, between two tables and under a card's held margin, every page where the layout sets it, line for line, where one set of margins ran a page with a larger bottom margin on past its foot. Each section is named (`page margin`): its page break is fixed when the text above it is edited. Side margins stay the document's, which the body is written against and the page sets its header and footer from; a page whose side margins differ is named. A page that starts among a body table's rows splits the table at that row, the second part opening with copies of the header rows the layout repeats, the hairline between the parts ending the section — measured in both editors, every page holds the lines the layout sets on it; the split is named on the table. A page that starts inside another block (a paragraph's lines, a panel), at a table's row a cell spans rows across or only the header rows stand above, under a header row holding a bookmark, a drawing or a numbered list item, where a header row or a row that would move holds a page reference, after a page whose content reaches its foot under a table or in its rows, or in a document of sections whose header or footer counts its section's pages starts none and is named, Word setting it with the margins in force |
| Deterministic output (render twice → identical bytes) | ✅ `PdfDeterminismWriter` | ✅ `PptxDeterminismWriter` (pinned OPC created/modified + zip entry-time normalization) | ✅ `DocxDeterminism` (`DocxSemanticBackend.builder().deterministic(true)` or `deterministic(Instant)`: pinned OPC created/modified + zip entry-time normalization; an embedded font's obfuscation key is derived from the font, so it needs no pinning) |
| ServiceLoader discovery (`FixedLayoutBackendProvider`) | ✅ `PdfFixedLayoutBackendProvider` (`format() == "pdf"`) | ✅ `PptxFixedLayoutBackendProvider` (`format() == "pptx"`) | n/a (semantic SPI: `SemanticBackend`) |
| `DocumentSession` convenience methods | ✅ `buildPdf` / `writePdf` / `toPdfBytes` / `toImages` | ✅ `buildPptx` / `writePptx` / `toPptxBytes` (resolved via `BackendProviders.fixedLayout("pptx")`; session chrome applies; fails with `MissingBackendException` naming `graph-compose-render-pptx` when the backend is absent) | ✅ `session.export(new DocxSemanticBackend(...), target)` — the semantic backend is named directly rather than discovered |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index ff95d0070..4dc18105b 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -197,7 +197,7 @@ on it. The report names the split on the table (`page margin`).
A page that starts inside any other block — the lines of a paragraph, a panel — does not start a
section. Nor does a page that starts at a table's row where a cell spans rows across it, where
only the header rows stand above it, or where a header row holds a bookmark or a drawing, which
-the file can hold only once. Nor where a header row or a row from that one on holds a page
+the file can hold only once, or a numbered list item, which its copy would number on. Nor where a header row or a row from that one on holds a page
reference, which the export settles in the paragraph it wrote it in once the body is written. A
page after one whose content reaches its foot, under a table or in its rows, starts none either,
as the line that would end the section has no room. Neither does a page of a
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSections.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSections.java
index 3a038c58c..4fb24e067 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSections.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSections.java
@@ -43,8 +43,8 @@
* margins differ is named.
*
* A section ends in a paragraph of the body, so it can start only between blocks written
- * there. A table the page starts among the rows of is split at the row it starts with
- * ({@link DocxTableSplit}). Where the page starts inside any other block — the lines of a
+ * there. A table the page starts among the rows of is split at the row it starts with, where it
+ * can be ({@link DocxTableSplit#rowStarting}). Where the page starts inside any other block — the lines of a
* paragraph, a panel — its margins are named instead, and Word sets that page with the margins in
* force.
*
@@ -222,9 +222,9 @@ static Plan plan(XWPFDocument document, int from, Map owner
if (table != null) {
report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, layout.pathOf(inside.node()), differ
+ ": written as a Word section that starts at the row page " + (page + 1) + " starts with, the "
- + "table written as two there" + (DocxTableSplit.headerRows(table) > 0
- ? ", the second part opening with its header rows" : "") + ", and its page break fixed there, "
- + "so rows an edit adds above it no longer run onto that page");
+ + "table split there" + (DocxTableSplit.headerRows(table) > 0
+ ? ", the part after it opening with its header rows" : "") + ", and its page break fixed "
+ + "there, so rows an edit adds above it no longer run onto that page");
} else {
report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, section, differ + ": written as a Word "
+ "section that starts where page " + (page + 1) + " does, its page break fixed there, so text "
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java
index e11377cb9..8a97c3e8c 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java
@@ -30,8 +30,9 @@ final class DocxTableSplit {
private static final String W =
"declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' ";
- // What a file holds once: a bookmark's name marks one place, and a drawing's id names one drawing.
- private static final List HELD_ONCE = List.of("bookmarkStart", "drawing", "pict", "object");
+ // What a header row's copy cannot hold again: a bookmark's name marks one place, a drawing's id
+ // names one drawing, and a list item's number is counted on from the item before it.
+ private static final List NOT_REPEATED = List.of("bookmarkStart", "drawing", "pict", "object", "numPr");
// A field, which the export settles once the body is written, in the paragraph it wrote it in: a
// row moved to the second part leaves that paragraph behind, and a copy of one is never settled.
@@ -44,8 +45,9 @@ private DocxTableSplit() {
* The row of a table the layout starts a page with, where the table can be split there.
*
* It cannot be where a cell spans rows across that row, where only the header rows stand
- * above it, or where a header row holds what the file can hold only once — a bookmark or a
- * drawing — as the part after it would hold that row again. Nor where a header row or a row
+ * above it, or where a header row holds what its copy in the part after it cannot hold again —
+ * a bookmark or a drawing, which the file holds once, or a numbered list item, which the copy
+ * would number on from the one above. Nor where a header row or a row
* from that one on holds a field, a page reference's: the export settles it once the body is
* written, in the paragraph it wrote it in.
*
@@ -57,7 +59,7 @@ private DocxTableSplit() {
static int rowStarting(XWPFTable table, int page, IntUnaryOperator pageOf) {
int rows = table.getNumberOfRows();
int header = headerRows(table);
- if (holds(table, 0, header, HELD_ONCE) || holds(table, 0, header, FIELDS)) {
+ if (holds(table, 0, header, NOT_REPEATED) || holds(table, 0, header, FIELDS)) {
return -1;
}
for (int row = header; row < rows; row++) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java
index d1a4cab37..be1e6924f 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java
@@ -103,9 +103,9 @@ void aPageStartingAmongATablesRowsSplitsTheTableAtTheRowItStartsWith() throws Ex
assertThat(note.path()).endsWith("TableNode[0]");
assertThat(note.detail()).isEqualTo("page 2's top and bottom margins differ from page 1's: written "
+ "as a Word section that starts at the row page 2 starts with, "
- + "the table written as two there, the second part opening with "
- + "its header rows, and its page break fixed there, so rows an "
- + "edit adds above it no longer run onto that page");
+ + "the table split there, the part after it opening with its "
+ + "header rows, and its page break fixed there, so rows an edit "
+ + "adds above it no longer run onto that page");
});
}
@@ -164,9 +164,57 @@ void aTableWithoutHeaderRowsIsSplitIntoPartsWithNone() throws Exception {
.isEqualTo("Item " + exported.rowsOn(0));
assertThat(tables.get(1).getRow(0).isRepeatHeader()).isFalse();
assertThat(exported.notes()).singleElement().asString().isEqualTo("page 2's top and bottom margins differ from "
- + "page 1's: written as a Word section that starts at the row page 2 starts with, the table written "
- + "as two there, and its page break fixed there, so rows an edit adds above it no longer run onto "
- + "that page");
+ + "page 1's: written as a Word section that starts at the row page 2 starts with, the table split "
+ + "there, and its page break fixed there, so rows an edit adds above it no longer run onto that page");
+ }
+
+ @Test
+ void aTableSplitBeforeAPageBreakIntoOtherMarginsEndsTwoSections() throws Exception {
+ // The split ends page 1's section; the page break, carrying the next, becomes its break.
+ DocumentInsets third = new DocumentInsets(36, 36, 86, 36);
+ Exported exported = export(List.of(PageMarginRule.page(2, LATER), PageMarginRule.from(3, third)), page -> {
+ page.addTable(table -> rows(table, "Item", 20));
+ page.addPageBreak(b -> { });
+ lines(page, "Line", 10);
+ });
+
+ List tables = exported.document().getTables();
+ assertThat(tables).hasSize(2);
+ List ending = exported.paragraphsEndingASection();
+ assertThat(ending).hasSize(2);
+ List body = exported.document().getBodyElements();
+ assertThat(body.indexOf(ending.get(0))).as("between the parts").isEqualTo(body.indexOf(tables.get(0)) + 1);
+ assertThat(body.indexOf(ending.get(1))).as("the page break, after the second part")
+ .isEqualTo(body.indexOf(tables.get(1)) + 1);
+ assertThat(exported.document().getDocument().xmlText()).doesNotContain("w:type=\"page\"");
+ assertThat(bottom(ending.get(1).getCTP().getPPr().getSectPr())).isEqualTo(136 * 20);
+ assertThat(bottom(exported.body())).isEqualTo(86 * 20);
+ }
+
+ @Test
+ void aTableOfALaterSectionOfADocumentOfSeveralIsSplitInIt() throws Exception {
+ DocumentSession body = GraphCompose.document().pageSize(400, 400).margin(FIRST).create();
+ body.pageMargins(List.of(PageMarginRule.from(2, LATER)));
+ body.pageFlow(page -> page.addTable(table -> rows(table, "Item", 30)));
+ DocumentSession cover = GraphCompose.document().pageSize(400, 400).margin(FIRST).create();
+ cover.pageFlow(page -> page.addParagraph("Cover"));
+ byte[] bytes;
+ try (com.demcha.compose.document.api.MultiSectionDocument document = GraphCompose.documents()
+ .section(cover).section(body).create()) {
+ bytes = document.toDocxBytes();
+ }
+
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(bytes))) {
+ assertThat(document.getTables()).hasSize(2);
+ assertThat(document.getParagraphs())
+ .filteredOn(p -> p.getCTP().isSetPPr() && p.getCTP().getPPr().isSetSectPr())
+ .as("the cover's, then the hairline between the parts").hasSize(2);
+ List elements = document.getBodyElements();
+ assertThat(elements.get(elements.indexOf(document.getTables().get(0)) + 1))
+ .isInstanceOfSatisfying(XWPFParagraph.class,
+ between -> assertThat(between.getCTP().getPPr().isSetSectPr()).isTrue());
+ assertThat(bottom(document.getDocument().getBody().getSectPr())).isEqualTo(136 * 20);
+ }
}
@Test
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java
index 6aad9360f..7a5ddc360 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java
@@ -96,6 +96,17 @@ void aTableIsNotSplitWhereAHeaderRowHoldsWhatTheFileHoldsOnce() throws Exception
}
}
+ @Test
+ void aTableIsNotSplitWhereAHeaderRowHoldsANumberedItem() throws Exception {
+ // Word numbers the copy on from the original: the page's "1." would read "2." there.
+ try (XWPFDocument document = new XWPFDocument()) {
+ XWPFTable table = table(document, 1, 5);
+ table.getRow(0).getCell(0).getParagraphs().get(0).setNumID(java.math.BigInteger.ONE);
+
+ assertThat(DocxTableSplit.rowStarting(table, 1, row -> row < 4 ? 0 : 1)).isEqualTo(-1);
+ }
+ }
+
@Test
void aTableIsNotSplitWhereARowItMovesOrCopiesHoldsAField() throws Exception {
// The export settles a page reference in the paragraph it wrote it in, once the body is written.