From dbcdf32b38f4592de6456b6d281136de3eb16ca7 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 22 Sep 2026 13:36:56 +0100 Subject: [PATCH 1/2] feat(render-docx): carry whatever a composed cell is built from DocumentTableCell.node(...) lets a cell hold anything the document can hold, and the export wrote paragraphs out of it and nothing else: a cell built from an image, a list or a table came out empty. Not wrong -- empty, with content the page draws simply missing from the file and one line in a log to say so. A table is where a document keeps the things a reader counts, which makes it the worst place to lose something quietly. The fix is not a second writer that knows about cells. The cell is a destination now: newBodyParagraph points at it and writeNode does the rest, so everything that can be written anywhere is written here too. A nested table is a real w:tbl followed by the paragraph Word requires a cell to end with, registered through insertTable so the model and the XML agree -- getTables() is unmodifiable, and adding to it throws rather than letting them drift apart. This uncovered a defect in reading columns back from the layout. A table whose cell is built from another table emits that inner table's rows under the OWNER's path, so the fragments at one path are not all one table's: a two-column table came out with a three-column grid, 45.3 / 32.7 / 76.9 where 78.0 / 76.9 belonged, and Word placed every edge exactly where it was told. Only rows that span the table are read now, and the derived count must match the one the table resolves -- a wrong grid is worse than none. A nested table is given the width of the column it sits in, less the margins Word keeps inside a cell. That is not the width the page gives it: the layout reports a composed cell's content under the owner's path, so which measured row belongs to which nested table cannot be told apart there. The choice was not between exact and approximate but between approximate and unreadable -- with no width at all, Word squeezes a nested table to about one character a line, confirmed by opening the export in Word 16.0 before and after. Tests: seven over the exported package -- an image, a list, a nested table, the paragraph that has to follow it, a transparent wrapper, the width, and the destination returning to the body afterwards. Verification: ./mvnw -B -ntp clean verify over the seven-module gate, 1784 tests, BUILD SUCCESS; render-docx 175 to 182; the examples module separately, 93 tests, BUILD SUCCESS, no committed preview drifted. --- .../semantic/docx/DocxLayoutMetrics.java | 44 ++-- .../semantic/docx/DocxSemanticBackend.java | 146 ++++++++++--- .../semantic/docx/DocxComposedCellTest.java | 193 ++++++++++++++++++ 3 files changed, 336 insertions(+), 47 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java 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 02428853e..a4815e0d0 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 @@ -160,24 +160,42 @@ OptionalDouble lineHeight(DocumentNode node) { * @param node the table being written * @return one width per column in order, or {@code null} when the table laid out nothing */ - double[] tableColumns(DocumentNode node) { - List cells = new ArrayList<>(); - double rightEdge = 0; + double[] tableColumns(DocumentNode node, int columnCount) { + PlacedNode placedTable = placedFor(node); + if (placedTable == null || placedTable.placementWidth() <= 0) { + return null; + } + double width = placedTable.placementWidth(); + + TreeSet boundaries = new TreeSet<>(); for (PlacedFragment fragment : fragmentsOf(node)) { - if (fragment.payload() instanceof TableRowFragmentPayload row) { - cells.addAll(row.cells()); + if (!(fragment.payload() instanceof TableRowFragmentPayload row) || row.cells().isEmpty()) { + continue; + } + // A table whose cell is built from another table emits that inner table's rows + // under the *owner's* path, so the fragments at one path are not all one + // table's. A row of this table spans this table; a nested one stops short, and + // mixing the two produced a grid with more columns than the table has. + double rowRight = 0; + for (TableResolvedCell cell : row.cells()) { + rowRight = Math.max(rowRight, cell.x() + cell.width()); + } + if (Math.abs(rowRight - width) > 0.5) { + continue; + } + for (TableResolvedCell cell : row.cells()) { + boundaries.add(round(cell.x())); } } - if (cells.isEmpty()) { + if (boundaries.isEmpty()) { return null; } - TreeSet boundaries = new TreeSet<>(); - for (TableResolvedCell cell : cells) { - boundaries.add(round(cell.x())); - rightEdge = Math.max(rightEdge, cell.x() + cell.width()); - } - boundaries.add(round(rightEdge)); - return widthsBetween(new ArrayList<>(boundaries)); + boundaries.add(round(width)); + double[] widths = widthsBetween(new ArrayList<>(boundaries)); + // The grid has to be the table's own. A column count that disagrees with the one + // the table resolves means something else contributed a boundary, and a wrong grid + // is worse than none: Word would place every column edge where it was told. + return widths != null && widths.length == columnCount ? widths : null; } /** 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 ca466a133..3060c0576 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 @@ -169,6 +169,14 @@ public final class DocxSemanticBackend implements SemanticBackend { // The last paragraph written into the body, so a container can hand it the space it // holds below itself once its children are done. private XWPFParagraph lastBodyParagraph; + // The cell being filled, when one is. A composed cell is written by the ordinary + // writers pointed at it rather than by a second set that knows about cells: the first + // arrangement only ever learned about paragraphs, so a cell built from an image or a + // list came out empty. + private XWPFTableCell currentCell; + // How wide content may be inside that cell, so a table nested in it gets a width + // instead of being squeezed by Word to a character a line. + private double currentCellWidth = Double.NaN; // Every family this export can name, by the logical name a style asks for. The // session's own registrations win over the bundled ones, the way they do everywhere. private java.util.Map wordFamilies = java.util.Map.of(); @@ -267,6 +275,8 @@ public byte[] export(DocumentGraph graph, SemanticExportContext context) throws } carriedSpacingBefore = 0; lastBodyParagraph = null; + currentCell = null; + currentCellWidth = Double.NaN; contentWidth = context.canvas() == null ? Double.MAX_VALUE : context.canvas().innerWidth(); try (XWPFDocument document = new XWPFDocument()) { applyPageGeometry(document, context.canvas()); @@ -1145,7 +1155,7 @@ private void warnContainerRadiusDropped(DocumentNode node) { * table cannot reach the band the container is drawing.

*/ private XWPFParagraph newBodyParagraph(XWPFDocument document) { - XWPFParagraph para = document.createParagraph(); + XWPFParagraph para = currentCell != null ? currentCell.addParagraph() : document.createParagraph(); ContainerPaint paint = containerPaint.peek(); if (paint != null) { applyContainerPaint(para, paint); @@ -1565,7 +1575,7 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception // to place anything in. Word still needs a cell in a table, so write the empty // one this used to produce — widening the count instead would leave a position // no placement covers, and reading it back is a crash rather than an empty cell. - document.createTable(rowCount, 1); + newTable(document, rowCount, 1); return; } TableGrid.Placement[][] cover = new TableGrid.Placement[rowCount][columnCount]; @@ -1582,7 +1592,7 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception // One cell per row to start with, then as many as that row actually needs: a merged // cell is one cell carrying a span, not several, so a row's physical count is not // the column count. - XWPFTable table = document.createTable(rowCount, 1); + XWPFTable table = newTable(document, rowCount, 1); applyTableWidth(table, node, columnCount); for (int rowIdx = 0; rowIdx < rowCount; rowIdx++) { XWPFTableRow row = table.getRow(rowIdx); @@ -1700,7 +1710,13 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement, if (source.content() != null) { // A composed cell keeps its node and leaves lines() empty, so reading lines() // exported it as an empty cell. - writeCellBody(cell, source.content()); + double previous = currentCellWidth; + currentCellWidth = usableWidthOf(cell, placement); + try { + writeCellBody(cell, source.content()); + } finally { + currentCellWidth = previous; + } return; } XWPFParagraph para = cell.addParagraph(); @@ -1786,7 +1802,7 @@ private void writeRow(XWPFDocument document, RowNode node) throws Exception { if (node.children().isEmpty()) { return; } - XWPFTable table = document.createTable(1, node.children().size()); + XWPFTable table = newTable(document, 1, node.children().size()); // A row is a layout device, not a table anybody asked to see. POI's createTable // ships Word's default single-line grid, so without this every two-column block — // a header pair, a label beside a value — exported with visible rules the PDF @@ -2087,7 +2103,7 @@ private static void setCellMargin(CTTblWidth margin, double points) { * widths.

*/ private void applyTableWidth(XWPFTable table, TableNode node, int columnCount) { - double[] measured = layout.tableColumns(node); + double[] measured = layout.tableColumns(node, columnCount); if (measured != null && measured.length > 0) { // The layout resolved every column, an auto one included, so there is nothing // left to decide: write the widths it arrived at and stop Word re-fitting them. @@ -2101,10 +2117,14 @@ private void applyTableWidth(XWPFTable table, TableNode node, int columnCount) { List fixedColumns = fixedColumnWidths(node, columnCount); if (fixedColumns == null) { - // One of the columns is as wide as its content needs. The split is Word's, - // and so is the total unless the author stated one. + // One of the columns is as wide as its content needs. The split is Word's, and + // so is the total unless the author stated one — or unless this table sits in a + // cell, where leaving the total to Word is not neutral: it squeezes a nested + // table to about one character a line. if (authored != null) { setTableWidth(table, authored); + } else if (Double.isFinite(nestedTableWidth())) { + setTableWidth(table, nestedTableWidth()); } return; } @@ -2241,33 +2261,91 @@ private static boolean isSemanticallyTransparent(DocumentNode node) { || node instanceof com.demcha.compose.document.node.AlignNode; } + /** + * Creates a table where the writer is currently pointing — the body, or a cell. + * + *

A table inside a cell is a real nested {@code w:tbl}, not a flattened copy of its + * text. Word requires a cell to end with a paragraph, and a table is not one, so an + * empty paragraph follows it: without that the cell is malformed and Word refuses the + * file rather than showing the table.

+ * + * @param document the document being written + * @param rows row count + * @param columns column count of the first row + * @return the created table, already attached where it belongs + */ + /** Word keeps this much clear inside every cell edge unless a table says otherwise. */ + private static final double DEFAULT_CELL_MARGIN_POINTS = 5.4; + + /** + * How wide content can be inside one cell: the columns it spans, less Word's margins. + * + *

Read back from the grid this export just wrote rather than recomputed, so a cell + * cannot disagree with the table it is in.

+ * + * @return the usable width in points, or {@code NaN} when the table has no written grid + */ + private static double usableWidthOf(XWPFTableCell cell, TableGrid.Placement placement) { + CTTblGrid grid = cell.getTableRow().getTable().getCTTbl().getTblGrid(); + if (grid == null || grid.sizeOfGridColArray() == 0) { + return Double.NaN; + } + double twips = 0; + int last = Math.min(placement.column() + placement.colSpan(), grid.sizeOfGridColArray()); + for (int index = placement.column(); index < last; index++) { + twips += Long.parseLong(String.valueOf(grid.getGridColArray(index).getW())); + } + double points = twips / POINT_TO_TWIP - 2 * DEFAULT_CELL_MARGIN_POINTS; + return points > 0 ? points : Double.NaN; + } + + /** + * The width a table nested in the current cell may take, or {@code NaN} outside a cell. + * + *

The column it sits in, less the margins Word keeps inside every cell. It is not + * the width the page gives that table — the layout reports a composed cell's content + * under the owner's path, so which measured row belongs to which nested table cannot be + * told apart there — but it is a width, and a nested table without one is squeezed by + * Word to about one character per line, which is not a document anybody can read.

+ */ + private double nestedTableWidth() { + return currentCellWidth; + } + + private XWPFTable newTable(XWPFDocument document, int rows, int columns) { + if (currentCell == null) { + return document.createTable(rows, columns); + } + XWPFTable nested = new XWPFTable(currentCell.getCTTc().addNewTbl(), currentCell, rows, columns); + // The XML already carries the table; this is what tells the cell's own lists about + // it, so reading the cell back finds it. getTables() is unmodifiable on purpose — + // adding to it throws rather than quietly leaving the model and the XML disagreeing. + currentCell.insertTable(currentCell.getBodyElements().size(), nested); + currentCell.addParagraph(); + return nested; + } + + /** + * Writes one node into a cell, through the same writers that write it anywhere else. + * + *

A cell used to have a dispatcher of its own, and it had learned about paragraphs + * and about the wrappers a paragraph can sit in — so a cell built from an image or a + * list was warned about and left empty, silently losing content the page draws. The + * cell is now a destination instead: {@link #newBodyParagraph} points at it, + * and {@link #writeNode} does the rest, which is how everything that can be written at + * all can be written here.

+ * + *

The destination is restored afterwards rather than cleared, because a cell can + * hold a table whose cells hold their own content, and the inner one must not leave + * the outer one writing into the body.

+ */ private void writeCellNode(XWPFTableCell cell, DocumentNode child) throws Exception { - if (child instanceof ParagraphNode paragraph) { - // Same walk as writeParagraph, all of it: a cell paragraph keeps per-run - // styling instead of being flattened into one style — and its alignment and - // direction too. Writing only the runs left every right-to-left paragraph - // inside a table undeclared, which is where an invoice keeps its line items. - XWPFParagraph cellParagraph = cell.addParagraph(); - boolean cellRightToLeft = applyParagraphProperties(cellParagraph, paragraph); - writeParagraphRuns(cellParagraph, paragraph, cellRightToLeft); - } else if (child instanceof ContainerNode container) { - for (DocumentNode grandChild : container.children()) { - writeCellNode(cell, grandChild); - } - } else if (child instanceof SectionNode section) { - for (DocumentNode grandChild : section.children()) { - writeCellNode(cell, grandChild); - } - } else if (isSemanticallyTransparent(child)) { - for (DocumentNode grandChild : child.children()) { - writeCellNode(cell, grandChild); - } - } else if (child instanceof SpacerNode) { - cell.addParagraph(); - } else { - warnUnsupported(child); - // Unsupported cell content gets an empty paragraph placeholder. - cell.addParagraph(); + XWPFTableCell previous = currentCell; + currentCell = cell; + try { + writeNode(cell.getXWPFDocument(), child); + } finally { + currentCell = previous; } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java new file mode 100644 index 000000000..0f5a9c176 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java @@ -0,0 +1,193 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A cell built from a node carries that node, whatever it is. + * + *

{@code DocumentTableCell.node(...)} lets a cell hold anything the document can hold, + * and the export wrote paragraphs out of it and nothing else: a cell built from an image or + * a list came out empty — not wrong, empty, with the content the page draws simply + * missing from the file and one line in a log to say so. Silent loss inside a table is the + * worst place for it, because a table is where a document keeps the things a reader counts.

+ * + *

The fix is not a second writer that knows about cells. The cell became a destination, + * so the writers that handle a node anywhere handle it here too — which is why a nested + * table works without anything in this class knowing how a table is written.

+ * + * @author Artem Demchyshyn + */ +class DocxComposedCellTest { + + @Test + void aCellBuiltFromAnImageCarriesThePicture() throws Exception { + XWPFTableCell cell = onlyTableCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.text("Logo"), + DocumentTableCell.node(image())))); + + assertThat(cell.getParagraphs()) + .as("the picture is in the cell, not dropped with a warning") + .anyMatch(p -> !p.getRuns().isEmpty() && !p.getRuns().get(0).getEmbeddedPictures().isEmpty()); + } + + @Test + void aCellBuiltFromAListCarriesEveryItem() throws Exception { + XWPFTableCell cell = onlyTableCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.text("Included"), + DocumentTableCell.node(list("Setup", "Training", "Support"))))); + + assertThat(cell.getParagraphs()) + .extracting(p -> p.getText()) + .contains("Setup", "Training", "Support"); + assertThat(cell.getParagraphs()) + .as("and they are list items, so Enter continues the list in the cell too") + .anyMatch(p -> p.getCTP().getPPr() != null && p.getCTP().getPPr().isSetNumPr()); + } + + @Test + void aCellBuiltFromATableCarriesARealNestedTable() throws Exception { + XWPFTableCell cell = onlyTableCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.text("Breakdown"), + DocumentTableCell.node(innerTable())))); + + assertThat(cell.getTables()).hasSize(1); + assertThat(cell.getTables().get(0).getRow(0).getCell(0).getText()).isEqualTo("Hours"); + assertThat(cell.getTables().get(0).getRow(0).getCell(1).getText()).isEqualTo("12"); + } + + @Test + void aNestedTableIsFollowedByAParagraph() throws Exception { + // Word requires a cell to end with a paragraph. A cell ending in a table is + // malformed, and Word refuses the file rather than showing the table — so the + // guard is on the structure, not on the render. + XWPFTableCell cell = onlyTableCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.text("Breakdown"), + DocumentTableCell.node(innerTable())))); + + String xml = cell.getCTTc().xmlText(); + assertThat(xml.lastIndexOf("")) + .as("the last block in the cell is a paragraph, not the table") + .isGreaterThan(xml.lastIndexOf("")); + } + + @Test + void aNestedTableIsGivenTheWidthOfTheColumnItSitsIn() throws Exception { + // Not the width the page gives it — the layout reports a composed cell's content + // under the owner's path, so which measured row belongs to which nested table + // cannot be told apart there. But a nested table with no width at all is squeezed + // by Word to about one character a line, which is not a document anybody can read. + XWPFTableCell cell = onlyTableCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.text("Breakdown"), + DocumentTableCell.node(innerTable())))); + + long column = Long.parseLong(String.valueOf(cell.getTableRow().getTable() + .getCTTbl().getTblGrid().getGridColArray(1).getW())); + long nested = Long.parseLong(String.valueOf(cell.getTables().get(0) + .getCTTbl().getTblPr().getTblW().getW())); + + assertThat(cell.getTables().get(0).getCTTbl().getTblPr().getTblW().getType().toString()) + .as("stated, not left to Word") + .isEqualTo("dxa"); + // The column less the margins Word keeps inside every cell edge: 5.4pt a side. + assertThat(nested).isEqualTo(column - 2 * 108); + } + + @Test + void aWrapperInsideTheCellIsStillTransparent() throws Exception { + // A section around the content is not content: its children are written where it + // stood, here as much as anywhere else. + XWPFTableCell cell = onlyTableCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.text("Notes"), + DocumentTableCell.node(new com.demcha.compose.document.dsl.SectionBuilder() + .name("Wrapper") + .addParagraph(p -> p.text("First")) + .addParagraph(p -> p.text("Second")) + .build())))); + + assertThat(cell.getParagraphs()) + .extracting(p -> p.getText()) + .contains("First", "Second"); + } + + @Test + void anImageInACellDoesNotEscapeIntoTheBody() throws Exception { + // The destination is restored after the cell, not cleared: the paragraph after the + // table has to land in the body. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addTable(t -> t + .columns(DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.node(image()))) + .addParagraph(p -> p.text("After the table")))) { + + assertThat(document.getParagraphs()) + .extracting(p -> p.getText()) + .as("written to the body, not appended to the cell") + .contains("After the table"); + } + } + + private static DocumentNode image() { + return new com.demcha.compose.document.dsl.ImageBuilder() + .name("Logo") + .source(DocumentImageData.fromBytes(pngBytes())) + .width(40) + .height(20) + .build(); + } + + private static DocumentNode list(String... items) { + return new com.demcha.compose.document.dsl.ListBuilder() + .name("Items") + .bullet() + .items(items) + .build(); + } + + private static DocumentNode innerTable() { + return new com.demcha.compose.document.dsl.TableBuilder() + .name("Inner") + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .row("Hours", "12") + .build(); + } + + private static byte[] pngBytes() { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + ImageIO.write(new BufferedImage(40, 20, BufferedImage.TYPE_INT_RGB), "png", out); + return out.toByteArray(); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + } + + /** The second cell of the only table — the one these cases compose. */ + private static XWPFTableCell onlyTableCell(Consumer content) throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, content)) { + assertThat(document.getTables()).hasSize(1); + XWPFTable table = document.getTables().get(0); + return table.getRow(0).getCell(table.getRow(0).getTableCells().size() - 1); + } + } +} From ef7818396c6419fbd91aeb15e62a489e6f691d45 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 22 Sep 2026 13:38:08 +0100 Subject: [PATCH 2/2] docs(render-docx): record that a composed cell carries its node The capability matrix said a composed cell writes paragraphs only and one built from an image or a list lands empty, which is no longer what happens. The recipe listed tables and said nothing about what a cell can be built from. --- CHANGELOG.md | 17 +++++++++++++++++ docs/architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21edb3476..9521c1b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A composed table cell carries whatever it was built from.** `DocumentTableCell.node(...)` + lets a cell hold anything the document can hold, and the export wrote paragraphs out of it + and nothing else: a cell built from an image, a list or a table came out empty — content + the page draws simply missing from the file, with one line in a log to say so. A cell is + now a destination the ordinary writers point at rather than a place with a writer of its + own, so everything that can be written anywhere is written there too. A nested table is a + real `w:tbl` followed by the paragraph Word requires a cell to end with. +

+ Reading a table's columns back from the layout was wrong wherever a cell was composed: a + table whose cell holds another table emits that inner table's rows under the *owner's* + path, so a two-column table came out with a three-column grid and Word placed every edge + where it was told. Only rows that span the table are read now, and the derived count must + match the one the table resolves. A nested table is given the width of the column it sits + in, less the margins Word keeps inside a cell — not the width the page gives it, which the + layout does not report separately, but with no width at all Word squeezes a nested table + to about one character a line. + - **A container's fill and borders now reach the DOCX export.** A `SectionNode` or `ContainerNode` carrying a `fillColor`, per-side `borders` or a uniform `stroke` was treated as a transparent wrapper: its children were written and its paint was dropped diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index c9db483e3..eed93a854 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -71,7 +71,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) | | Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ❌ | -| 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`; a composed cell writes paragraphs and their wrappers only — one built from an image or a list lands empty, and a fill's opacity is dropped since `w:shd` is opaque) | +| 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`; 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; a fill's opacity is dropped since `w:shd` is opaque) | | 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 | | 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 | ⚠️ omitted: the rail is resolved fixed-layout geometry and `DocxSemanticBackend` consumes the semantic tree, never a `LayoutGraph`. A timeline's **content** exports in full — entries, titles, meta and bodies — and the export does not throw; only the drawn line is absent. Marker shapes are geometry and may be omitted for the same reason | | 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 913e088cf..8bf0e9a70 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -44,6 +44,7 @@ PDF never pull POI. | Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved | | 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. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back" | +| 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, 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 | | Images | Embedded pictures at the node's declared size | | Rows | A one-row table spanning the content width, so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin (cell content limited to atomic children) | | Sections / containers | Children written in order; a fill, per-side borders or a uniform stroke travel to each paragraph inside as `w:shd` and `w:pBdr`, so a card keeps its panel — see "What a panel keeps and loses" below |