diff --git a/CHANGELOG.md b/CHANGELOG.md index d622bef71..9792b94a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **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 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 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. 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, 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. + - The corpus is unchanged: none of its documents has margins that change from page to page. + - **A DOCX export writes top and bottom margins that change from page to page as Word sections.** - `DocumentSession.pageMargins(...)` reached the layout only. The DOCX wrote the canvas's @@ -30,11 +57,11 @@ follow semantic versioning; release dates are ISO 8601. panel's room among them — and from which the page sets its header and footer. A page whose side margins differ is named. - The report names, and the export starts no section at, a page that starts inside a block — - the rows of a table, 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. The four invoice presets - that set another bottom margin from page 2 start their second page inside their line items, - so their later pages are named, not written. + 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 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 01fe5f666..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 inside a block (a table's rows, a paragraph's lines, a panel), after a page whose content reaches its foot under a table, 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 7ed4a21f5..4dc18105b 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -188,12 +188,22 @@ Side margins stay the document's. The body is written against its width — a ru panel's room — and the page sets its header and footer there, so a page whose side margins differ is named and Word sets its text between the document's. -A section ends in a paragraph of the body, so a page that starts inside a block — the rows of a -table, the lines of a paragraph, a panel — does not start one, nor does a page after one whose -content reaches its foot under a table, where the line that would end the section has no room. -Neither does a page of a document of several sections whose header or footer counts its -section's pages: Word would count a section of its own apart. The report names each (`page -margin`), on the block where there is one, and Word sets that page with the margins in force. +A section ends in a paragraph of the body. A page that starts among a table's rows splits the +table there: the rows from that one on are written as a table of their own, under copies of the +header rows the layout repeats on each page, and the hairline between the two parts ends the +section. Measured in Word and LibreOffice on Windows, every page holds the lines the layout sets +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, 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 +document of several sections whose header or footer counts its section's pages: Word would count +a section of its own apart. The report names each (`page margin`), on the block where there is +one, and Word sets that page with the margins in force. ## Finding out what the export could not carry 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 ad00a69eb..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,10 @@ * margins differ is named.

* *

A section ends in a paragraph of the body, so it can start only between blocks written - * there. Where the page starts inside a block — the rows of a table, the lines of a paragraph, - * a panel — its margins are named instead, and Word sets that page with the margins in force.

+ * 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.

* * @author Artem Demchyshyn */ @@ -56,9 +58,10 @@ final class DocxPageMarginSections { private static final double POINT_TO_TWIP = 20; // A section's end needs a line under the block before it; past a table there is none but a - // hairline, the one it is given or the one between two tables, which a page whose content - // reaches its foot moves onto a page of its own. Two points, as a table's rows rounded to whole - // twips stand an editor's page of them a little lower than the layout's. + // hairline — the one it is given, the one between two tables or the one between a table's two + // parts — which a page whose content reaches its foot moves onto a page of its own. Two + // points, as a table's rows rounded to whole twips stand an editor's page of them a little + // lower than the layout's. private static final double ROOM_FOR_THE_END = 2; /** @@ -83,11 +86,14 @@ private record Start(int page, Margin margin, Carrier carrier) { /** * What ends the section before a start: a page break written there, a paragraph standing there — - * the one before it, or a hairline between two tables — or else a hairline put before {@code at}. + * the one before it, or a hairline between two tables — or else a hairline put before {@code at}, + * or, where {@code row} is not -1, between the two parts of the table {@code at} is split into + * before that row. * * @param hairline whether what ends it is a hairline under a table, which needs room on its page */ - private record Carrier(XWPFParagraph pageBreak, XWPFParagraph paragraph, IBodyElement at, boolean hairline) { + private record Carrier(XWPFParagraph pageBreak, XWPFParagraph paragraph, IBodyElement at, int row, + boolean hairline) { } /** @@ -181,12 +187,18 @@ static Plan plan(XWPFDocument document, int from, Map owner return new Plan(opening, List.of(), zoned); } Owner inside = spanning(page, elements, owners); - int at = inside != null ? -1 : firstOn(page, elements, owners); - if (at >= 0 && owners.get(elements.get(at)).first() != page) { - // The first block after it starts on a later page: none starts this one. - at = -1; + XWPFTable table = inside == null ? null : tableOf(inside, elements, owners); + Carrier carrier = null; + if (table != null) { + int row = DocxTableSplit.rowStarting(table, page, index -> layout.rowPage(inside.node(), index)); + carrier = row < 0 ? null : new Carrier(null, null, table, row, true); + } else if (inside == null) { + int at = firstOn(page, elements, owners); + // Where the first block after it starts on a later page, none starts this one. + carrier = at < 0 || owners.get(elements.get(at)).first() != page ? null + : carrierBefore(at, elements, owners, separators); } - if (at < 0) { + if (carrier == null) { if (inside == null || inside.node() != lastNamed) { report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, inside != null ? layout.pathOf(inside.node()) : section, @@ -197,21 +209,31 @@ static Plan plan(XWPFDocument document, int from, Map owner } continue; } - Carrier carrier = carrierBefore(at, elements, owners, separators); // A page with no content has room for a hairline. double room = layout.lowestContentOn(page - 1) - inForce.bottom(); if (carrier.hairline() && room < ROOM_FOR_THE_END) { - report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, section, notWritten + "page " + page + report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, + table != null ? layout.pathOf(inside.node()) : section, notWritten + "page " + page + "'s content reaches its foot, where the paragraph that would end a Word section before page " + (page + 1) + " would open a page of its own"); continue; } starts.add(new Start(page, margin, carrier)); - 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 an " - + "edit adds above it no longer runs onto that page"); + 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 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 " + + "an edit adds above it no longer runs onto that page"); + } inForce = margin; inForceFrom = page; + // A block named for a page before this section is named again for one after it. + lastNamed = null; } return new Plan(opening, starts, zoned); } @@ -250,22 +272,26 @@ static void apply(XWPFDocument document, Plan plan) { } CTSectPr body = bodySectPr(document); CTSectPr opening = (CTSectPr) body.copy(); + // Found last to first: a table split at two rows is split at the later one first, so the + // earlier still stands where the plan found it. + XWPFParagraph[] carriers = new XWPFParagraph[plan.starts().size()]; + for (int index = carriers.length - 1; index >= 0; index--) { + carriers[index] = carrierOf(document, plan.starts().get(index).carrier()); + } Margin margin = plan.opening(); - boolean first = true; - for (Start start : plan.starts()) { + for (int index = 0; index < carriers.length; index++) { CTSectPr ending = (CTSectPr) opening.copy(); - if (!first) { + if (index > 0) { asALaterSection(ending, opening, margin, plan.zoned()); } - XWPFParagraph carrier = carrierOf(document, start.carrier()); + XWPFParagraph carrier = carriers[index]; CTPPr properties = carrier.getCTP().isSetPPr() ? carrier.getCTP().getPPr() : carrier.getCTP().addNewPPr(); // Kept with the next paragraph, on the page the section's break opens, it would go there. if (properties.isSetKeepNext()) { properties.unsetKeepNext(); } properties.setSectPr(ending); - margin = start.margin(); - first = false; + margin = plan.starts().get(index).margin(); } asALaterSection(body, opening, margin, plan.zoned()); } @@ -307,15 +333,29 @@ private static Carrier carrierBefore(int at, List elements, Map 0 ? elements.get(at - 1) : null; if (element instanceof XWPFParagraph breaking && owners.get(breaking).node() instanceof PageBreakNode) { - return new Carrier(breaking, null, null, false); + return new Carrier(breaking, null, null, -1, false); } if (element instanceof XWPFParagraph separator && separators.contains(separator)) { - return new Carrier(null, separator, null, true); + return new Carrier(null, separator, null, -1, true); } if (before instanceof XWPFParagraph last) { - return new Carrier(null, last, null, false); + return new Carrier(null, last, null, -1, false); } - return new Carrier(null, null, element, true); + return new Carrier(null, null, element, -1, true); + } + + /** + * The table a block wrote in the body, if it wrote one: a table node's, its rows the node's, + * or a panel's, of no rows the layout placed. + */ + private static XWPFTable tableOf(Owner block, List elements, Map owners) { + for (IBodyElement element : elements) { + Owner owner = owners.get(element); + if (owner != null && owner.node() == block.node() && element instanceof XWPFTable table) { + return table; + } + } + return null; } private static XWPFParagraph carrierOf(XWPFDocument document, Carrier carrier) { @@ -333,6 +373,9 @@ private static XWPFParagraph carrierOf(XWPFDocument document, Carrier carrier) { if (carrier.paragraph() != null) { return carrier.paragraph(); } + if (carrier.row() >= 0) { + return hairline(DocxTableSplit.split(document, (XWPFTable) carrier.at(), carrier.row())); + } XmlObject at = carrier.at() instanceof XWPFTable table ? table.getCTTbl() : ((XWPFParagraph) carrier.at()).getCTP(); try (XmlCursor cursor = at.newCursor()) { return hairline(document.insertNewParagraph(cursor)); 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 new file mode 100644 index 000000000..8a97c3e8c --- /dev/null +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java @@ -0,0 +1,148 @@ +package com.demcha.compose.document.backend.semantic.docx; + +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.XWPFTableCell; +import org.apache.poi.xwpf.usermodel.XWPFTableRow; +import org.apache.xmlbeans.XmlCursor; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBody; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTbl; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge; + +import java.util.List; +import java.util.function.IntUnaryOperator; + +/** + * Splits a table of the body in two at the row a page starts with, so that a Word section can + * start there: a section ends in a paragraph, which stands between blocks. + * + *

The rows from that row on go to a table of their own after it, which opens with copies of + * the table's header rows: the layout repeats them at the top of each page the table runs onto, + * as Word repeats a table's leading header rows. A paragraph between the two parts ends the + * first.

+ * + * @author Artem Demchyshyn + */ +final class DocxTableSplit { + + private static final String W = + "declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' "; + + // 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. + private static final List FIELDS = List.of("fldChar", "instrText", "fldSimple"); + + 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 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.

+ * + * @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, NOT_REPEATED) || 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..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 @@ -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,247 @@ 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 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"); + }); + } + + @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 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 + 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 +382,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 +628,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 +693,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 +701,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..7a5ddc360 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java @@ -0,0 +1,186 @@ +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 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. + 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(); + } + } +}