Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified assets/readme/examples/word-export-companion.docx
Binary file not shown.
137 changes: 137 additions & 0 deletions core/src/main/java/com/demcha/compose/document/layout/TableGrid.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*
* <p>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.</p>
*
* @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.
*
* <p>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.</p>
*
* @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<List<Placement>> resolve(TableNode node) {
int columnCount = columnCount(node);
int rowCount = node.rows().size();
boolean[][] occupied = new boolean[rowCount][columnCount];
List<List<Placement>> result = new ArrayList<>(rowCount);

for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) {
List<DocumentTableCell> source = node.rows().get(rowIndex);
List<Placement> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ static ResolvedTableLayoutWithContents resolveTableLayout(TableNode node,
double availableWidth) {
validateRowsExist(node);
int columnCount = resolveColumnCount(node);
List<List<LogicalCell>> logicalRows = buildLogicalRows(node, columnCount);
List<List<LogicalCell>> logicalRows = buildLogicalRows(node);
List<TableColumnLayout> normalizedSpecs = normalizeSpecs(node, columnCount);
TableCellLayoutStyle[][] stylesGrid = buildStylesGrid(node, logicalRows, columnCount);
double innerAvailableWidth = Math.max(0.0, availableWidth - node.padding().horizontal());
Expand Down Expand Up @@ -382,71 +382,23 @@ private static Map<CellKey, PreparedNode<?>> 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.
*
* <p>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.</p>
* <p>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.</p>
*/
private static List<List<LogicalCell>> buildLogicalRows(TableNode node, int columnCount) {
int rowCount = node.rows().size();
boolean[][] occupied = new boolean[rowCount][columnCount];
List<List<LogicalCell>> result = new ArrayList<>(rowCount);

for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) {
List<DocumentTableCell> source = node.rows().get(rowIndex);
List<LogicalCell> 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<List<LogicalCell>> buildLogicalRows(TableNode node) {
List<List<TableGrid.Placement>> placements = TableGrid.resolve(node);
List<List<LogicalCell>> result = new ArrayList<>(placements.size());
for (List<TableGrid.Placement> sourceRow : placements) {
List<LogicalCell> 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));
}
Expand Down Expand Up @@ -667,18 +619,7 @@ private static List<TableColumnLayout> 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) {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | ❌ |
Expand Down
Loading
Loading