From bf4132e8984146bde6017fc6e659f8d19067cb11 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 25 Jun 2026 11:23:57 +0100
Subject: [PATCH] =?UTF-8?q?feat(api):=20DocumentSession.pageIndex()=20?=
=?UTF-8?q?=E2=80=94=20resolve=20anchors=20to=20pages?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
pageIndex() resolves every declared anchor(...) to its final page in a single,
backend-neutral pass over the laid-out document — pageNumberOf("intro") for a
"see page N" cross-reference, forAnchor(...) for the full PageReference. New
public PageReference(anchor, page) and PageIndex (forAnchor / pageOf /
pageNumberOf / all / totalPages) in document.snapshot; PageIndexExtractor reads
the AnchorMarkerPayload marker fragments every anchored node already emits, so
the result is computed from the layout graph (not from rendered bytes) and a
duplicate anchor resolves to the same destination linkTo(anchor) jumps to.
Cached per layout revision alongside layoutSnapshot(); SessionChromeApi and the
snapshot format are untouched (no PlacedNode field, no formatVersion bump).
Verified: ./mvnw test -pl . — 0 baselines changed. PageIndexTest covers
multi-page resolution, duplicate last-wins, a split section resolving to its
start page, zero-anchor and null-argument leniency, and revision caching;
PageIndexAnchorAgreementTest renders to PDF and asserts the go-to destination
page equals pageOf(anchor). A runnable PageReferenceExample (two-pass
cross-reference) ships with a committed preview.
---
CHANGELOG.md | 9 ++
assets/readme/examples/page-reference.pdf | Bin 0 -> 1213 bytes
examples/README.md | 18 +++
.../demcha/examples/GenerateAllExamples.java | 2 +
.../navigation/PageReferenceExample.java | 94 +++++++++++++
.../document/api/DocumentLayoutCache.java | 29 ++++
.../compose/document/api/DocumentSession.java | 32 +++++
.../debug/snapshot/PageIndexExtractor.java | 44 ++++++
.../compose/document/snapshot/PageIndex.java | 104 ++++++++++++++
.../document/snapshot/PageReference.java | 24 ++++
.../pdf/PageIndexAnchorAgreementTest.java | 73 ++++++++++
.../document/snapshot/PageIndexTest.java | 133 ++++++++++++++++++
12 files changed, 562 insertions(+)
create mode 100644 assets/readme/examples/page-reference.pdf
create mode 100644 examples/src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java
create mode 100644 src/main/java/com/demcha/compose/document/debug/snapshot/PageIndexExtractor.java
create mode 100644 src/main/java/com/demcha/compose/document/snapshot/PageIndex.java
create mode 100644 src/main/java/com/demcha/compose/document/snapshot/PageReference.java
create mode 100644 src/test/java/com/demcha/compose/document/backend/fixed/pdf/PageIndexAnchorAgreementTest.java
create mode 100644 src/test/java/com/demcha/compose/document/snapshot/PageIndexTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fbafe0580..9cf580bb8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,15 @@ PDF `GoTo` actions. External links are unchanged.
### Public API
+- **`DocumentSession.pageIndex()` + `PageIndex` / `PageReference`** (`@since 1.9.0`).
+ Resolves every declared `anchor(...)` to its final page in a single,
+ backend-neutral pass over the laid-out document — `pageNumberOf("intro")` for a
+ "see page N" cross-reference, `forAnchor(...)` for the full `PageReference`.
+ Computed from the resolved layout graph (not from rendered PDF bytes) and cached
+ per layout revision alongside `layoutSnapshot()`. The read-side foundation for
+ clickable tables of contents and cross-references. A duplicate anchor resolves to
+ its last registration — the same destination a `linkTo(anchor)` jumps to.
+
- **`DocumentPageNumbering` / `DocumentPageNumberStyle`** (`@since 1.9.0`). Header
and footer `{page}` / `{pages}` tokens can now offset, restart, restyle, and
suppress-on-first-page numbering per zone via
diff --git a/assets/readme/examples/page-reference.pdf b/assets/readme/examples/page-reference.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..1ad7bdcbb12588d30711999042bec08741cd1825
GIT binary patch
literal 1213
zcmY!laBZ^4=fsl4ocwey{jk)c;>`R!
z1$~fe{eZ;u)M5oApzn?MGE?EIf*5y
zE~&}+DXAb`#U(|liMhO76?1aWS@In+5MjCSZ+?-AVg-Vq)ml%6FbE)2~(RsYY
zZB^!`$J16H>9~6!ldFEw)aAN!?RT7QJ^58U{QrkDV#n-4jn#^Osjgq2+r6v%cfpRh
zRf;8ovG!q3rHNY&zkHe&IaAtNW96K(WX7Bq_h;W}j98dod*Z;EcjsCX
zIW%5ank>-^XfR#OA}*>`5b4BKuv9^H%8CsU7v6nJc94I2kmbU$Z;V^L)NVhSu#s`?
z-v>$(r=PyBy|DXb?(R?NR~~sP*dLXQOwajr>tfILn_b(~emuOfbU~8PwR=ZR_-FL~
zk!L>D-taT_K(2iDP0`coV
z@9koKae7y}_#0o3ws6*~#p>7s&=@lSfuW%vl%HRs0LphkT>73a3eh&Mu7-w&u9i+N
zu7(zFW=;l{Mi!PXh9)MiP6lSiE-q#!&aMVVjwX&Ku5NDTu5Ko7P8N>NZZ2kSu7;*Y
zrj8bN3X~fW3yar?pwu)j{ou^1R0Ts|I`+&It is a two-pass workflow — exactly what {@code pageIndex()} exists for. A
+ * throwaway first pass lays out the document and reads the anchor's page; the
+ * second pass renders the same document with the resolved number substituted.
+ *
+ * {@code
+ * int page = probe.pageIndex().pageNumberOf("appendix").orElse(0);
+ * // ... render again, printing "see page " + page
+ * }
+ *
+ * @author Artem Demchyshyn
+ */
+public final class PageReferenceExample {
+
+ private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38);
+ private static final DocumentColor MUTED = DocumentColor.rgb(96, 102, 112);
+
+ private PageReferenceExample() {
+ }
+
+ /**
+ * Renders a two-page note whose first page cross-references the appendix by
+ * its resolved page number.
+ *
+ * @return path to the generated PDF
+ * @throws Exception if rendering or file IO fails
+ */
+ public static Path generate() throws Exception {
+ Path pdfFile = ExampleOutputPaths.prepare("features/navigation", "page-reference.pdf");
+
+ // Pass 1 — throwaway layout to resolve the anchor's page. The appendix is
+ // forced onto its own page by the break, so its page is stable regardless
+ // of the (still-unresolved) reference text on page 1.
+ int appendixPage;
+ try (DocumentSession probe = newSession(null)) {
+ compose(probe, 0);
+ appendixPage = probe.pageIndex().pageNumberOf("appendix").orElse(0);
+ }
+
+ // Pass 2 — render with the resolved cross-reference.
+ try (DocumentSession document = newSession(pdfFile)) {
+ compose(document, appendixPage);
+ document.buildPdf();
+ }
+ return pdfFile;
+ }
+
+ private static DocumentSession newSession(Path output) {
+ return (output == null ? GraphCompose.document() : GraphCompose.document(output))
+ .pageSize(320, 240)
+ .margin(DocumentInsets.of(30))
+ .create();
+ }
+
+ private static void compose(DocumentSession session, int appendixPage) {
+ DocumentTextStyle title = DocumentTextStyle.DEFAULT.withSize(18).withColor(INK);
+ DocumentTextStyle body = DocumentTextStyle.DEFAULT.withSize(11).withColor(INK);
+ DocumentTextStyle ref = DocumentTextStyle.DEFAULT.withSize(11).withColor(MUTED);
+
+ session.pageFlow(page -> {
+ page.addParagraph(p -> p.text("Release notes").textStyle(title));
+ String reference = appendixPage > 0
+ ? "Full configuration options are listed in the Appendix on page " + appendixPage + "."
+ : "Full configuration options are listed in the Appendix.";
+ page.addParagraph(p -> p.text(reference).textStyle(ref).padding(DocumentInsets.top(10)));
+
+ page.addPageBreak(b -> b.name("toAppendix"));
+
+ page.addSection(s -> s.anchor("appendix")
+ .addParagraph(p -> p.text("Appendix").textStyle(title)));
+ page.addParagraph(p -> p.text("Configuration keys, defaults, and units.")
+ .textStyle(body).padding(DocumentInsets.top(6)));
+ });
+ }
+
+ public static void main(String[] args) throws Exception {
+ System.out.println("Generated: " + generate());
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/api/DocumentLayoutCache.java b/src/main/java/com/demcha/compose/document/api/DocumentLayoutCache.java
index de9cbc1b3..b18dc9efc 100644
--- a/src/main/java/com/demcha/compose/document/api/DocumentLayoutCache.java
+++ b/src/main/java/com/demcha/compose/document/api/DocumentLayoutCache.java
@@ -2,6 +2,7 @@
import com.demcha.compose.document.layout.LayoutGraph;
import com.demcha.compose.document.snapshot.LayoutSnapshot;
+import com.demcha.compose.document.snapshot.PageIndex;
import java.util.function.Supplier;
@@ -27,6 +28,8 @@ final class DocumentLayoutCache {
private long cachedLayoutRevision = -1;
private LayoutSnapshot cachedSnapshot;
private long cachedSnapshotRevision = -1;
+ private PageIndex cachedPageIndex;
+ private long cachedPageIndexRevision = -1;
DocumentLayoutCache() {
}
@@ -47,6 +50,7 @@ void invalidate() {
revision++;
cachedLayout = null;
cachedSnapshot = null;
+ cachedPageIndex = null;
}
/**
@@ -100,4 +104,29 @@ LayoutSnapshot snapshot(Supplier compute) {
cachedSnapshotRevision = revision;
return cachedSnapshot;
}
+
+ /**
+ * Indicates whether the page-index cache still matches the current revision.
+ *
+ * @return {@code true} when the cached page index is still valid
+ */
+ boolean isPageIndexCached() {
+ return cachedPageIndex != null && cachedPageIndexRevision == revision;
+ }
+
+ /**
+ * Returns the cached page index or computes a fresh one through the supplied
+ * function and stores it for the current revision.
+ *
+ * @param compute lazy compute path used on cache miss
+ * @return cached or freshly computed page index
+ */
+ PageIndex pageIndex(Supplier compute) {
+ if (isPageIndexCached()) {
+ return cachedPageIndex;
+ }
+ cachedPageIndex = compute.get();
+ cachedPageIndexRevision = revision;
+ return cachedPageIndex;
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/api/DocumentSession.java b/src/main/java/com/demcha/compose/document/api/DocumentSession.java
index c8dca031f..171b44789 100644
--- a/src/main/java/com/demcha/compose/document/api/DocumentSession.java
+++ b/src/main/java/com/demcha/compose/document/api/DocumentSession.java
@@ -10,6 +10,7 @@
import com.demcha.compose.document.backend.fixed.pdf.options.PdfWatermarkOptions;
import com.demcha.compose.document.backend.semantic.SemanticBackend;
import com.demcha.compose.document.debug.snapshot.LayoutGraphSnapshotExtractor;
+import com.demcha.compose.document.debug.snapshot.PageIndexExtractor;
import com.demcha.compose.document.dsl.DocumentDsl;
import com.demcha.compose.document.dsl.PageFlowBuilder;
import com.demcha.compose.document.exceptions.DocumentRenderingException;
@@ -18,6 +19,7 @@
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.output.*;
import com.demcha.compose.document.snapshot.LayoutSnapshot;
+import com.demcha.compose.document.snapshot.PageIndex;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.font.FontFamilyDefinition;
@@ -762,6 +764,36 @@ public LayoutSnapshot layoutSnapshot() {
return computed;
}
+ /**
+ * Resolves every declared {@code anchor(...)} to its final page in a single,
+ * backend-neutral pass over the laid-out document — the foundation for
+ * cross-references ("see page N") and clickable tables of contents.
+ *
+ * Computed from the resolved layout graph (not from rendered output) and
+ * cached per layout revision alongside {@link #layoutSnapshot()}. A duplicate
+ * anchor resolves to its last registration, matching where a
+ * {@code linkTo(anchor)} jumps.
+ *
+ * @return the resolved anchor-to-page index
+ * @throws IllegalStateException if this session has already been closed
+ * @since 1.9.0
+ */
+ public PageIndex pageIndex() {
+ ensureOpen();
+ long revision = layoutCache.revision();
+ if (layoutCache.isPageIndexCached()) {
+ PageIndex cached = layoutCache.pageIndex(() -> {
+ throw new IllegalStateException("PageIndex cache miss after isPageIndexCached() returned true.");
+ });
+ LIFECYCLE_LOG.debug("document.pageIndex.cache.hit sessionId={} revision={} roots={}", sessionId, revision, roots.size());
+ return cached;
+ }
+ long startNanos = System.nanoTime();
+ PageIndex computed = layoutCache.pageIndex(() -> PageIndexExtractor.from(layoutGraph()));
+ LIFECYCLE_LOG.debug("document.pageIndex.end sessionId={} revision={} roots={} anchors={} durationMs={}", sessionId, revision, roots.size(), computed.all().size(), elapsedMillis(startNanos));
+ return computed;
+ }
+
/**
* Renders the current layout graph with the supplied fixed-layout backend.
*
diff --git a/src/main/java/com/demcha/compose/document/debug/snapshot/PageIndexExtractor.java b/src/main/java/com/demcha/compose/document/debug/snapshot/PageIndexExtractor.java
new file mode 100644
index 000000000..c55d11b4c
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/debug/snapshot/PageIndexExtractor.java
@@ -0,0 +1,44 @@
+package com.demcha.compose.document.debug.snapshot;
+
+import com.demcha.compose.document.layout.LayoutGraph;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.AnchorMarkerPayload;
+import com.demcha.compose.document.snapshot.PageIndex;
+import com.demcha.compose.document.snapshot.PageReference;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Builds a {@link PageIndex} from a resolved layout graph by reading the
+ * {@link AnchorMarkerPayload} marker fragments every anchored node emits at its
+ * top-left. Backend-neutral — it walks the same {@code graph.fragments()} list,
+ * in the same order, that the PDF backend resolves to go-to destinations, so a
+ * resolved page matches where a {@code linkTo(anchor)} jumps.
+ *
+ * @author Artem Demchyshyn
+ * @since 1.9.0
+ */
+public final class PageIndexExtractor {
+
+ private PageIndexExtractor() {
+ }
+
+ /**
+ * Resolves every declared anchor to its page from the layout graph.
+ *
+ * @param graph resolved layout graph
+ * @return the page index; a duplicate anchor keeps its last registration
+ */
+ public static PageIndex from(LayoutGraph graph) {
+ Objects.requireNonNull(graph, "graph");
+ List references = new ArrayList<>();
+ for (PlacedFragment fragment : graph.fragments()) {
+ if (fragment.payload() instanceof AnchorMarkerPayload marker && !marker.anchor().isEmpty()) {
+ references.add(new PageReference(marker.anchor(), fragment.pageIndex()));
+ }
+ }
+ return new PageIndex(references, graph.totalPages());
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/snapshot/PageIndex.java b/src/main/java/com/demcha/compose/document/snapshot/PageIndex.java
new file mode 100644
index 000000000..09e856750
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/snapshot/PageIndex.java
@@ -0,0 +1,104 @@
+package com.demcha.compose.document.snapshot;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalInt;
+
+/**
+ * Backend-neutral index resolving every declared {@code anchor(...)} to its
+ * final page in a single pass over the laid-out document — the read-side
+ * primitive behind cross-references ("see page N") and clickable tables of
+ * contents.
+ *
+ * Computed from the resolved layout graph, not from rendered PDF bytes, so it
+ * is independent of the output backend. A duplicate anchor name resolves to its
+ * last registration — the same destination a {@code linkTo(anchor)} jumps to.
+ * (Edge case: an anchor on a node nested inside a repeated table header
+ * resolves to the header's last repetition, since the marker re-emits per
+ * continuation — consistent with where the link jumps.) Obtained via
+ * {@code DocumentSession.pageIndex()} (cached per layout revision). Instances are
+ * immutable.
+ *
+ * @author Artem Demchyshyn
+ * @see PageReference
+ * @since 1.9.0
+ */
+public final class PageIndex {
+
+ private final Map byAnchor;
+ private final int totalPages;
+
+ /**
+ * Creates a page index over the given resolved references, keyed by
+ * {@link PageReference#anchor()} in layout (document-flow) order; a repeated
+ * anchor name keeps its last reference.
+ *
+ * @param references resolved anchor references in layout order
+ * @param totalPages total page count of the laid-out document
+ */
+ public PageIndex(Collection references, int totalPages) {
+ Map map = new LinkedHashMap<>();
+ if (references != null) {
+ for (PageReference reference : references) {
+ if (reference != null) {
+ map.put(reference.anchor(), reference);
+ }
+ }
+ }
+ this.byAnchor = Collections.unmodifiableMap(map);
+ this.totalPages = totalPages;
+ }
+
+ /**
+ * Returns the resolved reference for an anchor.
+ *
+ * @param anchor anchor name; a {@code null} or undeclared name yields empty
+ * @return the reference, or empty if the anchor is not declared
+ */
+ public Optional forAnchor(String anchor) {
+ return anchor == null ? Optional.empty() : Optional.ofNullable(byAnchor.get(anchor));
+ }
+
+ /**
+ * Returns the zero-based page of an anchor.
+ *
+ * @param anchor anchor name; a {@code null} or undeclared name yields empty
+ * @return the page, or empty if the anchor is not declared
+ */
+ public OptionalInt pageOf(String anchor) {
+ PageReference reference = anchor == null ? null : byAnchor.get(anchor);
+ return reference == null ? OptionalInt.empty() : OptionalInt.of(reference.page());
+ }
+
+ /**
+ * Returns the 1-based printable page number of an anchor.
+ *
+ * @param anchor anchor name; a {@code null} or undeclared name yields empty
+ * @return the page number, or empty if the anchor is not declared
+ */
+ public OptionalInt pageNumberOf(String anchor) {
+ PageReference reference = anchor == null ? null : byAnchor.get(anchor);
+ return reference == null ? OptionalInt.empty() : OptionalInt.of(reference.pageNumber());
+ }
+
+ /**
+ * Returns every resolved anchor in layout (document-flow) order.
+ *
+ * @return an unmodifiable anchor-to-reference map
+ */
+ public Map all() {
+ return byAnchor;
+ }
+
+ /**
+ * Returns the total page count of the laid-out document.
+ *
+ * @return total pages
+ */
+ public int totalPages() {
+ return totalPages;
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/snapshot/PageReference.java b/src/main/java/com/demcha/compose/document/snapshot/PageReference.java
new file mode 100644
index 000000000..e8679e0b0
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/snapshot/PageReference.java
@@ -0,0 +1,24 @@
+package com.demcha.compose.document.snapshot;
+
+/**
+ * Resolved page of a declared {@code anchor(...)} navigation destination — the
+ * page the anchored node's top-left lands on after layout. An anchor is a point,
+ * so it resolves to a single page.
+ *
+ * @param anchor the declared anchor name
+ * @param page zero-based page the anchor resolves to
+ * @author Artem Demchyshyn
+ * @see PageIndex
+ * @since 1.9.0
+ */
+public record PageReference(String anchor, int page) {
+
+ /**
+ * Returns the 1-based printable page number of the anchor's page.
+ *
+ * @return {@code page + 1}
+ */
+ public int pageNumber() {
+ return page + 1;
+ }
+}
diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PageIndexAnchorAgreementTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PageIndexAnchorAgreementTest.java
new file mode 100644
index 000000000..bd0bbd5d4
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PageIndexAnchorAgreementTest.java
@@ -0,0 +1,73 @@
+package com.demcha.compose.document.backend.fixed.pdf;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.style.DocumentInsets;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.interactive.action.PDActionGoTo;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
+import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageDestination;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Pins the backend-neutral guarantee: {@code DocumentSession.pageIndex()} resolves
+ * an anchor to the same page the rendered PDF's {@code linkTo(anchor)} go-to
+ * action jumps to. The read API (anchor markers in the layout graph) and the PDF
+ * destination writer are independent passes over the same fragments; this test
+ * fails if they ever diverge.
+ */
+class PageIndexAnchorAgreementTest {
+
+ @TempDir
+ Path tempDir;
+
+ private static List goToLinks(PDDocument document, int pageIndex) throws IOException {
+ List result = new ArrayList<>();
+ for (PDAnnotation annotation : document.getPage(pageIndex).getAnnotations()) {
+ if (annotation instanceof PDAnnotationLink link && link.getAction() instanceof PDActionGoTo) {
+ result.add(link);
+ }
+ }
+ return result;
+ }
+
+ @Test
+ void pageIndexResolvesToTheSamePageTheGoToDestinationTargets() throws Exception {
+ Path out = tempDir.resolve("agreement.pdf");
+
+ int pageIndexTarget;
+ try (DocumentSession session = GraphCompose.document(out)
+ .pageSize(220, 140)
+ .margin(DocumentInsets.of(18))
+ .create()) {
+ session.pageFlow(page -> {
+ page.addParagraph(p -> p.inlineLinkTo("see target", "target")); // GoTo link on page 1
+ page.addPageBreak(b -> b.name("brk"));
+ page.addSection(s -> s.anchor("target").addParagraph("Target")); // anchor on page 2
+ });
+ pageIndexTarget = session.pageIndex().pageOf("target").orElseThrow();
+ session.buildPdf();
+ }
+
+ try (PDDocument document = Loader.loadPDF(out.toFile())) {
+ List links = goToLinks(document, 0);
+ assertThat(links).isNotEmpty();
+ // Every go-to annotation for the anchor targets the page pageIndex() reported.
+ for (PDAnnotationLink link : links) {
+ PDPageDestination destination =
+ (PDPageDestination) ((PDActionGoTo) link.getAction()).getDestination();
+ assertThat(destination.retrievePageNumber()).isEqualTo(pageIndexTarget);
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/demcha/compose/document/snapshot/PageIndexTest.java b/src/test/java/com/demcha/compose/document/snapshot/PageIndexTest.java
new file mode 100644
index 000000000..91dd58efd
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/snapshot/PageIndexTest.java
@@ -0,0 +1,133 @@
+package com.demcha.compose.document.snapshot;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.node.SpacerNode;
+import com.demcha.compose.document.style.DocumentInsets;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Contract for {@link DocumentSession#pageIndex()}: every declared
+ * {@code anchor(...)} resolves to its final page in one pass, a duplicate keeps
+ * its last registration, a split node resolves to its start page, unknown/null
+ * anchors return empty, and the result is cached per layout revision.
+ */
+class PageIndexTest {
+
+ private DocumentSession smallPage() {
+ return GraphCompose.document()
+ .pageSize(220, 140)
+ .margin(DocumentInsets.of(18))
+ .create();
+ }
+
+ private DocumentSession multiPage() {
+ DocumentSession session = smallPage();
+ session.pageFlow(page -> {
+ page.addSection(s -> s.anchor("intro").addParagraph("Introduction"));
+ page.addPageBreak(b -> b.name("toBody"));
+ page.addSection(s -> s.anchor("body").addParagraph("Body"));
+ });
+ return session;
+ }
+
+ @Test
+ void resolvesEachAnchorToItsFinalPage() {
+ try (DocumentSession session = multiPage()) {
+ PageIndex index = session.pageIndex();
+
+ assertThat(index.pageNumberOf("intro")).hasValue(1);
+ assertThat(index.pageNumberOf("body")).hasValue(2);
+ assertThat(index.pageOf("body")).hasValue(1); // zero-based
+ assertThat(index.forAnchor("intro")).hasValue(new PageReference("intro", 0));
+ assertThat(index.all().keySet()).containsExactly("intro", "body");
+ assertThat(index.totalPages()).isEqualTo(session.layoutGraph().totalPages());
+ }
+ }
+
+ @Test
+ void unknownOrNullAnchorResolvesEmpty() {
+ try (DocumentSession session = multiPage()) {
+ PageIndex index = session.pageIndex();
+
+ assertThat(index.forAnchor("missing")).isEmpty();
+ assertThat(index.pageOf("missing")).isEmpty();
+ assertThat(index.pageNumberOf("missing")).isEmpty();
+ // null must be lenient (empty), not an NPE — on a populated index...
+ assertThat(index.forAnchor(null)).isEmpty();
+ assertThat(index.pageNumberOf(null)).isEmpty();
+ }
+ }
+
+ @Test
+ void documentWithNoAnchorsYieldsEmptyIndexWithoutThrowing() {
+ try (DocumentSession session = smallPage()) {
+ session.pageFlow(page -> page.addParagraph("No anchors here"));
+ PageIndex index = session.pageIndex();
+
+ assertThat(index.all()).isEmpty();
+ assertThat(index.totalPages()).isEqualTo(1);
+ assertThat(index.forAnchor("x")).isEmpty();
+ // ...and on an empty index, null is still lenient (the Map.of()-NPE regression).
+ assertThat(index.pageOf(null)).isEmpty();
+ }
+ }
+
+ @Test
+ void duplicateAnchorResolvesToLastRegistration() {
+ try (DocumentSession session = smallPage()) {
+ session.pageFlow(page -> {
+ page.addSection(s -> s.anchor("dup").addParagraph("First"));
+ page.addPageBreak(b -> b.name("brk"));
+ page.addSection(s -> s.anchor("dup").addParagraph("Second"));
+ });
+ PageIndex index = session.pageIndex();
+
+ // Last registration wins — the same destination linkTo("dup") jumps to.
+ assertThat(index.pageNumberOf("dup")).hasValue(2);
+ assertThat(index.all()).hasSize(1);
+ }
+ }
+
+ @Test
+ void anchorOnSectionSplitAcrossPagesResolvesToStartPage() {
+ try (DocumentSession session = smallPage()) {
+ session.pageFlow(page -> page.addSection(s -> {
+ s.anchor("report");
+ for (int i = 0; i < 16; i++) {
+ s.addParagraph("Line " + i);
+ }
+ }));
+ PageIndex index = session.pageIndex();
+
+ assertThat(session.layoutGraph().totalPages()).isGreaterThan(1); // the section actually split
+ assertThat(index.pageNumberOf("report")).hasValue(1); // resolves to its top, not its tail
+ }
+ }
+
+ @Test
+ void leafAnchorIsResolved() {
+ try (DocumentSession session = smallPage()) {
+ session.pageFlow(page -> page.addLine(l -> l.horizontal(60).anchor("rule")));
+ PageIndex index = session.pageIndex();
+
+ assertThat(index.pageNumberOf("rule")).hasValue(1);
+ }
+ }
+
+ @Test
+ void cachedPerRevisionAndRecomputesCorrectlyAfterMutation() {
+ try (DocumentSession session = multiPage()) {
+ PageIndex first = session.pageIndex();
+ assertThat(session.pageIndex()).isSameAs(first); // cached on the same revision
+ assertThat(first.all()).containsOnlyKeys("intro", "body");
+
+ session.add(new SpacerNode("Tail", 40, 40, DocumentInsets.zero(), DocumentInsets.zero()));
+ PageIndex recomputed = session.pageIndex();
+ assertThat(recomputed).isNotSameAs(first); // recomputed after a mutation
+ assertThat(recomputed.all()).containsOnlyKeys("intro", "body"); // and is correct, not stale/empty
+ }
+ }
+}