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

### Public API

- **A table cell's alignment reaches Word.** The DOCX export read neither half of a cell's
`textAnchor`: every column a template right-aligns or centres — amounts, quantities,
totals — came out flush left, and since Word's default is the top of the cell while the
engine's is the vertical middle, a single line beside a taller neighbour sat at the top of
its row. The anchor, cascaded as the layout cascades it, is now `w:vAlign` on every cell
and `w:jc` on a text cell's paragraph, with the engine's default when none is stated
(middle left, middle right for a right-to-left cell) and `DEFAULT` at the bottom left as
the renderer draws it. A composed cell takes the vertical half only, as the engine places
it. Measured in LibreOffice against the engine's render: a centred quantity, a
right-aligned amount and a bottom-right cell beside a three-line cell land as on the page,
and a right-to-left cell starts on the right.

- **A card is a card in Word.** The DOCX export painted a container's fill and borders onto
each paragraph inside it, and Word has no element that wraps a run of paragraphs, so in an
editor the card came apart: the accent bar broke beside every row and table inside it, the
Expand Down
Binary file modified assets/readme/examples/word-export-companion.docx
Binary file not shown.
2 changes: 1 addition & 1 deletion docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,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 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; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way) |
| 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`; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; 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; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way) |
| 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
2 changes: 1 addition & 1 deletion docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ creation date is real metadata.
|---|---|
| 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. Each cell states its own padding as `w:tcMar`, on all four sides, so a row is as tall as the page draws it, and a cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one |
| Tables | Word tables, one cell per cell. Each cell states its own padding as `w:tcMar`, on all four sides, so a row is as tall as the page draws it. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one |
| 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 — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table — 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 |
| Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below |
| Images | Embedded pictures at the node's declared size |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import com.demcha.compose.document.table.DocumentTableCell;
import com.demcha.compose.document.table.DocumentTableColumn;
import com.demcha.compose.document.table.DocumentTableStyle;
import com.demcha.compose.document.table.DocumentTableTextAnchor;
import com.demcha.compose.font.FontFamilyDefinition;
import com.demcha.compose.font.FontLibrary;
import com.demcha.compose.font.FontName;
Expand Down Expand Up @@ -3073,6 +3074,7 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception
DocumentColor fill = resolveCellFill(node, placement);
applyCellPaint(cell, fill, resolveCellValue(node, placement, DocumentTableStyle::stroke));
applyCellPadding(cell, resolveCellPadding(node, placement));
applyVerticalAnchor(cell, resolveCellAnchor(node, placement));
if (placement.row() != rowIdx) {
// A covered position carries the merge marker and no content of its own.
continue;
Expand Down Expand Up @@ -3327,6 +3329,12 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement,
// edge. So the cell is told, and the text goes over untouched.
boolean rightToLeft = resolveCellDirection(node, placement, lines);
applyDirection(para, rightToLeft);
ParagraphAlignment alignment = toAlignment(horizontalOf(resolveCellAnchor(node, placement, rightToLeft)),
rightToLeft);
if (alignment != ParagraphAlignment.LEFT) {
// LEFT is where Word starts the line anyway, in either direction.
para.setAlignment(alignment);
}
// The height the row was sized with. Without it Word sets the cell at its own
// spacing for the font — measured on the probe corpus, a totals row whose style
// states a 14pt face came out 3pt taller than the page draws it.
Expand All @@ -3343,6 +3351,59 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement,
}
}

/**
* Where the page places a cell's content inside its box, most specific style wins.
*
* <p>The same cascade the layout merges, and the same default when none states one: the
* engine's cell style sets text at the vertical middle, on the left — or on the right for
* a right-to-left cell, which the layout gives {@code CENTER_RIGHT} the same way. Word's
* default is the top left, so a cell left to it put a single line at the top of a row a
* taller neighbour had stretched, and an amount column the page right-aligns flush left.</p>
*/
private DocumentTableTextAnchor resolveCellAnchor(TableNode node, TableGrid.Placement placement,
boolean rightToLeft) {
DocumentTableTextAnchor authored = resolveCellValue(node, placement, DocumentTableStyle::textAnchor);
if (authored != null) {
return authored;
}
return rightToLeft ? DocumentTableTextAnchor.CENTER_RIGHT : DocumentTableTextAnchor.CENTER_LEFT;
}

/** The anchor for the vertical half, where direction does not matter. */
private DocumentTableTextAnchor resolveCellAnchor(TableNode node, TableGrid.Placement placement) {
return resolveCellAnchor(node, placement, false);
}

/**
* Writes where a cell's content sits vertically.
*
* <p>{@code DEFAULT} is the bottom edge, as the page places it: the engine maps it to
* {@code Anchor.defaultAnchor()}, whose vertical half the cell renderer and the composed
* cell both treat as the bottom. Top is Word's own default and is not written.</p>
*/
private static void applyVerticalAnchor(XWPFTableCell cell, DocumentTableTextAnchor anchor) {
XWPFTableCell.XWPFVertAlign vertical = switch (anchor) {
case TOP_LEFT, TOP_RIGHT -> null;
case CENTER_LEFT, CENTER, CENTER_RIGHT -> XWPFTableCell.XWPFVertAlign.CENTER;
case BOTTOM_LEFT, BOTTOM_RIGHT, DEFAULT -> XWPFTableCell.XWPFVertAlign.BOTTOM;
};
if (vertical != null) {
cell.setVerticalAlignment(vertical);
}
}

/**
* The horizontal half of a cell's anchor, as the alignment the page draws a line with.
* {@code DEFAULT} is the left, as the cell renderer places it.
*/
private static TextAlign horizontalOf(DocumentTableTextAnchor anchor) {
return switch (anchor) {
case CENTER -> TextAlign.CENTER;
case CENTER_RIGHT, TOP_RIGHT, BOTTOM_RIGHT -> TextAlign.RIGHT;
case CENTER_LEFT, TOP_LEFT, BOTTOM_LEFT, DEFAULT -> TextAlign.LEFT;
};
}

/**
* Whether a cell's text runs right to left.
*
Expand All @@ -3358,7 +3419,13 @@ private boolean resolveCellDirection(TableNode node, TableGrid.Placement placeme
if (declared == null) {
return false;
}
return ParagraphDirection.resolve(String.join("\n", lines), declared) == TextDirection.RTL;
// Read the lines as the layout reads them, a break inside a line flattened to a
// space, so the two decide the same direction from the same text.
List<String> asLaidOut = new ArrayList<>(lines.size());
for (String line : lines) {
asLaidOut.add(line == null ? "" : line.replace('\r', ' ').replace('\n', ' '));
}
return ParagraphDirection.resolve(String.join("\n", asLaidOut), declared) == TextDirection.RTL;
}

/**
Expand Down
Loading
Loading