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

### Tests

- **Every page reference a DOCX export writes names a bookmark the file holds.**
- `DocxPageReferenceGuardTest` reads each `PAGEREF` field, bookmark and paragraph from every XML
part of the exported file — the body, its headers and footers, nested tables and text boxes.
It fails on a field naming a bookmark the body does not hold, or a name Word does not give a
bookmark, which Word reads as "Error! Bookmark not defined." once it updates the field; and on
one naming more than one bookmark, one in another case or one never closed, since the export
names exactly the bookmark it wrote.
- It holds the template corpus, which refers to no page, and four documents that refer to an
anchor on each kind of node that declares one — the kinds read off the node classes — from a
table of contents, the body, a table's composed cell, a layer stack's column, a panel and a
row's column: blocks of drawings alone, a layer stack's columns, anchors named past Word's 40
characters, names that clean or fold to another's, and tables split for other page margins —
the split tables and the page nothing is written on held by the notes the export's report
gives them, the rest by the bookmarks the fields name. A reference whose anchor the export
writes no bookmark for — a block that draws nothing, one whose first drawing fills a page
nothing is written on, one composed in a table's cell, a drawn shape, an anchor the document
lacks — must be the number it reads, read off its paragraph, with a report note. Each
document must be exported from its layout: a document the layout cannot lay out is exported
without it, and its references read no page.
- Measured on the four documents in Word 16, every field updated, and again with a page put in
at the start: each of the 27 fields read its bookmark's page, and the next one with the page
put in; the 8 references written as text did not move. LibreOffice 24.2 read 25 of the fields
alike. The DOCX recipe and the capability matrix name the three cases that differ: a reference
in a table's composed cell stores no number, and LibreOffice reads neither a bookmark named like
one of its numbering sequences nor one of a block that opens with a table right after another
table.

- **The DOCX fidelity corpus holds the ways a page breaks inside a panel.**
- Five documents join it, so the gate holds every line it finds of each where the editor set
it, in LibreOffice in CI and in Word on Windows, as it holds the presets:
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 @@ -104,7 +104,7 @@ Payload records live in `core` under
|---|---|---|---|
| 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 stack's layer written as a column around what it writes, a rule's around its paragraph (`bookmarkAround`); a block that writes no paragraph — a section, a container or a column of drawings alone — at the start of the paragraph its first drawing is anchored in, which Word keeps the drawing on the page of. A drawing floated over the text — a shape, an ellipse, a line not written as a rule — a page zone's paragraph or picture, and a block that draws nothing, whose first drawing stands on a page nothing is written on, or that is composed in a table's cell 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 |
| 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. Every field names a bookmark the file holds. A reference in a table's composed cell stores no number: Word shows it once its fields are updated, and one whose anchor has no bookmark is empty; LibreOffice 24.2 reads a reference as empty where its bookmark is named like one of its numbering sequences (`Table`, `Figure`, `Illustration`, `Drawing`, `Text`), and drops the bookmark of an anchored block that opens with a table right after another table, opened on the paragraph between the two tables, reading its reference as an error. `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) | ❌ |

## Document chrome and output options
Expand Down
18 changes: 18 additions & 0 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,24 @@ first time it updates. Each field is named after the bookmark its anchor was wri
or taken back to its text, once the body is written, so a reference before its anchor is
settled too.

Every field the export writes names a bookmark the file holds. Measured in Word 16 and
LibreOffice 24.2, with the fields updated and again with a page put in at the start, each read
its bookmark's page and then the next — an anchor in a table's header row the page repeats reads
the row's first page, as the links and anchors row says — but for three cases:

- A page reference in a table's composed cell stores no number: the layout keeps no text for what
such a cell holds. As a field, it shows nothing in Word until its fields are updated, and the
page in LibreOffice. Where its anchor has no bookmark it is empty for good, though its report
note says it is written as its number.
- LibreOffice reads a page reference as empty where its bookmark is named like one of its own
numbering sequences: `Table` (measured as `table`, `Table` and `TABLE`), `Figure`,
`Illustration`, `Drawing` or `Text`. Word reads the page.
- An anchored block that opens with a table right after another table — a panel under a layer
stack's columns, say — is bookmarked from the paragraph Word keeps between the two tables, which
stays with the table below it, to the block's last line. LibreOffice drops a bookmark opened on
that paragraph, and a page reference to it reads "Error: Reference source not found"; one opened
on an ordinary paragraph above a table, or closed on one below it, is kept. Word reads the page.

## Several sections in one document

A `MultiSectionDocument` — a cover in one page size, a body in another — exports to Word
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,26 @@ private static Path export(DocxCorpusDocument document, Path dir) throws Excepti

/** Writes a document's PDF into {@code dir}; returns its DOCX. */
private static byte[] exportTo(DocxCorpusDocument document, Path dir) throws Exception {
try (DocumentSession session = composed(document)) {
Files.write(dir.resolve(document.stem() + ".pdf"), session.toPdfBytes());
return session.export(DocxSemanticBackend.builder().deterministic(true).build());
}
}

/** A fresh session holding a corpus document, on its page; the caller closes it. */
static DocumentSession composed(DocxCorpusDocument document) {
var builder = GraphCompose.document();
if (document.margin() >= 0) {
float margin = (float) document.margin();
builder.pageSize(DocumentPageSize.A4).margin(margin, margin, margin, margin);
}
try (DocumentSession session = builder.create()) {
DocumentSession session = builder.create();
try {
document.compose().accept(session);
Files.write(dir.resolve(document.stem() + ".pdf"), session.toPdfBytes());
return session.export(DocxSemanticBackend.builder().deterministic(true).build());
} catch (RuntimeException | Error e) {
session.close();
throw e;
}
return session;
}
}
Loading
Loading