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(SupplierComputed 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"); + ListComputed 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