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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.</p>
*
* @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<String> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@
// 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();
// Anchors this export writes a bookmark for, so a page reference knows it has a target.
private java.util.Set<String> 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<DocxExportReport> reportSink;
// The instant every clock in the package is pinned to, or null for live timestamps.
Expand Down Expand Up @@ -450,6 +452,7 @@
report = new DocxExportReport.Builder();
bookmarkNames = new DocxBookmarkNames();
headingLevels = headingLevelsIn(whole);
bookmarkedAnchors = bookmarkedAnchorsIn(whole);
wordFamilies = DocxFontTable.familiesByName(fonts);
documentDefaultStyle = dominantTextStyle(whole);
currentCell = null;
Expand Down Expand Up @@ -1119,6 +1122,8 @@
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) {
Expand Down Expand Up @@ -1807,6 +1812,25 @@
* A large paragraph is a large paragraph; a document that never asked for an outline
* does not get one invented from its typography.</p>
*/
/**
* 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<String> bookmarkedAnchorsIn(DocumentGraph graph) {
java.util.Set<String> anchors = new java.util.HashSet<>();
java.util.ArrayDeque<DocumentNode> 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<Integer> headingLevelsIn(DocumentGraph graph) {
java.util.Set<Integer> levels = new java.util.TreeSet<>();
for (DocumentNode root : graph.roots()) {
Expand Down Expand Up @@ -2128,6 +2152,46 @@
}
}

/**
* 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.
*
* <p>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.</p>
*
* <p>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.</p>
*/
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* @author Artem Demchyshyn
*/
class DocxPageReferenceTest {

@Test
void aTableOfContentsCarriesAFieldPerEntryReadingTheLaidOutPage() throws Exception {
try (XWPFDocument document = export(threeChapters())) {
Map<String, String> 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("/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<com.demcha.compose.document.dsl.PageFlowBuilder> 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<com.demcha.compose.document.dsl.PageFlowBuilder> 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<String, String> pageReferences(XWPFDocument document) {
Map<String, String> 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<String> bookmarkNames(XWPFDocument document) {
List<String> names = new ArrayList<>();
for (XWPFParagraph paragraph : allParagraphs(document)) {
for (CTBookmark bookmark : paragraph.getCTP().getBookmarkStartList()) {
names.add(bookmark.getName());
}
}
return names;
}

private static List<XWPFParagraph> allParagraphs(XWPFDocument document) {
List<XWPFParagraph> 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;
}
}
Loading