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/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx index 2169cd98..87b71a6e 100644 Binary files a/assets/readme/examples/word-export-companion.docx and b/assets/readme/examples/word-export-companion.docx differ 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..7106921b --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableStructureTest.java @@ -0,0 +1,223 @@ +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 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() + .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); + } + } +}