From 87f64c1277bbb1fde42151bb2f58fe7a2df09bb6 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 6 Oct 2026 08:12:51 +0100 Subject: [PATCH 1/2] fix(docx): keep the layout's grid, row heights and unbroken rows for a table with side padding The page draws a table's rows inside its padding, narrower than its placement by that much, and the rows were matched to the layout by the placement's width: a table with side padding matched none and was written without the layout's grid, row heights or unbroken rows, standing its padding left of the page's. The rows are measured inside the padding now, and the padding holds the table in as its margin does, so a table with side padding is written as one with those margins. --- CHANGELOG.md | 16 ++- docs/recipes/docx-export.md | 4 +- .../semantic/docx/DocxLayoutMetrics.java | 14 ++- .../semantic/docx/DocxSemanticBackend.java | 48 ++++---- .../docx/DocxBlockGeometryReportTest.java | 30 +---- .../docx/DocxNodeFieldLedgerTest.java | 3 +- .../semantic/docx/DocxPaddedTableTest.java | 115 ++++++++++++++++++ 7 files changed, 169 insertions(+), 61 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPaddedTableTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index abe907a27..5f6d10751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX table with padding on its sides keeps the layout's grid, row heights and unbroken + rows.** The page draws a table's rows inside its padding, narrower than the table's + placement by that much, and the export matched a table's rows to the layout by the + placement's width: a table with side padding matched none, and was written without the + layout's grid, with no row heights and rows Word could break, its drawings anchored outside + its rows, and its rows standing its padding left of the page's. Its rows are now measured inside the padding, + and the padding holds the table in as its margin does — its left side in the indent, both + in the room its columns are given — so a table with side padding is written as one with + those margins. In `DocxNodeFieldLedgerTest` a table's padding moves from a gap to + `WRITTEN`; 22 node-field gaps remain. - **A DOCX export's report names the geometry a block is written without.** A logo given a left margin stood at the column's edge, a spacer with a margin held only its height and everything under it rose, and a section fixed to half the column ran its text the column's @@ -19,7 +29,6 @@ follow semantic versioning; release dates are ISO 8601. - on a page reference: the margin and padding on the side or sides its alignment sets it from, and, where the export writes no bookmark for its anchor, that its number is written as text — the shape the anchor is on says so too, and a table of contents' entry now does; - - on a table: its left padding (its margin is its indent); - on a spacer: its margin and padding above and below; on a chart, those and the ones on its left, which are not written round its data table. Below the lowest block of a band, or of a layer another resumes after in its column, the space is measured from the page, the @@ -29,9 +38,8 @@ follow semantic versioning; release dates are ISO 8601. width of. None changes what is written. In `DocxNodeFieldLedgerTest` 11 node fields move from a gap to - `REPORTED`; 23 node-field gaps remain, each named — among them a table's padding, since a - table with side padding is not matched to the rows the layout placed, and the fixed width of - a layer stack's column. + `REPORTED`; 23 node-field gaps remain, each named — among them the fixed width of a layer + stack's column. - **A DOCX export's report names what each written or drawn node goes without.** The export wrote these nodes and said nothing of what it left behind: a linked logo became an unlinked picture, a rotated photo stood upright, a dashed line was drawn solid, and a link to an anchor diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 64e7127e8..6e5ed5b02 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -69,8 +69,8 @@ creation date is real metadata. |---|---| | Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, or in a header or footer. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | | Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs | -| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it; its left padding is not written, and the report names it | -| Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | +| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given | +| Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | | Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below | | Images | Embedded pictures at the node's declared size. In the flow, the margin and padding above and below are the space round the picture's paragraph; on the left they are not written, and the report names them, as it does a barcode's and a page reference's sides. A picture drawn beside its text is fitted to its box with its padding in it, which the report names too | | Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is | diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index 30f8abe60..eeabc8374 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -900,7 +900,7 @@ java.util.Optional laidOutText(DocumentNode node) { if (placedTable == null || placedTable.placementWidth() <= 0) { return null; } - double width = placedTable.placementWidth(); + double width = rowsWidth(placedTable); TreeSet boundaries = new TreeSet<>(); for (PlacedFragment fragment : ownRows(node)) { @@ -970,7 +970,7 @@ private List ownRows(DocumentNode table) { if (placedTable == null || placedTable.placementWidth() <= 0) { return List.of(); } - double width = placedTable.placementWidth(); + double width = rowsWidth(placedTable); List rows = new ArrayList<>(); for (PlacedFragment fragment : fragmentsOf(table)) { if (!(fragment.payload() instanceof TableRowFragmentPayload row) || row.cells().isEmpty()) { @@ -987,6 +987,16 @@ private List ownRows(DocumentNode table) { return rows; } + /** + * How wide a table's rows are: its placement less its padding on either side, which the + * page draws the rows inside. Measured against the placement itself, a table with side + * padding matched none of its rows, and lost its grid, its row heights and its unbroken + * rows to Word's own. + */ + private static double rowsWidth(PlacedNode table) { + return table.placementWidth() - table.padding().left() - table.padding().right(); + } + private List fragmentsOf(DocumentNode node) { String path = paths.get(node); return path == null ? List.of() : fragments.getOrDefault(path, List.of()); diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index 1c15c8855..10cf04d86 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -2556,26 +2556,24 @@ private void reportWrittenWithout(DocumentNode node, String writtenAs, ListA side counts where it moves the block: only the left for a picture, a barcode or a - * table, which their paragraph or indent sets from the left; for a page reference, the side - * or sides its alignment sets it from.

- * - * @param node the block written - * @param marginWritten whether its side margins are written, so only its padding can be lost - * @param left whether its left side counts - * @param right whether its right side counts + * What of a block's sides its paragraph leaves out: a picture, a barcode and a page reference + * are written at the edge the containers round them set, with none of their own margin or + * padding. A cell of a row the layout placed already starts past a block's left margin. + * + *

A side counts where it moves the block: only the left for a picture or a barcode, which + * its paragraph sets from the left; for a page reference, the side or sides its alignment + * sets it from.

+ * + * @param node the block written + * @param left whether its left side counts + * @param right whether its right side counts * @return the phrases, empty when every side that counts is written */ - private List sidesLost(DocumentNode node, boolean marginWritten, boolean left, boolean right) { + private List sidesLost(DocumentNode node, boolean left, boolean right) { List lost = new ArrayList<>(2); String side = left && right ? "side" : left ? "left" : "right"; double marginLeft = node == leftMarginInCell ? 0 : node.margin().left(); - if (!marginWritten && (left && marginLeft != 0 || right && node.margin().right() != 0)) { + if (left && marginLeft != 0 || right && node.margin().right() != 0) { lost.add("its " + side + (left && right ? " margins are" : " margin is") + " not in the file"); } if (left && node.padding().left() != 0 || right && node.padding().right() != 0) { @@ -6323,7 +6321,7 @@ private void writePageReference(XWPFDocument document, appendField(para, " PAGEREF " + bookmark + " \\h ", shown, node.textStyle()); } // Unlike a paragraph's (writeParagraph), its own sides do not hold its line in. - lost.addAll(sidesLost(node, false, node.align() != TextAlign.RIGHT, node.align() != TextAlign.LEFT)); + lost.addAll(sidesLost(node, node.align() != TextAlign.RIGHT, node.align() != TextAlign.LEFT)); reportWrittenWithout(node, "written as a paragraph", lost); } @@ -8191,7 +8189,7 @@ private void writeImage(XWPFDocument document, ImageNode node) throws Exception .setPrst(org.openxmlformats.schemas.drawingml.x2006.main.STShapeType.ELLIPSE); } } - reportWrittenWithout(node, "written as an inline picture", sidesLost(node, false, true, false)); + reportWrittenWithout(node, "written as an inline picture", sidesLost(node, true, false)); } /** @@ -8476,7 +8474,7 @@ private void writeBarcode(XWPFDocument document, com.demcha.compose.document.nod for (String lost : carriedWithout(node, false)) { message.append("; ").append(lost); } - for (String lost : sidesLost(node, false, true, false)) { + for (String lost : sidesLost(node, true, false)) { message.append("; ").append(lost); } report.add(DocxExportReport.Severity.APPROXIMATED, "barcode", layout.pathOf(node), message.toString()); @@ -8680,7 +8678,7 @@ private static boolean startsWith(byte[] bytes, int... signature) { private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node) throws Exception { if (node instanceof TableNode table && !table.rows().isEmpty()) { - reportWrittenWithout(node, "written as a Word table", sidesLost(node, true, true, false)); + reportWrittenWithout(node, "written as a Word table"); } // The layout starts a block it moves to a new page at the block's own top edge: what // the page above holds below its last block, and the gap between the two, stay there. @@ -8725,8 +8723,11 @@ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode nod double outerLeft = insetLeft; double outerRight = insetRight; // A row's column already starts past the margin of the block the layout placed in it. - insetLeft += node == leftMarginInCell ? 0 : node.margin().left(); - insetRight += node.margin().right(); + // A table's padding holds its rows in too, as the page draws them inside it; a row's + // rides in its cells' margins instead (writeRow). + boolean table = node instanceof TableNode; + insetLeft += (node == leftMarginInCell ? 0 : node.margin().left()) + (table ? node.padding().left() : 0); + insetRight += node.margin().right() + (table ? node.padding().right() : 0); try { if (node instanceof RowNode row) { writeRow(document, row); @@ -11255,8 +11256,9 @@ private double[] withEditorSlack(TableNode node, double[] measured) { autoColumns++; } } + // Its padding is already among the insets it is held in by (writeTableWithItsOwnSpacing). double available = Double.isFinite(nestedTableWidth()) ? nestedTableWidth() : availableWidth(); - double room = available - node.padding().horizontal() - sum(measured); + double room = available - sum(measured); if (autoColumns == 0 || !(room > 0)) { return measured; } @@ -11488,7 +11490,7 @@ private static double usableWidthOf(XWPFTableCell cell, int column, int span) { * Word to about one character per line, which is not a document anybody can read.

*/ private double nestedTableWidth() { - // Less what the table is held in by, its own margins included (writeTableWithItsOwnSpacing). + // Less what the table is held in by, its own margins and padding included (writeTableWithItsOwnSpacing). return currentCellWidth - Math.max(0, insetLeft) - Math.max(0, insetRight); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockGeometryReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockGeometryReportTest.java index 711a71838..783394fc5 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockGeometryReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockGeometryReportTest.java @@ -29,8 +29,8 @@ /** * What of a block's own geometry the export does not write is in its report note: the sides of - * a picture, a barcode or a page reference, a table's padding, the room a spacer holds past its - * height, the space round a chart's table, the fixed width of an unpainted box. + * a picture, a barcode or a page reference, the room a spacer holds past its height, the space + * round a chart's table, the fixed width of an unpainted box. * *

Each was left out in silence: a logo given a left margin stood at the column's edge, a * spacer with a margin held only its height and everything under it rose, and a section fixed @@ -93,26 +93,6 @@ void aBarcodeNamesItsSides() throws Exception { .addBarcode(barcode -> barcode.qrCode().data("GC-1").size(60, 60)))); } - @Test - void aTableNamesItsSidePaddingAndHoldsItsMargin() throws Exception { - DocxExportReport padded = reportOf(page -> page - .addTable(table -> table.columns(DocumentTableColumn.fixed(200)).row("Cell") - .margin(new DocumentInsets(0, 0, 0, 10)).padding(new DocumentInsets(0, 0, 0, 30)))); - DocxExportReport indented = reportOf(page -> page - .addTable(table -> table.columns(DocumentTableColumn.fixed(200)).row("Cell") - .margin(new DocumentInsets(0, 0, 0, 30)))); - - assertThat(detailOf(padded, "TableNode")) - .isEqualTo("written as a Word table; its left padding is not in the file"); - assertThat(indented.bySubject()).as("a table's margin is its indent").doesNotContainKey("TableNode"); - String marginOnly = indentOf(bodyOf(page -> page.addTable(table -> table - .columns(DocumentTableColumn.fixed(200)).row("Cell").margin(new DocumentInsets(0, 0, 0, 10))))); - assertThat(marginOnly).as("its margin is its indent").isNotEmpty(); - assertThat(indentOf(bodyOf(page -> page.addTable(table -> table.columns(DocumentTableColumn.fixed(200)) - .row("Cell").margin(new DocumentInsets(0, 0, 0, 10)).padding(new DocumentInsets(0, 0, 0, 30)))))) - .as("its indent does not carry its padding").isEqualTo(marginOnly); - } - @Test void aPageReferenceNamesTheSidesItsAlignmentSetsItFrom() throws Exception { DocxExportReport left = reportOf(page -> page @@ -329,12 +309,6 @@ private static String bodyOf(Consumer content) throws Exception } } - /** A table's {@code w:tblInd} in the body's XML, or none. */ - private static String indentOf(String body) { - java.util.regex.Matcher indent = java.util.regex.Pattern.compile("]*/>").matcher(body); - return indent.find() ? indent.group() : ""; - } - private static String detailOf(DocxExportReport report, String subject) { List notes = report.bySubject().get(subject); assertThat(notes).as("the note on the %s", subject).isNotNull().hasSize(1); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java index 0c3d1dcca..5d191f67c 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -197,8 +197,7 @@ private record Entry(Fate fate, String note) { node(TableNode.class, "name:INERT", "columns:WRITTEN", "rows:WRITTEN", "defaultCellStyle:WRITTEN", "rowStyles:WRITTEN", "columnStyles:WRITTEN", "width:WRITTEN", "linkTarget:REPORTED", "bookmarkOptions:REPORTED", - "padding:GAP:with side padding, its rows are not matched to the layout's and lose its grid, " - + "row heights, unbroken rows and the anchoring of drawings in them; its left padding is reported", + "padding:WRITTEN", "margin:WRITTEN", "repeatedHeaderRowCount:WRITTEN", "anchor:WRITTEN"); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPaddedTableTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPaddedTableTest.java new file mode 100644 index 000000000..001292456 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPaddedTableTest.java @@ -0,0 +1,115 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.TableBuilder; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.table.DocumentTableColumn; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A table with padding on its sides is written as the page draws it: its rows inside the + * padding, on the grid the layout resolved, at the heights it gave them. + * + *

The page draws a table's rows inside its padding, so they are narrower than the table's + * placement by that padding. The export matched a table's rows to the layout by the + * placement's width, and a table with side padding matched none: it was written without the + * layout's grid, with no row heights and rows Word could break, and stood its padding left of + * the page's.

+ */ +class DocxPaddedTableTest { + + @Test + void aTableWithSidePaddingIsWrittenAsOneWithThoseMarginsIs() throws Exception { + // The page draws both tables' rows at the same place and width. + String padded = bodyOf(page -> page.addTable(table -> rows(table.columns( + DocumentTableColumn.fixed(120), DocumentTableColumn.fixed(140)), 2) + .padding(new DocumentInsets(0, 10, 0, 30)))); + String margined = bodyOf(page -> page.addTable(table -> rows(table.columns( + DocumentTableColumn.fixed(120), DocumentTableColumn.fixed(140)), 2) + .margin(new DocumentInsets(0, 10, 0, 30)))); + + assertThat(padded).isEqualTo(margined); + assertThat(padded).as("the layout's grid").contains("") + .as("rows Word may not break").contains(" page.addTable(table -> rows(table.columns( + DocumentTableColumn.auto(), DocumentTableColumn.auto()), 2) + .padding(new DocumentInsets(0, 12, 0, 18)))); + String margined = bodyOf(page -> page.addTable(table -> rows(table.columns( + DocumentTableColumn.auto(), DocumentTableColumn.auto()), 2) + .margin(new DocumentInsets(0, 12, 0, 18)))); + + assertThat(padded).isEqualTo(margined).contains(""); + } + + @Test + void anAutoColumnGetsTheSlackTheRoomInsideThePaddingLeaves() throws Exception { + // The fixed column and the auto one leave less room than the padding: counted once, the + // auto column takes its point of editor slack; counted twice, there is none to give. + String padded = bodyOf(page -> page.addTable(table -> rows(table.columns( + DocumentTableColumn.fixed(300), DocumentTableColumn.auto()), 2) + .padding(new DocumentInsets(0, 12, 0, 18)))); + String margined = bodyOf(page -> page.addTable(table -> rows(table.columns( + DocumentTableColumn.fixed(300), DocumentTableColumn.auto()), 2) + .margin(new DocumentInsets(0, 12, 0, 18)))); + + assertThat(padded).isEqualTo(margined); + } + + @Test + void aTableComposedInACellIsHeldInByItsPadding() throws Exception { + String body = bodyOf(page -> page.addTable(outer -> outer.columns(DocumentTableColumn.fixed(240)) + .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(new TableBuilder() + .columns(DocumentTableColumn.fixed(120)).row("Inner") + .padding(new DocumentInsets(0, 0, 0, 15)).build())))); + + assertThat(body).as("its indent in the cell, the padding").containsPattern("]*w:w=\"300\""); + } + + @Test + void aTableWithSidePaddingLosesNothingTheReportNames() throws Exception { + AtomicReference report = new AtomicReference<>(); + try (DocumentSession session = session()) { + session.pageFlow(page -> page.addTable(table -> rows(table.columns(DocumentTableColumn.fixed(200)), 1) + .padding(new DocumentInsets(4, 10, 4, 30)))); + session.export(new DocxSemanticBackend(report::set)); + } + + assertThat(report.get().bySubject()).doesNotContainKey("TableNode"); + } + + /** Two rows of a table of one column or two. */ + private static TableBuilder rows(TableBuilder table, int columns) { + return columns == 1 ? table.row("First").row("Second") : table.row("First", "A").row("Second", "B"); + } + + private static String bodyOf(Consumer content) throws Exception { + try (DocumentSession session = session()) { + session.pageFlow(content::accept); + try (XWPFDocument document = new XWPFDocument( + new ByteArrayInputStream(session.export(new DocxSemanticBackend())))) { + return document.getDocument().getBody().xmlText(); + } + } + } + + private static DocumentSession session() { + return GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create(); + } +} From 5d8b6c71b5b736795fba5a8d7ef68c6f6dcfa5b3 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 6 Oct 2026 08:36:46 +0100 Subject: [PATCH 2/2] test(docx): pin a padded table under an alignment wrapper and a drawing in its cell A table held in by an alignment wrapper, and a drawing a padded table's cell is all of, are written as for a table with those margins. The recipe's indent paragraph, the capability matrix's nested-table width and the CHANGELOG entry's wrapping say what the padding now does. --- CHANGELOG.md | 8 ++-- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 3 +- .../semantic/docx/DocxPaddedTableTest.java | 38 ++++++++++++++++++- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6d10751..14c4568a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,10 @@ follow semantic versioning; release dates are ISO 8601. placement by that much, and the export matched a table's rows to the layout by the placement's width: a table with side padding matched none, and was written without the layout's grid, with no row heights and rows Word could break, its drawings anchored outside - its rows, and its rows standing its padding left of the page's. Its rows are now measured inside the padding, - and the padding holds the table in as its margin does — its left side in the indent, both - in the room its columns are given — so a table with side padding is written as one with - those margins. In `DocxNodeFieldLedgerTest` a table's padding moves from a gap to + its rows, and its rows standing its padding left of the page's. Its rows are now measured + inside the padding, and the padding holds the table in as its margin does — its left side in + the indent, both in the room its columns are given — so a table with side padding is written + as one with those margins. In `DocxNodeFieldLedgerTest` a table's padding moves from a gap to `WRITTEN`; 22 node-field gaps remain. - **A DOCX export's report names the geometry a block is written without.** A logo given a left margin stood at the column's edge, a spacer with a margin held only its height and diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 4319010eb..8c1e4df52 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -80,7 +80,7 @@ Payload records live in `core` under | Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | ✅ `PptxGradientFill` (native `ln`/`gradFill`) | ❌ | | Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes; its left margin and padding are not written, and a picture drawn beside its text is fitted to its box with its padding in it — each named in the report) | | Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ⚠️ `DocxSemanticBackend.writeBarcode` (a PNG picture of the same ZXing bit matrix through `BarcodeMatrices`, one pixel a cell, in the symbol's two colours with their alpha and at the node's size, its data as the picture's description; it scans, but its data is part of the picture rather than editable, reported `APPROXIMATED`, which also names a link, a transform or its left margin and padding as not carried; an `anchor` is a bookmark on its paragraph; in a page zone it is skipped) | -| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row); a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a fill's opacity is dropped since `w:shd` is opaque; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) | +| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row); a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, less its own margins and padding, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a fill's opacity is dropped since `w:shd` is opaque; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) | | Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a floating picture over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less the borders both editors draw outside it where its padding does not hold its top border, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing` where the layout puts it, anchored as a rectangle is | | Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape, and the markers as the shapes they are, anchored as a rectangle is: beside an entry's text they move with it when the text above is edited | | Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 6e5ed5b02..c2e02a913 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -272,7 +272,8 @@ measure, and keeps the point-tall paragraph. The horizontal half is carried as an indent: outside any panel, every paragraph by each enclosing container's margin and padding, a row or a table by the same amount as `w:tblInd`, its own left margin -added unless it is a row's column, whose cell already starts past it — see "What a panel +added unless it is a row's column, whose cell already starts past it, and a table's left +padding added too, since the page draws its rows inside it — see "What a panel keeps and loses". A table that opens a layer of a stack written as a band, or of a column layer, starts where the layer resumes, its own top margin below that; at the top of a cell an empty hairline line above it holds that space, as it does for a row. diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPaddedTableTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPaddedTableTest.java index 001292456..a4fee4a11 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPaddedTableTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPaddedTableTest.java @@ -4,7 +4,9 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.PageFlowBuilder; import com.demcha.compose.document.dsl.TableBuilder; +import com.demcha.compose.document.style.DocumentColor; 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.XWPFDocument; import org.junit.jupiter.api.Test; @@ -28,7 +30,7 @@ class DocxPaddedTableTest { @Test - void aTableWithSidePaddingIsWrittenAsOneWithThoseMarginsIs() throws Exception { + void aTableWithSidePaddingIsWrittenAsOneWithThoseMargins() throws Exception { // The page draws both tables' rows at the same place and width. String padded = bodyOf(page -> page.addTable(table -> rows(table.columns( DocumentTableColumn.fixed(120), DocumentTableColumn.fixed(140)), 2) @@ -75,13 +77,45 @@ void anAutoColumnGetsTheSlackTheRoomInsideThePaddingLeaves() throws Exception { @Test void aTableComposedInACellIsHeldInByItsPadding() throws Exception { String body = bodyOf(page -> page.addTable(outer -> outer.columns(DocumentTableColumn.fixed(240)) - .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(new TableBuilder() + .rowCells(DocumentTableCell.node(new TableBuilder() .columns(DocumentTableColumn.fixed(120)).row("Inner") .padding(new DocumentInsets(0, 0, 0, 15)).build())))); assertThat(body).as("its indent in the cell, the padding").containsPattern("]*w:w=\"300\""); } + @Test + void aTableHeldInByAnAlignmentWrapperIsWrittenAsAMarginedOneThereToo() throws Exception { + // The wrapper holds the table in to its margin box; its margin or its padding then + // lands on its rows either way. + String padded = bodyOf(page -> page.addAligned(com.demcha.compose.document.node.HorizontalAlign.CENTER, + rows(new TableBuilder().columns(DocumentTableColumn.fixed(160)), 1) + .padding(new DocumentInsets(0, 10, 0, 30)).build())); + String margined = bodyOf(page -> page.addAligned(com.demcha.compose.document.node.HorizontalAlign.CENTER, + rows(new TableBuilder().columns(DocumentTableColumn.fixed(160)), 1) + .margin(new DocumentInsets(0, 10, 0, 30)).build())); + + assertThat(padded).isEqualTo(margined).contains(""); + } + + @Test + void aDrawingInAPaddedTablesCellIsAnchoredInItsRow() throws Exception { + // A drawing a cell is all of is anchored in that cell, found by the cell's box on the + // page, which the table's padding offsets. + String padded = bodyOf(page -> page.addTable(table -> table + .columns(DocumentTableColumn.fixed(120), DocumentTableColumn.fixed(140)) + .rowCells(DocumentTableCell.node(new com.demcha.compose.document.dsl.ShapeBuilder().size(20, 20) + .fillColor(DocumentColor.rgb(26, 86, 148)).build()), DocumentTableCell.text("Label")) + .padding(new DocumentInsets(0, 10, 0, 30)))); + String margined = bodyOf(page -> page.addTable(table -> table + .columns(DocumentTableColumn.fixed(120), DocumentTableColumn.fixed(140)) + .rowCells(DocumentTableCell.node(new com.demcha.compose.document.dsl.ShapeBuilder().size(20, 20) + .fillColor(DocumentColor.rgb(26, 86, 148)).build()), DocumentTableCell.text("Label")) + .margin(new DocumentInsets(0, 10, 0, 30)))); + + assertThat(padded).isEqualTo(margined).contains(" report = new AtomicReference<>();