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, MapThe 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 ListIt 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, ListMeasured 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