diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ab9f314..42d204817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/assets/readme/examples/page-reference.pdf b/assets/readme/examples/page-reference.pdf index 1ad7bdcbb..ed37ecd04 100644 Binary files a/assets/readme/examples/page-reference.pdf and b/assets/readme/examples/page-reference.pdf differ diff --git a/examples/README.md b/examples/README.md index 6a955a144..73f8775c7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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) | @@ -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) · 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 index efac9d74c..b761ffae6 100644 --- a/examples/src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java +++ b/examples/src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java @@ -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. - * - *
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.
+ * 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. * *{@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
* }
*
* @author Artem Demchyshyn
@@ -35,7 +38,7 @@ 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
@@ -43,49 +46,37 @@ private PageReferenceExample() {
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 {
diff --git a/src/main/java/com/demcha/compose/document/api/DocumentSession.java b/src/main/java/com/demcha/compose/document/api/DocumentSession.java
index df8ad6d10..cc884f87c 100644
--- a/src/main/java/com/demcha/compose/document/api/DocumentSession.java
+++ b/src/main/java/com/demcha/compose/document/api/DocumentSession.java
@@ -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;
@@ -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;
@@ -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;
@@ -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) {
@@ -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);
+ }
+ MapThe leaf is sized to the number's glyph width (not a full text block), so an + * unresolved reference reserves only its content width and never over-constrains + * the row it sits in during the first pass.
+ * + * @author Artem Demchyshyn + */ +public final class PageReferenceDefinition implements NodeDefinitionBecause the number is resolved after layout, a document containing a + * page reference is laid out twice; the reference reserves a single text line in + * both passes, so its own footprint does not shift the pages it reports.
+ * + *Resolved in document flows and rows (the table-of-contents row). A reference + * nested inside a table cell is not resolved in this release.
+ * + * @param name node name used in snapshots and layout graph paths + * @param anchor the anchor whose page number is printed + * @param textStyle text style for the number + * @param align horizontal alignment within the node box + * @param placeholderText text shown before the number is resolved, or when the + * anchor is unknown + * @param padding inner padding + * @param margin outer margin + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public record PageReferenceNode( + String name, + String anchor, + DocumentTextStyle textStyle, + TextAlign align, + String placeholderText, + DocumentInsets padding, + DocumentInsets margin +) implements DocumentNode { + + /** + * Normalizes defaults and requires a non-blank anchor. + */ + public PageReferenceNode { + name = name == null ? "" : name; + anchor = anchor == null ? "" : anchor.trim(); + if (anchor.isBlank()) { + throw new IllegalArgumentException("PageReferenceNode anchor must not be blank."); + } + textStyle = textStyle == null ? DocumentTextStyle.DEFAULT : textStyle; + align = align == null ? TextAlign.LEFT : align; + placeholderText = placeholderText == null ? "" : placeholderText; + padding = padding == null ? DocumentInsets.zero() : padding; + margin = margin == null ? DocumentInsets.zero() : margin; + } + + /** + * Convenience constructor for an unnamed reference with default spacing and no + * placeholder text. + * + * @param anchor the anchor whose page number is printed + * @param textStyle text style for the number + * @param align horizontal alignment within the node box + */ + public PageReferenceNode(String anchor, DocumentTextStyle textStyle, TextAlign align) { + this("", anchor, textStyle, align, "", DocumentInsets.zero(), DocumentInsets.zero()); + } +} diff --git a/src/test/java/com/demcha/compose/document/api/DocumentSessionTest.java b/src/test/java/com/demcha/compose/document/api/DocumentSessionTest.java index 8c67484eb..55f82bdaf 100644 --- a/src/test/java/com/demcha/compose/document/api/DocumentSessionTest.java +++ b/src/test/java/com/demcha/compose/document/api/DocumentSessionTest.java @@ -1128,6 +1128,11 @@ public boolean markdownEnabled() { return true; } + @Override + public java.util.OptionalInt resolvedPage(String anchor) { + return java.util.OptionalInt.empty(); + } + @Override public void close() throws Exception { measurementDocument.close(); diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PageReferenceTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PageReferenceTest.java new file mode 100644 index 000000000..9a941ffe6 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PageReferenceTest.java @@ -0,0 +1,112 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +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.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * End-to-end proof that {@code addPageReference(anchor)} prints the page a forward + * anchor lands on: the reference sits on page 1, its target several pages later, + * and the rendered number on page 1 equals {@code pageIndex().pageNumberOf} — so + * the two-pass resolve converges and is backend-correct. + */ +class PageReferenceTest { + + @TempDir + Path tempDir; + + private static String pageText(PDDocument doc, int oneBasedPage) throws Exception { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(oneBasedPage); + stripper.setEndPage(oneBasedPage); + return stripper.getText(doc); + } + + @Test + void pageReferencePrintsTheForwardAnchorsResolvedPage() throws Exception { + Path out = tempDir.resolve("page-ref.pdf"); + int resolved; + try (DocumentSession session = GraphCompose.document(out) + .pageSize(220, 180) + .margin(DocumentInsets.of(24)) + .create()) { + session.pageFlow(page -> { + page.addRow(r -> r.gap(4).columns(com.demcha.compose.document.style.DocumentRowColumn.auto(), + com.demcha.compose.document.style.DocumentRowColumn.auto()) + .addParagraph("Appendix on page") + .addPageReference("appendix", DocumentTextStyle.DEFAULT, TextAlign.LEFT)); + page.addPageBreak(b -> b.name("b1")); + page.addParagraph("Body"); + page.addPageBreak(b -> b.name("b2")); + page.addSection(s -> s.anchor("appendix").addParagraph("Appendix")); + }); + resolved = session.pageIndex().pageNumberOf("appendix").orElseThrow(); + session.buildPdf(); + } + + assertThat(resolved).isGreaterThan(1); // a genuine forward reference + try (PDDocument doc = Loader.loadPDF(out.toFile())) { + // The reference on page 1 prints exactly the page pageIndex() resolved. + assertThat(pageText(doc, 1)).contains("Appendix on page", Integer.toString(resolved)); + } + } + + @Test + void multipleReferencesEachReportTheirFinalPage() throws Exception { + Path out = tempDir.resolve("multi-ref.pdf"); + int appendix; + int glossary; + try (DocumentSession session = GraphCompose.document(out) + .pageSize(220, 180) + .margin(DocumentInsets.of(24)) + .create()) { + session.pageFlow(page -> { + page.addRow(r -> r.gap(4).addParagraph("Appendix") + .addPageReference("appendix", DocumentTextStyle.DEFAULT, TextAlign.RIGHT)); + page.addRow(r -> r.gap(4).addParagraph("Glossary") + .addPageReference("glossary", DocumentTextStyle.DEFAULT, TextAlign.RIGHT)); + page.addPageBreak(b -> b.name("b1")); + page.addSection(s -> s.anchor("appendix").addParagraph("Appendix")); + page.addPageBreak(b -> b.name("b2")); + page.addSection(s -> s.anchor("glossary").addParagraph("Glossary")); + }); + appendix = session.pageIndex().pageNumberOf("appendix").orElseThrow(); + glossary = session.pageIndex().pageNumberOf("glossary").orElseThrow(); + session.buildPdf(); + } + + assertThat(appendix).isNotEqualTo(glossary); // distinct target pages + try (PDDocument doc = Loader.loadPDF(out.toFile())) { + // Each reference on page 1 prints the page its own anchor finally lands on. + assertThat(pageText(doc, 1)).contains(Integer.toString(appendix), Integer.toString(glossary)); + } + } + + @Test + void unresolvedReferenceRendersWithoutThrowing() { + try (DocumentSession session = GraphCompose.document(tempDir.resolve("unresolved.pdf")) + .pageSize(220, 180) + .margin(DocumentInsets.of(24)) + .create()) { + session.pageFlow(page -> { + page.addParagraph("No such anchor below"); + page.addPageReference("missing"); + }); + // An unknown anchor resolves to empty → renders its placeholder, no throw. + assertThat(session.pageIndex().pageNumberOf("missing")).isEmpty(); + assertThatCode(session::buildPdf).doesNotThrowAnyException(); + } + } +}