From 22d63aed4e701c989957ec876173a2a46b03cc3c Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 25 Jun 2026 15:14:37 +0100 Subject: [PATCH] =?UTF-8?q?feat(api):=20addPageReference(anchor)=20?= =?UTF-8?q?=E2=80=94=20native=20"see=20page=20N"=20cross-reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 11 ++ assets/readme/examples/page-reference.pdf | Bin 1213 -> 1291 bytes examples/README.md | 21 ++-- .../navigation/PageReferenceExample.java | 85 ++++++------- .../compose/document/api/DocumentSession.java | 64 +++++++++- .../document/dsl/AbstractFlowBuilder.java | 29 +++++ .../compose/document/dsl/RowBuilder.java | 32 +++++ .../layout/BuiltInNodeDefinitions.java | 1 + .../layout/DocumentLayoutPassContext.java | 32 ++++- .../document/layout/FragmentContext.java | 13 ++ .../document/layout/PrepareContext.java | 15 +++ .../document/layout/TextFlowSupport.java | 14 +++ .../definitions/PageReferenceDefinition.java | 72 +++++++++++ .../document/node/PageReferenceNode.java | 67 +++++++++++ .../document/api/DocumentSessionTest.java | 5 + .../backend/fixed/pdf/PageReferenceTest.java | 112 ++++++++++++++++++ 16 files changed, 514 insertions(+), 59 deletions(-) create mode 100644 src/main/java/com/demcha/compose/document/layout/definitions/PageReferenceDefinition.java create mode 100644 src/main/java/com/demcha/compose/document/node/PageReferenceNode.java create mode 100644 src/test/java/com/demcha/compose/document/backend/fixed/pdf/PageReferenceTest.java 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 1ad7bdcbb12588d30711999042bec08741cd1825..ed37ecd0452289e589fea280cebf6cda3deab80e 100644 GIT binary patch delta 477 zcmdnX+08W}k=e-1aIzbVa{bZ?j{b)Ycv|286SX^}T72h-pGwP?Qjt#S?EDP&D{Pq= zJ-)67&a3O@_#_oPobcqN{=YA8WY0)xaO8$KYuzX@SgCWX;ViSyvzMh;mmU4eb6f62 zK*_X;Jxe9pj=LtkiMEJdQo8HCaNfN5p!&YE$}y$|+`DaK_7=|LFR12TdhHz{08GQYNdN!< delta 401 zcmeC?+RHg1k=f9~c=AFfv58f@^||LP`3@O~uw3{zKglbj=dFdIUS~ws1bqQjd4sMw z4a|>z2y8SJS^V3PG&3j+#tn%4HrAnGhjJ=$>RPWa4 zJYM3qDs$80X{(QP+&z%VRljKJa^1Q1JI=PA{3;&)|HB!vV|Jm&YQ?`)*RRj*-qmgP zyI@D$D#a4PSo<)i(!?!_ymu5_c-)UIdT(j=@L%PJIQ^o9Yo})2nQzm$&&puSkH0kx z$E;j7ZSG zb#pU!bu)2uvT$^Eb1`#sH8eFcb+oWku%W~V7LXZ}Q&@7X7Tb5Eu_l=*q@|}^N?~B| sYhuda 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); + } + Map resolved = resolvedPageNumbers(graph); + for (int pass = 0; pass < MAX_PAGE_REFERENCE_PASSES; pass++) { + graph = compilePass(resolved); + Map 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 resolvedPages) { + DocumentLayoutPassContext context = new DocumentLayoutPassContext(registry, canvas, + measurementResources.fontLibrary(), measurementResources.textMeasurementSystem(), markdown, resolvedPages); + return compiler.compile(documentGraph(), context, context); + } + + private static boolean containsPageReference(List nodes) { + for (DocumentNode node : nodes) { + if (node instanceof PageReferenceNode || containsPageReference(node.children())) { + return true; + } + } + return false; + } + + private static Map resolvedPageNumbers(LayoutGraph graph) { + Map 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. * diff --git a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java index db5c7f84b..da090a7aa 100644 --- a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java @@ -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.*; @@ -958,6 +960,33 @@ public T addSection(String name, Consumer 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. * diff --git a/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java index 50fedf98d..cc699b753 100644 --- a/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java @@ -57,6 +57,10 @@ private static boolean isAllowedRowChild(DocumentNode child) { || child instanceof EllipseNode || child instanceof SpacerNode || child instanceof BarcodeNode + // PageReferenceNode is an atomic one-line text leaf (the page + // number of an anchor), so it slots into a row like a paragraph — + // it is the number column of a table-of-contents row. + || child instanceof PageReferenceNode || child instanceof SectionNode || child instanceof ContainerNode // ShapeContainerNode is the same shape of thing as @@ -359,6 +363,34 @@ public RowBuilder addLine(Consumer spec) { return add(BuilderSupport.configure(new LineBuilder(), spec).build()); } + /** + * Adds a page reference that prints the page number of a named + * {@code anchor(...)} — the number column of a table-of-contents row, or the + * page in a "see page N" line. Resolved automatically from the laid-out + * document. + * + * @param anchor the anchor whose page number is printed + * @param textStyle text style for the number + * @param align horizontal alignment within the column + * @return this builder + * @since 1.9.0 + */ + public RowBuilder addPageReference(String anchor, DocumentTextStyle textStyle, TextAlign align) { + return add(new PageReferenceNode(anchor, textStyle, align)); + } + + /** + * Adds a page reference with the default text style, left-aligned (see + * {@link #addPageReference(String, DocumentTextStyle, TextAlign)}). + * + * @param anchor the anchor whose page number is printed + * @return this builder + * @since 1.9.0 + */ + public RowBuilder addPageReference(String anchor) { + return add(new PageReferenceNode(anchor, DocumentTextStyle.DEFAULT, TextAlign.LEFT)); + } + /** * Adds an ellipse/circle child configured through a nested builder. * diff --git a/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java b/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java index 7afe5dcc2..032f1fcd6 100644 --- a/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java +++ b/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java @@ -38,6 +38,7 @@ public static NodeRegistry registerDefaults(NodeRegistry registry) { .register(new ContainerDefinition()) .register(new SectionDefinition()) .register(new RowDefinition()) + .register(new PageReferenceDefinition()) .register(new LayerStackDefinition()) .register(new ShapeContainerDefinition()) .register(new TableDefinition()) diff --git a/src/main/java/com/demcha/compose/document/layout/DocumentLayoutPassContext.java b/src/main/java/com/demcha/compose/document/layout/DocumentLayoutPassContext.java index 1bd885b38..039511972 100644 --- a/src/main/java/com/demcha/compose/document/layout/DocumentLayoutPassContext.java +++ b/src/main/java/com/demcha/compose/document/layout/DocumentLayoutPassContext.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.OptionalInt; /** * Internal context for one canonical document layout pass. @@ -25,10 +26,12 @@ public final class DocumentLayoutPassContext implements PrepareContext, Fragment private final FontLibrary fontLibrary; private final TextMeasurementSystem textMeasurementSystem; private final boolean markdown; + private final Map resolvedPages; private final Map> preparedNodes = new HashMap<>(); /** - * Creates a layout-pass context. + * Creates a layout-pass context with no resolved page numbers — the first + * pass, where page-reference nodes render their placeholder. * * @param registry semantic node registry used for preparation * @param canvas active layout canvas @@ -41,11 +44,38 @@ public DocumentLayoutPassContext(NodeRegistry registry, FontLibrary fontLibrary, TextMeasurementSystem textMeasurementSystem, boolean markdown) { + this(registry, canvas, fontLibrary, textMeasurementSystem, markdown, Map.of()); + } + + /** + * Creates a layout-pass context carrying resolved anchor page numbers — the + * second pass of a page-reference / table-of-contents resolve. + * + * @param registry semantic node registry used for preparation + * @param canvas active layout canvas + * @param fontLibrary document font library + * @param textMeasurementSystem text measurement service for this pass + * @param markdown whether paragraph markdown parsing is enabled + * @param resolvedPages anchor name to 1-based page number + */ + public DocumentLayoutPassContext(NodeRegistry registry, + LayoutCanvas canvas, + FontLibrary fontLibrary, + TextMeasurementSystem textMeasurementSystem, + boolean markdown, + Map resolvedPages) { this.registry = Objects.requireNonNull(registry, "registry"); this.canvas = Objects.requireNonNull(canvas, "canvas"); this.fontLibrary = Objects.requireNonNull(fontLibrary, "fontLibrary"); this.textMeasurementSystem = Objects.requireNonNull(textMeasurementSystem, "textMeasurementSystem"); this.markdown = markdown; + this.resolvedPages = resolvedPages == null ? Map.of() : Map.copyOf(resolvedPages); + } + + @Override + public OptionalInt resolvedPage(String anchor) { + Integer page = anchor == null ? null : resolvedPages.get(anchor); + return page == null ? OptionalInt.empty() : OptionalInt.of(page); } @Override diff --git a/src/main/java/com/demcha/compose/document/layout/FragmentContext.java b/src/main/java/com/demcha/compose/document/layout/FragmentContext.java index 5bd0572dc..2a0270c7f 100644 --- a/src/main/java/com/demcha/compose/document/layout/FragmentContext.java +++ b/src/main/java/com/demcha/compose/document/layout/FragmentContext.java @@ -5,6 +5,7 @@ import com.demcha.compose.font.FontLibrary; import java.util.List; +import java.util.OptionalInt; /** * Shared fragment emission context passed to node definitions. @@ -68,6 +69,18 @@ default List emitChildFragments( throw new UnsupportedOperationException( "FragmentContext implementation does not support child fragment emission"); } + + /** + * Resolves the 1-based page number a named {@code anchor(...)} lands on, when + * the pass has page numbers available (the second pass of a page-reference / + * table-of-contents resolve). Empty on the first pass and for unknown anchors. + * + * @param anchor anchor name to resolve + * @return the 1-based page number, or empty when unresolved + */ + default OptionalInt resolvedPage(String anchor) { + return OptionalInt.empty(); + } } diff --git a/src/main/java/com/demcha/compose/document/layout/PrepareContext.java b/src/main/java/com/demcha/compose/document/layout/PrepareContext.java index f95cb043b..2fef1d3dd 100644 --- a/src/main/java/com/demcha/compose/document/layout/PrepareContext.java +++ b/src/main/java/com/demcha/compose/document/layout/PrepareContext.java @@ -4,6 +4,8 @@ import com.demcha.compose.engine.measurement.TextMeasurementSystem; import com.demcha.compose.font.FontLibrary; +import java.util.OptionalInt; + /** * Shared prepare context passed to node definitions. */ @@ -47,6 +49,19 @@ public interface PrepareContext { default boolean markdownEnabled() { return false; } + + /** + * Resolves the 1-based page number a named {@code anchor(...)} lands on, when + * the pass has page numbers available (the second pass of a page-reference / + * table-of-contents resolve). Empty on the first pass and for unknown anchors, + * so a page-reference node renders a placeholder until it is resolved. + * + * @param anchor anchor name to resolve + * @return the 1-based page number, or empty when unresolved + */ + default OptionalInt resolvedPage(String anchor) { + return OptionalInt.empty(); + } } diff --git a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java index e553f88e3..f18ec44da 100644 --- a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -56,6 +56,20 @@ public final class TextFlowSupport { private TextFlowSupport() { } + /** + * Measures the glyph width of a single text string in a document text style — + * the content width, not a full-block paragraph width. Used by leaves that + * size themselves to a short string (e.g. a page-reference number). + * + * @param style document text style + * @param text text to measure + * @param measurement text measurement service + * @return the measured glyph width in points + */ + public static double measureTextWidth(DocumentTextStyle style, String text, TextMeasurementSystem measurement) { + return measurement.textWidth(toTextStyle(style), text); + } + /** * Measures a paragraph node and wraps it into a prepared leaf carrying its * visual line layout. diff --git a/src/main/java/com/demcha/compose/document/layout/definitions/PageReferenceDefinition.java b/src/main/java/com/demcha/compose/document/layout/definitions/PageReferenceDefinition.java new file mode 100644 index 000000000..c6b2bc680 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/layout/definitions/PageReferenceDefinition.java @@ -0,0 +1,72 @@ +package com.demcha.compose.document.layout.definitions; + +import com.demcha.compose.document.layout.*; +import com.demcha.compose.document.node.PageReferenceNode; +import com.demcha.compose.document.node.ParagraphNode; + +import java.util.List; +import java.util.OptionalInt; + +/** + * Layout definition for {@link PageReferenceNode}: resolves the anchor's page + * number from the layout pass and renders it as a one-line text leaf. The text is + * laid out by reusing {@link TextFlowSupport}, so it shares the engine's text + * measurement and rendering with paragraphs. + * + *

The 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 NodeDefinition { + + /** Slack added to the measured glyph width so a single-token number is not char-wrapped by float rounding. */ + private static final double WRAP_SLACK_PT = 1.0; + + /** + * Creates the page-reference layout definition. + */ + public PageReferenceDefinition() { + } + + @Override + public Class nodeType() { + return PageReferenceNode.class; + } + + @Override + public PreparedNode prepare(PageReferenceNode node, PrepareContext ctx, BoxConstraints constraints) { + String text = resolveText(node, ctx.resolvedPage(node.anchor())); + // Size the box to the number's glyph width so the leaf measures as content, + // not as a full-width text block (an empty placeholder otherwise claims the + // whole row width). + 1pt guards against single-glyph wrap on float rounding. + double glyph = TextFlowSupport.measureTextWidth(node.textStyle(), text, ctx.textMeasurement()); + double width = Math.min(constraints.availableWidth(), glyph + node.padding().horizontal() + WRAP_SLACK_PT); + ParagraphNode paragraph = new ParagraphNode(node.name(), text, node.textStyle(), node.align(), + 0.0, node.padding(), node.margin()); + PreparedNode prepared = TextFlowSupport.prepareParagraph(paragraph, ctx, BoxConstraints.natural(width)); + return PreparedNode.leaf(node, prepared.measureResult(), new PreparedPageReference(prepared)); + } + + @Override + public PaginationPolicy paginationPolicy(PageReferenceNode node) { + return PaginationPolicy.ATOMIC; + } + + @Override + public List emitFragments(PreparedNode prepared, + FragmentContext ctx, + FragmentPlacement placement) { + PreparedPageReference layout = prepared.requirePreparedLayout(PreparedPageReference.class); + return TextFlowSupport.emitParagraphFragments(layout.paragraph(), placement); + } + + private static String resolveText(PageReferenceNode node, OptionalInt page) { + return page.isPresent() ? Integer.toString(page.getAsInt()) : node.placeholderText(); + } + + /** Carries the prepared transient paragraph from prepare to emit. */ + private record PreparedPageReference(PreparedNode paragraph) implements PreparedNodeLayout { + } +} diff --git a/src/main/java/com/demcha/compose/document/node/PageReferenceNode.java b/src/main/java/com/demcha/compose/document/node/PageReferenceNode.java new file mode 100644 index 000000000..1003e0c54 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/node/PageReferenceNode.java @@ -0,0 +1,67 @@ +package com.demcha.compose.document.node; + +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; + +/** + * Leaf that prints the resolved page number of a named {@code anchor(...)} — the + * "see page N" cross-reference. The number is resolved from the laid-out + * document on a second layout pass; until then (or when the anchor is unknown) + * it renders {@code placeholderText}. + * + *

Because 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(); + } + } +}