Skip to content

Commit 3cf16cd

Browse files
authored
feat(api): addPageReference(anchor) — native "see page N" cross-reference (#236)
A "see page N" cross-reference needed a manual two-pass: lay the document out, read pageIndex(), then re-render with the number substituted. addPageReference(anchor) does it in one authoring pass — the engine resolves the number from the laid-out document and renders it. A document that contains a page reference is compiled in two passes (the first resolves every anchor's page, the next renders the references), then re-resolved to a fixed point so a reference whose own width re-wraps a neighbour and shifts a page is corrected rather than left stale; the resolve is capped. The reference reserves only its glyph width, so its footprint does not shift the pages it reports. Documents without a page reference compile once and are byte-identical. PageReferenceNode is a text leaf; the resolved page flows to it through a default layout-context accessor, so existing node definitions are untouched. Available on flows and inside rows (the number column of a table-of-contents row); pageIndex() remains for programmatic access. Tests: PageReferenceTest covers a forward reference printing the resolved page (via PDFTextStripper, equal to pageIndex().pageNumberOf), multiple references each reporting their final page, and an unresolved anchor rendering without throwing. Example: PageReferenceExample rewritten to the native one-pass form (a dot-leader entry). Full suite green, no visual baselines changed.
1 parent ad89fc7 commit 3cf16cd

16 files changed

Lines changed: 514 additions & 59 deletions

File tree

CHANGELOG.md

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

1313
### Public API
1414

15+
- **`addPageReference(anchor)` + `PageReferenceNode`** (`@since 1.9.0`). Prints the
16+
page a declared `anchor(...)` lands on — a native "see page N" cross-reference —
17+
in a single authoring pass. A document that contains a page reference is laid
18+
out twice: the first pass resolves every anchor's page, the second renders the
19+
references with the resolved numbers; the reference reserves only its content
20+
width in both passes, so its own footprint does not shift the pages it reports.
21+
Available on flows (`addPageReference(anchor)`) and inside rows (the number
22+
column of a table-of-contents row). Documents without a page reference are
23+
unaffected (single pass, byte-identical). `pageIndex()` remains for programmatic
24+
access.
25+
1526
- **`RowBuilder.columns(...)` + `DocumentRowColumn`** (`@since 1.9.0`). Size each row
1627
column explicitly: `DocumentRowColumn.fixed(pt)`, `auto()` (intrinsic content
1728
width), or `weight(w)` (a share of the space left after the fixed and intrinsic
78 Bytes
Binary file not shown.

examples/README.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ are with the canonical DSL, then jump to its detailed section below.
101101
| [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) |
102102
| [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) |
103103
| [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) |
104-
| [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) |
104+
| [Page references](#page-references) | `addPageReference(anchor)`print the page an `anchor(...)` lands on (a native "see page N" cross-reference), resolved in one authoring pass | [PDF](../assets/readme/examples/page-reference.pdf) · [Source](src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java) |
105105
| [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) |
106106
| [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) |
107107
| [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) |
@@ -704,16 +704,19 @@ session.chrome().footer(DocumentHeaderFooter.builder()
704704

705705
### Page references
706706

707-
`DocumentSession.pageIndex()` resolves every declared `anchor(...)` to its final
708-
page, so a document can print a real "see page N" cross-reference. It is a
709-
two-pass workflow — a throwaway first pass lays the document out and reads the
710-
anchor's page; the second renders the same document with the resolved number.
711-
Computed from the layout graph (not from rendered bytes), so it is backend-neutral
712-
and consistent with where a `linkTo(anchor)` jumps.
707+
`addPageReference(anchor)` prints the page a declared `anchor(...)` lands on — a
708+
native "see page N" cross-reference — in a single authoring pass. The engine
709+
resolves the number from the laid-out document automatically (a second layout
710+
pass under the hood), so there is no manual probe-then-render. It is
711+
backend-neutral (read from the layout graph, not rendered bytes) and consistent
712+
with where a `linkTo(anchor)` jumps; `pageIndex()` remains for programmatic
713+
access.
713714

714715
```java
715-
int page = probe.pageIndex().pageNumberOf("appendix").orElse(0);
716-
// ... render again, printing "see page " + page
716+
flow.addRow(r -> r.columns(auto(), weight(1), auto())
717+
.addParagraph("Appendix")
718+
.addLine(l -> l.fill().dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)) // dot leader
719+
.addPageReference("appendix", style, TextAlign.RIGHT)); // resolves to the page
717720
```
718721

719722
[📄 View PDF](../assets/readme/examples/page-reference.pdf) ·

examples/src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java

Lines changed: 38 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,28 @@
22

33
import com.demcha.compose.GraphCompose;
44
import com.demcha.compose.document.api.DocumentSession;
5+
import com.demcha.compose.document.node.TextAlign;
56
import com.demcha.compose.document.style.DocumentColor;
67
import com.demcha.compose.document.style.DocumentInsets;
8+
import com.demcha.compose.document.style.DocumentLineCap;
9+
import com.demcha.compose.document.style.DocumentRowColumn;
10+
import com.demcha.compose.document.style.DocumentStroke;
711
import com.demcha.compose.document.style.DocumentTextStyle;
812
import com.demcha.examples.support.ExampleOutputPaths;
913

1014
import java.nio.file.Path;
1115

1216
/**
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>
17+
* Runnable showcase for v1.9 native page references: {@code addPageReference(anchor)}
18+
* prints the page a declared {@code anchor(...)} lands on — the "see page N"
19+
* cross-reference — in a single authoring pass. The engine resolves the number
20+
* from the laid-out document automatically (a second layout pass), so there is no
21+
* manual probe-then-render; {@code pageIndex()} remains for programmatic access.
2022
*
2123
* <pre>{@code
22-
* int page = probe.pageIndex().pageNumberOf("appendix").orElse(0);
23-
* // ... render again, printing "see page " + page
24+
* flow.addRow(r -> r.columns(auto(), auto())
25+
* .addParagraph("Configuration is in the Appendix on page")
26+
* .addPageReference("appendix", style, TextAlign.LEFT)); // resolves to the real page
2427
* }</pre>
2528
*
2629
* @author Artem Demchyshyn
@@ -35,57 +38,45 @@ private PageReferenceExample() {
3538

3639
/**
3740
* Renders a two-page note whose first page cross-references the appendix by
38-
* its resolved page number.
41+
* its resolved page number, in one authoring pass.
3942
*
4043
* @return path to the generated PDF
4144
* @throws Exception if rendering or file IO fails
4245
*/
4346
public static Path generate() throws Exception {
4447
Path pdfFile = ExampleOutputPaths.prepare("features/navigation", "page-reference.pdf");
4548

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) {
7149
DocumentTextStyle title = DocumentTextStyle.DEFAULT.withSize(18).withColor(INK);
7250
DocumentTextStyle body = DocumentTextStyle.DEFAULT.withSize(11).withColor(INK);
7351
DocumentTextStyle ref = DocumentTextStyle.DEFAULT.withSize(11).withColor(MUTED);
7452

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)));
53+
try (DocumentSession document = GraphCompose.document(pdfFile)
54+
.pageSize(320, 240)
55+
.margin(DocumentInsets.of(30))
56+
.create()) {
57+
DocumentStroke dots = DocumentStroke.of(MUTED, 1.2);
58+
document.pageFlow(page -> {
59+
page.addParagraph(p -> p.text("Release notes").textStyle(title));
60+
page.addParagraph(p -> p.text("Where to read more — the page resolves itself:")
61+
.textStyle(ref).padding(DocumentInsets.top(10)));
8162

82-
page.addPageBreak(b -> b.name("toAppendix"));
63+
// One authoring pass: the reference resolves to the appendix's real page.
64+
page.addRow(r -> r.gap(6).columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1), DocumentRowColumn.auto())
65+
.padding(DocumentInsets.top(8))
66+
.addParagraph(p -> p.text("Appendix").textStyle(body))
67+
.addLine(l -> l.fill().height(11).stroke(dots).dashed(0.1, 4).lineCap(DocumentLineCap.ROUND))
68+
.addPageReference("appendix", body, TextAlign.RIGHT));
8369

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-
});
70+
page.addPageBreak(b -> b.name("toAppendix"));
71+
72+
page.addSection(s -> s.anchor("appendix")
73+
.addParagraph(p -> p.text("Appendix").textStyle(title)));
74+
page.addParagraph(p -> p.text("Configuration keys, defaults, and units.")
75+
.textStyle(body).padding(DocumentInsets.top(6)));
76+
});
77+
document.buildPdf();
78+
}
79+
return pdfFile;
8980
}
9081

9182
public static void main(String[] args) throws Exception {

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

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import com.demcha.compose.document.layout.*;
1818
import com.demcha.compose.document.node.ContainerNode;
1919
import com.demcha.compose.document.node.DocumentNode;
20+
import com.demcha.compose.document.node.PageReferenceNode;
2021
import com.demcha.compose.document.output.*;
2122
import com.demcha.compose.document.snapshot.LayoutSnapshot;
2223
import com.demcha.compose.document.snapshot.PageIndex;
@@ -32,7 +33,9 @@
3233
import java.nio.file.Path;
3334
import java.util.ArrayList;
3435
import java.util.Collection;
36+
import java.util.HashMap;
3537
import java.util.List;
38+
import java.util.Map;
3639
import java.util.Objects;
3740
import java.util.concurrent.TimeUnit;
3841
import java.util.function.Consumer;
@@ -63,6 +66,13 @@
6366
public final class DocumentSession implements AutoCloseable {
6467
private static final Logger LIFECYCLE_LOG = LoggerFactory.getLogger("com.demcha.compose.document.lifecycle");
6568

69+
/**
70+
* Cap on page-reference recompiles after the first resolve. A table of
71+
* contents converges in one recompile; the cap bounds a pathological document
72+
* whose numbers keep shifting pages, falling back to the last layout.
73+
*/
74+
private static final int MAX_PAGE_REFERENCE_PASSES = 5;
75+
6676
private final String sessionId = Integer.toHexString(System.identityHashCode(this));
6777
private final Path defaultOutputFile;
6878
private final NodeRegistry registry;
@@ -754,8 +764,7 @@ public LayoutGraph layoutGraph() {
754764
long startNanos = System.nanoTime();
755765
LIFECYCLE_LOG.debug("document.layout.start sessionId={} revision={} roots={}", sessionId, revision, roots.size());
756766
try {
757-
DocumentLayoutPassContext context = new DocumentLayoutPassContext(registry, canvas, measurementResources.fontLibrary(), measurementResources.textMeasurementSystem(), markdown);
758-
LayoutGraph computed = layoutCache.layout(() -> DocumentPageBackgrounds.apply(compiler.compile(documentGraph(), context, context), pageBackgrounds));
767+
LayoutGraph computed = layoutCache.layout(this::computeLayout);
759768
LIFECYCLE_LOG.debug("document.layout.end sessionId={} revision={} roots={} pages={} nodes={} fragments={} durationMs={}", sessionId, revision, roots.size(), computed.totalPages(), computed.nodes().size(), computed.fragments().size(), elapsedMillis(startNanos));
760769
return computed;
761770
} catch (RuntimeException ex) {
@@ -764,6 +773,57 @@ public LayoutGraph layoutGraph() {
764773
}
765774
}
766775

776+
/**
777+
* Compiles the semantic graph for one layout revision. A document that
778+
* contains a {@link PageReferenceNode} is compiled in two passes — the first
779+
* resolves every anchor's page, the next renders the references with the
780+
* resolved numbers — then re-resolved to a fixed point: if rendering the
781+
* numbers shifted any anchor's page (a reference whose own width re-wrapped a
782+
* neighbour and pushed content across a boundary), it recompiles with the new
783+
* numbers until the pages stop moving, capped at {@link #MAX_PAGE_REFERENCE_PASSES}
784+
* recompiles. All passes run inside the cache compute, so the result is cached
785+
* once per revision. Documents without a page reference compile once and are
786+
* byte-identical to before.
787+
*/
788+
private LayoutGraph computeLayout() {
789+
LayoutGraph graph = compilePass(Map.of());
790+
if (!containsPageReference(roots)) {
791+
return DocumentPageBackgrounds.apply(graph, pageBackgrounds);
792+
}
793+
Map<String, Integer> resolved = resolvedPageNumbers(graph);
794+
for (int pass = 0; pass < MAX_PAGE_REFERENCE_PASSES; pass++) {
795+
graph = compilePass(resolved);
796+
Map<String, Integer> rendered = resolvedPageNumbers(graph);
797+
if (rendered.equals(resolved)) {
798+
return DocumentPageBackgrounds.apply(graph, pageBackgrounds);
799+
}
800+
resolved = rendered;
801+
}
802+
LIFECYCLE_LOG.debug("document.layout.pageReference.unconverged sessionId={} passes={}", sessionId, MAX_PAGE_REFERENCE_PASSES);
803+
return DocumentPageBackgrounds.apply(graph, pageBackgrounds);
804+
}
805+
806+
private LayoutGraph compilePass(Map<String, Integer> resolvedPages) {
807+
DocumentLayoutPassContext context = new DocumentLayoutPassContext(registry, canvas,
808+
measurementResources.fontLibrary(), measurementResources.textMeasurementSystem(), markdown, resolvedPages);
809+
return compiler.compile(documentGraph(), context, context);
810+
}
811+
812+
private static boolean containsPageReference(List<DocumentNode> nodes) {
813+
for (DocumentNode node : nodes) {
814+
if (node instanceof PageReferenceNode || containsPageReference(node.children())) {
815+
return true;
816+
}
817+
}
818+
return false;
819+
}
820+
821+
private static Map<String, Integer> resolvedPageNumbers(LayoutGraph graph) {
822+
Map<String, Integer> pages = new HashMap<>();
823+
PageIndexExtractor.from(graph).all().forEach((anchor, reference) -> pages.put(anchor, reference.pageNumber()));
824+
return pages;
825+
}
826+
767827
/**
768828
* Extracts the current deterministic layout snapshot used by regression tests.
769829
*

src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import com.demcha.compose.document.node.ChartNode;
88
import com.demcha.compose.document.node.DocumentLinkOptions;
99
import com.demcha.compose.document.node.DocumentNode;
10+
import com.demcha.compose.document.node.PageReferenceNode;
11+
import com.demcha.compose.document.node.TextAlign;
1012
import com.demcha.compose.document.style.*;
1113

1214
import java.awt.*;
@@ -958,6 +960,33 @@ public T addSection(String name, Consumer<SectionBuilder> spec) {
958960
return add(BuilderSupport.configure(new SectionBuilder().name(name), spec).build());
959961
}
960962

963+
/**
964+
* Adds a page reference that prints the page number a named {@code anchor(...)}
965+
* lands on — the "see page N" cross-reference. The number is resolved from the
966+
* laid-out document automatically (a second layout pass), so no manual
967+
* probe-then-render is needed. Uses the default text style, left-aligned.
968+
*
969+
* @param anchor the anchor whose page number is printed
970+
* @return this builder
971+
* @since 1.9.0
972+
*/
973+
public T addPageReference(String anchor) {
974+
return add(new PageReferenceNode(anchor, DocumentTextStyle.DEFAULT, TextAlign.LEFT));
975+
}
976+
977+
/**
978+
* Adds a styled page reference (see {@link #addPageReference(String)}).
979+
*
980+
* @param anchor the anchor whose page number is printed
981+
* @param textStyle text style for the number
982+
* @param align horizontal alignment within the node box
983+
* @return this builder
984+
* @since 1.9.0
985+
*/
986+
public T addPageReference(String anchor, DocumentTextStyle textStyle, TextAlign align) {
987+
return add(new PageReferenceNode(anchor, textStyle, align));
988+
}
989+
961990
/**
962991
* Adds a heading bar with the default light-grey band look.
963992
*

0 commit comments

Comments
 (0)