From 4d9d1a1d57d9bfd2e2dfd0da06feb31dcb8685bb Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 8 Aug 2026 16:24:24 +0100 Subject: [PATCH 1/3] fix(docx): write a table on the grid its cells occupy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An authored row is not a row of columns. A rowSpan covers positions in the rows below it and those rows do not repeat the covered cells; a colSpan makes the number of authored records differ from the number of columns. The backend read a row's records as its columns, so a rowSpan shifted every row beneath it one column left, and a colSpan did that and left the grid too narrow as well — dropping the cells past its end with nothing said. colSpan and rowSpan now reach Word as w:gridSpan and w:vMerge. A cell's text takes the most specific style in the table / column / row / cell cascade. A composed cell writes its node instead of the lines() it does not have, and a multi-line cell is separated by a real break rather than a newline Word reads as a space. The grid is resolved by TableGrid, moved out of the layout pipeline so the backend and the compiler answer from one implementation rather than two that can drift. It is @Internal: a seam for backends, not a public promise. A table whose rows cannot form a rectangle now fails the export naming the position at fault, which is the rule layout already applied. Two shapes that would otherwise have regressed: a table claiming no column at all has no position to place anything in, so the grid is not widened to one that nothing covers; and a cell whose content writes nothing keeps a paragraph, since a w:tc must hold a block-level element and the cell's own was removed first. --- CHANGELOG.md | 20 ++ .../compose/document/layout/TableGrid.java | 137 +++++++++++++ .../document/layout/TableLayoutSupport.java | 95 ++------- .../architecture/backend-capability-matrix.md | 2 +- render-docx/README.md | 11 +- .../semantic/docx/DocxSemanticBackend.java | 178 ++++++++++++++-- .../semantic/docx/DocxTableStructureTest.java | 193 ++++++++++++++++++ 7 files changed, 540 insertions(+), 96 deletions(-) create mode 100644 core/src/main/java/com/demcha/compose/document/layout/TableGrid.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableStructureTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index bc46e28c..8aaab684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,26 @@ follow semantic versioning; release dates are ISO 8601. - **`STRIKETHROUGH` reaches Word.** It was the one `DocumentTextDecoration` with no branch in the DOCX style mapping and fell through to no decoration at all. +- **DOCX writes a table on the grid its cells occupy.** An authored row is not a row of + columns: a `rowSpan` covers positions in the rows below and those rows do not repeat the + covered cells, and a `colSpan` makes the record count differ from the column count. The + backend read a row's records as its columns and sized the grid from the first row's + record count, so a `rowSpan` shifted every row beneath it one column to the left, and a + `colSpan` did that *and* left the grid too narrow, dropping the cells past its end + without a word. `colSpan` and `rowSpan` now map to Word's `w:gridSpan` and `w:vMerge`, a + cell takes the most specific text style in the table / column / row / cell cascade, a + composed cell exports its node instead of the empty `lines()` it has by definition, and + a multi-line cell is separated by a real break rather than a newline Word reads as a + space. + + A table whose authored rows cannot form a rectangle now fails the export with the + position at fault, where before it was drawn wrong. That is the rule the layout pipeline + already applied, so a document the PDF backend refuses is no longer one DOCX accepts. + + The grid itself is resolved by `TableGrid`, extracted from the layout pipeline so both it + and the backend answer from one implementation. It is `@Internal`: a backend seam, not a + public promise. + ## v2.1.1 — 2026-08-05 ### Build diff --git a/core/src/main/java/com/demcha/compose/document/layout/TableGrid.java b/core/src/main/java/com/demcha/compose/document/layout/TableGrid.java new file mode 100644 index 00000000..6d577ba1 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/TableGrid.java @@ -0,0 +1,137 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.document.api.Internal; +import com.demcha.compose.document.node.TableNode; +import com.demcha.compose.document.table.DocumentTableCell; + +import java.util.ArrayList; +import java.util.List; + +/** + * Resolves a {@link TableNode}'s authored rows into the grid positions they occupy. + * + *

A table is authored sparsely: a cell with {@code rowSpan} covers positions in the rows + * below it, and those rows do not repeat the covered cells. So a row's list of + * {@link DocumentTableCell} records is not a list of columns, and the number of records in + * the first row is not the table's column count either — a {@code colSpan} makes the two + * differ. Reading either as if it were is how a table comes out with its rows shifted, or + * with the cells past the end of a too-narrow grid dropped in silence.

+ * + *

This is the one place that walks the occupancy matrix and decides where each authored + * cell lands. It exists because the layout pipeline is not the only consumer: a semantic + * backend writes the same grid into a format with its own merge markup, and a second + * implementation of these rules would drift from this one without any test noticing.

+ * + *

Malformed grids are rejected here rather than being drawn wrong: a cell that would + * overrun the columns or rows, one that overlaps a position an earlier span already took, + * a row that runs out of cells before the grid is full, and a row that still has cells + * after it is. Each names the position, because the author's row indices and the grid's do + * not line up once a span is involved.

+ * + * @author Artem Demchyshyn + * @since 2.1.2 + */ +@Internal +public final class TableGrid { + + private TableGrid() { + } + + /** + * An authored cell and the grid rectangle it occupies. + * + * @param row grid row the cell starts in + * @param column grid column the cell starts in + * @param colSpan columns the cell occupies, at least 1 + * @param rowSpan rows the cell occupies, at least 1 + * @param cell the authored cell + */ + public record Placement(int row, int column, int colSpan, int rowSpan, DocumentTableCell cell) { + } + + /** + * The table's column count. + * + *

The first row by definition has no rowSpan-occupied slots from earlier rows, so its + * colSpan sum equals the column count. Subsequent rows may have fewer source cells when + * a prior rowSpan covers some of their columns, so they must not be used to derive it. + * A declared column spec wins when it asks for more.

+ * + * @param node the table + * @return the number of grid columns + */ + public static int columnCount(TableNode node) { + int firstRowColSpanSum = 0; + if (!node.rows().isEmpty()) { + for (DocumentTableCell cell : node.rows().get(0)) { + firstRowColSpanSum += cell.colSpan(); + } + } + return Math.max(node.columns().size(), firstRowColSpanSum); + } + + /** + * Places every authored cell on the grid, one list per authored row, in column order. + * + * @param node the table + * @return the placements, row by row + * @throws IllegalStateException if the authored rows do not describe a rectangular grid + */ + public static List> resolve(TableNode node) { + int columnCount = columnCount(node); + int rowCount = node.rows().size(); + boolean[][] occupied = new boolean[rowCount][columnCount]; + List> result = new ArrayList<>(rowCount); + + for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) { + List source = node.rows().get(rowIndex); + List placements = new ArrayList<>(source.size()); + int sourceIdx = 0; + int col = 0; + while (col < columnCount) { + if (occupied[rowIndex][col]) { + col++; + continue; + } + if (sourceIdx >= source.size()) { + throw new IllegalStateException("Row " + rowIndex + + " is missing a cell for column " + col + + " (table has " + columnCount + " columns; source row provides " + + source.size() + " cells, prior rowSpan covers some columns)."); + } + DocumentTableCell cell = source.get(sourceIdx++); + if (col + cell.colSpan() > columnCount) { + throw new IllegalStateException("Cell at row " + rowIndex + + " column " + col + " has colSpan " + cell.colSpan() + + " but only " + (columnCount - col) + " columns remain."); + } + if (rowIndex + cell.rowSpan() > rowCount) { + throw new IllegalStateException("Cell at row " + rowIndex + + " column " + col + " has rowSpan " + cell.rowSpan() + + " but only " + (rowCount - rowIndex) + " rows remain."); + } + for (int r = rowIndex; r < rowIndex + cell.rowSpan(); r++) { + for (int c = col; c < col + cell.colSpan(); c++) { + if (occupied[r][c]) { + throw new IllegalStateException("Cell at row " + rowIndex + + " column " + col + " (colSpan=" + cell.colSpan() + + ", rowSpan=" + cell.rowSpan() + + ") overlaps an already-spanned position (" + r + ", " + c + ")."); + } + occupied[r][c] = true; + } + } + placements.add(new Placement(rowIndex, col, cell.colSpan(), cell.rowSpan(), cell)); + col += cell.colSpan(); + } + if (sourceIdx < source.size()) { + throw new IllegalStateException("Row " + rowIndex + + " has " + (source.size() - sourceIdx) + " extra source cell(s) " + + "after the grid was already filled — column slots are accounted for " + + "by colSpan plus rowSpan from earlier rows."); + } + result.add(List.copyOf(placements)); + } + return List.copyOf(result); + } +} diff --git a/core/src/main/java/com/demcha/compose/document/layout/TableLayoutSupport.java b/core/src/main/java/com/demcha/compose/document/layout/TableLayoutSupport.java index 93be462f..7d568894 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/TableLayoutSupport.java +++ b/core/src/main/java/com/demcha/compose/document/layout/TableLayoutSupport.java @@ -44,7 +44,7 @@ static ResolvedTableLayoutWithContents resolveTableLayout(TableNode node, double availableWidth) { validateRowsExist(node); int columnCount = resolveColumnCount(node); - List> logicalRows = buildLogicalRows(node, columnCount); + List> logicalRows = buildLogicalRows(node); List normalizedSpecs = normalizeSpecs(node, columnCount); TableCellLayoutStyle[][] stylesGrid = buildStylesGrid(node, logicalRows, columnCount); double innerAvailableWidth = Math.max(0.0, availableWidth - node.padding().horizontal()); @@ -382,71 +382,23 @@ private static Map> sliceComposedCellContents( } /** - * Builds the logical-cell grid using an occupancy mask to reconcile - * source-order author input with multi-cell colSpan / rowSpan extents. + * Pairs each placement from {@link TableGrid} with the layout's view of its content. * - *

For each source row the algorithm walks columns left-to-right. - * When a column is already covered by a prior row's spanning cell the - * algorithm skips it (the author should not — and must not — provide - * a source cell there). Otherwise the algorithm consumes the next - * source cell, validates that its colSpan/rowSpan fit inside the - * remaining grid, and marks every {@code (r, c)} position it occupies. - * Misalignments raise a precise diagnostic instead of producing a - * silently corrupted grid.

+ *

The placement itself — which grid rectangle an authored cell occupies, and whether + * the authored rows describe a valid grid at all — is {@link TableGrid}'s, because the + * DOCX backend has to reach the same answer and two implementations of that walk would + * drift apart unnoticed.

*/ - private static List> buildLogicalRows(TableNode node, int columnCount) { - int rowCount = node.rows().size(); - boolean[][] occupied = new boolean[rowCount][columnCount]; - List> result = new ArrayList<>(rowCount); - - for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) { - List source = node.rows().get(rowIndex); - List logical = new ArrayList<>(source.size()); - int sourceIdx = 0; - int col = 0; - while (col < columnCount) { - if (occupied[rowIndex][col]) { - col++; - continue; - } - if (sourceIdx >= source.size()) { - throw new IllegalStateException("Row " + rowIndex - + " is missing a cell for column " + col - + " (table has " + columnCount + " columns; source row provides " - + source.size() + " cells, prior rowSpan covers some columns)."); - } - DocumentTableCell cell = source.get(sourceIdx++); - if (col + cell.colSpan() > columnCount) { - throw new IllegalStateException("Cell at row " + rowIndex - + " column " + col + " has colSpan " + cell.colSpan() - + " but only " + (columnCount - col) + " columns remain."); - } - if (rowIndex + cell.rowSpan() > rowCount) { - throw new IllegalStateException("Cell at row " + rowIndex - + " column " + col + " has rowSpan " + cell.rowSpan() - + " but only " + (rowCount - rowIndex) + " rows remain."); - } - for (int r = rowIndex; r < rowIndex + cell.rowSpan(); r++) { - for (int c = col; c < col + cell.colSpan(); c++) { - if (occupied[r][c]) { - throw new IllegalStateException("Cell at row " + rowIndex - + " column " + col + " (colSpan=" + cell.colSpan() - + ", rowSpan=" + cell.rowSpan() - + ") overlaps an already-spanned position (" + r + ", " + c + ")."); - } - occupied[r][c] = true; - } - } - TableCellContent content = toTableCell(cell); - logical.add(new LogicalCell(rowIndex, col, cell.colSpan(), cell.rowSpan(), - content, cell, sanitizeCellLines(content))); - col += cell.colSpan(); - } - if (sourceIdx < source.size()) { - throw new IllegalStateException("Row " + rowIndex - + " has " + (source.size() - sourceIdx) + " extra source cell(s) " - + "after the grid was already filled — column slots are accounted for " - + "by colSpan plus rowSpan from earlier rows."); + private static List> buildLogicalRows(TableNode node) { + List> placements = TableGrid.resolve(node); + List> result = new ArrayList<>(placements.size()); + for (List sourceRow : placements) { + List logical = new ArrayList<>(sourceRow.size()); + for (TableGrid.Placement placement : sourceRow) { + TableCellContent content = toTableCell(placement.cell()); + logical.add(new LogicalCell(placement.row(), placement.column(), + placement.colSpan(), placement.rowSpan(), + content, placement.cell(), sanitizeCellLines(content))); } result.add(List.copyOf(logical)); } @@ -667,18 +619,7 @@ private static List normalizeSpecs(TableNode node, int column } private static int resolveColumnCount(TableNode node) { - // The first row by definition has no rowSpan-occupied slots from - // earlier rows, so its colSpan sum equals the table's column count. - // Subsequent rows may have fewer source cells when prior rowSpan - // covers some of their columns, so they must not be used to derive - // the column count. - int firstRowColSpanSum = 0; - if (!node.rows().isEmpty()) { - for (DocumentTableCell cell : node.rows().get(0)) { - firstRowColSpanSum += cell.colSpan(); - } - } - return Math.max(node.columns().size(), firstRowColSpanSum); + return TableGrid.columnCount(node); } private static void validateRowsExist(TableNode node) { @@ -862,7 +803,7 @@ record ResolvedTableLayout( * has resolved its starting position and colSpan/rowSpan extent. A * spanning cell appears once at its starting (row, column); the * positions it occupies in subsequent rows are tracked by the - * occupancy grid built in {@link #buildLogicalRows(TableNode, int)} and + * occupancy grid built in {@link #buildLogicalRows(TableNode)} and * are skipped when iterating later source rows. {@code source} is the * original public {@link DocumentTableCell}, retained so the layout * can detect composed-content cells via diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 07ef4766..f07e3573 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -69,7 +69,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 picture is embedded at the node's width/height; `fitMode` and `scale` are never read, so CONTAIN and COVER behave as STRETCH, a node with neither width nor height falls back to 100×100 pt, and every picture is declared `PICTURE_TYPE_PNG`) | | Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (ZXing raster) | ✅ `PptxBarcodeFragmentRenderHandler` (identical ZXing raster) | ❌ | -| 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` (cell text becomes a real Word table; `colSpan` / `rowSpan`, the per-cell `DocumentTableStyle`, and fill/border paint are not applied, and cell runs carry no text style) | +| 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; fill and border paint are not applied, and a composed cell writes paragraphs and their wrappers only — one built from an image or a list lands empty) | | 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 | | 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 | | Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ❌ | diff --git a/render-docx/README.md b/render-docx/README.md index 37b568dc..5690a0c0 100644 --- a/render-docx/README.md +++ b/render-docx/README.md @@ -52,11 +52,12 @@ underline and strikethrough, per run rather than per paragraph. What maps only in part: -- **Table cells keep their text, not their structure.** `colSpan` and `rowSpan` are not - applied, so a table with merged cells exports with its columns misaligned. Per-cell - style and fill/border paint are dropped, and a `table` cell's text carries no styling - at all — it is written from the cell's lines rather than from runs. (A `row` cell is - a paragraph and does keep per-run styling.) +- **Table cells keep their structure, not their paint.** `colSpan` and `rowSpan` map to + Word's own `w:gridSpan` and `w:vMerge`, and a cell's text takes the most specific style + in the table / column / row / cell cascade. Still dropped: the fill and border paint of a + `DocumentTableStyle`, so a merged, styled table exports with the right shape on Word's + default rules. A composed cell writes the shapes a cell can hold — paragraphs, and the + wrappers around them — so one built from an image or a list still lands empty. - **Image fit is ignored.** The picture is embedded at the node's width and height; `CONTAIN` and `COVER` therefore behave as `STRETCH`, and an image sized only by `scale` falls back to 100 × 100 pt. 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 9139e22d..6916e6c1 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 @@ -8,6 +8,7 @@ import com.demcha.compose.document.image.DocumentImageData; import com.demcha.compose.document.layout.DocumentGraph; import com.demcha.compose.document.layout.LayoutCanvas; +import com.demcha.compose.document.layout.TableGrid; import com.demcha.compose.document.node.ChartNode; import com.demcha.compose.document.node.ContainerNode; import com.demcha.compose.document.output.DocumentMetadata; @@ -25,6 +26,7 @@ import com.demcha.compose.document.node.TextAlign; import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableStyle; import org.apache.poi.util.Units; import org.apache.poi.xwpf.usermodel.BreakType; import org.apache.poi.xwpf.usermodel.Document; @@ -38,6 +40,8 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageSz; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STPageOrientation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,6 +51,7 @@ import java.math.BigInteger; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; @@ -358,27 +363,139 @@ private byte[] readBytes(Path path) { } } - private void writeTable(XWPFDocument document, TableNode node) { + /** + * Writes a table on the grid its cells actually occupy. + * + *

An authored row is not a row of columns: a {@code rowSpan} covers positions in the + * rows below it and those rows do not repeat the covered cells, and a {@code colSpan} + * makes the number of authored records differ from the number of columns. Sizing the + * grid from the first row's record count therefore built a table too narrow whenever a + * span was involved, and the loop that filled it stopped at the last column that + * existed — so the cells past it were not written at all. {@link TableGrid} is the + * layout pipeline's own resolution of that grid, used here so the two cannot disagree. + *

+ * + *

Word expresses the merges natively: {@code w:gridSpan} widens a cell, and + * {@code w:vMerge} restarts on the cell that owns a vertical span and continues on the + * ones it covers.

+ */ + private void writeTable(XWPFDocument document, TableNode node) throws Exception { if (node.rows().isEmpty()) { return; } - int columnCount = node.rows().get(0).size(); - XWPFTable table = document.createTable(node.rows().size(), Math.max(1, columnCount)); - for (int rowIdx = 0; rowIdx < node.rows().size(); rowIdx++) { - List rowCells = node.rows().get(rowIdx); + int rowCount = node.rows().size(); + int columnCount = TableGrid.columnCount(node); + if (columnCount == 0) { + // Nothing declares a column and no cell claims one, so the grid has no positions + // 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); + return; + } + TableGrid.Placement[][] cover = new TableGrid.Placement[rowCount][columnCount]; + for (List sourceRow : TableGrid.resolve(node)) { + for (TableGrid.Placement placement : sourceRow) { + for (int r = placement.row(); r < placement.row() + placement.rowSpan(); r++) { + for (int c = placement.column(); c < placement.column() + placement.colSpan(); c++) { + cover[r][c] = placement; + } + } + } + } + + // 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); + for (int rowIdx = 0; rowIdx < rowCount; rowIdx++) { XWPFTableRow row = table.getRow(rowIdx); - for (int columnIdx = 0; columnIdx < rowCells.size() && columnIdx < row.getTableCells().size(); columnIdx++) { - XWPFTableCell cell = row.getCell(columnIdx); + List physical = new ArrayList<>(); + for (int col = 0; col < columnCount; ) { + TableGrid.Placement placement = cover[rowIdx][col]; + physical.add(placement); + col += placement.colSpan(); + } + while (row.getTableCells().size() < physical.size()) { + row.createCell(); + } + for (int i = 0; i < physical.size(); i++) { + TableGrid.Placement placement = physical.get(i); + XWPFTableCell cell = row.getCell(i); + applySpans(cell, placement, rowIdx); + if (placement.row() != rowIdx) { + // A covered position carries the merge marker and no content of its own. + continue; + } cell.removeParagraph(0); - XWPFParagraph para = cell.addParagraph(); - XWPFRun run = para.createRun(); - String text = String.join("\n", rowCells.get(columnIdx).lines()); - run.setText(text); + writeCellContent(cell, placement, node); } } } - private void writeRow(XWPFDocument document, RowNode node) { + private void applySpans(XWPFTableCell cell, TableGrid.Placement placement, int rowIdx) { + if (placement.colSpan() == 1 && placement.rowSpan() == 1) { + return; + } + CTTcPr properties = cell.getCTTc().isSetTcPr() + ? cell.getCTTc().getTcPr() + : cell.getCTTc().addNewTcPr(); + if (placement.colSpan() > 1) { + properties.addNewGridSpan().setVal(BigInteger.valueOf(placement.colSpan())); + } + if (placement.rowSpan() > 1) { + properties.addNewVMerge().setVal( + placement.row() == rowIdx ? STMerge.RESTART : STMerge.CONTINUE); + } + } + + private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement, TableNode node) + throws Exception { + DocumentTableCell source = placement.cell(); + 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()); + return; + } + XWPFParagraph para = cell.addParagraph(); + XWPFRun run = para.createRun(); + applyStyle(run, resolveCellTextStyle(node, placement)); + List lines = source.lines(); + for (int i = 0; i < lines.size(); i++) { + if (i > 0) { + // A joined "\n" is not a line break in Word; it renders as one line. + run.addBreak(); + } + run.setText(lines.get(i) == null ? "" : lines.get(i), i); + } + } + + /** + * The text style a cell resolves to, most specific wins. + * + *

The same order the layout pipeline merges in: the table's default, then the + * column's, then the row's, then the cell's own.

+ */ + private DocumentTextStyle resolveCellTextStyle(TableNode node, TableGrid.Placement placement) { + DocumentTextStyle resolved = null; + for (DocumentTableStyle candidate : List.of( + orEmpty(node.defaultCellStyle()), + orEmpty(node.columnStyles().get(placement.column())), + orEmpty(node.rowStyles().get(placement.row())), + orEmpty(placement.cell().style()))) { + if (candidate.textStyle() != null) { + resolved = candidate.textStyle(); + } + } + return resolved; + } + + private static DocumentTableStyle orEmpty(DocumentTableStyle style) { + return style == null ? DocumentTableStyle.empty() : style; + } + + private void writeRow(XWPFDocument document, RowNode node) throws Exception { // Represent rows as a single one-row table so downstream editors get a // visual side-by-side layout. Cell content is restricted to atomic // children; richer composition is scheduled for a follow-up release. @@ -395,14 +512,49 @@ private void writeRow(XWPFDocument document, RowNode node) { } } - private void writeRowCellChild(XWPFTableCell cell, DocumentNode child) { + private void writeRowCellChild(XWPFTableCell cell, DocumentNode child) throws Exception { + writeCellBody(cell, child); + } + + /** + * Writes {@code child} into an emptied cell and leaves a paragraph behind either way. + * + *

A {@code w:tc} must hold at least one block-level element. POI puts a paragraph in + * every cell it creates and the callers here remove it before writing their own, so a + * node that contributes nothing — a wrapper that ended up with no children — would + * otherwise leave the cell with no block child at all. Word tolerates less of that than + * the schema validator notices.

+ */ + private void writeCellBody(XWPFTableCell cell, DocumentNode child) throws Exception { + writeCellNode(cell, child); + if (cell.getParagraphs().isEmpty()) { + cell.addParagraph(); + } + } + + /** + * Writes a node into a table cell. + * + *

A wrapper contributes nothing of its own to a Word cell, so its children are + * written in its place rather than the wrapper being dropped with them inside.

+ */ + private void writeCellNode(XWPFTableCell cell, DocumentNode child) throws Exception { if (child instanceof ParagraphNode paragraph) { // Same walk as writeParagraph: a cell paragraph keeps per-run styling // instead of being flattened into the concatenated text in one style. writeParagraphRuns(cell.addParagraph(), paragraph); + } 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 (child instanceof SpacerNode) { cell.addParagraph(); } else { + warnUnsupported(child); // Unsupported cell content gets an empty paragraph placeholder. cell.addParagraph(); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableStructureTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableStructureTest.java new file mode 100644 index 00000000..ce303409 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableStructureTest.java @@ -0,0 +1,193 @@ +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.TableBuilder; +import com.demcha.compose.document.node.ContainerNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.node.TableNode; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextDecoration; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; +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 org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge; + +import java.io.ByteArrayInputStream; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Table structure in the DOCX semantic backend. + * + *

An authored row is not a row of columns. A {@code rowSpan} covers positions in the rows + * below and those rows do not repeat the covered cells; a {@code colSpan} makes the number of + * authored records differ from the number of columns. The backend used to size the grid from + * the first row's record count and stop filling at the last column that existed, so a table + * with any span came out narrow and the cells past the end were dropped without a word.

+ * + *

These pin the grid the cells actually occupy, the merge markup Word uses to express it, + * and the two cell shapes that carried nothing before: a composed cell, whose {@code lines()} + * is empty by definition, and a multi-line cell, whose lines were joined with a character + * Word does not read as a break.

+ */ +class DocxTableStructureTest { + + @Test + void aColSpanWidensItsCellInsteadOfNarrowingTheTable() throws Exception { + XWPFTable table = firstTable(new TableBuilder() + .name("Spans") + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.text("Header spans two"). colSpan(2), + DocumentTableCell.text("Third")) + .row("a", "b", "c") + .build()); + + // Three columns, from the first row's colSpan sum — not its two records. + assertThat(table.getRow(0).getTableCells()).hasSize(2); + assertThat(gridSpan(table.getRow(0).getCell(0))).isEqualTo(2); + + // The row below keeps all three cells. The third used to fall outside the grid. + assertThat(table.getRow(1).getTableCells()).hasSize(3); + assertThat(table.getRow(1).getCell(0).getText()).isEqualTo("a"); + assertThat(table.getRow(1).getCell(1).getText()).isEqualTo("b"); + assertThat(table.getRow(1).getCell(2).getText()).isEqualTo("c"); + } + + @Test + void aRowSpanMergesVerticallyAndTheRowBelowKeepsItsOwnCells() throws Exception { + XWPFTable table = firstTable(new TableBuilder() + .name("Merged") + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.text("Tall").rowSpan(2), DocumentTableCell.text("top")) + .rowCells(DocumentTableCell.text("bottom")) + .build()); + + assertThat(vMerge(table.getRow(0).getCell(0))).isEqualTo(STMerge.RESTART); + assertThat(table.getRow(0).getCell(0).getText()).isEqualTo("Tall"); + + // The covered position is a cell carrying the continuation marker, so the authored + // cell beside it stays in its own column instead of sliding left. + assertThat(table.getRow(1).getTableCells()).hasSize(2); + assertThat(vMerge(table.getRow(1).getCell(0))).isEqualTo(STMerge.CONTINUE); + assertThat(table.getRow(1).getCell(1).getText()).isEqualTo("bottom"); + } + + @Test + void aComposedCellExportsItsContentInsteadOfNothing() throws Exception { + XWPFTable table = firstTable(new TableBuilder() + .name("Composed") + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells( + DocumentTableCell.node(new ParagraphNode("CellParagraph", "composed text", + DocumentTextStyle.DEFAULT, TextAlign.LEFT, 0.0, + DocumentInsets.zero(), DocumentInsets.zero())), + DocumentTableCell.text("plain")) + .build()); + + // lines() is empty for a composed cell, which is what the backend used to write. + assertThat(table.getRow(0).getCell(0).getText()).contains("composed text"); + assertThat(table.getRow(0).getCell(1).getText()).isEqualTo("plain"); + } + + @Test + void aCellTakesTheMostSpecificStyleInTheCascade() throws Exception { + DocumentTableStyle bold = DocumentTableStyle.builder() + .textStyle(DocumentTextStyle.builder().size(11) + .decoration(DocumentTextDecoration.BOLD).build()) + .build(); + DocumentTableStyle plain = DocumentTableStyle.builder() + .textStyle(DocumentTextStyle.builder().size(11).build()) + .build(); + + XWPFTable table = firstTable(new TableBuilder() + .name("Styled") + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .defaultCellStyle(plain) + .rowCells(DocumentTableCell.text("bold").withStyle(bold), + DocumentTableCell.text("default")) + .build()); + + assertThat(table.getRow(0).getCell(0).getParagraphs().get(0).getRuns().get(0).isBold()).isTrue(); + assertThat(table.getRow(0).getCell(1).getParagraphs().get(0).getRuns().get(0).isBold()).isFalse(); + } + + @Test + void aMultiLineCellBreaksItsLinesRatherThanJoiningThem() throws Exception { + XWPFTable table = firstTable(new TableBuilder() + .name("Lines") + .columns(DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.lines("first", "second")) + .build()); + + XWPFTableCell cell = table.getRow(0).getCell(0); + var run = cell.getParagraphs().get(0).getRuns().get(0).getCTR(); + // The lines used to be joined into one w:t with a newline inside, which Word reads + // as a space. They are two texts around a real break now. Asserting on the markup + // rather than on getText(), which renders a break back as "\n" either way. + assertThat(run.getBrList()).hasSize(1); + assertThat(run.getTList()).hasSize(2); + assertThat(run.getTList().get(0).getStringValue()).isEqualTo("first"); + assertThat(run.getTList().get(1).getStringValue()).isEqualTo("second"); + } + + @Test + void aTableThatClaimsNoColumnAtAllStillExports() throws Exception { + // No declared column and a row with no cells: the grid has no positions. Sizing it + // to one anyway leaves a slot nothing covers, and reading that slot aborts the whole + // export for a document the PDF backend renders. + XWPFTable table = firstTable(new TableBuilder().name("Empty").row().build()); + + assertThat(table.getRows()).hasSize(1); + assertThat(table.getRow(0).getTableCells()).hasSize(1); + } + + @Test + void aCellWhoseContentWritesNothingKeepsAParagraph() throws Exception { + // A wrapper that ended up with no children contributes nothing, and the cell's own + // paragraph was removed before writing. A w:tc with no block-level child is not a + // shape Word should be handed. + XWPFTable table = firstTable(new TableBuilder() + .name("EmptyComposed") + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells( + DocumentTableCell.node(new ContainerNode("Wrapper", List.of(), 0.0, + DocumentInsets.zero(), DocumentInsets.zero(), null, null)), + DocumentTableCell.text("beside")) + .build()); + + assertThat(table.getRow(0).getCell(0).getParagraphs()).isNotEmpty(); + assertThat(table.getRow(0).getCell(1).getText()).isEqualTo("beside"); + } + + private static int gridSpan(XWPFTableCell cell) { + return cell.getCTTc().getTcPr().getGridSpan().getVal().intValue(); + } + + private static STMerge.Enum vMerge(XWPFTableCell cell) { + return cell.getCTTc().getTcPr().getVMerge().getVal(); + } + + private static XWPFTable firstTable(TableNode node) throws Exception { + byte[] docx; + try (DocumentSession session = GraphCompose.document() + .pageSize(595, 842) + .margin(DocumentInsets.of(36)) + .create()) { + session.add(node); + docx = session.export(new DocxSemanticBackend()); + } + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) { + List tables = document.getTables(); + assertThat(tables).hasSize(1); + return tables.get(0); + } + } +} From 701f6ae1c843caa2ecffb08fde0703faf137da21 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 8 Aug 2026 16:24:46 +0100 Subject: [PATCH 2/3] chore(examples): re-render the Word companion preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example builds a side-by-side row from addSection(...), and a section in a cell had no branch of its own — it reached the "unsupported content" placeholder and wrote an empty paragraph. Two cells of visible text were leaving the document that way. The committed preview shows the state before; the catalogue renders them now, and the drift guard fails on any preview that no longer matches its example. --- .../examples/word-export-companion.docx | Bin 7969 -> 8011 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx index 2169cd98a34d876fd5c9286a76500184aec3b978..87b71a6ed7a36016b02d556e5c4815e998b06a92 100644 GIT binary patch delta 2178 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 From fdf0a81dcc40847d6fc7c11cc7c8d5710e0ba58c Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 8 Aug 2026 19:29:05 +0100 Subject: [PATCH 3/3] test(docx): pin the cell that spans both ways A cell owning a rectangle rather than a strip is where the cover matrix, the gridSpan and the vMerge marker all meet, and the two existing cases each exercised only one axis. The continuation cell needs the width as well as the merge: without gridSpan the covered row is two grid columns short of its neighbours, which the assertion now catches. --- .../semantic/docx/DocxTableStructureTest.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableStructureTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableStructureTest.java index ce303409..7106921b 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableStructureTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableStructureTest.java @@ -80,6 +80,36 @@ void aRowSpanMergesVerticallyAndTheRowBelowKeepsItsOwnCells() throws Exception { assertThat(table.getRow(1).getCell(1).getText()).isEqualTo("bottom"); } + @Test + void aCellSpanningBothWaysCarriesTheMergeOnEveryRowItCovers() throws Exception { + // The hardest position for the cover matrix: one cell owning a 2x2 rectangle of a + // 3x3 grid. The row below authors one cell, not three, and the continuation needs + // the width as well as the merge marker — a w:vMerge without w:gridSpan would leave + // Word a row two grid columns short of the others. + XWPFTable table = firstTable(new TableBuilder() + .name("Both") + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.text("A").colSpan(2).rowSpan(2), DocumentTableCell.text("B")) + .rowCells(DocumentTableCell.text("C")) + .row("D", "E", "F") + .build()); + + assertThat(table.getRow(0).getTableCells()).hasSize(2); + assertThat(gridSpan(table.getRow(0).getCell(0))).isEqualTo(2); + assertThat(vMerge(table.getRow(0).getCell(0))).isEqualTo(STMerge.RESTART); + assertThat(table.getRow(0).getCell(0).getText()).isEqualTo("A"); + assertThat(table.getRow(0).getCell(1).getText()).isEqualTo("B"); + + assertThat(table.getRow(1).getTableCells()).hasSize(2); + assertThat(gridSpan(table.getRow(1).getCell(0))).isEqualTo(2); + assertThat(vMerge(table.getRow(1).getCell(0))).isEqualTo(STMerge.CONTINUE); + assertThat(table.getRow(1).getCell(1).getText()).isEqualTo("C"); + + // Every row still accounts for three grid columns. + assertThat(table.getRow(2).getTableCells()).hasSize(3); + assertThat(table.getRow(2).getCell(2).getText()).isEqualTo("F"); + } + @Test void aComposedCellExportsItsContentInsteadOfNothing() throws Exception { XWPFTable table = firstTable(new TableBuilder()