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 000000000..1ad7bdcbb Binary files /dev/null and b/assets/readme/examples/page-reference.pdf differ diff --git a/examples/README.md b/examples/README.md index 103cb344a..867269018 100644 --- a/examples/README.md +++ b/examples/README.md @@ -99,6 +99,7 @@ are with the canonical DSL, then jump to its detailed section below. | [Charts](#charts) | Native vector bar, line, and pie/donut charts — data/spec/style layers, axis & grid toggles, point markers, value labels, legend | [PDF](../assets/readme/examples/chart-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/charts/ChartShowcaseExample.java) | | [PDF chrome](#pdf-chrome) | `DocumentMetadata`, `DocumentWatermark`, `DocumentHeaderFooter`, `DocumentBookmarkOptions` | [PDF](../assets/readme/examples/pdf-chrome.pdf) · [Source](src/main/java/com/demcha/examples/features/chrome/PdfChromeExample.java) | | [Page numbering](#page-numbering) | `DocumentPageNumbering` — offset / restart / roman / suppress-on-first-page for `{page}` / `{pages}` footer tokens | [PDF](../assets/readme/examples/page-numbering.pdf) · [Source](src/main/java/com/demcha/examples/features/chrome/PageNumberingExample.java) | +| [Page references](#page-references) | `DocumentSession.pageIndex()` — resolve an `anchor(...)` to its page for a two-pass "see page N" cross-reference | [PDF](../assets/readme/examples/page-reference.pdf) · [Source](src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java) | | [HTTP streaming](#http-streaming) | `writePdf(OutputStream)` for Servlet / S3 / GCS — caller's stream is not closed | [PDF](../assets/readme/examples/invoice-http-stream.pdf) · [Source](src/main/java/com/demcha/examples/features/streaming/HttpStreamingExample.java) | | [Word export (DOCX)](#word-export-docx) | `DocxSemanticBackend` — the same session renders a fixed-layout PDF and an editable Word file; paragraphs / lists / tables / images map 1:1, charts fall back to their data table | [PDF](../assets/readme/examples/word-export-companion.pdf) · [DOCX](../assets/readme/examples/word-export-companion.docx) · [Source](src/main/java/com/demcha/examples/features/docx/WordExportExample.java) | | [Layout snapshot regression](#layout-snapshot-regression) | Deterministic `layoutSnapshot()` workflow with baseline + drift report — production regression-testing pattern | [PDF](../assets/readme/examples/invoice-snapshot-regression.pdf) · [Source](src/main/java/com/demcha/examples/features/snapshots/LayoutSnapshotRegressionExample.java) | @@ -664,6 +665,23 @@ session.chrome().footer(DocumentHeaderFooter.builder() [📄 View PDF](../assets/readme/examples/page-numbering.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/chrome/PageNumberingExample.java) +### Page references + +`DocumentSession.pageIndex()` resolves every declared `anchor(...)` to its final +page, so a document can print a real "see page N" cross-reference. It is a +two-pass workflow — a throwaway first pass lays the document out and reads the +anchor's page; the second renders the same document with the resolved number. +Computed from the layout graph (not from rendered bytes), so it is backend-neutral +and consistent with where a `linkTo(anchor)` jumps. + +```java +int page = probe.pageIndex().pageNumberOf("appendix").orElse(0); +// ... render again, printing "see page " + page +``` + +[📄 View PDF](../assets/readme/examples/page-reference.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java) + --- ## Production patterns diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index 2b7190142..ef7b459b0 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -27,6 +27,7 @@ import com.demcha.examples.features.text.InlineSvgIconExample; import com.demcha.examples.features.text.InlineHighlightExample; import com.demcha.examples.features.navigation.InPdfNavigationExample; +import com.demcha.examples.features.navigation.PageReferenceExample; import com.demcha.examples.features.text.RichTextShowcaseExample; import com.demcha.examples.features.text.SectionPresetsExample; import com.demcha.examples.features.themes.CustomBusinessThemeExample; @@ -164,6 +165,7 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + RichTextShowcaseExample.generate()); System.out.println("Generated: " + SectionPresetsExample.generate()); System.out.println("Generated: " + InPdfNavigationExample.generate()); + System.out.println("Generated: " + PageReferenceExample.generate()); // Theming + chrome System.out.println("Generated: " + CustomBusinessThemeExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java b/examples/src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java new file mode 100644 index 000000000..efac9d74c --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java @@ -0,0 +1,94 @@ +package com.demcha.examples.features.navigation; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Runnable showcase for v1.9 page references: {@code DocumentSession.pageIndex()} + * resolves a declared {@code anchor(...)} to its final page, so a document can + * print a real "see page N" cross-reference. + * + *

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 + } + } +}