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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ follow semantic versioning; release dates are ISO 8601.

### Public API

- **A composed table cell carries whatever it was built from.** `DocumentTableCell.node(...)`
lets a cell hold anything the document can hold, and the export wrote paragraphs out of it
and nothing else: a cell built from an image, a list or a table came out empty — content
the page draws simply missing from the file, with one line in a log to say so. A cell is
now a destination the ordinary writers point at rather than a place with a writer of its
own, so everything that can be written anywhere is written there too. A nested table is a
real `w:tbl` followed by the paragraph Word requires a cell to end with.
<br><br>
Reading a table's columns back from the layout was wrong wherever a cell was composed: a
table whose cell holds another table emits that inner table's rows under the *owner's*
path, so a two-column table came out with a three-column grid and Word placed every edge
where it was told. Only rows that span the table are read now, and the derived count must
match the one the table resolves. A nested table is given the width of the column it sits
in, less the margins Word keeps inside a cell — not the width the page gives it, which the
layout does not report separately, but with no width at all Word squeezes a nested table
to about one character a line.

- **A container's fill and borders now reach the DOCX export.** A `SectionNode` or
`ContainerNode` carrying a `fillColor`, per-side `borders` or a uniform `stroke` was
treated as a transparent wrapper: its children were written and its paint was dropped
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 @@ -71,7 +71,7 @@ Payload records live in `core` under
| Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | ✅ `PptxGradientFill` (native `ln`/`gradFill`) | ❌ |
| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes) |
| Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ❌ |
| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders`; a composed cell writes paragraphs and their wrappers only — one built from an image or a list lands empty, and a fill's opacity is dropped since `w:shd` is opaque) |
| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders`; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; a fill's opacity is dropped since `w:shd` is opaque) |
| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning |
| Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ omitted: the rail is resolved fixed-layout geometry and `DocxSemanticBackend` consumes the semantic tree, never a `LayoutGraph`. A timeline's **content** exports in full — entries, titles, meta and bodies — and the export does not throw; only the drawn line is absent. Marker shapes are geometry and may be omitted for the same reason |
| Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning |
Expand Down
1 change: 1 addition & 0 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ PDF never pull POI.
| Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved |
| Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs |
| Tables | Word tables, one cell per cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back" |
| Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with, and takes the width of the column it sits in — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path |
| Images | Embedded pictures at the node's declared size |
| Rows | A one-row table spanning the content width, so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin (cell content limited to atomic children) |
| Sections / containers | Children written in order; a fill, per-side borders or a uniform stroke travel to each paragraph inside as `w:shd` and `w:pBdr`, so a card keeps its panel — see "What a panel keeps and loses" below |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,24 +160,42 @@ OptionalDouble lineHeight(DocumentNode node) {
* @param node the table being written
* @return one width per column in order, or {@code null} when the table laid out nothing
*/
double[] tableColumns(DocumentNode node) {
List<TableResolvedCell> cells = new ArrayList<>();
double rightEdge = 0;
double[] tableColumns(DocumentNode node, int columnCount) {
PlacedNode placedTable = placedFor(node);
if (placedTable == null || placedTable.placementWidth() <= 0) {
return null;
}
double width = placedTable.placementWidth();

TreeSet<Double> boundaries = new TreeSet<>();
for (PlacedFragment fragment : fragmentsOf(node)) {
if (fragment.payload() instanceof TableRowFragmentPayload row) {
cells.addAll(row.cells());
if (!(fragment.payload() instanceof TableRowFragmentPayload row) || row.cells().isEmpty()) {
continue;
}
// A table whose cell is built from another table emits that inner table's rows
// under the *owner's* path, so the fragments at one path are not all one
// table's. A row of this table spans this table; a nested one stops short, and
// mixing the two produced a grid with more columns than the table has.
double rowRight = 0;
for (TableResolvedCell cell : row.cells()) {
rowRight = Math.max(rowRight, cell.x() + cell.width());
}
if (Math.abs(rowRight - width) > 0.5) {
continue;
}
for (TableResolvedCell cell : row.cells()) {
boundaries.add(round(cell.x()));
}
}
if (cells.isEmpty()) {
if (boundaries.isEmpty()) {
return null;
}
TreeSet<Double> boundaries = new TreeSet<>();
for (TableResolvedCell cell : cells) {
boundaries.add(round(cell.x()));
rightEdge = Math.max(rightEdge, cell.x() + cell.width());
}
boundaries.add(round(rightEdge));
return widthsBetween(new ArrayList<>(boundaries));
boundaries.add(round(width));
double[] widths = widthsBetween(new ArrayList<>(boundaries));
// The grid has to be the table's own. A column count that disagrees with the one
// the table resolves means something else contributed a boundary, and a wrong grid
// is worse than none: Word would place every column edge where it was told.
return widths != null && widths.length == columnCount ? widths : null;
}

/**
Expand Down
Loading
Loading