From dd5f371be0835b8138ba44fbbe194177c9f58cf1 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 23 Sep 2026 15:57:07 +0100
Subject: [PATCH] feat(docx): write a page reference as a PAGEREF field Word
keeps current
The DOCX export dropped every PageReferenceNode, so a table of contents
reached Word with its entries and no page numbers. Each is now a
PAGEREF field to the anchor's bookmark, as a hyperlink, storing the
page the layout resolved; the editor recomputes it as the document
changes. A reference to an anchor with no bookmark is written as its
placeholder text instead of a field Word would turn into an error.
w:updateFields is not set. The recipe states which field each page
number is and what updates it.
---
CHANGELOG.md | 12 ++
.../architecture/backend-capability-matrix.md | 1 +
docs/recipes/docx-export.md | 22 +++
.../semantic/docx/DocxLayoutMetrics.java | 22 +++
.../semantic/docx/DocxSemanticBackend.java | 64 +++++++
.../semantic/docx/DocxPageReferenceTest.java | 158 ++++++++++++++++++
6 files changed, 279 insertions(+)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageReferenceTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 02d884321..f65875e41 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,18 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A table of contents keeps its page numbers in Word, and they stay right.** The DOCX export
+ dropped every page reference, so `addTableOfContents(...)` reached Word with its entries and
+ no numbers, and `addPageReference(...)` wrote nothing. Each is now Word's own `PAGEREF`
+ field to the anchor's bookmark, as a hyperlink, storing the page the layout resolved — so
+ the file opens reading the PDF's numbers, and the editor recomputes them as the document
+ changes. Measured in LibreOffice: with the stored numbers replaced by 99, the table of
+ contents still showed pages 2, 3 and 5. A reference to an anchor the document has no
+ bookmark for is written as its placeholder text rather than a field Word would turn into
+ "Error! Bookmark not defined.". The export does not set `w:updateFields`, which would make
+ Word ask on every open to recompute numbers that already read correctly; the recipe states
+ which field each page number is and what updates it.
+
- **A link to a section, table or image reaches it in Word.** The DOCX export wrote a
bookmark only for a paragraph's `anchor(...)`; an anchor on a section, container, table or
image was dropped, so an internal link to it went nowhere in Word. Such a block's anchor is
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index fa83e6ff5..45f6c6c62 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -93,6 +93,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) | ❌ |
| 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 |
+| 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 its placeholder text, since Word turns a `PAGEREF` to a missing bookmark into 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
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index a2e35b82e..9d818a350 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -82,6 +82,28 @@ Page geometry (size, margins and orientation — a page wider than it is tall is
landscape) and session metadata (title, author, subject, keywords) carry into the Word
document as well.
+## Fields, and when they update
+
+Everything that states a page number is a Word field rather than text, so it stays right
+when the reader edits the document:
+
+| Where | Field | Updated by |
+|---|---|---|
+| a page zone's `pageNumber()` | `PAGE` | the editor, every time it lays the pages out |
+| a page zone's `pageTotal()` | `NUMPAGES`, or `SECTIONPAGES` in a multi-section document | the editor, every time it lays the pages out — except LibreOffice, which does not update `SECTIONPAGES` |
+| a table of contents' page numbers, `addPageReference(...)` | `PAGEREF` to the anchor's bookmark, as a hyperlink | LibreOffice on every layout (measured: a field whose stored number was replaced by 99 showed the real page); Word when fields are updated — F9, or printing with field updates on |
+
+Each field also stores a result, which is what a reader sees before an editor updates it
+and what a text extractor finds: the page the layout resolved, and for a page total the
+number of pages it laid out. A file therefore opens reading the same numbers as the PDF.
+
+The export does not set `w:updateFields`. It would make Word ask, on every open, whether to
+update fields — to recompute numbers that already read correctly.
+
+A page reference to an anchor the document does not bookmark is written as its text, the
+placeholder the page prints: Word turns a `PAGEREF` to a missing bookmark into "Error!
+Bookmark not defined." the first time it updates.
+
## Several sections in one document
A `MultiSectionDocument` — a cover in one page size, a body in another — exports to Word
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
index 57d079674..db701b039 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
@@ -312,6 +312,28 @@ OptionalDouble lineHeight(DocumentNode node) {
return OptionalDouble.empty();
}
+ /**
+ * The text a node laid out as paragraph lines, as the layout wrote it.
+ *
+ * A page reference's number is known only once the document is paginated, so the
+ * layout resolves it and lays out the number as text; this reads that text back rather
+ * than resolving the page a second time.
+ *
+ * @param node any node that lays out as paragraph lines
+ * @return the laid-out text, or empty when the node laid out nothing
+ */
+ java.util.Optional laidOutText(DocumentNode node) {
+ for (PlacedFragment fragment : fragmentsOf(node)) {
+ if (fragment.payload() instanceof ParagraphFragmentPayload paragraph
+ && !paragraph.lines().isEmpty()) {
+ StringBuilder text = new StringBuilder();
+ paragraph.lines().forEach(line -> text.append(line.text()));
+ return java.util.Optional.of(text.toString());
+ }
+ }
+ return java.util.Optional.empty();
+ }
+
/**
* The resolved width of every column of a table.
*
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 24fdc812f..a6a24ce40 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -234,6 +234,8 @@ public final class DocxSemanticBackend implements SemanticBackend {
// 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 headingLevels = java.util.Set.of();
+ // Anchors this export writes a bookmark for, so a page reference knows it has a target.
+ private java.util.Set bookmarkedAnchors = java.util.Set.of();
// Where the finished report goes, when the caller configured somewhere for it to go.
private final java.util.function.Consumer reportSink;
// The instant every clock in the package is pinned to, or null for live timestamps.
@@ -450,6 +452,7 @@ private byte[] write(List sections, Path outputFile) throws Exc
report = new DocxExportReport.Builder();
bookmarkNames = new DocxBookmarkNames();
headingLevels = headingLevelsIn(whole);
+ bookmarkedAnchors = bookmarkedAnchorsIn(whole);
wordFamilies = DocxFontTable.familiesByName(fonts);
documentDefaultStyle = dominantTextStyle(whole);
currentCell = null;
@@ -1119,6 +1122,8 @@ private static void keepOnOnePage(List written, boolean withNext)
private void writeNodeContent(XWPFDocument document, DocumentNode node) throws Exception {
if (node instanceof ParagraphNode paragraph) {
writeParagraph(document, paragraph);
+ } else if (node instanceof com.demcha.compose.document.node.PageReferenceNode reference) {
+ writePageReference(document, reference);
} else if (node instanceof ImageNode image) {
writeImage(document, image);
} else if (node instanceof TableNode table) {
@@ -1807,6 +1812,25 @@ private void applyHeadingRole(XWPFParagraph para, ParagraphNode node) {
* A large paragraph is a large paragraph; a document that never asked for an outline
* does not get one invented from its typography.
*/
+ /**
+ * Every anchor this export writes a bookmark for: a paragraph's, and a block's the export
+ * writes (see {@link #blockAnchorOf}). A page reference is a live field only when its
+ * anchor is one of these.
+ */
+ private static java.util.Set bookmarkedAnchorsIn(DocumentGraph graph) {
+ java.util.Set anchors = new java.util.HashSet<>();
+ java.util.ArrayDeque pending = new java.util.ArrayDeque<>(graph.roots());
+ while (!pending.isEmpty()) {
+ DocumentNode node = pending.pop();
+ String anchor = node instanceof ParagraphNode paragraph ? paragraph.anchor() : blockAnchorOf(node);
+ if (anchor != null && !anchor.isBlank()) {
+ anchors.add(anchor.trim());
+ }
+ pending.addAll(node.children());
+ }
+ return anchors;
+ }
+
private static java.util.Set headingLevelsIn(DocumentGraph graph) {
java.util.Set levels = new java.util.TreeSet<>();
for (DocumentNode root : graph.roots()) {
@@ -2128,6 +2152,46 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node)
}
}
+ /**
+ * Writes a page reference — a table of contents' page number, a "see page N" — as Word's
+ * own {@code PAGEREF} field on the anchor's bookmark.
+ *
+ * The export dropped the node, so a table of contents reached Word with its entries
+ * and without a single page number. A number written as text would be right until the
+ * reader edits the document; the field is the page Word counts, a hyperlink to it
+ * ({@code \h}) as the entry's label already is. What it reads before an editor updates it
+ * is the page the layout resolved, so the file opens showing the numbers the PDF does.
+ *
+ * A reference whose anchor this export writes no bookmark for is written as its text
+ * alone: Word turns a {@code PAGEREF} to a missing bookmark into "Error! Bookmark not
+ * defined." the first time the field updates, which is worse than a number that does not
+ * move.
+ */
+ private void writePageReference(XWPFDocument document,
+ com.demcha.compose.document.node.PageReferenceNode node) {
+ String shown = layout.laidOutText(node).orElse(node.placeholderText());
+ // The layout lays a page reference out as this paragraph, so its properties are
+ // written exactly as that paragraph's would be.
+ ParagraphNode asLaidOut = new ParagraphNode(node.name(), shown, node.textStyle(), node.align(),
+ 0.0, node.padding(), node.margin());
+ XWPFParagraph para = newBodyParagraph(document);
+ applyParagraphProperties(para, asLaidOut);
+ applyLineHeight(para, layout.lineHeight(node));
+ String bookmark = bookmarkedAnchors.contains(node.anchor())
+ ? bookmarkNames.nameFor(node.anchor())
+ : null;
+ XWPFRun run;
+ if (bookmark == null) {
+ run = para.createRun();
+ } else {
+ CTSimpleField field = para.getCTP().addNewFldSimple();
+ field.setInstr(" PAGEREF " + bookmark + " \\h ");
+ run = new XWPFRun(field.addNewR(), para);
+ }
+ applyStyle(run, node.textStyle());
+ run.setText(shown);
+ }
+
private void writeParagraph(XWPFDocument document, ParagraphNode node) {
XWPFParagraph para = newBodyParagraph(document);
boolean rightToLeft = applyParagraphProperties(para, node);
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageReferenceTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageReferenceTest.java
new file mode 100644
index 000000000..5764890f5
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageReferenceTest.java
@@ -0,0 +1,158 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.style.DocumentInsets;
+import org.apache.poi.openxml4j.opc.PackagePart;
+import org.apache.poi.openxml4j.opc.PackagingURIHelper;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.apache.poi.xwpf.usermodel.XWPFTableCell;
+import org.apache.poi.xwpf.usermodel.XWPFTableRow;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBookmark;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSimpleField;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A page reference is Word's own {@code PAGEREF} field, reading the page the layout resolved.
+ *
+ * The export dropped page references, so a table of contents reached Word with its entries
+ * and no page numbers. As a field the number is the page the editor counts — measured in
+ * LibreOffice, a field whose cached number was replaced by 99 still showed the real pages —
+ * and until an editor updates it, it reads the page the PDF shows.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxPageReferenceTest {
+
+ @Test
+ void aTableOfContentsCarriesAFieldPerEntryReadingTheLaidOutPage() throws Exception {
+ try (XWPFDocument document = export(threeChapters())) {
+ Map fields = pageReferences(document);
+
+ assertThat(fields).containsExactly(
+ Map.entry("PAGEREF intro \\h", "2"),
+ Map.entry("PAGEREF terms \\h", "3"),
+ Map.entry("PAGEREF prices \\h", "4"));
+ assertThat(bookmarkNames(document))
+ .as("every field names a bookmark the document has, a section's anchor included")
+ .contains("intro", "terms", "prices");
+ }
+ }
+
+ @Test
+ void aStandalonePageReferenceIsAFieldToo() throws Exception {
+ try (XWPFDocument document = export(page -> page
+ .addPageReference("terms")
+ .addPageBreak(b -> { })
+ .addSection(s -> s.anchor("terms").addParagraph(p -> p.text("Terms"))))) {
+ assertThat(pageReferences(document)).containsExactly(Map.entry("PAGEREF terms \\h", "2"));
+ }
+ }
+
+ @Test
+ void aReferenceToAnAnchorTheDocumentDoesNotHaveIsPlainText() throws Exception {
+ try (XWPFDocument document = export(page -> page
+ .addPageReference("nowhere")
+ .addParagraph(p -> p.text("Body")))) {
+ assertThat(pageReferences(document))
+ .as("Word would turn it into 'Error! Bookmark not defined.' on the first update")
+ .isEmpty();
+ assertThat(document.getParagraphs().get(0).getText())
+ .as("the placeholder the page prints for an unresolved reference — empty unless set")
+ .isEmpty();
+ }
+ }
+
+ @Test
+ void theDocumentDoesNotAskToUpdateItsFieldsOnOpen() throws Exception {
+ try (XWPFDocument document = export(threeChapters())) {
+ PackagePart settings = document.getPackage()
+ .getPart(PackagingURIHelper.createPartName("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/word/settings.xml"));
+ String xml;
+ try (InputStream input = settings.getInputStream()) {
+ xml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+ }
+
+ // The fields open reading the layout's numbers; w:updateFields would only add a
+ // prompt asking the reader to recompute what is already right.
+ assertThat(xml).doesNotContain("updateFields");
+ }
+ }
+
+ private static java.util.function.Consumer threeChapters() {
+ return page -> page
+ .addTableOfContents(toc -> toc.title("Contents")
+ .entry("Intro", "intro")
+ .entry("Terms", "terms")
+ .entry("Prices", "prices"))
+ .addPageBreak(b -> { })
+ .addParagraph(p -> p.text("Intro").anchor("intro"))
+ .addPageBreak(b -> { })
+ .addSection(s -> s.anchor("terms").addParagraph(p -> p.text("Terms")))
+ .addPageBreak(b -> { })
+ .addSection(s -> s.anchor("prices").addParagraph(p -> p.text("Prices")));
+ }
+
+ private static XWPFDocument export(
+ java.util.function.Consumer content) throws Exception {
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(300, 300)
+ .margin(DocumentInsets.of(30))
+ .create()) {
+ session.pageFlow(content::accept);
+ docx = session.toDocxBytes();
+ }
+ return new XWPFDocument(new ByteArrayInputStream(docx));
+ }
+
+ /** Every PAGEREF field in the body and its tables, instruction to the text it reads. */
+ private static Map pageReferences(XWPFDocument document) {
+ Map fields = new LinkedHashMap<>();
+ for (XWPFParagraph paragraph : allParagraphs(document)) {
+ for (CTSimpleField field : paragraph.getCTP().getFldSimpleList()) {
+ String instruction = field.getInstr().trim();
+ if (instruction.startsWith("PAGEREF")) {
+ StringBuilder text = new StringBuilder();
+ field.getRList().forEach(run -> run.getTList().forEach(t -> text.append(t.getStringValue())));
+ fields.put(instruction, text.toString());
+ }
+ }
+ }
+ return fields;
+ }
+
+ private static List bookmarkNames(XWPFDocument document) {
+ List names = new ArrayList<>();
+ for (XWPFParagraph paragraph : allParagraphs(document)) {
+ for (CTBookmark bookmark : paragraph.getCTP().getBookmarkStartList()) {
+ names.add(bookmark.getName());
+ }
+ }
+ return names;
+ }
+
+ private static List allParagraphs(XWPFDocument document) {
+ List paragraphs = new ArrayList<>(document.getParagraphs());
+ for (XWPFTable table : document.getTables()) {
+ for (XWPFTableRow row : table.getRows()) {
+ for (XWPFTableCell cell : row.getTableCells()) {
+ paragraphs.addAll(cell.getParagraphs());
+ }
+ }
+ }
+ return paragraphs;
+ }
+}