Skip to content

Commit a36839e

Browse files
authored
feat(api): DocumentSession.pageIndex() — resolve anchors to pages (#231)
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.
1 parent 9b61478 commit a36839e

12 files changed

Lines changed: 562 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ PDF `GoTo` actions. External links are unchanged.
1212

1313
### Public API
1414

15+
- **`DocumentSession.pageIndex()` + `PageIndex` / `PageReference`** (`@since 1.9.0`).
16+
Resolves every declared `anchor(...)` to its final page in a single,
17+
backend-neutral pass over the laid-out document — `pageNumberOf("intro")` for a
18+
"see page N" cross-reference, `forAnchor(...)` for the full `PageReference`.
19+
Computed from the resolved layout graph (not from rendered PDF bytes) and cached
20+
per layout revision alongside `layoutSnapshot()`. The read-side foundation for
21+
clickable tables of contents and cross-references. A duplicate anchor resolves to
22+
its last registration — the same destination a `linkTo(anchor)` jumps to.
23+
1524
- **`DocumentPageNumbering` / `DocumentPageNumberStyle`** (`@since 1.9.0`). Header
1625
and footer `{page}` / `{pages}` tokens can now offset, restart, restyle, and
1726
suppress-on-first-page numbering per zone via
1.18 KB
Binary file not shown.

examples/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ are with the canonical DSL, then jump to its detailed section below.
9999
| [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) |
100100
| [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) |
101101
| [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) |
102+
| [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) |
102103
| [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) |
103104
| [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) |
104105
| [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()
664665
[📄 View PDF](../assets/readme/examples/page-numbering.pdf) ·
665666
[📜 Full source](src/main/java/com/demcha/examples/features/chrome/PageNumberingExample.java)
666667

668+
### Page references
669+
670+
`DocumentSession.pageIndex()` resolves every declared `anchor(...)` to its final
671+
page, so a document can print a real "see page N" cross-reference. It is a
672+
two-pass workflow — a throwaway first pass lays the document out and reads the
673+
anchor's page; the second renders the same document with the resolved number.
674+
Computed from the layout graph (not from rendered bytes), so it is backend-neutral
675+
and consistent with where a `linkTo(anchor)` jumps.
676+
677+
```java
678+
int page = probe.pageIndex().pageNumberOf("appendix").orElse(0);
679+
// ... render again, printing "see page " + page
680+
```
681+
682+
[📄 View PDF](../assets/readme/examples/page-reference.pdf) ·
683+
[📜 Full source](src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java)
684+
667685
---
668686

669687
## Production patterns

examples/src/main/java/com/demcha/examples/GenerateAllExamples.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import com.demcha.examples.features.text.InlineSvgIconExample;
2828
import com.demcha.examples.features.text.InlineHighlightExample;
2929
import com.demcha.examples.features.navigation.InPdfNavigationExample;
30+
import com.demcha.examples.features.navigation.PageReferenceExample;
3031
import com.demcha.examples.features.text.RichTextShowcaseExample;
3132
import com.demcha.examples.features.text.SectionPresetsExample;
3233
import com.demcha.examples.features.themes.CustomBusinessThemeExample;
@@ -164,6 +165,7 @@ public static void main(String[] args) throws Exception {
164165
System.out.println("Generated: " + RichTextShowcaseExample.generate());
165166
System.out.println("Generated: " + SectionPresetsExample.generate());
166167
System.out.println("Generated: " + InPdfNavigationExample.generate());
168+
System.out.println("Generated: " + PageReferenceExample.generate());
167169

168170
// Theming + chrome
169171
System.out.println("Generated: " + CustomBusinessThemeExample.generate());
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.demcha.examples.features.navigation;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentSession;
5+
import com.demcha.compose.document.style.DocumentColor;
6+
import com.demcha.compose.document.style.DocumentInsets;
7+
import com.demcha.compose.document.style.DocumentTextStyle;
8+
import com.demcha.examples.support.ExampleOutputPaths;
9+
10+
import java.nio.file.Path;
11+
12+
/**
13+
* Runnable showcase for v1.9 page references: {@code DocumentSession.pageIndex()}
14+
* resolves a declared {@code anchor(...)} to its final page, so a document can
15+
* print a real "see page N" cross-reference.
16+
*
17+
* <p>It is a two-pass workflow — exactly what {@code pageIndex()} exists for. A
18+
* throwaway first pass lays out the document and reads the anchor's page; the
19+
* second pass renders the same document with the resolved number substituted.</p>
20+
*
21+
* <pre>{@code
22+
* int page = probe.pageIndex().pageNumberOf("appendix").orElse(0);
23+
* // ... render again, printing "see page " + page
24+
* }</pre>
25+
*
26+
* @author Artem Demchyshyn
27+
*/
28+
public final class PageReferenceExample {
29+
30+
private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38);
31+
private static final DocumentColor MUTED = DocumentColor.rgb(96, 102, 112);
32+
33+
private PageReferenceExample() {
34+
}
35+
36+
/**
37+
* Renders a two-page note whose first page cross-references the appendix by
38+
* its resolved page number.
39+
*
40+
* @return path to the generated PDF
41+
* @throws Exception if rendering or file IO fails
42+
*/
43+
public static Path generate() throws Exception {
44+
Path pdfFile = ExampleOutputPaths.prepare("features/navigation", "page-reference.pdf");
45+
46+
// Pass 1 — throwaway layout to resolve the anchor's page. The appendix is
47+
// forced onto its own page by the break, so its page is stable regardless
48+
// of the (still-unresolved) reference text on page 1.
49+
int appendixPage;
50+
try (DocumentSession probe = newSession(null)) {
51+
compose(probe, 0);
52+
appendixPage = probe.pageIndex().pageNumberOf("appendix").orElse(0);
53+
}
54+
55+
// Pass 2 — render with the resolved cross-reference.
56+
try (DocumentSession document = newSession(pdfFile)) {
57+
compose(document, appendixPage);
58+
document.buildPdf();
59+
}
60+
return pdfFile;
61+
}
62+
63+
private static DocumentSession newSession(Path output) {
64+
return (output == null ? GraphCompose.document() : GraphCompose.document(output))
65+
.pageSize(320, 240)
66+
.margin(DocumentInsets.of(30))
67+
.create();
68+
}
69+
70+
private static void compose(DocumentSession session, int appendixPage) {
71+
DocumentTextStyle title = DocumentTextStyle.DEFAULT.withSize(18).withColor(INK);
72+
DocumentTextStyle body = DocumentTextStyle.DEFAULT.withSize(11).withColor(INK);
73+
DocumentTextStyle ref = DocumentTextStyle.DEFAULT.withSize(11).withColor(MUTED);
74+
75+
session.pageFlow(page -> {
76+
page.addParagraph(p -> p.text("Release notes").textStyle(title));
77+
String reference = appendixPage > 0
78+
? "Full configuration options are listed in the Appendix on page " + appendixPage + "."
79+
: "Full configuration options are listed in the Appendix.";
80+
page.addParagraph(p -> p.text(reference).textStyle(ref).padding(DocumentInsets.top(10)));
81+
82+
page.addPageBreak(b -> b.name("toAppendix"));
83+
84+
page.addSection(s -> s.anchor("appendix")
85+
.addParagraph(p -> p.text("Appendix").textStyle(title)));
86+
page.addParagraph(p -> p.text("Configuration keys, defaults, and units.")
87+
.textStyle(body).padding(DocumentInsets.top(6)));
88+
});
89+
}
90+
91+
public static void main(String[] args) throws Exception {
92+
System.out.println("Generated: " + generate());
93+
}
94+
}

src/main/java/com/demcha/compose/document/api/DocumentLayoutCache.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.demcha.compose.document.layout.LayoutGraph;
44
import com.demcha.compose.document.snapshot.LayoutSnapshot;
5+
import com.demcha.compose.document.snapshot.PageIndex;
56

67
import java.util.function.Supplier;
78

@@ -27,6 +28,8 @@ final class DocumentLayoutCache {
2728
private long cachedLayoutRevision = -1;
2829
private LayoutSnapshot cachedSnapshot;
2930
private long cachedSnapshotRevision = -1;
31+
private PageIndex cachedPageIndex;
32+
private long cachedPageIndexRevision = -1;
3033

3134
DocumentLayoutCache() {
3235
}
@@ -47,6 +50,7 @@ void invalidate() {
4750
revision++;
4851
cachedLayout = null;
4952
cachedSnapshot = null;
53+
cachedPageIndex = null;
5054
}
5155

5256
/**
@@ -100,4 +104,29 @@ LayoutSnapshot snapshot(Supplier<LayoutSnapshot> compute) {
100104
cachedSnapshotRevision = revision;
101105
return cachedSnapshot;
102106
}
107+
108+
/**
109+
* Indicates whether the page-index cache still matches the current revision.
110+
*
111+
* @return {@code true} when the cached page index is still valid
112+
*/
113+
boolean isPageIndexCached() {
114+
return cachedPageIndex != null && cachedPageIndexRevision == revision;
115+
}
116+
117+
/**
118+
* Returns the cached page index or computes a fresh one through the supplied
119+
* function and stores it for the current revision.
120+
*
121+
* @param compute lazy compute path used on cache miss
122+
* @return cached or freshly computed page index
123+
*/
124+
PageIndex pageIndex(Supplier<PageIndex> compute) {
125+
if (isPageIndexCached()) {
126+
return cachedPageIndex;
127+
}
128+
cachedPageIndex = compute.get();
129+
cachedPageIndexRevision = revision;
130+
return cachedPageIndex;
131+
}
103132
}

src/main/java/com/demcha/compose/document/api/DocumentSession.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.demcha.compose.document.backend.fixed.pdf.options.PdfWatermarkOptions;
1111
import com.demcha.compose.document.backend.semantic.SemanticBackend;
1212
import com.demcha.compose.document.debug.snapshot.LayoutGraphSnapshotExtractor;
13+
import com.demcha.compose.document.debug.snapshot.PageIndexExtractor;
1314
import com.demcha.compose.document.dsl.DocumentDsl;
1415
import com.demcha.compose.document.dsl.PageFlowBuilder;
1516
import com.demcha.compose.document.exceptions.DocumentRenderingException;
@@ -18,6 +19,7 @@
1819
import com.demcha.compose.document.node.DocumentNode;
1920
import com.demcha.compose.document.output.*;
2021
import com.demcha.compose.document.snapshot.LayoutSnapshot;
22+
import com.demcha.compose.document.snapshot.PageIndex;
2123
import com.demcha.compose.document.style.DocumentColor;
2224
import com.demcha.compose.document.style.DocumentInsets;
2325
import com.demcha.compose.font.FontFamilyDefinition;
@@ -762,6 +764,36 @@ public LayoutSnapshot layoutSnapshot() {
762764
return computed;
763765
}
764766

767+
/**
768+
* Resolves every declared {@code anchor(...)} to its final page in a single,
769+
* backend-neutral pass over the laid-out document — the foundation for
770+
* cross-references ("see page N") and clickable tables of contents.
771+
*
772+
* <p>Computed from the resolved layout graph (not from rendered output) and
773+
* cached per layout revision alongside {@link #layoutSnapshot()}. A duplicate
774+
* anchor resolves to its last registration, matching where a
775+
* {@code linkTo(anchor)} jumps.</p>
776+
*
777+
* @return the resolved anchor-to-page index
778+
* @throws IllegalStateException if this session has already been closed
779+
* @since 1.9.0
780+
*/
781+
public PageIndex pageIndex() {
782+
ensureOpen();
783+
long revision = layoutCache.revision();
784+
if (layoutCache.isPageIndexCached()) {
785+
PageIndex cached = layoutCache.pageIndex(() -> {
786+
throw new IllegalStateException("PageIndex cache miss after isPageIndexCached() returned true.");
787+
});
788+
LIFECYCLE_LOG.debug("document.pageIndex.cache.hit sessionId={} revision={} roots={}", sessionId, revision, roots.size());
789+
return cached;
790+
}
791+
long startNanos = System.nanoTime();
792+
PageIndex computed = layoutCache.pageIndex(() -> PageIndexExtractor.from(layoutGraph()));
793+
LIFECYCLE_LOG.debug("document.pageIndex.end sessionId={} revision={} roots={} anchors={} durationMs={}", sessionId, revision, roots.size(), computed.all().size(), elapsedMillis(startNanos));
794+
return computed;
795+
}
796+
765797
/**
766798
* Renders the current layout graph with the supplied fixed-layout backend.
767799
*
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.demcha.compose.document.debug.snapshot;
2+
3+
import com.demcha.compose.document.layout.LayoutGraph;
4+
import com.demcha.compose.document.layout.PlacedFragment;
5+
import com.demcha.compose.document.layout.payloads.AnchorMarkerPayload;
6+
import com.demcha.compose.document.snapshot.PageIndex;
7+
import com.demcha.compose.document.snapshot.PageReference;
8+
9+
import java.util.ArrayList;
10+
import java.util.List;
11+
import java.util.Objects;
12+
13+
/**
14+
* Builds a {@link PageIndex} from a resolved layout graph by reading the
15+
* {@link AnchorMarkerPayload} marker fragments every anchored node emits at its
16+
* top-left. Backend-neutral — it walks the same {@code graph.fragments()} list,
17+
* in the same order, that the PDF backend resolves to go-to destinations, so a
18+
* resolved page matches where a {@code linkTo(anchor)} jumps.
19+
*
20+
* @author Artem Demchyshyn
21+
* @since 1.9.0
22+
*/
23+
public final class PageIndexExtractor {
24+
25+
private PageIndexExtractor() {
26+
}
27+
28+
/**
29+
* Resolves every declared anchor to its page from the layout graph.
30+
*
31+
* @param graph resolved layout graph
32+
* @return the page index; a duplicate anchor keeps its last registration
33+
*/
34+
public static PageIndex from(LayoutGraph graph) {
35+
Objects.requireNonNull(graph, "graph");
36+
List<PageReference> references = new ArrayList<>();
37+
for (PlacedFragment fragment : graph.fragments()) {
38+
if (fragment.payload() instanceof AnchorMarkerPayload marker && !marker.anchor().isEmpty()) {
39+
references.add(new PageReference(marker.anchor(), fragment.pageIndex()));
40+
}
41+
}
42+
return new PageIndex(references, graph.totalPages());
43+
}
44+
}

0 commit comments

Comments
 (0)