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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ PDF `GoTo` actions. External links are unchanged.

### Public API

- **`addPageReference(anchor)` + `PageReferenceNode`** (`@since 1.9.0`). Prints the
page a declared `anchor(...)` lands on — a native "see page N" cross-reference —
in a single authoring pass. A document that contains a page reference is laid
out twice: the first pass resolves every anchor's page, the second renders the
references with the resolved numbers; the reference reserves only its content
width in both passes, so its own footprint does not shift the pages it reports.
Available on flows (`addPageReference(anchor)`) and inside rows (the number
column of a table-of-contents row). Documents without a page reference are
unaffected (single pass, byte-identical). `pageIndex()` remains for programmatic
access.

- **`RowBuilder.columns(...)` + `DocumentRowColumn`** (`@since 1.9.0`). Size each row
column explicitly: `DocumentRowColumn.fixed(pt)`, `auto()` (intrinsic content
width), or `weight(w)` (a share of the space left after the fixed and intrinsic
Expand Down
Binary file modified assets/readme/examples/page-reference.pdf
Binary file not shown.
21 changes: 12 additions & 9 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,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) |
| [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) |
| [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 @@ -704,16 +704,19 @@ session.chrome().footer(DocumentHeaderFooter.builder()

### 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.
`addPageReference(anchor)` prints the page a declared `anchor(...)` lands on — a
native "see page N" cross-reference — in a single authoring pass. The engine
resolves the number from the laid-out document automatically (a second layout
pass under the hood), so there is no manual probe-then-render. It is
backend-neutral (read from the layout graph, not rendered bytes) and consistent
with where a `linkTo(anchor)` jumps; `pageIndex()` remains for programmatic
access.

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

[📄 View PDF](../assets/readme/examples/page-reference.pdf) ·
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,28 @@

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.node.TextAlign;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentLineCap;
import com.demcha.compose.document.style.DocumentRowColumn;
import com.demcha.compose.document.style.DocumentStroke;
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>
* Runnable showcase for v1.9 native page references: {@code addPageReference(anchor)}
* prints the page a declared {@code anchor(...)} lands on — the "see page N"
* cross-reference — in a single authoring pass. The engine resolves the number
* from the laid-out document automatically (a second layout pass), so there is no
* manual probe-then-render; {@code pageIndex()} remains for programmatic access.
*
* <pre>{@code
* int page = probe.pageIndex().pageNumberOf("appendix").orElse(0);
* // ... render again, printing "see page " + page
* flow.addRow(r -> r.columns(auto(), auto())
* .addParagraph("Configuration is in the Appendix on page")
* .addPageReference("appendix", style, TextAlign.LEFT)); // resolves to the real page
* }</pre>
*
* @author Artem Demchyshyn
Expand All @@ -35,57 +38,45 @@ private PageReferenceExample() {

/**
* Renders a two-page note whose first page cross-references the appendix by
* its resolved page number.
* its resolved page number, in one authoring pass.
*
* @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)));
try (DocumentSession document = GraphCompose.document(pdfFile)
.pageSize(320, 240)
.margin(DocumentInsets.of(30))
.create()) {
DocumentStroke dots = DocumentStroke.of(MUTED, 1.2);
document.pageFlow(page -> {
page.addParagraph(p -> p.text("Release notes").textStyle(title));
page.addParagraph(p -> p.text("Where to read more — the page resolves itself:")
.textStyle(ref).padding(DocumentInsets.top(10)));

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

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)));
});
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)));
});
document.buildPdf();
}
return pdfFile;
}

public static void main(String[] args) throws Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.demcha.compose.document.layout.*;
import com.demcha.compose.document.node.ContainerNode;
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.PageReferenceNode;
import com.demcha.compose.document.output.*;
import com.demcha.compose.document.snapshot.LayoutSnapshot;
import com.demcha.compose.document.snapshot.PageIndex;
Expand All @@ -32,7 +33,9 @@
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
Expand Down Expand Up @@ -63,6 +66,13 @@
public final class DocumentSession implements AutoCloseable {
private static final Logger LIFECYCLE_LOG = LoggerFactory.getLogger("com.demcha.compose.document.lifecycle");

/**
* Cap on page-reference recompiles after the first resolve. A table of
* contents converges in one recompile; the cap bounds a pathological document
* whose numbers keep shifting pages, falling back to the last layout.
*/
private static final int MAX_PAGE_REFERENCE_PASSES = 5;

private final String sessionId = Integer.toHexString(System.identityHashCode(this));
private final Path defaultOutputFile;
private final NodeRegistry registry;
Expand Down Expand Up @@ -754,8 +764,7 @@ public LayoutGraph layoutGraph() {
long startNanos = System.nanoTime();
LIFECYCLE_LOG.debug("document.layout.start sessionId={} revision={} roots={}", sessionId, revision, roots.size());
try {
DocumentLayoutPassContext context = new DocumentLayoutPassContext(registry, canvas, measurementResources.fontLibrary(), measurementResources.textMeasurementSystem(), markdown);
LayoutGraph computed = layoutCache.layout(() -> DocumentPageBackgrounds.apply(compiler.compile(documentGraph(), context, context), pageBackgrounds));
LayoutGraph computed = layoutCache.layout(this::computeLayout);
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));
return computed;
} catch (RuntimeException ex) {
Expand All @@ -764,6 +773,57 @@ public LayoutGraph layoutGraph() {
}
}

/**
* Compiles the semantic graph for one layout revision. A document that
* contains a {@link PageReferenceNode} is compiled in two passes — the first
* resolves every anchor's page, the next renders the references with the
* resolved numbers — then re-resolved to a fixed point: if rendering the
* numbers shifted any anchor's page (a reference whose own width re-wrapped a
* neighbour and pushed content across a boundary), it recompiles with the new
* numbers until the pages stop moving, capped at {@link #MAX_PAGE_REFERENCE_PASSES}
* recompiles. All passes run inside the cache compute, so the result is cached
* once per revision. Documents without a page reference compile once and are
* byte-identical to before.
*/
private LayoutGraph computeLayout() {
LayoutGraph graph = compilePass(Map.of());
if (!containsPageReference(roots)) {
return DocumentPageBackgrounds.apply(graph, pageBackgrounds);
}
Map<String, Integer> resolved = resolvedPageNumbers(graph);
for (int pass = 0; pass < MAX_PAGE_REFERENCE_PASSES; pass++) {
graph = compilePass(resolved);
Map<String, Integer> rendered = resolvedPageNumbers(graph);
if (rendered.equals(resolved)) {
return DocumentPageBackgrounds.apply(graph, pageBackgrounds);
}
resolved = rendered;
}
LIFECYCLE_LOG.debug("document.layout.pageReference.unconverged sessionId={} passes={}", sessionId, MAX_PAGE_REFERENCE_PASSES);
return DocumentPageBackgrounds.apply(graph, pageBackgrounds);
}

private LayoutGraph compilePass(Map<String, Integer> resolvedPages) {
DocumentLayoutPassContext context = new DocumentLayoutPassContext(registry, canvas,
measurementResources.fontLibrary(), measurementResources.textMeasurementSystem(), markdown, resolvedPages);
return compiler.compile(documentGraph(), context, context);
}

private static boolean containsPageReference(List<DocumentNode> nodes) {
for (DocumentNode node : nodes) {
if (node instanceof PageReferenceNode || containsPageReference(node.children())) {
return true;
}
}
return false;
}

private static Map<String, Integer> resolvedPageNumbers(LayoutGraph graph) {
Map<String, Integer> pages = new HashMap<>();
PageIndexExtractor.from(graph).all().forEach((anchor, reference) -> pages.put(anchor, reference.pageNumber()));
return pages;
}

/**
* Extracts the current deterministic layout snapshot used by regression tests.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import com.demcha.compose.document.node.ChartNode;
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.PageReferenceNode;
import com.demcha.compose.document.node.TextAlign;
import com.demcha.compose.document.style.*;

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

/**
* Adds a page reference that prints the page number a named {@code anchor(...)}
* lands on — the "see page N" cross-reference. The number is resolved from the
* laid-out document automatically (a second layout pass), so no manual
* probe-then-render is needed. Uses the default text style, left-aligned.
*
* @param anchor the anchor whose page number is printed
* @return this builder
* @since 1.9.0
*/
public T addPageReference(String anchor) {
return add(new PageReferenceNode(anchor, DocumentTextStyle.DEFAULT, TextAlign.LEFT));
}

/**
* Adds a styled page reference (see {@link #addPageReference(String)}).
*
* @param anchor the anchor whose page number is printed
* @param textStyle text style for the number
* @param align horizontal alignment within the node box
* @return this builder
* @since 1.9.0
*/
public T addPageReference(String anchor, DocumentTextStyle textStyle, TextAlign align) {
return add(new PageReferenceNode(anchor, textStyle, align));
}

/**
* Adds a heading bar with the default light-grey band look.
*
Expand Down
Loading