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

### Public API

- **A link in the document is a link in the exported Word file.** Every one was dropped: a
reader opened an export, found the text of a link with nothing behind it, and a reference
to another section that went nowhere — which is most of the point of handing a document
over editable rather than as a PDF. A `linkTarget` now becomes a `w:hyperlink`: a
relationship carrying the address for an external one, `w:anchor` for one of the
document's own anchors. A run's own link wins over the paragraph's, so a sentence with one
linked phrase in it exports as one. An `anchor(...)` becomes a `w:bookmarkStart` /
`w:bookmarkEnd` pair wrapping that paragraph's text rather than sitting before it, so a
reader following the link lands on the paragraph.
<br><br>
Word takes far less of a name than a document gives: letters, digits and underscores,
starting with a letter, forty characters. A name it dislikes is not a broken link but a
file it refuses to open, so names are cleaned once and remembered — `Terms & conditions`
becomes `Terms_conditions` on both sides — and two anchors that clean to one name stay two
bookmarks rather than collapsing onto each other. Forward references need nothing extra:
neither side resolves anything at write time.
<br><br>
Measured by opening the export in Word 16.0: its own PDF carries two live links, one to
the address and one jumping within the document. What is **not** written is the outline —
`bookmarkOptions` builds a PDF outline tree, and Word's Navigation Pane comes from heading
styles, so promoting an anchored paragraph to a heading would restyle the document.

- **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
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Payload records live in `core` under

| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; `linkTarget` is still dropped |
| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's |
| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker, which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column. Word numbering does not honour the gap either and does not claim to — the level's marker column is a stated constant (180 twips, plus 120 per nesting level), chosen near the single space the old text form used |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ❌ |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ❌ |
Expand All @@ -75,8 +75,8 @@ Payload records live in `core` under
| 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 |
| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ❌ |
| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ❌ |
| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name stay two bookmarks |
| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ❌ the outline is not written. Word builds its Navigation Pane from heading styles, and promoting an anchored paragraph to a heading would restyle the document; a `linkTo(...)` anchor navigates instead — see the DOCX recipe |
| Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState` on every surface — shape fills/strokes, text runs, lines, side borders, table paint) | ✅ native `<a:alpha>` via POI on every surface — fills, strokes, text runs, table paint | ❌ |
| Text decorations — underline / strikethrough (`DocumentTextDecoration`) | ✅ `PdfTextDecorations` (em-proportional marks: underline −0.10 em, strikethrough +0.28 em, thickness 0.05 em) | ✅ `PptxTextFrames.applyStyle` (PowerPoint draws its own marks — sub-point placement differences vs the PDF's constants) | ✅ `DocxSemanticBackend.applyStyle` (underline maps to Word's single underline, strikethrough to `w:strike`) |
| Writing direction — right-to-left paragraphs (`ParagraphBuilder.direction`, `TextDirection`) | ✅ `ParagraphWrapping` resolves the line with the Unicode Bidirectional Algorithm and `PdfParagraphFragmentRenderHandler` draws it reordered — the page is painted, so the engine owns the order | ⚠️ `PptxParagraphFragmentRenderHandler` — a right-to-left line goes through **per-span absolute frames** rather than one flowing frame, each pinned where the layout put it, because a shared frame lets PowerPoint re-flow the runs and undo the resolved order. Every frame this handler emits — plain span and chip text alike — declares its direction (`rtl`), which is what puts a neutral on the correct side. A table cell declares it too, through the overload of `PptxTextFrames.singleRunBox` that takes a direction. A header/footer and a watermark still take the overload that declares nothing, so right-to-left text there shows the original defect. The deviation is that the line is not one editable paragraph, and that the text a reader copies out carries mirrored punctuation (see the mirroring row) | ✅ `DocxSemanticBackend.applyParagraphProperties` writes `w:bidi` (resolving `AUTO` through the same `ParagraphDirection` the page used) and hands Word logical text for its own bidi engine, which orders and joins it. Every run of that paragraph also carries `w:rtl`: `w:bidi` settles which edge the line starts from, `w:rtl` settles how Word resolves the characters inside a run, and a run without it is handled as Latin — measured in Word, `(2026)` closing an Arabic line was drawn as `)2026(` with `w:bidi` alone. Hebrew was unaffected, so the defect needed Arabic, where digits after a letter resolve as an Arabic number. Alignment is mapped through the direction, because Word reads `w:jc`'s left/right as start/end **relative to the paragraph** — written physically, a flush-right right-to-left paragraph came out flush left. Size and weight are written to the complex-script twins (`w:szCs`, `w:bCs`, `w:iCs`) as well as the Latin ones, since Word takes Hebrew and Arabic from those. Column order in a right-to-left table is not mirrored: `w:tblPr/w:bidiVisual` is unwritten |
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 @@ -46,6 +46,7 @@ PDF never pull POI.
| 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 |
| Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires. A `bookmarkOptions` outline entry is **not** written: Word's Navigation Pane comes from heading styles, and promoting a paragraph to a heading would restyle the document |
| 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 |
| Spacers | Empty paragraphs carrying the vertical gap as spacing-after |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.demcha.compose.document.backend.semantic.docx;

import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

/**
* The name Word will accept for one of the document's own anchors.
*
* <p>A document names its anchors to suit itself — {@code "Terms & conditions"},
* {@code "section-2"}, a heading's whole sentence. Word accepts far less: a bookmark name
* starts with a letter, carries only letters, digits and underscores, and stops at 40
* characters. Written through unchanged, a name Word dislikes is not a broken link but a
* file Word refuses to open, and a name truncated twice in two places is a link that points
* at nothing.</p>
*
* <p>So the mapping is made once and remembered: the same anchor asked for twice gets the
* same name, and two different anchors that clean to the same text get different ones. That
* is the whole reason this is an object rather than a static method — a link and the
* bookmark it points at are written at different times, by different parts of the export,
* and they have to agree.</p>
*
* @author Artem Demchyshyn
*/
final class DocxBookmarkNames {

/** {@code ST_String} caps a bookmark name here; Word enforces it on open. */
private static final int MAX_LENGTH = 40;

private final Map<String, String> byAnchor = new HashMap<>();
private final Map<String, Integer> collisions = new HashMap<>();
private int nextId;

/**
* The Word name for an anchor, the same one every time it is asked for.
*
* @param anchor the document's own anchor name
* @return a name Word accepts, or null when the anchor is empty
*/
String nameFor(String anchor) {
if (anchor == null || anchor.isBlank()) {
return null;
}
return byAnchor.computeIfAbsent(anchor, this::mint);
}

/** @return the next bookmark id; Word wants them distinct within the document */
int nextId() {
return nextId++;
}

private String mint(String anchor) {
StringBuilder cleaned = new StringBuilder(anchor.length());
for (int index = 0; index < anchor.length() && cleaned.length() < MAX_LENGTH; index++) {
char character = anchor.charAt(index);
if (Character.isLetterOrDigit(character) || character == '_') {
cleaned.append(character);
} else if (cleaned.length() > 0 && cleaned.charAt(cleaned.length() - 1) != '_') {
// One separator for a run of them, so "Terms & conditions" does not become
// "Terms___conditions" and spend its 40 characters on punctuation.
cleaned.append('_');
}
}
while (cleaned.length() > 0 && cleaned.charAt(cleaned.length() - 1) == '_') {
cleaned.setLength(cleaned.length() - 1);
}
if (cleaned.length() == 0 || !Character.isLetter(cleaned.charAt(0))) {
// A name starting with a digit or emptied by cleaning is not a name Word takes.
cleaned.insert(0, "anchor_");
}
String base = cleaned.length() > MAX_LENGTH ? cleaned.substring(0, MAX_LENGTH) : cleaned.toString();

// Two anchors that clean to the same text are still two anchors. Without this the
// second one's bookmark would sit on the first one's name and every link to either
// would land in the same place.
String key = base.toLowerCase(Locale.ROOT);
int seen = collisions.merge(key, 1, Integer::sum);
if (seen == 1) {
return base;
}
String suffix = "_" + seen;
String trimmed = base.length() + suffix.length() > MAX_LENGTH
? base.substring(0, MAX_LENGTH - suffix.length())
: base;
return trimmed + suffix;
}
}
Loading
Loading