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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3482,6 +3482,16 @@ follow semantic versioning; release dates are ISO 8601.
zone, the text header and footer slots, watermarks and `markerGap`. The DOCX recipe, the
charts recipe and the examples README no longer say the export has no layout pass.

- **The capability matrix's DOCX column says what the export carries.** It said the export
ignores the layout's geometry, carries no font, writes no external link and no reproducible
bytes, and that an anchor on a shape or a barcode marks nothing. The export reads the layout
for how tall a line is, how wide a column came out and where a drawing stands. It ships the
faces its styles name (`DocxFontTable`), naming one whose licence keeps it out. It writes the
link of a paragraph, a run and a picture in a line as a `w:hyperlink`, and names one on a
block it holds as a picture, a drawing, a rule or a table. It pins its clocks under
`deterministic(true)`, and bookmarks a barcode's anchor, naming a drawing's. The two link rows
are ⚠️ for the blocks that carry none.

## v2.4.1 — 2026-09-21

### Performance
Expand Down
15 changes: 9 additions & 6 deletions docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ Backends:
status in the tables below; the API shape may still change in a minor
while feedback lands (see [../api-stability.md](../api-stability.md)).
- **DOCX (semantic)** — `graph-compose-render-docx`,
`DocxSemanticBackend`. Walks the semantic node tree, deliberately
ignores fixed-layout geometry; Word owns the flow.
`DocxSemanticBackend`. Walks the semantic node tree and writes editable
Word content, which Word paginates and re-flows after an edit. It reads
the resolved layout, when it has one, for what Word cannot work out
itself: how tall a line is, how wide a column came out, where a drawing
stands (see [Measured geometry](../recipes/docx-export.md#measured-geometry)).

Reading the DOCX column: a row is `n/a` only when the capability is
declared on the fixed-layout SPI and can never reach a semantic backend —
Expand Down Expand Up @@ -92,15 +95,15 @@ Payload records live in `core` under
| Arabic contextual shaping — joined letter forms (`ArabicShaper`) | ✅ shaped into Presentation Forms-B before measurement, because `showText` walks the font `cmap` and never runs `GSUB`; a font carrying the letters but not the forms degrades to unjoined base letters rather than `?` | ✅ base letters restored (`PptxParagraphFragmentRenderHandler` → `ArabicShaper.toBaseLetters`, joining controls kept) — PowerPoint shapes Arabic itself, and frozen forms would land in a file users search and copy from | ✅ never shaped — Word receives the letters and shapes them itself |
| Bidi mirroring — paired punctuation in a reordered line (UAX #9 L4) | ✅ `BidiMirroring` swaps at the PDF seam, so a parenthesis faces what it encloses; the mirrored set is document punctuation rather than the whole Unicode table. An atomic chip that mixes directions is resolved level by level (`BidiVisualOrder`) rather than reversed whole, so its left-to-right interior keeps reading the way it was typed | ⚠️ `PptxParagraphFragmentRenderHandler` + `BidiMirroring` — measured on PowerPoint, it places a neutral from the declared direction but does not go on to mirror it, so the swap is made before the text is handed over. **That measurement is the load-bearing assumption of this row**, not a property of the format: a consumer that does apply L4 (any ICU-backed viewer) mirrors the character a second time and draws issue [#545](https://github.com/DemchaAV/GraphCompose/issues/545) again, with the stored text wrong as well. Only in PowerPoint is the result correct. The swap is keyed to the run, and only a run that is **uniformly** right-to-left gets it (`BidiVisualOrder.mirrorRightToLeftLevels`): that is the one PowerPoint reverses for itself, so the glyph has to be swapped for it, while a run mixing levels comes out right from the text exactly as typed and pre-swapping it drew `)a > b(` on a slide. A chip is the span that can hold both, since one rounded fill means the wrapper cannot split it — so `renderChip` splits its **text** instead, one frame per directional run placed in visual order (`BidiVisualOrder.visualRuns`), leaving each frame single-level and PowerPoint nothing to re-resolve. Handed over whole it was re-resolved as a fragment without the line around it, and the deck disagreed with the PDF about where a chip's Latin sat. The runs are measured on the joined forms, the width the fill was sized with; measured unshaped, an Arabic chip's last run ended past its own background. The stored run holds the mirrored character where the swap applies, including `<` and `>` | n/a — Word mirrors from the logical text it is given |
| Text extraction fidelity of reordered/shaped text | ✅ the font's `ToUnicode` map names the letters an author wrote rather than the shaped forms (`PdfShapedGlyphUnicode`, applied after subsetting and before encryption), and each reversed run carries its written text as `ActualText` (`PdfActualText`) so the reading order is in the file rather than re-derived; per-glyph text inside a section is reachable through the font's map rather than plain extraction | ⚠️ the runs are stored as written except for mirrored punctuation, so copying a right-to-left line out of a slide yields swapped brackets; a chip's letters stay in logical order, its right-to-left-level punctuation mirrored (see the mirroring row) | n/a — the runs are stored as written |
| Font portability — does a file carry the family it drew with | ✅ every embedded family is subset into the file (`PdfFontLoader`), so a reader needs nothing installed | ✅ `PptxRenderEnvironment.embedBundledFontsUsed` + `PptxEmbeddedFont` — a registered family and a bundled one the deck drew with are both carried. Whole-font, no subsetting — the **bundled** path narrows that to the faces a run actually drew, so a five-script deck grows from 27 KB to about 1.4 MB rather than carrying weights no slide contains; a **registered** family is still taken at family granularity, all four faces. `PptxFixedLayoutBackend.Builder.embedBundledFonts(false)` declines the bundled half (a registered family travels regardless), and a declined or failed family is warned about by name. Standard-14 names carry no binary and map to metric-compatible viewer replacements (Helvetica to Arial, and so on) rather than being embedded | ❌ `w:rFonts` names the family and nothing is carried, so a reader without it installed substitutes — Word's own `w:embedRegular` + font table is unused |
| Font portability — does a file carry the family it drew with | ✅ every embedded family is subset into the file (`PdfFontLoader`), so a reader needs nothing installed | ✅ `PptxRenderEnvironment.embedBundledFontsUsed` + `PptxEmbeddedFont` — a registered family and a bundled one the deck drew with are both carried. Whole-font, no subsetting — the **bundled** path narrows that to the faces a run actually drew, so a five-script deck grows from 27 KB to about 1.4 MB rather than carrying weights no slide contains; a **registered** family is still taken at family granularity, all four faces. `PptxFixedLayoutBackend.Builder.embedBundledFonts(false)` declines the bundled half (a registered family travels regardless), and a declined or failed family is warned about by name. Standard-14 names carry no binary and map to metric-compatible viewer replacements (Helvetica to Arial, and so on) rather than being embedded | ✅ `DocxFontTable` + `DocxFontEmbedding` — a bundled or registered family the body, a page zone or a header band sets text in is carried in Word's font table (`w:embedRegular`, `w:embedBold`, …), only the faces its styles name, each obfuscated against a key derived from the font as Word stores it. Whole faces, no subsetting. A face whose `OS/2` `fsType` forbids embedding, or allows it for reading and printing only, is named but not shipped, and so is one that cannot be read; the report names each (`embedded font`). Standard-14 names carry no binary and fall to Word's own substitution |
| Writing direction inside a **table cell** (`DocumentTableStyle.direction`) — the row grades the plain-**string** cell (`table.row("…")`). A cell holding a composed **paragraph** is a paragraph everywhere and takes the row above. | ✅ `TableLayoutSupport` answers the declared direction against the cell's own text — `AUTO` per cell, never per line — and `PdfTableRowFragmentRenderHandler` shapes the letters, draws them in display order, and marks the line with its written text as `ActualText`. Auto columns are measured on the joined forms, so the column fits what is drawn | ✅ `PptxTableRowFragmentRenderHandler` declares the direction on the cell's frame and hands PowerPoint the text as written, mirroring nothing and keeping every Unicode formatting control (`PdfFont.sanitizeForLogicalTextExport`) — the joining controls because PowerPoint shapes, the direction marks because it also resolves the order, which is more than the paragraph path leaves it. This is the opposite of the paragraph rows above and for a stated reason: a paragraph arrives as one frame per span, so no frame holds a bracket pair for PowerPoint to resolve and the swap has to be made first, whereas a cell is one frame holding a whole line — the input PowerPoint's own implementation is complete for. Measured on a slide: pre-mirroring a cell drew `(2026)` as `)2026(`. A copy out of a cell therefore carries the brackets as typed, which the paragraph path cannot promise | ⚠️ `DocxSemanticBackend.writeCellContent` writes `w:bidi` on the cell's paragraph and hands Word the text untouched, for its own bidi engine to order and join. Cell **alignment** is written on no cell, right-to-left or not, so an explicit `textAnchor` does not reach Word — the direction-derived edge does, because Word's own default is the start of the flow. Column order is not mirrored (`w:tblPr/w:bidiVisual` is unwritten) |

## Navigation and interactivity

| Capability | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
| External hyperlinks (fragment- and run-level) | ✅ `PdfLinkAnnotationWriter` + link rects in `PdfFixedLayoutBackend` | ✅ `PptxNavigationWriter` (transparent hotspots for measured span, line, and fragment rectangles, emitted above all content after the fragment pass) | ❌ |
| Internal links (anchor jump, forward references) | ✅ `PdfInternalLinkWriter` (two-pass) | ✅ `PptxNavigationWriter` (deferred slide-jump hyperlinks, resolved after all fragments — including across sections) | ✅ `DocxSemanticBackend` — an internal `linkTarget` is a `w:hyperlink` with `w:anchor`, and every anchor the export writes is a bookmark: a paragraph's around its text, a section's, container's, table's or image's around everything the block wrote (`bookmarkAround`). An anchor on a node the export drops (a shape, a barcode) has nothing to mark |
| External hyperlinks (fragment- and run-level) | ✅ `PdfLinkAnnotationWriter` + link rects in `PdfFixedLayoutBackend` | ✅ `PptxNavigationWriter` (transparent hotspots for measured span, line, and fragment rectangles, emitted above all content after the fragment pass) | ⚠️ `DocxSemanticBackend.newRun` — a paragraph's, a run's, a picture-in-a-line's and a page zone picture's link is a `w:hyperlink` with an external relationship, a run's own link winning over its paragraph's. A block written as a picture, a drawing or a rule — an image, a barcode, a shape, an ellipse, a line — and a table carry none, and the report names the link |
| Internal links (anchor jump, forward references) | ✅ `PdfInternalLinkWriter` (two-pass) | ✅ `PptxNavigationWriter` (deferred slide-jump hyperlinks, resolved after all fragments — including across sections) | ⚠️ `DocxSemanticBackend` — an internal `linkTarget` is a `w:hyperlink` with `w:anchor`, on what an external one is written on, and every anchor the export writes is a bookmark: a paragraph's around its text, a section's, container's, table's, image's or barcode's around everything the block wrote, a rule's around its paragraph (`bookmarkAround`). A drawing floated over the text — a shape, an ellipse, a line not written as a rule — a stack's layer written as a column, and a page zone's paragraph or picture hold no bookmark: the report names the anchor, a link to it points at no bookmark, and a page reference to it is written as the number the layout found |
| Page references — a table of contents' numbers, `addPageReference(...)` (`PageReferenceNode`) | ✅ `PageReferenceDefinition` lays out the resolved page as text, drawn by `PdfParagraphFragmentRenderHandler` | ✅ the same laid-out text through `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writePageReference` — a `PAGEREF` field to the anchor's bookmark, as a hyperlink (`\h`), storing the page the layout resolved; the editor recomputes it (LibreOffice on layout, Word on a field update). A reference to an anchor the export writes no bookmark for is the text the page prints, since Word turns a `PAGEREF` to a missing bookmark into an error, and the report names it, as it does the margins and padding on the sides its alignment sets it from, which are not written. `w:updateFields` is not set |
| Document outline / bookmarks tree | ✅ `PdfBookmarkOutlineWriter` | ⚠️ `PptxNavigationWriter` (no PPTX outline concept — slide names where 1:1, extra bookmarks dropped with a note) | ❌ |

Expand Down Expand Up @@ -130,7 +133,7 @@ honour an option ignores it (documented contract).
| Render to images (`renderToImages`) | ✅ PDFBox `PDFRenderer` | ❌ (throws with a pointer to the PDF backend — POI's slide rasterizer cannot honour embedded fonts, and the PDF raster of the same graph is the canonical image output) | n/a (a `FixedLayoutRenderer` surface; the semantic SPI has no raster output) |
| Raster-slide mode — every page as one full-slide picture, pixel-exact to the PDF/PNG output (`Builder.rasterSlides(dpi)`) | n/a (the PDF raster is the source) | ✅ `PptxFixedLayoutBackend` | n/a (a fixed-layout backend builder option) |
| Multi-section documents (`renderSections`, per-section chrome, cross-section links) | ✅ `buildSectionsDocument` in `PdfFixedLayoutBackend` | ⚠️ `renderSections` in `PptxFixedLayoutBackend` (a deck carries one slide size, so every section must share the same page canvas — differing sizes throw) | ✅ `DocxSemanticBackend.exportSections`, reached through `MultiSectionDocument.export` / `toDocxBytes` — a Word section per section, each with its own page size, orientation, margins and page-zone header and footer (the text header and footer slots are written per section, their page total as `SECTIONPAGES`). Page numbers restart at 1 in every section and a zone's total is `SECTIONPAGES`, as the PDF counts; a section with no zone of its own gets an empty header or footer rather than Word's inherited one; styles, fonts and bookmark names are shared, so links cross sections |
| Deterministic output (render twice → identical bytes) | ✅ `PdfDeterminismWriter` | ✅ `PptxDeterminismWriter` (pinned OPC created/modified + zip entry-time normalization) | ❌ |
| Deterministic output (render twice → identical bytes) | ✅ `PdfDeterminismWriter` | ✅ `PptxDeterminismWriter` (pinned OPC created/modified + zip entry-time normalization) | ✅ `DocxDeterminism` (`DocxSemanticBackend.builder().deterministic(true)` or `deterministic(Instant)`: pinned OPC created/modified + zip entry-time normalization; an embedded font's obfuscation key is derived from the font, so it needs no pinning) |
| ServiceLoader discovery (`FixedLayoutBackendProvider`) | ✅ `PdfFixedLayoutBackendProvider` (`format() == "pdf"`) | ✅ `PptxFixedLayoutBackendProvider` (`format() == "pptx"`) | n/a (semantic SPI: `SemanticBackend`) |
| `DocumentSession` convenience methods | ✅ `buildPdf` / `writePdf` / `toPdfBytes` / `toImages` | ✅ `buildPptx` / `writePptx` / `toPptxBytes` (resolved via `BackendProviders.fixedLayout("pptx")`; session chrome applies; fails with `MissingBackendException` naming `graph-compose-render-pptx` when the backend is absent) | ✅ `session.export(new DocxSemanticBackend(...), target)` — the semantic backend is named directly rather than discovered |

Expand Down
Loading