diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18a09babe..1c0493353 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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:
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 10fc0694f..243ba8961 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -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
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 92ee1e311..925b72e5a 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -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
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFidelityCorpusTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFidelityCorpusTest.java
index 4da920484..75460a878 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFidelityCorpusTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFidelityCorpusTest.java
@@ -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;
}
}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceFields.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceFields.java
new file mode 100644
index 000000000..621466459
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceFields.java
@@ -0,0 +1,351 @@
+package com.demcha.compose.document.templates.fidelity;
+
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Pattern;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+/**
+ * The {@code PAGEREF} fields, the bookmarks and the paragraphs of a DOCX, read from every XML part
+ * of the file: the body, its headers and footers, and the tables and text boxes nested in them.
+ *
+ *
A model's list of a document's paragraphs leaves out a nested table's, a text box's and a
+ * header's, and a field there naming a bookmark the file lacks would pass unseen. Read as the
+ * parts' XML, every field is found wherever it stands.
+ *
+ * {@link #problems()} names each page reference that does not name exactly one bookmark of the
+ * body, closed, under a name Word gives a bookmark, and each field whose marks stand out of order.
+ * Word reads a {@code PAGEREF} to a bookmark it does not find as "Error! Bookmark not defined."
+ * once it updates the field; it reads names without case, and keeps one bookmark of a name.
+ */
+final class DocxPageReferenceFields {
+
+ private static final String W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+
+ /** The part the export writes its bookmarks in. */
+ private static final String BODY = "word/document.xml";
+
+ /** Word's bookmark names: a letter, then letters, digits and underscores, 40 at most. */
+ private static final Pattern BOOKMARK_NAME = Pattern.compile("\\p{L}[\\p{L}\\p{Nd}_]{0,39}");
+
+ private static final XMLInputFactory XML = XMLInputFactory.newFactory();
+
+ static {
+ XML.setProperty(XMLInputFactory.SUPPORT_DTD, false);
+ XML.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
+ XML.setProperty(XMLInputFactory.IS_NAMESPACE_AWARE, true);
+ }
+
+ private final List parts = new ArrayList<>();
+ private final List fields = new ArrayList<>();
+ private final List bookmarks = new ArrayList<>();
+ private final List paragraphs = new ArrayList<>();
+ private final List malformed = new ArrayList<>();
+
+ private DocxPageReferenceFields() {
+ }
+
+ /**
+ * A field, its instruction as its runs spell it and the text it reads before an editor
+ * updates it.
+ *
+ * @param part the part it stands in
+ * @param instruction its instruction, trimmed
+ * @param result what it reads
+ */
+ record Field(String part, String instruction, String result) {
+
+ /** Whether it is a page reference. */
+ boolean isPageReference() {
+ String[] words = words();
+ return words.length > 0 && words[0].equalsIgnoreCase("PAGEREF");
+ }
+
+ /** The bookmark a page reference names, or an empty string where it names none. */
+ String bookmark() {
+ String[] words = words();
+ return words.length > 1 ? words[1] : "";
+ }
+
+ private String[] words() {
+ return instruction.isEmpty() ? new String[0] : instruction.split("\\s+");
+ }
+ }
+
+ /**
+ * A bookmark the file opens.
+ *
+ * @param part the part it stands in
+ * @param name its name
+ * @param id its id, which its end names
+ * @param ended whether an end of its id follows it in its part
+ */
+ record Bookmark(String part, String name, String id, boolean ended) {
+ }
+
+ /**
+ * A paragraph, in the order the paragraphs of its part start.
+ *
+ * @param part the part it stands in
+ * @param text the text of its runs, what its fields read included, and not a paragraph's
+ * nested in it
+ * @param holdsAField whether a field stands in it
+ */
+ record Paragraph(String part, String text, boolean holdsAField) {
+ }
+
+ /** Reads every XML part of a DOCX. */
+ static DocxPageReferenceFields read(byte[] docx) throws IOException, XMLStreamException {
+ DocxPageReferenceFields read = new DocxPageReferenceFields();
+ try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(docx))) {
+ for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) {
+ if (!entry.isDirectory() && entry.getName().endsWith(".xml")) {
+ read.readPart(entry.getName(), zip.readAllBytes());
+ }
+ }
+ }
+ return read;
+ }
+
+ /** The XML parts read, by their names in the package. */
+ List parts() {
+ return List.copyOf(parts);
+ }
+
+ /** Every paragraph, in the order of the parts and of each part. */
+ List paragraphs() {
+ return List.copyOf(paragraphs);
+ }
+
+ /**
+ * The paragraphs of the body after the one paragraph whose text is the given text.
+ *
+ * @param count how many to return
+ * @throws IllegalArgumentException where no paragraph of the body, or more than one, has the text,
+ * or fewer than {@code count} follow it
+ */
+ List following(String text, int count) {
+ List body = paragraphs.stream().filter(paragraph -> paragraph.part().equals(BODY)).toList();
+ List found = new ArrayList<>();
+ for (int index = 0; index < body.size(); index++) {
+ if (body.get(index).text().equals(text)) {
+ found.add(index);
+ }
+ }
+ if (found.size() != 1 || found.get(0) + count >= body.size()) {
+ throw new IllegalArgumentException(found.size() + " paragraph(s) of the body read \"" + text
+ + "\", where one, and " + count + " after it, was expected");
+ }
+ return body.subList(found.get(0) + 1, found.get(0) + 1 + count);
+ }
+
+ /** Every page reference, in the order of the parts and of each part. */
+ List pageReferences() {
+ return fields.stream().filter(Field::isPageReference).toList();
+ }
+
+ /** Every bookmark the file opens. */
+ List bookmarks() {
+ return List.copyOf(bookmarks);
+ }
+
+ /**
+ * Each page reference that does not name exactly one bookmark of the body, closed, in its own
+ * case, under a name Word gives a bookmark, and each field whose marks stand out of order;
+ * empty where there is none.
+ */
+ List problems() {
+ List problems = new ArrayList<>(malformed);
+ for (Field field : pageReferences()) {
+ String name = field.bookmark();
+ String where = "PAGEREF \"" + name + "\" in " + field.part();
+ if (!BOOKMARK_NAME.matcher(name).matches()) {
+ problems.add(where + " names no bookmark Word can hold: \"" + field.instruction() + "\"");
+ continue;
+ }
+ // Word reads bookmark names without case.
+ List named = bookmarks.stream()
+ .filter(bookmark -> bookmark.part().equals(BODY))
+ .filter(bookmark -> bookmark.name().toLowerCase(Locale.ROOT).equals(name.toLowerCase(Locale.ROOT)))
+ .toList();
+ if (named.isEmpty()) {
+ problems.add(where + " names a bookmark the file does not hold");
+ } else if (named.size() > 1) {
+ problems.add(where + " names " + named.size() + " bookmarks: " + named);
+ } else if (!named.get(0).name().equals(name)) {
+ problems.add(where + " names the bookmark \"" + named.get(0).name() + "\" in another case");
+ } else if (!named.get(0).ended()) {
+ problems.add(where + " names a bookmark its part never closes");
+ }
+ }
+ return problems;
+ }
+
+ private void readPart(String part, byte[] xml) throws XMLStreamException {
+ parts.add(part);
+ XMLStreamReader reader = XML.createXMLStreamReader(new ByteArrayInputStream(xml));
+ try {
+ // The fields open in this part, the innermost last. A simple field is open from its
+ // element's start to its end; a complex one from its begin mark to its end mark.
+ Deque open = new ArrayDeque<>();
+ // The paragraphs open, the innermost last, by their places in the part's list.
+ Deque within = new ArrayDeque<>();
+ List partParagraphs = new ArrayList<>();
+ // The part's bookmark marks in its order: a start's name and id, an end's id alone.
+ List marks = new ArrayList<>();
+ boolean inText = false;
+ boolean inInstruction = false;
+ while (reader.hasNext()) {
+ int event = reader.next();
+ if (event == XMLStreamConstants.START_ELEMENT && W.equals(reader.getNamespaceURI())) {
+ switch (reader.getLocalName()) {
+ case "p" -> {
+ within.push(new OpenParagraph(partParagraphs.size()));
+ partParagraphs.add(null);
+ }
+ case "fldSimple" -> {
+ OpenField simple = new OpenField(true);
+ simple.instruction.append(attribute(reader, "instr"));
+ simple.separated = true;
+ open.push(simple);
+ holdsAField(within);
+ }
+ case "fldChar" -> {
+ String type = attribute(reader, "fldCharType");
+ if (!type.equals("begin") && !type.equals("separate") && !type.equals("end")) {
+ malformed.add("a field mark of no type Word reads (\"" + type + "\"), in " + part);
+ } else if (type.equals("begin")) {
+ open.push(new OpenField(false));
+ holdsAField(within);
+ } else if (open.isEmpty() || open.peek().simple) {
+ malformed.add("a field's " + type + " mark with no field begun, in " + part);
+ } else if (type.equals("separate")) {
+ open.peek().separated = true;
+ } else {
+ close(part, open.pop());
+ }
+ }
+ case "instrText" -> {
+ if (open.isEmpty() || open.peek().separated) {
+ malformed.add("an instruction where no field's code is open, in " + part);
+ }
+ inInstruction = true;
+ }
+ case "t" -> inText = true;
+ case "bookmarkStart" -> marks.add(new String[]{attribute(reader, "name"), attribute(reader, "id")});
+ case "bookmarkEnd" -> marks.add(new String[]{attribute(reader, "id")});
+ default -> {
+ }
+ }
+ } else if (event == XMLStreamConstants.END_ELEMENT && W.equals(reader.getNamespaceURI())) {
+ switch (reader.getLocalName()) {
+ case "p" -> {
+ OpenParagraph ended = within.pop();
+ partParagraphs.set(ended.at, new Paragraph(part, ended.text.toString(), ended.holdsAField));
+ }
+ case "fldSimple" -> {
+ if (open.isEmpty() || !open.peek().simple) {
+ malformed.add("a simple field closed over an open one, in " + part);
+ } else {
+ close(part, open.pop());
+ }
+ }
+ case "instrText" -> inInstruction = false;
+ case "t" -> inText = false;
+ default -> {
+ }
+ }
+ } else if (event == XMLStreamConstants.CHARACTERS || event == XMLStreamConstants.CDATA) {
+ if (inInstruction && !open.isEmpty()) {
+ open.peek().instruction.append(reader.getText());
+ } else if (inText) {
+ if (!within.isEmpty()) {
+ within.peek().text.append(reader.getText());
+ }
+ if (!open.isEmpty() && open.peek().separated) {
+ open.peek().result.append(reader.getText());
+ }
+ }
+ }
+ }
+ if (!open.isEmpty()) {
+ malformed.add(open.size() + " field(s) never ended, in " + part);
+ }
+ paragraphs.addAll(partParagraphs);
+ readBookmarks(part, marks);
+ } finally {
+ reader.close();
+ }
+ }
+
+ private static void holdsAField(Deque within) {
+ if (!within.isEmpty()) {
+ within.peek().holdsAField = true;
+ }
+ }
+
+ private void close(String part, OpenField field) {
+ fields.add(new Field(part, field.instruction.toString().trim(), field.result.toString()));
+ }
+
+ /**
+ * Records a part's bookmarks, each ended where an end of its id follows its start in the part.
+ *
+ * @param marks the part's marks in its order: a start's name and id, an end's id alone
+ */
+ private void readBookmarks(String part, List marks) {
+ List opened = new ArrayList<>();
+ for (int at = 0; at < marks.size(); at++) {
+ String[] mark = marks.get(at);
+ if (mark.length == 1) {
+ continue;
+ }
+ String id = mark[1];
+ if (opened.contains(id)) {
+ malformed.add("bookmark id " + id + " opened twice, in " + part);
+ }
+ opened.add(id);
+ boolean ended = marks.subList(at + 1, marks.size()).stream()
+ .anyMatch(later -> later.length == 1 && later[0].equals(id));
+ bookmarks.add(new Bookmark(part, mark[0], id, ended));
+ }
+ }
+
+ private static String attribute(XMLStreamReader reader, String name) {
+ String value = reader.getAttributeValue(W, name);
+ return value == null ? "" : value;
+ }
+
+ /** A field read up to its end. */
+ private static final class OpenField {
+ private final boolean simple;
+ private final StringBuilder instruction = new StringBuilder();
+ private final StringBuilder result = new StringBuilder();
+ private boolean separated;
+
+ private OpenField(boolean simple) {
+ this.simple = simple;
+ }
+ }
+
+ /** A paragraph read up to its end, at its place in its part's list. */
+ private static final class OpenParagraph {
+ private final int at;
+ private final StringBuilder text = new StringBuilder();
+ private boolean holdsAField;
+
+ private OpenParagraph(int at) {
+ this.at = at;
+ }
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java
new file mode 100644
index 000000000..af1c9b644
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java
@@ -0,0 +1,391 @@
+package com.demcha.compose.document.templates.fidelity;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.backend.semantic.docx.DocxExportReport;
+import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend;
+import com.demcha.compose.document.layout.LayoutAnchorNode;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.PageReferenceNode;
+import com.demcha.compose.document.templates.fidelity.PageReferenceDocxDocuments.Reference;
+import com.demcha.compose.document.templates.fidelity.PageReferenceDocxDocuments.ReferringDocument;
+import org.assertj.core.groups.Tuple;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.lang.reflect.Modifier;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.jar.JarFile;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+
+/**
+ * Every {@code PAGEREF} field the DOCX export writes names exactly one bookmark the file holds, so
+ * Word shows a page number once it updates its fields rather than "Error! Bookmark not defined."
+ *
+ * Held over the template corpus, which refers to no page today, and over documents that refer
+ * to an anchor on every kind of node that declares one, from a table of contents, the body, a
+ * table's composed cell, a layer stack's column, a panel and a row's column
+ * ({@link PageReferenceDocxDocuments}). Where the export writes an anchor no bookmark — 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 does not have — the reference is the number
+ * it reads, and the report says so: never a field on a bookmark the file lacks.
+ *
+ * The fields, bookmarks and paragraphs are read from every XML part of the file
+ * ({@link DocxPageReferenceFields}), wherever they stand.
+ */
+class DocxPageReferenceGuardTest {
+
+ private static final String NO_BOOKMARK = "its anchor has no bookmark in the Word file";
+
+ @Test
+ void everyPageReferenceInTheCorpusNamesABookmarkTheFileHolds() throws Exception {
+ List problems = new ArrayList<>();
+ int read = 0;
+ for (DocxCorpusDocument document : DocxFidelityCorpusTest.corpus()) {
+ AtomicReference report = new AtomicReference<>();
+ byte[] docx;
+ int references;
+ try (DocumentSession session = DocxFidelityCorpusTest.composed(document)) {
+ references = PageReferenceDocxDocuments.pageReferencesIn(session.roots());
+ docx = session.export(DocxSemanticBackend.builder().deterministic(true).reportSink(report::set).build());
+ }
+ assertLaidOut(document.stem(), report.get());
+ DocxPageReferenceFields fields = DocxPageReferenceFields.read(docx);
+ assertThat(fields.parts()).as("%s: its body read", document.stem()).contains("word/document.xml");
+ assertThat(fields.paragraphs()).as("%s: its paragraphs read", document.stem()).isNotEmpty();
+ fields.problems().forEach(problem -> problems.add(document.stem() + ": " + problem));
+ assertThat(fields.pageReferences().size() + notedAsText(report.get()))
+ .as("%s: each page reference a field or its number, named", document.stem())
+ .isEqualTo(references);
+ read++;
+ }
+ assertThat(read).as("the corpus read, as the fidelity gate holds it").isGreaterThanOrEqualTo(60);
+ assertThat(problems).isEmpty();
+ }
+
+ @Test
+ void eachReferenceIsAFieldOnItsAnchorsBookmarkOrItsNumberNamed() throws Exception {
+ for (ReferringDocument document : PageReferenceDocxDocuments.documents()) {
+ AtomicReference report = new AtomicReference<>();
+ byte[] docx;
+ Map pages = new HashMap<>();
+ try (DocumentSession session = GraphCompose.document().create()) {
+ document.compose().accept(session);
+ assertThat(PageReferenceDocxDocuments.pageReferencesIn(session.roots()))
+ .as("%s: every reference it holds listed", document.name())
+ .isEqualTo(document.references().size());
+ for (Reference reference : document.references()) {
+ session.pageIndex().pageNumberOf(reference.anchor())
+ .ifPresent(page -> pages.put(reference.anchor(), page));
+ }
+ docx = session.export(DocxSemanticBackend.builder().deterministic(true).reportSink(report::set).build());
+ }
+ assertLaidOut(document.name(), report.get());
+ for (String premise : document.premises()) {
+ assertThat(report.get().notes()).as("%s holds the shape its report names: %s", document.name(), premise)
+ .anyMatch(note -> note.detail().contains(premise));
+ }
+ DocxPageReferenceFields fields = DocxPageReferenceFields.read(docx);
+
+ assertThat(fields.problems()).as(document.name()).isEmpty();
+ // Each field on its anchor's bookmark, reading the page the layout resolved for it.
+ List expected = document.fields().stream()
+ .map(reference -> tuple(reference.bookmark(), reads(reference, pages)))
+ .toList();
+ assertThat(fields.pageReferences())
+ .extracting(DocxPageReferenceFields.Field::bookmark, DocxPageReferenceFields.Field::result)
+ .as("%s: a field for each reference whose anchor has a bookmark, on it", document.name())
+ .containsExactlyInAnyOrderElementsOf(expected);
+ assertThat(notedAsText(report.get()))
+ .as("%s: each reference whose anchor has none written as its number, and named", document.name())
+ .isEqualTo(document.asText());
+ // A reference standing between paragraphs of its own: what its paragraph reads, and that it
+ // is there at all — one reading nothing is otherwise one not written.
+ for (Reference reference : document.references()) {
+ if (reference.label() == null) {
+ continue;
+ }
+ List following = fields.following(reference.label(), 2);
+ assertThat(following.get(1).text()).as("%s: the paragraph after the reference to %s",
+ document.name(), reference.anchor()).isEqualTo(reference.next());
+ assertThat(following.get(0).text()).as("%s: the reference to %s reads", document.name(),
+ reference.anchor()).isEqualTo(reads(reference, pages));
+ assertThat(following.get(0).holdsAField()).as("%s: the reference to %s is a field", document.name(),
+ reference.anchor()).isEqualTo(reference.bookmark() != null);
+ }
+ }
+ }
+
+ @Test
+ void theDocumentsReferToAnAnchorOfEveryKindOfNodeThatDeclaresOne() throws Exception {
+ Set referred = new TreeSet<>();
+ for (ReferringDocument document : PageReferenceDocxDocuments.documents()) {
+ try (DocumentSession session = GraphCompose.document().create()) {
+ document.compose().accept(session);
+ List anchors = document.references().stream().map(Reference::anchor).toList();
+ PageReferenceDocxDocuments.anchored(session.roots()).forEach((anchor, kind) -> {
+ if (anchors.contains(anchor)) {
+ referred.add(kind);
+ }
+ });
+ }
+ }
+
+ assertThat(nodeKindsDeclaringAnAnchor()).as("the premise: the kinds GraphCompose ships with an anchor")
+ .hasSizeGreaterThanOrEqualTo(9);
+ assertThat(referred).containsExactlyInAnyOrderElementsOf(nodeKindsDeclaringAnAnchor());
+ }
+
+ // The reader, on files written by hand: it must find what it is to check wherever it stands.
+
+ @Test
+ void aFieldOnABookmarkTheFileHoldsHasNoProblem() throws Exception {
+ DocxPageReferenceFields fields = DocxPageReferenceFields.read(docx(Map.of("word/document.xml",
+ body(paragraph(bookmark("1", "intro") + field(" PAGEREF intro \\h ", "2"))))));
+
+ assertThat(fields.pageReferences()).singleElement().satisfies(field -> {
+ assertThat(field.bookmark()).isEqualTo("intro");
+ assertThat(field.result()).isEqualTo("2");
+ });
+ assertThat(fields.bookmarks()).singleElement().satisfies(bookmark -> assertThat(bookmark.ended()).isTrue());
+ assertThat(fields.paragraphs()).singleElement().satisfies(paragraph -> {
+ assertThat(paragraph.text()).isEqualTo("2");
+ assertThat(paragraph.holdsAField()).isTrue();
+ });
+ assertThat(fields.problems()).isEmpty();
+ }
+
+ @Test
+ void aFieldOnABookmarkTheBodyLacksIsAProblem() throws Exception {
+ // The export writes its bookmarks in the body: one of the name in a header is not the anchor's.
+ DocxPageReferenceFields fields = DocxPageReferenceFields.read(docx(Map.of(
+ "word/document.xml", body(paragraph(bookmark("1", "intro") + field(" PAGEREF terms \\h ", "3")
+ + field(" PAGEREF head \\h ", "3"))),
+ "word/header1.xml", "" + paragraph(bookmark("2", "head")) + "")));
+
+ assertThat(fields.problems()).satisfiesExactly(
+ problem -> assertThat(problem).contains("PAGEREF \"terms\"").contains("names a bookmark the file does not hold"),
+ problem -> assertThat(problem).contains("PAGEREF \"head\"").contains("names a bookmark the file does not hold"));
+ }
+
+ @Test
+ void aFieldIsFoundInAHeaderANestedTableAndATextBox() throws Exception {
+ String nested = "" + paragraph(field(" PAGEREF inTable \\h ", "1"))
+ + "";
+ String textBox = paragraph(""
+ + "" + paragraph(field(" PAGEREF inBox \\h ", "1"))
+ + ""
+ + "");
+ DocxPageReferenceFields fields = DocxPageReferenceFields.read(docx(Map.of(
+ "word/document.xml", body(nested + textBox),
+ "word/header1.xml", "" + paragraph(field(" PAGEREF inHeader \\h ", "1"))
+ + "")));
+
+ assertThat(fields.pageReferences()).extracting(DocxPageReferenceFields.Field::bookmark)
+ .containsExactlyInAnyOrder("inTable", "inBox", "inHeader");
+ assertThat(fields.problems()).hasSize(3).allSatisfy(problem -> assertThat(problem).contains("does not hold"));
+ }
+
+ @Test
+ void anInstructionSpelledOverRunsInAnyCaseNestedOrSimpleIsRead() throws Exception {
+ String split = ""
+ + " PAGE"
+ + "REF split \\h "
+ + "4"
+ + "";
+ String simple = "5";
+ // A field in another's instruction, as { IF { PAGEREF nested } = 6 "six" "other" }.
+ String nested = ""
+ + " IF "
+ + field(" PAGEREF nested ", "6")
+ + " = 6 \"six\" \"other\" "
+ + "six"
+ + "";
+ DocxPageReferenceFields fields = DocxPageReferenceFields.read(docx(Map.of("word/document.xml",
+ body(paragraph(split) + paragraph(simple) + paragraph(field(" pageref lower \\h ", "7"))
+ + paragraph(nested)))));
+
+ assertThat(fields.pageReferences()).extracting(DocxPageReferenceFields.Field::bookmark,
+ DocxPageReferenceFields.Field::result).containsExactly(
+ tuple("split", "4"), tuple("simple", "5"), tuple("lower", "7"), tuple("nested", "6"));
+ }
+
+ @Test
+ void aBookmarkHeldTwiceNeverClosedOrInAnotherCaseIsAProblem() throws Exception {
+ String twice = bookmark("1", "twice") + bookmark("2", "TWICE");
+ String open = "";
+ String cased = bookmark("4", "Cased");
+ DocxPageReferenceFields fields = DocxPageReferenceFields.read(docx(Map.of("word/document.xml",
+ body(paragraph(twice + open + cased + field(" PAGEREF twice \\h ", "1") + field(" PAGEREF open \\h ", "1")
+ + field(" PAGEREF cased \\h ", "1"))))));
+
+ assertThat(fields.problems()).satisfiesExactly(
+ problem -> assertThat(problem).contains("\"twice\"").contains("names 2 bookmarks"),
+ problem -> assertThat(problem).contains("\"open\"").contains("never closes"),
+ problem -> assertThat(problem).contains("\"cased\"").contains("in another case"));
+ }
+
+ @Test
+ void aNameWordDoesNotTakeIsAProblem() throws Exception {
+ // Past 40 characters, and no name at all: the switch is read as the name.
+ String longName = "Terms_and_conditions_of_the_agreement_between";
+ DocxPageReferenceFields fields = DocxPageReferenceFields.read(docx(Map.of("word/document.xml",
+ body(paragraph(bookmark("1", longName) + field(" PAGEREF " + longName + " \\h ", "1")
+ + field(" PAGEREF \\h ", "1"))))));
+
+ assertThat(fields.problems()).satisfiesExactly(
+ problem -> assertThat(problem).contains(longName).contains("names no bookmark Word can hold"),
+ problem -> assertThat(problem).contains("PAGEREF \"\\h\"").contains("names no bookmark Word can hold"));
+ }
+
+ @Test
+ void aFieldLeftOpenOrMarkedOutOfOrderIsAProblem() throws Exception {
+ String stray = "";
+ String untyped = "";
+ String unended = ""
+ + " PAGEREF a \\h ";
+ DocxPageReferenceFields fields = DocxPageReferenceFields.read(docx(Map.of(
+ "word/document.xml", body(paragraph(bookmark("1", "a") + stray) + paragraph(untyped)
+ + paragraph(unended)))));
+
+ assertThat(fields.problems()).satisfiesExactly(
+ problem -> assertThat(problem).contains("end mark with no field begun"),
+ problem -> assertThat(problem).contains("no type Word reads (\"middle\")"),
+ problem -> assertThat(problem).contains("never ended"));
+ }
+
+ @Test
+ void aStrayInstructionASimpleFieldClosedOverAnotherOrAnIdOpenedTwiceIsAProblem() throws Exception {
+ // Each in a part of its own, so the order they are named in is the parts'.
+ String stray = " PAGEREF a \\h ";
+ String over = "";
+ String twice = ""
+ + "";
+ DocxPageReferenceFields fields = DocxPageReferenceFields.read(docx(Map.of(
+ "word/document.xml", body(paragraph(stray)),
+ "word/header1.xml", "" + paragraph(over) + "",
+ "word/header2.xml", "" + paragraph(twice) + "")));
+
+ assertThat(fields.problems()).satisfiesExactly(
+ problem -> assertThat(problem).isEqualTo("an instruction where no field's code is open, in word/document.xml"),
+ problem -> assertThat(problem).isEqualTo("a simple field closed over an open one, in word/header1.xml"),
+ problem -> assertThat(problem).isEqualTo("2 field(s) never ended, in word/header1.xml"),
+ problem -> assertThat(problem).isEqualTo("bookmark id 5 opened twice, in word/header2.xml"));
+ }
+
+ /** What a reference reads in the file: as the document states, or the page of its anchor, or nothing. */
+ private static String reads(Reference reference, Map pages) {
+ if (reference.reads() != null) {
+ return reference.reads();
+ }
+ Integer page = pages.get(reference.anchor());
+ return page == null ? "" : String.valueOf(page);
+ }
+
+ /**
+ * The export was made from the document's layout: where the layout fails, the export writes the
+ * document without it, saying so under this subject, and a page reference reads no page.
+ */
+ private static void assertLaidOut(String document, DocxExportReport report) {
+ assertThat(report.bySubject()).as("%s: exported from its layout", document).doesNotContainKey("measured geometry");
+ }
+
+ private static long notedAsText(DocxExportReport report) {
+ return report.notes().stream()
+ .filter(note -> note.subject().equals("PageReferenceNode") && note.detail().contains(NO_BOOKMARK))
+ .count();
+ }
+
+ /**
+ * The node kinds GraphCompose ships whose record has an anchor of its own, read off the classes
+ * of the node packages: a page reference names an anchor and declares none.
+ */
+ private static Set nodeKindsDeclaringAnAnchor() throws Exception {
+ Path source = Path.of(DocumentNode.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+ List classFiles = new ArrayList<>();
+ for (String pkg : List.of(DocumentNode.class.getPackageName(), LayoutAnchorNode.class.getPackageName())) {
+ String folder = pkg.replace('.', '/') + "/";
+ if (Files.isDirectory(source)) {
+ try (Stream files = Files.list(source.resolve(folder))) {
+ files.map(file -> folder + file.getFileName()).forEach(classFiles::add);
+ }
+ } else {
+ try (JarFile jar = new JarFile(source.toFile())) {
+ jar.stream().map(ZipEntry::getName)
+ .filter(name -> name.startsWith(folder) && name.indexOf('/', folder.length()) < 0)
+ .forEach(classFiles::add);
+ }
+ }
+ }
+ Set kinds = new TreeSet<>();
+ for (String file : classFiles) {
+ if (!file.endsWith(".class") || file.endsWith("package-info.class")) {
+ continue;
+ }
+ Class> type = Class.forName(file.substring(0, file.length() - ".class".length()).replace('/', '.'),
+ false, DocumentNode.class.getClassLoader());
+ if (DocumentNode.class.isAssignableFrom(type) && type.isRecord() && type != PageReferenceNode.class
+ && !Modifier.isAbstract(type.getModifiers())
+ && Arrays.stream(type.getRecordComponents())
+ .anyMatch(component -> component.getName().equals("anchor") && component.getType() == String.class)) {
+ kinds.add(type.getSimpleName());
+ }
+ }
+ return kinds;
+ }
+
+ private static final String NAMESPACES =
+ "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" "
+ + "xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" "
+ + "xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" "
+ + "xmlns:wps=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\"";
+
+ private static String body(String content) {
+ return "" + content + "";
+ }
+
+ private static String paragraph(String content) {
+ return "" + content + "";
+ }
+
+ private static String bookmark(String id, String name) {
+ return "";
+ }
+
+ private static String field(String instruction, String result) {
+ return ""
+ + "" + instruction + ""
+ + ""
+ + "" + result + ""
+ + "";
+ }
+
+ /** A package of the given parts, enough for the reader, which reads XML parts alone. */
+ private static byte[] docx(Map parts) throws Exception {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
+ for (Map.Entry part : new TreeMap<>(parts).entrySet()) {
+ zip.putNextEntry(new ZipEntry(part.getKey()));
+ zip.write(part.getValue().getBytes(StandardCharsets.UTF_8));
+ zip.closeEntry();
+ }
+ }
+ return bytes.toByteArray();
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/PageReferenceDocxDocuments.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/PageReferenceDocxDocuments.java
new file mode 100644
index 000000000..cb9dc9eb3
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/PageReferenceDocxDocuments.java
@@ -0,0 +1,470 @@
+package com.demcha.compose.document.templates.fidelity;
+
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.api.PageMarginRule;
+import com.demcha.compose.document.dsl.LayerStackBuilder;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.image.DocumentImageData;
+import com.demcha.compose.document.node.ContainerNode;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.LayerAlign;
+import com.demcha.compose.document.node.PageReferenceNode;
+import com.demcha.compose.document.node.TableNode;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.table.DocumentTableCell;
+import com.demcha.compose.document.table.DocumentTableColumn;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.lang.reflect.RecordComponent;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+import java.util.function.Consumer;
+
+/**
+ * Documents that refer to the pages of their anchors: an anchor on every kind of node that declares
+ * one, references from a table of contents, the body, a table's composed cell, a layer stack's
+ * column, a panel and a row's column, and the anchors the export writes no bookmark for.
+ *
+ * The template corpus declares no anchor: no preset refers to a page. These documents do, so
+ * that {@link DocxPageReferenceGuardTest} reads a page reference of every kind the export writes.
+ * A reference whose anchor has no bookmark stands between paragraphs of its own, the one before
+ * naming it, so its number can be found and read, and told from a reference not written.
+ */
+final class PageReferenceDocxDocuments {
+
+ /** An anchor named by a sentence, longer than Word lets a bookmark's name be. */
+ private static final String TERMS = "Terms and conditions of the agreement between both parties";
+ /** Another, of text, cut to a name of its own. */
+ private static final String SCHEDULE = "Payment schedule for the second phase of the work";
+
+ /** The paragraph closing a run of references written as their numbers. */
+ private static final String END = "End of the references.";
+
+ private static final DocumentInsets MARGIN = DocumentInsets.of(30);
+ private static final DocumentInsets LEFT_COLUMN = new DocumentInsets(0, 190, 0, 0);
+ private static final DocumentInsets RIGHT_COLUMN = new DocumentInsets(0, 0, 0, 170);
+ private static final DocumentColor INK = DocumentColor.rgb(26, 86, 148);
+ private static final DocumentColor ACCENT = DocumentColor.rgb(200, 60, 60);
+ private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249);
+
+ private PageReferenceDocxDocuments() {
+ }
+
+ /**
+ * A page reference a document holds, and how the export is to write it.
+ *
+ * @param anchor the anchor it reads the page of
+ * @param bookmark the bookmark its {@code PAGEREF} field names, or {@code null} where the export
+ * writes the anchor no bookmark and the reference is written as its number, with
+ * a note
+ * @param reads what it reads in the file — its field until an editor updates it, or its text —
+ * or {@code null} for the page the layout resolved for its anchor, and nothing
+ * where the layout resolved none
+ * @param label the text of the paragraph right before its own, or {@code null} where the
+ * paragraph before it is not one of its own: a table of contents' entry
+ * @param next the text of the paragraph right after its own, where it has a label: so a
+ * reference reading nothing is told from one not written
+ */
+ record Reference(String anchor, String bookmark, String reads, String label, String next) {
+
+ Reference {
+ if ((label == null) != (next == null)) {
+ throw new IllegalArgumentException("a label and the paragraph after, or neither");
+ }
+ }
+
+ static Reference field(String anchor, String bookmark) {
+ return new Reference(anchor, Objects.requireNonNull(bookmark, "bookmark"), null, null, null);
+ }
+
+ static Reference asText(String anchor, String label, String next) {
+ return new Reference(anchor, null, null, Objects.requireNonNull(label, "label"),
+ Objects.requireNonNull(next, "next"));
+ }
+ }
+
+ /**
+ * A document that refers to pages, and every page reference it holds, in no particular order.
+ *
+ * @param name the document's name
+ * @param compose sets its page and composes it into a fresh session
+ * @param references its page references
+ * @param premises what its export's report says of the shapes it is built to hold, each a part
+ * of a note's text: without them, the document no longer holds those shapes
+ */
+ record ReferringDocument(String name, Consumer compose, List references,
+ List premises) {
+
+ ReferringDocument {
+ references = List.copyOf(references);
+ premises = List.copyOf(premises);
+ }
+
+ /** The references written as fields. */
+ List fields() {
+ return references.stream().filter(reference -> reference.bookmark() != null).toList();
+ }
+
+ /** How many of its references are written as their number, with a note. */
+ long asText() {
+ return references.stream().filter(reference -> reference.bookmark() == null).count();
+ }
+ }
+
+ /** The documents, one for each group of shapes. */
+ static List documents() {
+ return List.of(
+ new ReferringDocument("anchor_kinds", PageReferenceDocxDocuments::anchorKinds, List.of(
+ Reference.field("para", "para"),
+ Reference.field("section", "section"),
+ Reference.field("container", "container"),
+ Reference.field("table", "table"),
+ Reference.field("image", "image"),
+ Reference.field("barcode", "barcode"),
+ Reference.field("rule", "rule"),
+ Reference.field("drawings", "drawings"),
+ Reference.field("drawn box", "drawn_box"),
+ Reference.field(TERMS, "Terms_and_conditions_of_the_agreement_be"),
+ Reference.field(SCHEDULE, "Payment_schedule_for_the_second_phase_of"),
+ // A drawn shape's own anchor has no bookmark for a field to name.
+ Reference.asText("shape", "See the shape:", "See the ellipse:"),
+ Reference.asText("ellipse", "See the ellipse:", END)),
+ List.of()),
+ new ReferringDocument("reference_places", PageReferenceDocxDocuments::referencePlaces, List.of(
+ Reference.field("cell", "cell"),
+ Reference.field("column", "column"),
+ Reference.field("drawn column", "drawn_column"),
+ Reference.field("panel", "panel"),
+ Reference.field("row", "row"),
+ // The one anchor, referred to from a composed cell, a stack's column, a panel,
+ // a row's column and the body. The layout holds no text for what a table's
+ // composed cell holds: the field there reads nothing until it is updated, and
+ // a reference there written as its number is empty.
+ new Reference("target", "target", "", null, null),
+ Reference.field("target", "target"),
+ Reference.field("target", "target"),
+ Reference.field("target", "target"),
+ Reference.field("target", "target"),
+ new Reference("mark", null, "", "From a table's cell, to a shape:", "End of the cell.")),
+ List.of()),
+ new ReferringDocument("unmarked", PageReferenceDocxDocuments::unmarked, List.of(
+ // Blocks that write no paragraph, and whose first drawing no paragraph
+ // carries as their own: one that draws nothing, one whose first drawing fills a
+ // page nothing is written on, one composed in a table's cell.
+ Reference.asText("empty", "See empty:", "See tall:"),
+ Reference.asText("tall", "See tall:", "See composed:"),
+ Reference.asText("composed", "See composed:", "See nowhere:"),
+ // An anchor the document does not have, and one that cleans to another's name:
+ // the link to "a." is named first, so "a" is marked under a name of its own,
+ // which its field names, and the link to "a." lands on no bookmark.
+ Reference.asText("nowhere", "See nowhere:", "See a.:"),
+ Reference.asText("a.", "See a.:", END),
+ Reference.field("a", "a_2"),
+ // Word reads names without case: a block of drawings declared after a
+ // paragraph of the same name in another case takes a name of its own.
+ Reference.field("Intro", "Intro"),
+ Reference.field("intro", "intro_2")),
+ List.of("it stands on a page no paragraph is written on")),
+ new ReferringDocument("margin_sections", PageReferenceDocxDocuments::marginSections, List.of(
+ // A block whose shape a row carries, in a table split where page 2 starts for
+ // its other margins; a block of drawings opening its page over a table split
+ // where page 5 starts; an anchor in a header row the page repeats.
+ Reference.field("carried", "carried"),
+ Reference.field("above", "above"),
+ Reference.field("header", "header")),
+ List.of("written as a Word section that starts at the row page 2 starts with, the table split there",
+ "written as a Word section that starts at the row page 5 starts with, the table split there, "
+ + "the part after it opening with its header row",
+ "anchored in a table cell, the page having no other paragraph",
+ "\"header\" is in a table's header row, which the page repeats")));
+ }
+
+ /**
+ * Every kind of node that declares an anchor, the references to them in a table of contents on
+ * page 1: text on page 2, pictures and a rule on page 3, blocks of drawings alone on page 4, and
+ * anchors named by sentences and drawn shapes on page 5.
+ */
+ private static void anchorKinds(DocumentSession session) {
+ session.pageSize(400, 500).margin(MARGIN);
+ session.pageFlow(page -> {
+ page.addTableOfContents(toc -> toc.title("Contents")
+ .entry("A paragraph", "para")
+ .entry("A section", "section")
+ .entry("A container", "container")
+ .entry("A table", "table")
+ .entry("A picture", "image")
+ .entry("A barcode", "barcode")
+ .entry("A rule", "rule")
+ .entry("A section of drawings alone", "drawings")
+ .entry("A container of drawings alone", "drawn box")
+ .entry("Drawings named by a sentence", TERMS)
+ .entry("Text named by a sentence", SCHEDULE));
+ page.addParagraph("See the shape:").addPageReference("shape");
+ page.addParagraph("See the ellipse:").addPageReference("ellipse");
+ page.addParagraph(END);
+ page.addPageBreak(pageBreak -> { });
+
+ page.addParagraph(p -> p.text("A paragraph, anchored.").anchor("para"));
+ page.addSection(s -> s.anchor("section").addParagraph("A section of text.").addParagraph("Its second line."));
+ page.add(new ContainerNode("Box", List.of(new ParagraphBuilder().text("A container of text.").build()), 0,
+ DocumentInsets.zero(), DocumentInsets.zero(), null, null, null, null, "container"));
+ page.addTable(t -> t.anchor("table").autoColumns(2).row("Net", "100").row("Tax", "20"));
+ page.addPageBreak(pageBreak -> { });
+
+ page.addParagraph("Pictures and a rule.");
+ page.addImage(image -> image.source(DocumentImageData.fromBytes(png())).width(40).height(20).anchor("image"));
+ page.addBarcode(barcode -> barcode.qrCode().data("GC-PAGEREF").size(60, 60).anchor("barcode"));
+ page.addLine(line -> line.horizontal(200).thickness(1).anchor("rule"));
+ page.addParagraph("Under the rule.");
+ page.addPageBreak(pageBreak -> { });
+
+ lines(page, "Top of page four", 3);
+ page.add(drawings("drawings"));
+ page.addParagraph("Between the blocks of drawings.");
+ page.add(new ContainerNode("Drawn box", List.of(drawings(null)), 0, DocumentInsets.zero(),
+ DocumentInsets.zero(), null, null, null, null, "drawn box"));
+ page.addParagraph("After the drawings.");
+ page.addPageBreak(pageBreak -> { });
+
+ lines(page, "Top of page five", 2);
+ page.add(drawings(TERMS));
+ page.addSection(s -> s.anchor(SCHEDULE).addParagraph("The schedule, anchored by a sentence."));
+ page.addShape(shape -> shape.size(60, 20).fillColor(INK).anchor("shape"));
+ page.addEllipse(ellipse -> ellipse.circle(20).fillColor(ACCENT).anchor("ellipse"));
+ page.addParagraph("The last paragraph.");
+ });
+ }
+
+ /**
+ * Page references standing in a table's composed cell, a stack's column, a panel and a row's
+ * column, to an anchor on page 2, and one in a composed cell to a drawn shape there; and on page
+ * 2, anchors in each of those places, named in a table of contents on page 1.
+ */
+ private static void referencePlaces(DocumentSession session) {
+ session.pageSize(400, 500).margin(MARGIN);
+ session.pageFlow(page -> {
+ page.addTableOfContents(toc -> toc.title("Places")
+ .entry("In a table's cell", "cell")
+ .entry("In a stack's column", "column")
+ .entry("Drawings alone in a stack's column", "drawn column")
+ .entry("In a panel", "panel")
+ .entry("In a row's column", "row"));
+ page.addTable(t -> t.autoColumns(1).rowCells(DocumentTableCell.node(new SectionBuilder()
+ .addParagraph("From a table's cell:").add(reference("target"))
+ .addParagraph("From a table's cell, to a shape:").add(reference("mark"))
+ .addParagraph("End of the cell.").build())));
+ page.add(new LayerStackBuilder()
+ .layer(new SectionBuilder().margin(LEFT_COLUMN).addParagraph("From a stack's column:")
+ .add(reference("target")).build(), LayerAlign.TOP_LEFT)
+ .layer(new SectionBuilder().margin(RIGHT_COLUMN).addParagraph("Its other column.").build(),
+ LayerAlign.TOP_LEFT)
+ .build());
+ page.addSection(panel -> panel.fillColor(SURFACE).padding(DocumentInsets.of(6))
+ .addParagraph("From a panel:").add(reference("target")));
+ page.addRow(row -> row.spacing(12)
+ .addSection(column -> column.addParagraph("From a row's column:").add(reference("target")))
+ .addParagraph("Beside it."));
+ page.addParagraph("From the body:").addPageReference("target");
+ page.addPageBreak(pageBreak -> { });
+
+ page.addParagraph(p -> p.text("The target, on page 2.").anchor("target"));
+ page.addTable(t -> t.autoColumns(2).rowCells(
+ DocumentTableCell.node(new ParagraphBuilder().text("In a table's cell.").anchor("cell").build()),
+ DocumentTableCell.text("Beside it.")));
+ page.add(new LayerStackBuilder()
+ .layer(new SectionBuilder().anchor("column").margin(LEFT_COLUMN)
+ .addParagraph("In a stack's column.").build(), LayerAlign.TOP_LEFT)
+ .layer(new SectionBuilder().anchor("drawn column").margin(RIGHT_COLUMN)
+ .addCircle(30, INK).build(), LayerAlign.TOP_LEFT)
+ .build());
+ page.addSection(panel -> panel.anchor("panel").fillColor(SURFACE).padding(DocumentInsets.of(6))
+ .addParagraph("In a panel."));
+ page.addRow(row -> row.spacing(12)
+ .addSection(column -> column.anchor("row").addParagraph("In a row's column."))
+ .addParagraph("Beside it."));
+ // Taller than a rule: the export writes a filled shape 12pt tall or less in the flow as
+ // a paragraph's border, which holds a bookmark.
+ page.addShape(shape -> shape.size(40, 24).fillColor(ACCENT).anchor("mark"));
+ page.addParagraph("After the shape.");
+ });
+ }
+
+ /**
+ * Anchors the export writes no bookmark for, each referred to as the number it reads with a
+ * note, beside anchors whose names clean or fold to another's.
+ */
+ private static void unmarked(DocumentSession session) {
+ // Tall enough for the shape of 550pt that fills page 3 alone.
+ session.pageSize(400, 600).margin(DocumentInsets.of(20));
+ session.pageFlow(page -> {
+ page.addParagraph(p -> p.text("A link to a name that cleans to another's.").linkTo("a."));
+ page.addTableOfContents(toc -> toc.title("Unmarked")
+ .entry("The anchor a", "a")
+ .entry("A paragraph", "Intro")
+ .entry("Drawings named in another case", "intro"));
+ page.addParagraph("See empty:").addPageReference("empty");
+ page.addParagraph("See tall:").addPageReference("tall");
+ page.addParagraph("See composed:").addPageReference("composed");
+ page.addParagraph("See nowhere:").addPageReference("nowhere");
+ page.addParagraph("See a.:").addPageReference("a.");
+ page.addParagraph(END);
+ page.addPageBreak(pageBreak -> { });
+
+ page.addParagraph(p -> p.text("The anchor a, on page 2.").anchor("a"));
+ page.addParagraph(p -> p.text("Intro, on page 2.").anchor("Intro"));
+ page.add(drawings("intro"));
+ page.addParagraph("After the drawings named intro.");
+ page.addSection(s -> s.anchor("empty"));
+ page.addTable(t -> t.autoColumns(1).rowCells(DocumentTableCell.node(drawings("composed"))));
+ page.addPageBreak(pageBreak -> { });
+
+ // Its tall shape fills page 3, which no paragraph is written on; its circle goes to page 4.
+ page.add(new SectionBuilder().anchor("tall")
+ .addShape(120, 550, DocumentColor.LIGHT_GRAY)
+ .addCircle(24, DocumentColor.LIGHT_GRAY).build());
+ page.addParagraph("After the tall drawings.");
+ });
+ }
+
+ /**
+ * Pages with other margins, which the export writes as sections of their own, splitting a table
+ * where such a page starts: a block of drawings a row of a split table carries, one opening its
+ * page over a table split under a repeated header row, and an anchor in a header row the page
+ * repeats.
+ */
+ private static void marginSections(DocumentSession session) {
+ session.pageSize(400, 300).margin(DocumentInsets.of(20));
+ session.pageMargins(List.of(
+ PageMarginRule.from(2, new DocumentInsets(45, 20, 34, 20)),
+ PageMarginRule.from(5, new DocumentInsets(30, 20, 30, 20))));
+ session.pageFlow(page -> {
+ page.addParagraph("See pages").addPageReference("carried").addPageReference("above")
+ .addPageReference("header");
+ // Its shape stands at page 2's foot, which a row of the table's second part carries.
+ page.addTable(table -> {
+ table.columns(DocumentTableColumn.fixed(200));
+ for (int row = 1; row <= 17; row++) {
+ table.rowCells(DocumentTableCell.text("Row " + row));
+ }
+ });
+ page.add(drawings("carried"));
+ page.addParagraph("After the drawings a row carries.");
+ page.addPageBreak(pageBreak -> { });
+
+ // Opening its page, over a table split where page 5 starts, under a copy of its header row.
+ page.add(drawings("above"));
+ page.addTable(table -> {
+ table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100))
+ .header("Item", "Amount").repeatHeader();
+ for (int row = 1; row <= 20; row++) {
+ table.rowCells(DocumentTableCell.text("Row " + row), DocumentTableCell.text(String.valueOf(row)));
+ }
+ });
+ page.addParagraph("After the table.");
+ page.addPageBreak(pageBreak -> { });
+
+ // A header row holding an anchor, which the page repeats: the file holds it once.
+ page.addTable(table -> {
+ table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100))
+ .repeatHeader()
+ .headerCells(DocumentTableCell.node(new ParagraphBuilder().text("Item").anchor("header").build()),
+ DocumentTableCell.text("Amount"));
+ for (int row = 1; row <= 20; row++) {
+ table.rowCells(DocumentTableCell.text("Line " + row), DocumentTableCell.text(String.valueOf(row)));
+ }
+ });
+ });
+ }
+
+ /** Every page reference a document's flow holds, a table's composed cells included. */
+ static int pageReferencesIn(List roots) {
+ return (int) nodesUnder(roots).stream().filter(PageReferenceNode.class::isInstance).count();
+ }
+
+ /**
+ * The anchors the nodes of a document's flow declare, each with the kind of the node declaring it:
+ * every node whose record has an {@code anchor} of its own, a page reference's, which names one,
+ * aside.
+ */
+ static Map anchored(List roots) {
+ Map anchors = new TreeMap<>();
+ for (DocumentNode node : nodesUnder(roots)) {
+ if (node instanceof PageReferenceNode || !node.getClass().isRecord()) {
+ continue;
+ }
+ for (RecordComponent component : node.getClass().getRecordComponents()) {
+ if (component.getName().equals("anchor") && component.getType() == String.class) {
+ try {
+ Object anchor = component.getAccessor().invoke(node);
+ if (anchor != null) {
+ anchors.put((String) anchor, node.nodeKind());
+ }
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+ }
+ }
+ return anchors;
+ }
+
+ private static List nodesUnder(List roots) {
+ List found = new ArrayList<>();
+ List nodes = new ArrayList<>(roots);
+ while (!nodes.isEmpty()) {
+ DocumentNode node = nodes.remove(nodes.size() - 1);
+ found.add(node);
+ nodes.addAll(node.children());
+ if (node instanceof TableNode table) {
+ table.rows().forEach(row -> row.stream().filter(DocumentTableCell::hasComposedContent)
+ .forEach(cell -> nodes.add(cell.content())));
+ }
+ }
+ return found;
+ }
+
+ private static DocumentNode reference(String anchor) {
+ return new PageReferenceNode(anchor, DocumentTextStyle.DEFAULT, TextAlign.LEFT);
+ }
+
+ private static DocumentNode drawings(String anchor) {
+ SectionBuilder section = new SectionBuilder().addShape(120, 30, INK).addCircle(24, ACCENT);
+ if (anchor != null) {
+ section.anchor(anchor);
+ }
+ return section.build();
+ }
+
+ private static void lines(PageFlowBuilder page, String prefix, int count) {
+ for (int line = 1; line <= count; line++) {
+ page.addParagraph(prefix + ", line " + line + ".");
+ }
+ }
+
+ private static byte[] png() {
+ BufferedImage image = new BufferedImage(8, 4, BufferedImage.TYPE_INT_RGB);
+ for (int x = 0; x < 8; x++) {
+ for (int y = 0; y < 4; y++) {
+ image.setRGB(x, y, x < 4 ? 0x1A5694 : 0xC83C3C);
+ }
+ }
+ try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ ImageIO.write(image, "png", out);
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}