Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added assets/readme/examples/page-reference.pdf
Binary file not shown.
18 changes: 18 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <pre>{@code
* int page = probe.pageIndex().pageNumberOf("appendix").orElse(0);
* // ... render again, printing "see page " + page
* }</pre>
*
* @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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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() {
}
Expand All @@ -47,6 +50,7 @@ void invalidate() {
revision++;
cachedLayout = null;
cachedSnapshot = null;
cachedPageIndex = null;
}

/**
Expand Down Expand Up @@ -100,4 +104,29 @@ LayoutSnapshot snapshot(Supplier<LayoutSnapshot> 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<PageIndex> compute) {
if (isPageIndexCached()) {
return cachedPageIndex;
}
cachedPageIndex = compute.get();
cachedPageIndexRevision = revision;
return cachedPageIndex;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
*
* <p>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.</p>
*
* @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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PageReference> 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());
}
}
Loading