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

### Public API

- **An exported Word document has an outline.** A reader opened a twenty-page export and
found one flat run of paragraphs: nothing in the Navigation Pane, nothing in the outline
view, and a generated table of contents that came back empty. Word builds all three from
heading *styles*, and the export wrote none. The outline level a document states when it
declares a bookmark now becomes Word's own `HeadingN` style — verified through Word 16.0's
own heading enumeration, which lists the document's chapters and their nesting.
<br><br>
The style carries the outline level and nothing else. The paragraph already has the look
its author gave it, and a heading style that also set a font and a size would restyle every
heading on the way out — describing the page is the job, not redesigning it. Word knows its
built-in headings by the pair `HeadingN` and `heading N`, so both are written: with one of
them it is a custom style that happens to be called Heading and the Navigation Pane stays
empty. Only the levels a document uses are defined, and a level past Word's nine is clamped
rather than written as a style that does not exist.
<br><br>
The role is read from what the document declared and never inferred from type size. A
heading guessed from a large first line turns a subtitle into a chapter and leaves a small
real heading as body text, and both are wrong in a document someone then edits.

- **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
Expand All @@ -26,9 +45,7 @@ follow semantic versioning; release dates are ISO 8601.
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.
the address and one jumping within 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
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 @@ -76,7 +76,7 @@ Payload records live in `core` under
| 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) | ✅ `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 |
| 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) | ✅ `DocxSemanticBackend` — the stated outline level becomes Word's own `HeadingN` style, so the Navigation Pane, the outline view and a generated table of contents all see the document's structure. The style carries the outline level and no formatting, so the paragraph keeps the look its author gave it; only the levels the document uses are defined, and one past Word's nine is clamped. The role is never inferred from type size |
| 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
12 changes: 8 additions & 4 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +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 |
| 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 `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is |
| 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 Expand Up @@ -134,9 +134,13 @@ restate it stay silent, so changing `Normal` in Word changes the body the way a
expects. A run whose font, size or colour differs keeps saying so, so headings, chips and
accents are unaffected.

There is one `Normal` and no generated heading styles yet: a heading still carries its
own direct formatting rather than a named `Heading 1`. Restyling the body works; restyling
"all headings" in one go does not.
A paragraph that declared an outline level — `bookmark(new DocumentBookmarkOptions(name,
level))` — also carries Word's own `HeadingN` style, which is what fills the Navigation
Pane, the outline view and a generated table of contents. The style states the outline
level and no formatting, so the paragraph keeps the look its author gave it and "restyle
all headings" in Word still reaches it. Only the levels the document uses are defined, and
a level past Word's nine is clamped. A heading is never inferred from type size: a large
first line claims nothing about structure.

## What a list becomes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import com.demcha.compose.document.node.ContainerNode;
import com.demcha.compose.document.output.DocumentMetadata;
import com.demcha.compose.document.output.DocumentOutputOptions;
import com.demcha.compose.document.node.DocumentBookmarkOptions;
import com.demcha.compose.document.node.DocumentLinkTarget;
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.ExternalLinkTarget;
Expand Down Expand Up @@ -75,6 +76,7 @@
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTLvl;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPBdr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTString;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyles;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STStyleType;
Expand Down Expand Up @@ -192,6 +194,9 @@ public final class DocxSemanticBackend implements SemanticBackend<byte[]> {
// The Word names this export gave the document's anchors, so a link and the bookmark
// it points at agree.
private DocxBookmarkNames bookmarkNames = new DocxBookmarkNames();
// The outline levels this document asks for, so the styles part defines those and no
// others. Filled before the styles part is written, which comes before the body.
private java.util.Set<Integer> headingLevels = java.util.Set.of();
// Where the finished report goes, when the caller configured somewhere for it to go.
private final java.util.function.Consumer<DocxExportReport> reportSink;

Expand Down Expand Up @@ -271,6 +276,7 @@ public byte[] export(DocumentGraph graph, SemanticExportContext context) throws
listNumbering.clear();
report = new DocxExportReport.Builder();
bookmarkNames = new DocxBookmarkNames();
headingLevels = headingLevelsIn(graph);
wordFamilies = DocxFontTable.familiesByName(context.customFontFamilies());
documentDefaultStyle = dominantTextStyle(graph);
layout = DocxLayoutMetrics.of(graph, context.layoutGraph());
Expand Down Expand Up @@ -966,9 +972,40 @@ private void writeStylesPart(XWPFDocument document) {
normal.addNewName().setVal(NORMAL_STYLE_ID);
applyDefaultRunProperties(normal.addNewRPr(), defaults);

headingLevels.stream().sorted().forEach(level -> writeHeadingStyle(styles, level));

document.createStyles().setStyles(styles);
}

/**
* Defines one of Word's heading styles, as a role and nothing else.
*
* <p>The style carries an outline level and no formatting at all. That is the point: a
* heading in this export is a <em>statement about structure</em>, made by the document
* when it asked for an outline entry, and the paragraph already carries the look its
* author gave it. A heading style that also set a font and a size would restyle every
* heading in the document on the way out — the export would be redesigning the page
* rather than describing it.</p>
*
* <p>Word recognises its built-in headings by the pair: the id {@code HeadingN} and the
* name {@code heading N}. Written with only one of them, the style is a custom style
* that happens to be called Heading, the Navigation Pane stays empty, and "promote to
* heading 2" in Word does something else.</p>
*
* @param styles the styles part being built
* @param level the zero-based outline level, as the document states it
*/
private static void writeHeadingStyle(CTStyles styles, int level) {
int ordinal = level + 1;
CTStyle heading = styles.addNewStyle();
heading.setType(STStyleType.PARAGRAPH);
heading.setStyleId("Heading" + ordinal);
heading.addNewName().setVal("heading " + ordinal);
heading.addNewBasedOn().setVal(NORMAL_STYLE_ID);
heading.addNewQFormat();
heading.addNewPPr().addNewOutlineLvl().setVal(BigInteger.valueOf(level));
}

private void applyDefaultRunProperties(CTRPr properties, DocumentTextStyle defaults) {
if (defaults.fontName() != null) {
// All four slots, exactly as XWPFRun.setFontFamily writes them on a run.
Expand Down Expand Up @@ -1026,6 +1063,75 @@ private String wordFamilyOf(FontName fontName) {
return definition == null ? family.name() : definition.wordFamily();
}

/** Word has nine heading levels; a document asking for a tenth is clamped to the ninth. */
private static final int MAX_HEADING_LEVEL = 8;

/**
* Gives a paragraph the heading role the document asked for, and only the role.
*
* <p>Word builds its Navigation Pane, its table of contents and its outline view from
* heading <em>styles</em>, not from bookmarks — so an export that wrote a bookmark and
* stopped there produced a document that could be jumped to by name and had no
* structure at all to move around in.</p>
*
* <p>The role comes from the outline level the document stated when it declared the
* bookmark. It is never inferred from how the paragraph looks: a heading guessed from
* font size turns a large first line into a chapter and a small real heading into body
* text, and both are wrong in a document a person then edits.</p>
*
* <p>The paragraph keeps its own formatting. The style it points at carries an outline
* level and nothing else, so what changes is what Word knows about the paragraph, not
* how it is drawn.</p>
*/
private void applyHeadingRole(XWPFParagraph para, ParagraphNode node) {
Integer level = headingLevelOf(node);
if (level == null) {
return;
}
CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr();
CTString style = properties.isSetPStyle() ? properties.getPStyle() : properties.addNewPStyle();
style.setVal("Heading" + (level + 1));
}

/**
* The outline levels this document actually asks for.
*
* <p>Collected before the styles part is written, because that part comes first in the
* package and a style a paragraph refers to has to exist. Only the levels in use are
* defined: nine heading styles in a document with two headings is nine entries in
* Word's style gallery that nothing in the document uses.</p>
*
* <p>A heading is read from what the document <em>states</em> — the outline level it
* asked for when it declared a bookmark — and never inferred from how big the text is.
* A large paragraph is a large paragraph; a document that never asked for an outline
* does not get one invented from its typography.</p>
*/
private static java.util.Set<Integer> headingLevelsIn(DocumentGraph graph) {
java.util.Set<Integer> levels = new java.util.TreeSet<>();
for (DocumentNode root : graph.roots()) {
collectHeadingLevels(root, levels);
}
return levels;
}

private static void collectHeadingLevels(DocumentNode node, java.util.Set<Integer> levels) {
if (node instanceof ParagraphNode paragraph) {
Integer level = headingLevelOf(paragraph);
if (level != null) {
levels.add(level);
}
}
for (DocumentNode child : node.children()) {
collectHeadingLevels(child, levels);
}
}

/** @return the paragraph's outline level, clamped to Word's nine, or null when it is not a heading */
private static Integer headingLevelOf(ParagraphNode node) {
DocumentBookmarkOptions bookmark = node.bookmarkOptions();
return bookmark == null ? null : Math.min(bookmark.level(), MAX_HEADING_LEVEL);
}

/**
* The text style the document is mostly written in.
*
Expand Down Expand Up @@ -1275,6 +1381,7 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node)
private void writeParagraph(XWPFDocument document, ParagraphNode node) {
XWPFParagraph para = newBodyParagraph(document);
boolean rightToLeft = applyParagraphProperties(para, node);
applyHeadingRole(para, node);
int anchor = openAnchor(para, node.anchor());
writeParagraphRuns(para, node, rightToLeft);
closeAnchor(para, anchor);
Expand Down
Loading
Loading