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 ListFor 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 ListAn 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++) { - ListThe 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