From cad9c68bee257c3e05f6213b8f05f31091f17094 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 9 Sep 2026 15:48:15 +0100
Subject: [PATCH 1/2] feat(layout): let a feature draw from geometry the layout
has already resolved
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Some things cannot be drawn while laying out because they depend on where
other things ended up — a rail between markers, a bracket across sections, a
leader joining a callout to its subject. Nothing carried that back: a node
definition is handed its own box and nothing else, the positions its children
resolved to go straight into the compiler's lists, and CompilerState is
package-private.
A pass runs afterwards, over the finished graph, and may only add fragments.
Not remove, reorder, mutate, add pages or touch the canvas — so with nothing
registered the driver hands back the very graph it was given, asserted by
reference. Comparing two compiles to each other would have proved only that
the new path is deterministic: an apply() that dropped a fragment would drop
it from both and every assertion would still pass.
Anchors carry identity, not a name. LayoutAnchorId compares groupKey and kind
with ==, so two timelines on one page cannot collide and no path or node name
is ever parsed — paths are the compiler's business and it renames them freely.
It refuses a String or a boxed number for the same reason it uses ==: the same
literal twice is interned and matches, the same text computed does not, and a
caller who reached for a readable key would get an empty anchor set, nothing
drawn and no error to read.
LayoutAnchorNode measures to its child rather than to the width it is offered,
which is what makes the anchor the marker's box: dropped bare into a table cell
an 8x8 ellipse reports 16x8, and a centre taken from that lands 4pt out.
Wrapped, it reports 8x8 at the ellipse's own x, asserted in a cell. An anchored
subtree that spans pages reports one anchor per page, sharing an id and each
carrying the whole height — pinned by a test rather than left to be discovered,
since small atomic content, which anchors are for, never reaches it.
Metadata is collected once, before the first pass runs, and the same immutable
view goes to every pass, so nothing a pass contributes can seed an anchor for
the next one. Passes run in registration order; UNDER_BODY additions splice
ahead of the body and OVER_BODY behind it, because this engine has no z-index
and draw order is list order. They run before the page backgrounds for the same
reason: a background prepends, so a pass running after it would have its
under-body fragment pushed beneath an opaque fill and never seen.
Internal on purpose. Opening this would mean settling when passes run, what one
sees of another, whether one may change nodes or add pages, failure handling,
re-entrancy and thread safety — none of which the case that motivated it needs
answered. ResolvedLayoutSeamStaysInternalTest reads the generated surfaces and
says so; making registerLayoutPass public turns it red.
The two render handlers draw nothing and exist because handlerFor throws on a
payload class it does not know. They are public like every other handler, which
is the one piece of surface this adds — named in the guard rather than omitted
from it, so a third would go red.
---
CHANGELOG.md | 17 +
.../compose/document/api/DocumentSession.java | 32 +-
.../document/api/ResolvedLayoutPasses.java | 110 ++++
.../layout/BuiltInNodeDefinitions.java | 1 +
.../document/layout/LayoutAnchorId.java | 93 ++++
.../document/layout/LayoutAnchorNode.java | 53 ++
.../compose/document/layout/LayoutDepth.java | 27 +
.../layout/ResolvedLayoutAddition.java | 24 +
.../document/layout/ResolvedLayoutAnchor.java | 66 +++
.../layout/ResolvedLayoutMetadata.java | 95 ++++
.../document/layout/ResolvedLayoutPass.java | 60 +++
.../definitions/LayoutAnchorDefinition.java | 85 +++
.../layout/payloads/LayoutAnchorPayload.java | 49 ++
knowledge/api/backends.json | 157 +++++-
knowledge/api/backends.md | 14 +-
knowledge/api/excluded.json | 65 ++-
.../document/api/ResolvedLayoutPassTest.java | 489 ++++++++++++++++++
.../ResolvedLayoutSeamStaysInternalTest.java | 102 ++++
.../fixed/pdf/PdfFixedLayoutBackend.java | 1 +
.../PdfLayoutAnchorRenderHandler.java | 40 ++
.../backend/fixed/pptx/PptxClipSafety.java | 4 +-
.../fixed/pptx/PptxFixedLayoutBackend.java | 2 +
.../PptxLayoutAnchorRenderHandler.java | 40 ++
23 files changed, 1619 insertions(+), 7 deletions(-)
create mode 100644 core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java
create mode 100644 core/src/main/java/com/demcha/compose/document/layout/LayoutAnchorId.java
create mode 100644 core/src/main/java/com/demcha/compose/document/layout/LayoutAnchorNode.java
create mode 100644 core/src/main/java/com/demcha/compose/document/layout/LayoutDepth.java
create mode 100644 core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutAddition.java
create mode 100644 core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutAnchor.java
create mode 100644 core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutMetadata.java
create mode 100644 core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutPass.java
create mode 100644 core/src/main/java/com/demcha/compose/document/layout/definitions/LayoutAnchorDefinition.java
create mode 100644 core/src/main/java/com/demcha/compose/document/layout/payloads/LayoutAnchorPayload.java
create mode 100644 qa/src/test/java/com/demcha/compose/document/api/ResolvedLayoutPassTest.java
create mode 100644 qa/src/test/java/com/demcha/documentation/ResolvedLayoutSeamStaysInternalTest.java
create mode 100644 render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfLayoutAnchorRenderHandler.java
create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxLayoutAnchorRenderHandler.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 79c71338d..3adb0512d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -83,6 +83,23 @@ follow semantic versioning; release dates are ISO 8601.
### Layout
+- **A built-in feature can now draw from geometry the layout has already resolved.**
+ Some things cannot be drawn while laying out because they depend on where other things
+ ended up — a rail running between markers, a bracket spanning sections, a leader joining
+ a callout to its subject. Nothing carried that back: a node definition is handed its own
+ box and nothing else. A resolved-layout pass runs afterwards, over the finished graph,
+ and may only *add* fragments — never remove, reorder, mutate, add pages or touch the
+ canvas — so a document with nothing registered is handed back the very graph the
+ compiler produced, unchanged by identity rather than by comparison.
+
+ The mechanism is internal: neither the pass interface nor the anchor node is public API.
+ Opening them would mean settling when passes run, what one sees of another, whether one
+ may change nodes, failure handling and thread safety — none of which the built-in case
+ needs answered, and answering it by accident is worse than leaving it open. What does
+ reach the surface is two no-op render handlers, `PdfLayoutAnchorRenderHandler` and
+ `PptxLayoutAnchorRenderHandler`, which draw nothing and exist only because each fixed
+ backend refuses a fragment payload it has no handler for.
+
- **A decorated root flow and its children can disagree under per-page margins.**
A known limitation, now measured and written down rather than met by surprise.
A block is placed once and its background is that block's own box on every page
diff --git a/core/src/main/java/com/demcha/compose/document/api/DocumentSession.java b/core/src/main/java/com/demcha/compose/document/api/DocumentSession.java
index a503d8b6e..af8586f26 100644
--- a/core/src/main/java/com/demcha/compose/document/api/DocumentSession.java
+++ b/core/src/main/java/com/demcha/compose/document/api/DocumentSession.java
@@ -83,6 +83,7 @@ public final class DocumentSession implements AutoCloseable {
private boolean markdown;
private DocumentDebugOptions debug = DocumentDebugOptions.none();
private List pageBackgrounds = List.of();
+ private List layoutPasses = List.of();
private List pageMargins = List.of();
private MeasurementResources measurementResources;
private boolean closed;
@@ -434,6 +435,31 @@ public DocumentSession pageBackgrounds(List fills) {
return this;
}
+ /**
+ * Registers a resolved-layout pass, to run in registration order after the layout is
+ * compiled and before the page backgrounds are spliced.
+ *
+ *
Package-private on purpose. Passes exist so a built-in feature can draw from
+ * geometry it could not know during layout; opening that to authors would mean
+ * settling failure handling, re-entrancy, thread safety and what one pass may see of
+ * another, none of which the built-in cases need answered yet.
+ *
+ * @param pass the pass to register; {@code null} is ignored
+ * @return this session
+ * @throws IllegalStateException if this session has already been closed
+ */
+ DocumentSession registerLayoutPass(ResolvedLayoutPass pass) {
+ ensureOpen();
+ if (pass == null) {
+ return this;
+ }
+ List updated = new ArrayList<>(this.layoutPasses);
+ updated.add(pass);
+ this.layoutPasses = List.copyOf(updated);
+ invalidate();
+ return this;
+ }
+
/**
* Overrides the page margin for ranges of pages, replacing the document-wide
* {@link #margin(DocumentInsets)} on the pages each rule covers. Use this for a
@@ -752,8 +778,12 @@ public LayoutGraph layoutGraph() {
private LayoutGraph computeLayout() {
// Backgrounds go under the body, zones over it, so the two splices bracket
// the compiled graph in that order.
+ // Passes run before the backgrounds: a background prepends its fragments, so a
+ // pass running afterwards would have its under-body fragment pushed beneath an
+ // opaque page fill and never seen.
+ LayoutGraph withPasses = ResolvedLayoutPasses.apply(layoutResolver.resolve(), layoutPasses);
LayoutGraph withBackgrounds =
- DocumentPageBackgrounds.apply(layoutResolver.resolve(), pageBackgrounds);
+ DocumentPageBackgrounds.apply(withPasses, pageBackgrounds);
List zones = chromeOptions.zones();
// A zone rides the body's machinery, anchors included: a page reference
// inside one resolves against the graph the body just settled.
diff --git a/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java b/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java
new file mode 100644
index 000000000..e5274e59d
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java
@@ -0,0 +1,110 @@
+package com.demcha.compose.document.api;
+
+import com.demcha.compose.document.layout.LayoutDepth;
+import com.demcha.compose.document.layout.LayoutGraph;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.ResolvedLayoutAddition;
+import com.demcha.compose.document.layout.ResolvedLayoutMetadata;
+import com.demcha.compose.document.layout.ResolvedLayoutPass;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Runs the resolved-layout passes over a compiled graph and splices what they contribute.
+ *
+ *
Package-private: this is the wiring behind a built-in feature, not a seam authors
+ * reach. It sits beside {@code DocumentPageBackgrounds} and {@code DocumentPageZones}, the
+ * two other post-compilation splices, and runs before both.
+ *
+ *
The order matters and is not arbitrary. Backgrounds prepend their fragments and zones
+ * append theirs, so running passes first yields, with no index arithmetic:
+ *
+ *
+ * background < pass-under < body < pass-over < zone chrome
+ *
+ *
+ *
A pass running after backgrounds would have its under-body fragment prepended to
+ * index 0 — beneath an opaque page background, and invisible.
+ *
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+final class ResolvedLayoutPasses {
+
+ private ResolvedLayoutPasses() {
+ }
+
+ /**
+ * Applies the passes in registration order.
+ *
+ * @param base freshly compiled layout graph
+ * @param passes passes to run, in order; {@code null}/empty leaves {@code base} unchanged
+ * @return a graph carrying the contributed fragments, or {@code base} itself
+ * @throws NullPointerException if {@code base} is null
+ * @throws IllegalStateException if a pass returns null, or contributes a fragment for a
+ * page the document does not have
+ */
+ static LayoutGraph apply(LayoutGraph base, List passes) {
+ Objects.requireNonNull(base, "base");
+ if (passes == null || passes.isEmpty()) {
+ return base;
+ }
+
+ // Collected once, from the compiled graph, and handed to every pass. Nothing a
+ // pass contributes can seed a new anchor for a later pass, so the result does not
+ // depend on how the passes happen to interleave.
+ ResolvedLayoutMetadata metadata = ResolvedLayoutMetadata.from(base);
+
+ List under = new ArrayList<>();
+ List over = new ArrayList<>();
+ for (ResolvedLayoutPass pass : passes) {
+ List additions = pass.contribute(base, metadata);
+ if (additions == null) {
+ throw new IllegalStateException(
+ "Resolved-layout pass '" + pass.id() + "' returned null; return an empty list instead.");
+ }
+ for (ResolvedLayoutAddition addition : additions) {
+ if (addition == null) {
+ throw new IllegalStateException(
+ "Resolved-layout pass '" + pass.id() + "' returned a null addition.");
+ }
+ PlacedFragment fragment = addition.fragment();
+ if (fragment.pageIndex() < 0 || fragment.pageIndex() >= base.totalPages()) {
+ throw new IllegalStateException("Resolved-layout pass '" + pass.id()
+ + "' contributed a fragment on page " + fragment.pageIndex()
+ + ", but the document has " + base.totalPages()
+ + " page(s). A pass may not add pages.");
+ }
+ // A non-finite coordinate is not caught anywhere downstream: PlacedFragment
+ // does not validate, and NaN reaches the content stream as a broken operator.
+ requireFinite(pass, fragment.x(), "x");
+ requireFinite(pass, fragment.y(), "y");
+ requireFinite(pass, fragment.width(), "width");
+ requireFinite(pass, fragment.height(), "height");
+ (addition.depth() == LayoutDepth.UNDER_BODY ? under : over).add(fragment);
+ }
+ }
+
+ if (under.isEmpty() && over.isEmpty()) {
+ return base;
+ }
+
+ List combined =
+ new ArrayList<>(under.size() + base.fragments().size() + over.size());
+ combined.addAll(under);
+ combined.addAll(base.fragments());
+ combined.addAll(over);
+ // Nodes pass through untouched: a pass contributes drawing, never structure.
+ return new LayoutGraph(base.canvas(), base.totalPages(), base.nodes(), combined);
+ }
+
+ private static void requireFinite(ResolvedLayoutPass pass, double value, String name) {
+ if (!Double.isFinite(value)) {
+ throw new IllegalStateException("Resolved-layout pass '" + pass.id()
+ + "' contributed a fragment whose " + name + " is " + value
+ + ". A non-finite coordinate reaches the content stream unchecked.");
+ }
+ }
+}
diff --git a/core/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java b/core/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java
index a026940ce..23939cecc 100644
--- a/core/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java
+++ b/core/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java
@@ -47,6 +47,7 @@ public static NodeRegistry registerDefaults(NodeRegistry registry) {
.register(new PolygonDefinition())
.register(new PathDefinition())
.register(new AlignDefinition())
+ .register(new LayoutAnchorDefinition())
.register(new ChartDefinition());
}
}
diff --git a/core/src/main/java/com/demcha/compose/document/layout/LayoutAnchorId.java b/core/src/main/java/com/demcha/compose/document/layout/LayoutAnchorId.java
new file mode 100644
index 000000000..b20a05e77
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/layout/LayoutAnchorId.java
@@ -0,0 +1,93 @@
+package com.demcha.compose.document.layout;
+
+import java.util.Objects;
+
+/**
+ * Identity of a resolved-layout anchor, compared by reference rather than by name.
+ *
+ *
A feature that needs to draw from resolved geometry — a rail down a timeline, a
+ * bracket across sections, a connector between two nodes — has to find its own anchors in
+ * the finished layout. Matching them by generated path or node name would tie the feature
+ * to the compiler's naming, which is a private detail: {@code LayoutCompiler.pathFor} uses
+ * the node kind as the path segment of an unnamed node, so a rename anywhere reshuffles
+ * every path. It is also ambiguous — two timelines on one page have equally plausible
+ * paths.
+ *
+ *
So identity is explicit. {@code groupKey} and {@code kind} are compared with
+ * {@code ==}: the caller allocates one object per logical group (one per timeline) and
+ * uses a constant for the kind (an enum constant reads best). Nothing is parsed, and two
+ * groups can never collide however similar their content.
+ *
+ * @param groupKey the logical owner this anchor belongs to; compared by reference
+ * @param kind what sort of anchor this is within that owner; compared by reference
+ * @param index position within the group, in declaration order, starting at zero
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+public record LayoutAnchorId(Object groupKey, Object kind, int index) {
+
+ /**
+ * Validates the identity.
+ *
+ * @throws NullPointerException if {@code groupKey} or {@code kind} is null
+ * @throws IllegalArgumentException if {@code index} is negative
+ */
+ public LayoutAnchorId {
+ Objects.requireNonNull(groupKey, "groupKey");
+ Objects.requireNonNull(kind, "kind");
+ rejectValueLike(groupKey, "groupKey");
+ rejectValueLike(kind, "kind");
+ if (index < 0) {
+ throw new IllegalArgumentException("Anchor index must not be negative, was " + index + ".");
+ }
+ }
+
+ /**
+ * Refuses keys whose reference identity is decided by the JVM rather than the caller.
+ *
+ *
Comparison here is {@code ==}, which is a deliberate choice — but it turns a
+ * {@code String} or a boxed number into a coin flip: {@code "timeline"} written twice
+ * is one interned instance and compares equal, while the same text computed at runtime
+ * does not, and {@code Integer.valueOf(127)} is cached where {@code 128} is not. A
+ * caller who reached for a readable key would get an anchor set that silently fails to
+ * match, with nothing drawn and no error to read. Refusing them at construction turns
+ * that into a message at the call site.
+ */
+ private static void rejectValueLike(Object key, String name) {
+ if (key instanceof String || key instanceof Number || key instanceof Character
+ || key instanceof Boolean) {
+ throw new IllegalArgumentException(
+ "Anchor " + name + " must be an identity key, not a " + key.getClass().getSimpleName()
+ + ": these are compared with == and interning would decide whether two of them match. "
+ + "Allocate one object per group (or use an enum constant for the kind).");
+ }
+ }
+
+ /**
+ * Compares by reference on {@code groupKey} and {@code kind}, and by value on the
+ * index — deliberately not the record default, which would call {@code equals} on
+ * both and let two distinct groups compare equal because their keys happen to.
+ *
+ * @param other candidate
+ * @return whether both identities name the same anchor
+ */
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof LayoutAnchorId otherId)) {
+ return false;
+ }
+ return groupKey == otherId.groupKey
+ && kind == otherId.kind
+ && index == otherId.index;
+ }
+
+ /**
+ * Hashes on identity hash codes, to stay consistent with {@link #equals(Object)}.
+ *
+ * @return hash code
+ */
+ @Override
+ public int hashCode() {
+ return (System.identityHashCode(groupKey) * 31 + System.identityHashCode(kind)) * 31 + index;
+ }
+}
diff --git a/core/src/main/java/com/demcha/compose/document/layout/LayoutAnchorNode.java b/core/src/main/java/com/demcha/compose/document/layout/LayoutAnchorNode.java
new file mode 100644
index 000000000..662b2906a
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/layout/LayoutAnchorNode.java
@@ -0,0 +1,53 @@
+package com.demcha.compose.document.layout;
+
+import com.demcha.compose.document.node.DocumentNode;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Wraps one node so the finished layout reports where it landed.
+ *
+ *
The wrapper is transparent: it measures to exactly its child's size and adds no
+ * spacing, so inserting one changes no geometry. What it adds is a single non-visual
+ * fragment carrying {@link com.demcha.compose.document.layout.payloads.LayoutAnchorPayload},
+ * which a post-layout pass can find by identity.
+ *
+ *
Measuring to the child rather than to the available width is not a detail. It is what
+ * makes the anchor's box the child's box, so a caller reading the anchor learns where the
+ * marker is, not where its container is.
+ *
+ *
Lives in this {@code @Internal} package on purpose. Anchoring is engine plumbing that
+ * a built-in feature uses to reach its own resolved geometry; whether authors should ever
+ * declare an anchor themselves is a separate question, and answering it by accident here
+ * would stabilise a public API on the strength of one built-in use case.
+ *
+ * @param name semantic name, may be empty
+ * @param id the anchor's identity
+ * @param child the node whose resolved geometry is being reported
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+public record LayoutAnchorNode(String name, LayoutAnchorId id, DocumentNode child) implements DocumentNode {
+
+ /**
+ * Normalizes the name and validates the rest.
+ *
+ * @throws NullPointerException if {@code id} or {@code child} is null
+ */
+ public LayoutAnchorNode {
+ name = name == null ? "" : name;
+ Objects.requireNonNull(id, "id");
+ Objects.requireNonNull(child, "child");
+ }
+
+ /**
+ * The single anchored child.
+ *
+ * @return one child
+ */
+ @Override
+ public List children() {
+ return List.of(child);
+ }
+}
diff --git a/core/src/main/java/com/demcha/compose/document/layout/LayoutDepth.java b/core/src/main/java/com/demcha/compose/document/layout/LayoutDepth.java
new file mode 100644
index 000000000..e34affa57
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/layout/LayoutDepth.java
@@ -0,0 +1,27 @@
+package com.demcha.compose.document.layout;
+
+/**
+ * Where a pass's fragment sits relative to the document body.
+ *
+ *
This engine has no z-index: fragments draw in list order, so depth is expressed by
+ * splice position rather than by a number. The driver builds one list —
+ * under-body additions, then the compiled body, then over-body additions — and the
+ * backends walk it front to back.
+ *
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+public enum LayoutDepth {
+
+ /**
+ * Behind the body. A rail belongs here: a filled marker should cover the line running
+ * under it rather than be crossed by it.
+ */
+ UNDER_BODY,
+
+ /**
+ * In front of the body, for something that must remain legible over content — a
+ * callout leader, a highlight.
+ */
+ OVER_BODY
+}
diff --git a/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutAddition.java b/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutAddition.java
new file mode 100644
index 000000000..4a55826bb
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutAddition.java
@@ -0,0 +1,24 @@
+package com.demcha.compose.document.layout;
+
+import java.util.Objects;
+
+/**
+ * One fragment a pass wants added, and where it belongs relative to the body.
+ *
+ * @param depth behind or in front of the document body
+ * @param fragment the fragment to add, already in page coordinates
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+public record ResolvedLayoutAddition(LayoutDepth depth, PlacedFragment fragment) {
+
+ /**
+ * Validates the addition.
+ *
+ * @throws NullPointerException if either component is null
+ */
+ public ResolvedLayoutAddition {
+ Objects.requireNonNull(depth, "depth");
+ Objects.requireNonNull(fragment, "fragment");
+ }
+}
diff --git a/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutAnchor.java b/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutAnchor.java
new file mode 100644
index 000000000..8f02bd531
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutAnchor.java
@@ -0,0 +1,66 @@
+package com.demcha.compose.document.layout;
+
+import java.util.Objects;
+
+/**
+ * Where an anchored subtree ended up, in resolved page coordinates.
+ *
+ *
Coordinates are the same space every {@code PlacedFragment} uses: origin at the
+ * bottom-left of the page, y growing upwards. A pass reading these needs no conversion to
+ * emit a fragment beside them.
+ *
+ *
The box is the anchored child's own, not its container's — see
+ * {@code LayoutAnchorPayload}. That is what lets a caller treat this as the position of a
+ * marker rather than of whatever cell or column happened to hold it.
+ *
+ * @param id the anchor's identity
+ * @param pageIndex zero-based page the anchor landed on
+ * @param x left edge, page coordinates
+ * @param y bottom edge, page coordinates
+ * @param width the anchored child's width
+ * @param height the anchored child's height
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+public record ResolvedLayoutAnchor(LayoutAnchorId id, int pageIndex,
+ double x, double y, double width, double height) {
+
+ /**
+ * Validates the resolved anchor.
+ *
+ * @throws NullPointerException if {@code id} is null
+ * @throws IllegalArgumentException if the page index is negative
+ */
+ public ResolvedLayoutAnchor {
+ Objects.requireNonNull(id, "id");
+ if (pageIndex < 0) {
+ throw new IllegalArgumentException("Anchor page index must not be negative, was " + pageIndex + ".");
+ }
+ }
+
+ /**
+ * A point inside the anchor's box, given as fractions of its size.
+ *
+ *
{@code (0.5, 0.5)} is the centre; {@code (0.0, 0.5)} the middle of the left edge.
+ * A consumer that wants "the rail passes through the marker" asks for the centre; one
+ * reproducing an older layout that put its line at a container's edge asks for a
+ * fraction plus its own offset. Both are then the same arithmetic, which is what keeps
+ * a compatibility case from becoming a second code path.
+ *
+ * @param relativeX fraction of the width, left to right
+ * @return the x coordinate of that point
+ */
+ public double pointX(double relativeX) {
+ return x + width * relativeX;
+ }
+
+ /**
+ * The y coordinate of {@link #pointX(double)}'s companion point.
+ *
+ * @param relativeY fraction of the height, bottom to top
+ * @return the y coordinate of that point
+ */
+ public double pointY(double relativeY) {
+ return y + height * relativeY;
+ }
+}
diff --git a/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutMetadata.java b/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutMetadata.java
new file mode 100644
index 000000000..e9a9c0b7b
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutMetadata.java
@@ -0,0 +1,95 @@
+package com.demcha.compose.document.layout;
+
+import com.demcha.compose.document.layout.payloads.LayoutAnchorPayload;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Every anchor the finished layout resolved, collected once and shared by every pass.
+ *
+ *
Collected before the first pass runs, from the compiled fragment list. That
+ * ordering is the guarantee, not a convention: a pass's own additions go into a separate
+ * list this collector never reads again, so nothing a pass contributes can grow new
+ * anchors within the same compile. A pass therefore cannot influence what a later pass
+ * sees, and running the same passes twice over the same graph gives the same answer.
+ *
+ *
Anchors keep the order their fragments had, which is the order the compiler placed
+ * them — reading order down the document. A consumer that wants a stronger guarantee
+ * should sort by {@link LayoutAnchorId#index()}, which the declaring feature controls.
+ *
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+public final class ResolvedLayoutMetadata {
+
+ private static final ResolvedLayoutMetadata EMPTY = new ResolvedLayoutMetadata(List.of());
+
+ private final List anchors;
+
+ private ResolvedLayoutMetadata(List anchors) {
+ this.anchors = anchors;
+ }
+
+ /**
+ * Collects the anchors a compiled graph carries.
+ *
+ * @param graph resolved layout graph
+ * @return the metadata; empty when the document declares no anchors
+ * @throws NullPointerException if {@code graph} is null
+ */
+ public static ResolvedLayoutMetadata from(LayoutGraph graph) {
+ Objects.requireNonNull(graph, "graph");
+ List found = new ArrayList<>();
+ for (PlacedFragment fragment : graph.fragments()) {
+ if (fragment.payload() instanceof LayoutAnchorPayload anchor) {
+ // Position from the fragment, size from the payload. The fragment box is
+ // the anchor node's own — it measures to its child — but taking the size
+ // from the payload keeps that true even if a future container hands the
+ // anchor a wider placement.
+ found.add(new ResolvedLayoutAnchor(anchor.id(), fragment.pageIndex(),
+ fragment.x(), fragment.y(), anchor.width(), anchor.height()));
+ }
+ }
+ return found.isEmpty() ? EMPTY : new ResolvedLayoutMetadata(List.copyOf(found));
+ }
+
+ /**
+ * Every resolved anchor, in placement order.
+ *
+ * @return immutable list, possibly empty
+ */
+ public List anchors() {
+ return anchors;
+ }
+
+ /**
+ * The resolved anchors belonging to one logical owner and kind.
+ *
+ * @param groupKey the owner, compared by reference
+ * @param kind the anchor kind, compared by reference
+ * @return immutable list in placement order, possibly empty
+ * @throws NullPointerException if either argument is null
+ */
+ public List anchors(Object groupKey, Object kind) {
+ Objects.requireNonNull(groupKey, "groupKey");
+ Objects.requireNonNull(kind, "kind");
+ List matching = new ArrayList<>();
+ for (ResolvedLayoutAnchor anchor : anchors) {
+ if (anchor.id().groupKey() == groupKey && anchor.id().kind() == kind) {
+ matching.add(anchor);
+ }
+ }
+ return List.copyOf(matching);
+ }
+
+ /**
+ * Whether the document declared no anchors at all.
+ *
+ * @return true when there is nothing for a pass to anchor to
+ */
+ public boolean isEmpty() {
+ return anchors.isEmpty();
+ }
+}
diff --git a/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutPass.java b/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutPass.java
new file mode 100644
index 000000000..450a5d22e
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutPass.java
@@ -0,0 +1,60 @@
+package com.demcha.compose.document.layout;
+
+import java.util.List;
+
+/**
+ * Contributes fragments derived from geometry the layout has already resolved.
+ *
+ *
Some things cannot be drawn during layout because they depend on where other things
+ * ended up: a rail down a timeline runs between its markers, a bracket spans two sections,
+ * a leader line joins a callout to its subject. A node definition cannot see any of that —
+ * it is handed its own box and nothing else. A pass runs afterwards, over the finished
+ * graph, when every position is known.
+ *
+ *
What a pass may do
+ *
+ *
Add fragments. Nothing else. A pass cannot remove, reorder or modify
+ * what the compiler produced, and cannot add pages or change the canvas. This is what
+ * makes a document with no pass registered byte-identical to one compiled without the
+ * mechanism at all, and it stops one feature's pass from corrupting another's output.
+ *
+ *
A pass does not re-run layout. It reads a compiled {@link LayoutGraph} and the
+ * {@link ResolvedLayoutMetadata} collected from it, and returns additions. The metadata is
+ * gathered once, before any pass runs, so a pass never sees another pass's contributions
+ * and cannot make the result depend on execution order in a way the caller did not
+ * declare.
+ *
+ *
Ordering
+ *
+ *
Passes run in registration order, and each pass's additions keep the order it
+ * returned them in. Within a page, everything at {@link LayoutDepth#UNDER_BODY} draws
+ * behind the document body and everything at {@link LayoutDepth#OVER_BODY} in front of it;
+ * there is no z-index in this engine, so depth is expressed by where the driver splices
+ * the fragments into the list the backends walk.
+ *
+ *
Internal on purpose. Whether authors should be able to register their own passes is a
+ * larger question than the built-in features that need one — it would have to settle
+ * failure handling, re-entrancy, thread safety and what a pass may see of another — and
+ * answering it by accident is worse than leaving it open.
+ *
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+public interface ResolvedLayoutPass {
+
+ /**
+ * A short name for diagnostics. Never used to identify anything.
+ *
+ * @return non-null identifier
+ */
+ String id();
+
+ /**
+ * Produces the fragments this pass wants added.
+ *
+ * @param graph the compiled layout, already resolved
+ * @param metadata anchors collected from that graph before any pass ran
+ * @return additions in draw order within their depth; empty when there is nothing to add
+ */
+ List contribute(LayoutGraph graph, ResolvedLayoutMetadata metadata);
+}
diff --git a/core/src/main/java/com/demcha/compose/document/layout/definitions/LayoutAnchorDefinition.java b/core/src/main/java/com/demcha/compose/document/layout/definitions/LayoutAnchorDefinition.java
new file mode 100644
index 000000000..408bd25d9
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/layout/definitions/LayoutAnchorDefinition.java
@@ -0,0 +1,85 @@
+package com.demcha.compose.document.layout.definitions;
+
+import com.demcha.compose.document.layout.BoxConstraints;
+import com.demcha.compose.document.layout.CompositeLayoutSpec;
+import com.demcha.compose.document.layout.FragmentContext;
+import com.demcha.compose.document.layout.FragmentPlacement;
+import com.demcha.compose.document.layout.LayoutAnchorNode;
+import com.demcha.compose.document.layout.LayoutFragment;
+import com.demcha.compose.document.layout.MeasureResult;
+import com.demcha.compose.document.layout.NodeDefinition;
+import com.demcha.compose.document.layout.PaginationPolicy;
+import com.demcha.compose.document.layout.PrepareContext;
+import com.demcha.compose.document.layout.PreparedNode;
+import com.demcha.compose.document.layout.payloads.LayoutAnchorPayload;
+import com.demcha.compose.document.node.DocumentNode;
+
+import java.util.List;
+
+/**
+ * Layout definition for {@link LayoutAnchorNode}: lays the child out unchanged and emits
+ * one non-visual fragment saying where it ended up.
+ *
+ *
The wrapper measures to the child's own size rather than to the available width. A
+ * wrapper that filled the width would report its container's box, which is the error this
+ * whole seam exists to avoid — an 8×8 marker in a 16pt table cell must anchor at 8×8, not
+ * at the cell.
+ *
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+public final class LayoutAnchorDefinition implements NodeDefinition {
+
+ /**
+ * Creates the anchor layout definition.
+ */
+ public LayoutAnchorDefinition() {
+ }
+
+ @Override
+ public Class nodeType() {
+ return LayoutAnchorNode.class;
+ }
+
+ @Override
+ public PreparedNode prepare(LayoutAnchorNode node, PrepareContext ctx,
+ BoxConstraints constraints) {
+ DocumentNode child = node.child();
+ double childInner = Math.max(0.0, constraints.availableWidth() - child.margin().horizontal());
+ PreparedNode childPrepared = ctx.prepare(child, BoxConstraints.natural(childInner));
+ // Shrink to the child, both ways: the anchor must not report a box the child does
+ // not occupy, and it must not add height the author did not ask for.
+ double width = childPrepared.measureResult().width() + child.margin().horizontal();
+ double height = childPrepared.measureResult().height() + child.margin().vertical();
+ return PreparedNode.composite(node, new MeasureResult(width, height),
+ new CompositeLayoutSpec(0.0, CompositeLayoutSpec.Axis.VERTICAL));
+ }
+
+ @Override
+ public PaginationPolicy paginationPolicy(LayoutAnchorNode node) {
+ return PaginationPolicy.ATOMIC;
+ }
+
+ @Override
+ public List children(LayoutAnchorNode node) {
+ return node.children();
+ }
+
+ @Override
+ public List emitFragments(PreparedNode prepared,
+ FragmentContext ctx,
+ FragmentPlacement placement) {
+ // The size travels in the payload, taken from prepare()'s measurement of the
+ // child. The fragment's own box is left at the placement so the marker sits where
+ // the compiler put it; a reader that wants the child's extent reads the payload.
+ MeasureResult measured = prepared.measureResult();
+ return List.of(new LayoutFragment(
+ placement.path(),
+ 0,
+ 0.0,
+ 0.0,
+ measured.width(),
+ measured.height(),
+ new LayoutAnchorPayload(prepared.node().id(), measured.width(), measured.height())));
+ }
+}
diff --git a/core/src/main/java/com/demcha/compose/document/layout/payloads/LayoutAnchorPayload.java b/core/src/main/java/com/demcha/compose/document/layout/payloads/LayoutAnchorPayload.java
new file mode 100644
index 000000000..39b14c927
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/layout/payloads/LayoutAnchorPayload.java
@@ -0,0 +1,49 @@
+package com.demcha.compose.document.layout.payloads;
+
+import com.demcha.compose.document.layout.LayoutAnchorId;
+
+import java.util.Objects;
+
+/**
+ * Non-visual marker fragment payload that reports where an anchored subtree landed.
+ *
+ *
One of these is emitted per {@code LayoutAnchorNode}, and it draws nothing — the
+ * backends register a no-op handler for it. A post-layout pass reads the resolved
+ * fragments, keeps the ones carrying this payload, and gets the anchor's page and
+ * position without knowing anything about what the anchored subtree drew.
+ *
+ *
The size is declared, not observed. It comes from the anchored
+ * child's own measurement, never from the placement the anchor was handed. That
+ * distinction is the whole point: an 8×8 marker inside a table cell is placed in a
+ * 16pt-wide cell, and a payload built from {@code placement.width()} would report the
+ * cell. A caller anchoring a marker drawn as five SVG fragments still gets one anchor with
+ * one box, which is what makes the anchor a logical owner rather than a
+ * particular draw fragment.
The load-bearing case is the first one. A mechanism that runs on every document has
+ * to prove it changes nothing when nobody uses it, and the only assertion a consistent
+ * corruption cannot satisfy is that the driver hands back the same object it was given.
+ * Comparing two compiles to each other would prove determinism, not identity.
+ */
+class ResolvedLayoutPassTest {
+
+ private static final DocumentColor INK = DocumentColor.rgb(20, 60, 160);
+
+ private enum Kind { MARKER, OTHER }
+
+ // --- 1. a registered-nothing document is untouched -----------------------
+
+ @Test
+ void withNoPassRegisteredTheDriverHandsBackTheVeryGraphItWasGiven() throws Exception {
+ LayoutGraph compiled = compile(flow -> flow
+ .addParagraph("First paragraph of the document.")
+ .addParagraph("Second paragraph, a little longer so the page has content."),
+ List.of());
+
+ // Reference identity, not field-by-field equality. Comparing two compiles of the
+ // same document to each other would prove only that the new code is deterministic:
+ // an apply() that dropped a fragment would drop it from both and every assertion
+ // would still pass. The claim being defended is that with nothing registered the
+ // graph is untouched, and the only assertion that cannot be satisfied by a
+ // consistent corruption is that it is the same object.
+ assertThat(ResolvedLayoutPasses.apply(compiled, List.of()))
+ .as("an empty pass list returns the compiled graph itself")
+ .isSameAs(compiled);
+ assertThat(ResolvedLayoutPasses.apply(compiled, null))
+ .as("so does no pass list at all")
+ .isSameAs(compiled);
+ }
+
+ @Test
+ void aPassThatContributesNothingAlsoLeavesTheGraphAlone() throws Exception {
+ LayoutGraph compiled = compile(flow -> flow.addParagraph("body"), List.of());
+
+ assertThat(ResolvedLayoutPasses.apply(compiled, List.of(pass("silent", (g, m) -> List.of()))))
+ .as("registering a pass is not itself a change; contributing is")
+ .isSameAs(compiled);
+ }
+
+ @Test
+ void anAnchorAddsExactlyOneFragmentAndMovesNothingElse() throws Exception {
+ Object group = new Object();
+ LayoutGraph without = compile(flow -> flow
+ .addParagraph("Above")
+ .add(dot(8))
+ .addParagraph("Below"), List.of());
+ LayoutGraph with = compile(flow -> flow
+ .addParagraph("Above")
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(group, Kind.MARKER, 0), dot(8)))
+ .addParagraph("Below"), List.of());
+
+ assertThat(with.totalPages()).isEqualTo(without.totalPages());
+ assertThat(with.fragments()).hasSize(without.fragments().size() + 1);
+
+ // Geometry and payloads, not paths. The wrapper is a real node, so the anchored
+ // child's path legitimately gains a segment — ContainerNode[0]/dot[1] becomes
+ // ContainerNode[0]/LayoutAnchorNode[1]/dot[0]. Everything that decides what a
+ // reader sees must be untouched.
+ assertThat(geometry(nonAnchor(with)))
+ .as("wrapping a node in an anchor must not move it or anything near it")
+ .isEqualTo(geometry(without.fragments()));
+ assertThat(payloadsOf(nonAnchor(with))).isEqualTo(payloadsOf(without.fragments()));
+
+ // Pinned deliberately: paths do change, and anything keying on them — a layout
+ // snapshot, for one — will see it. Better stated here than discovered downstream.
+ assertThat(nonAnchor(with)).anySatisfy(fragment ->
+ assertThat(fragment.path()).contains("LayoutAnchorNode"));
+ }
+
+ // --- 2. what an anchor resolves to --------------------------------------
+
+ @Test
+ void aResolvedAnchorReportsItsPageAndBox() throws Exception {
+ Object group = new Object();
+ LayoutGraph graph = compile(flow -> flow
+ .addParagraph("Above the marker")
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(group, Kind.MARKER, 0), dot(8))),
+ List.of());
+
+ List anchors = ResolvedLayoutMetadata.from(graph).anchors(group, Kind.MARKER);
+ assertThat(anchors).hasSize(1);
+ ResolvedLayoutAnchor anchor = anchors.get(0);
+ assertThat(anchor.pageIndex()).isZero();
+ assertThat(anchor.width()).isEqualTo(8.0, within(1e-9));
+ assertThat(anchor.height()).isEqualTo(8.0, within(1e-9));
+
+ PlacedFragment ellipse = graph.fragments().stream()
+ .filter(f -> f.payload() != null && f.payload().getClass().getSimpleName().contains("Ellipse"))
+ .findFirst().orElseThrow();
+ assertThat(anchor.x()).as("the anchor lands on its child, not on its container")
+ .isEqualTo(ellipse.x(), within(1e-9));
+ assertThat(anchor.y()).isEqualTo(ellipse.y(), within(1e-9));
+ }
+
+ @Test
+ void anAnchorPointIsFractionsOfItsOwnBox() {
+ ResolvedLayoutAnchor anchor =
+ new ResolvedLayoutAnchor(new LayoutAnchorId(new Object(), Kind.MARKER, 0), 0, 20.0, 100.0, 8.0, 8.0);
+
+ assertThat(anchor.pointX(0.5)).as("centre").isEqualTo(24.0, within(1e-9));
+ assertThat(anchor.pointX(0.0)).as("left edge").isEqualTo(20.0, within(1e-9));
+ assertThat(anchor.pointY(0.5)).isEqualTo(104.0, within(1e-9));
+ }
+
+ // --- 3. the anchor is the logical owner, not a draw fragment -------------
+
+ @Test
+ void oneAnchorPerWrapperHoweverManyFragmentsTheChildDraws() throws Exception {
+ Object group = new Object();
+ // A container drawing several shapes stands in for a custom marker; the anchor
+ // must still be one box, and the child's, not the container's stretched width.
+ LayoutGraph graph = compile(flow -> flow
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(group, Kind.MARKER, 0), dot(12))),
+ List.of());
+
+ assertThat(ResolvedLayoutMetadata.from(graph).anchors(group, Kind.MARKER))
+ .singleElement()
+ .satisfies(a -> {
+ assertThat(a.width()).isEqualTo(12.0, within(1e-9));
+ assertThat(a.height()).isEqualTo(12.0, within(1e-9));
+ });
+ }
+
+ @Test
+ void insideATableCellTheAnchorIsTheMarkersBoxNotTheCells() throws Exception {
+ // The sharpest version of "logical owner, not draw fragment". A bare 8×8 ellipse
+ // dropped into a cell reports a 16×8 fragment — the cell stretches it, and a
+ // consumer computing a centre from that lands 4pt off. Wrapping it in an anchor
+ // has to give back the marker's own box and position.
+ Object group = new Object();
+ LayoutGraph graph = compile(flow -> flow.addTable(t -> {
+ t.columns(DocumentTableColumn.fixed(40), DocumentTableColumn.fixed(90));
+ t.rowCells(
+ new DocumentTableCell(List.of(), null, 1, 1,
+ new LayoutAnchorNode("", new LayoutAnchorId(group, Kind.MARKER, 0), dot(8))),
+ DocumentTableCell.text("beside"));
+ }), List.of());
+
+ ResolvedLayoutAnchor anchor =
+ ResolvedLayoutMetadata.from(graph).anchors(group, Kind.MARKER).get(0);
+ PlacedFragment ellipse = graph.fragments().stream()
+ .filter(f -> f.payload() != null && f.payload().getClass().getSimpleName().contains("Ellipse"))
+ .findFirst().orElseThrow();
+
+ assertThat(anchor.width()).as("the marker's width, not the cell's").isEqualTo(8.0, within(1e-9));
+ assertThat(anchor.height()).isEqualTo(8.0, within(1e-9));
+ assertThat(anchor.x()).isEqualTo(ellipse.x(), within(1e-9));
+ assertThat(anchor.y()).isEqualTo(ellipse.y(), within(1e-9));
+ assertThat(anchor.pointX(0.5))
+ .as("a centre taken from the cell's box would be 4pt to the right")
+ .isEqualTo(ellipse.x() + 4.0, within(1e-9));
+ }
+
+ @Test
+ void anchorsOfDifferentGroupsNeverMergeEvenWithEqualContent() throws Exception {
+ Object first = new Object();
+ Object second = new Object();
+ LayoutGraph graph = compile(flow -> flow
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(first, Kind.MARKER, 0), dot(8)))
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(second, Kind.MARKER, 0), dot(8))),
+ List.of());
+
+ ResolvedLayoutMetadata metadata = ResolvedLayoutMetadata.from(graph);
+ assertThat(metadata.anchors()).hasSize(2);
+ assertThat(metadata.anchors(first, Kind.MARKER)).hasSize(1);
+ assertThat(metadata.anchors(second, Kind.MARKER)).hasSize(1);
+ assertThat(metadata.anchors(first, Kind.OTHER))
+ .as("the kind narrows too")
+ .isEmpty();
+ }
+
+ // --- 4. deterministic ordering ------------------------------------------
+
+ @Test
+ void passesContributeInRegistrationOrderAndDepthDecidesSides() throws Exception {
+ RecordingPass under = new RecordingPass("under", LayoutDepth.UNDER_BODY, "u1", "u2");
+ RecordingPass over = new RecordingPass("over", LayoutDepth.OVER_BODY, "o1");
+ RecordingPass alsoUnder = new RecordingPass("alsoUnder", LayoutDepth.UNDER_BODY, "u3");
+
+ LayoutGraph graph = compile(flow -> flow.addParagraph("body"),
+ List.of(under, over, alsoUnder));
+
+ List tags = graph.fragments().stream()
+ .map(PlacedFragment::payload)
+ .filter(String.class::isInstance)
+ .map(String.class::cast)
+ .toList();
+ assertThat(tags)
+ .as("registration order within a depth, and every under-body tag before every over-body one")
+ .containsExactly("u1", "u2", "u3", "o1");
+
+ int firstBody = indexOfFirstNonTag(graph);
+ assertThat(graph.fragments().subList(0, 3))
+ .as("the three under-body fragments precede the body")
+ .allSatisfy(f -> assertThat(f.payload()).isInstanceOf(String.class));
+ assertThat(firstBody).isEqualTo(3);
+ }
+
+ @Test
+ void everyPassSeesTheSameMetadataRegardlessOfWhatAnEarlierPassAdded() throws Exception {
+ Object group = new Object();
+ List seen = new ArrayList<>();
+ ResolvedLayoutPass first = pass("first", (graph, metadata) -> {
+ seen.add(metadata.anchors().size());
+ // Contributing a fragment must not grow the anchor set a later pass sees.
+ return List.of(new ResolvedLayoutAddition(LayoutDepth.UNDER_BODY,
+ PlacedFragment.withZeroInsets("@t", 0, 0, 0, 0, 1, 1, "tag")));
+ });
+ ResolvedLayoutPass second = pass("second", (graph, metadata) -> {
+ seen.add(metadata.anchors().size());
+ return List.of();
+ });
+
+ compile(flow -> flow
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(group, Kind.MARKER, 0), dot(8))),
+ List.of(first, second));
+
+ assertThat(seen).as("collected once, before any pass ran").containsExactly(1, 1);
+ }
+
+ @Test
+ void ananchoredSubtreeThatSpansPagesReportsOneAnchorPerPage() throws Exception {
+ // Pinned because it contradicts the obvious reading of "one anchor per wrapper".
+ // A composite emits its fragments once per page it occupies, so an anchor wrapping
+ // content that paginates reports one per page — sharing an id, and each carrying
+ // the subtree's whole height rather than the slice on that page. Small atomic
+ // content, which is what anchors are for, never hits it. A consumer that anchors
+ // something tall has to group by page itself, and had better learn that here.
+ Object group = new Object();
+ StringBuilder body = new StringBuilder();
+ for (int i = 0; i < 30; i++) {
+ body.append("Sentence ").append(i).append(" of a body long enough to paginate. ");
+ }
+ LayoutGraph graph = compile(flow -> flow
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(group, Kind.MARKER, 0),
+ new SectionBuilder().addParagraph(body.toString()).build())),
+ List.of());
+
+ List anchors = ResolvedLayoutMetadata.from(graph).anchors(group, Kind.MARKER);
+ assertThat(graph.totalPages()).isGreaterThan(1);
+ assertThat(anchors)
+ .as("one per page the anchored subtree occupies, not one in total")
+ .hasSize(graph.totalPages());
+ assertThat(anchors.stream().map(ResolvedLayoutAnchor::pageIndex))
+ .containsExactlyElementsOf(java.util.stream.IntStream.range(0, graph.totalPages()).boxed().toList());
+ }
+
+ // --- 4. the owner carries the feature's own configuration -----------------
+
+ /**
+ * Stands in for a feature's owner: one object per logical instance, carrying whatever
+ * that instance needs, compared by reference. Deliberately a plain class and not a
+ * record — a record invites the reader to think in value equality, which is exactly
+ * what {@link LayoutAnchorId} does not do.
+ */
+ private static final class FeatureOwner {
+ private final double railWidth;
+
+ private FeatureOwner(double railWidth) {
+ this.railWidth = railWidth;
+ }
+ }
+
+ @Test
+ void theOwnerReachesThePassByReferenceCarryingItsOwnConfiguration() throws Exception {
+ // The shape a built-in feature needs: no registration call from the DSL, no session
+ // reference, no lookup by name. One owner is allocated, the feature's configuration
+ // hangs off it, every anchor is keyed on it, and the pass recovers both.
+ FeatureOwner owner = new FeatureOwner(1.5);
+ List seenByThePass = new ArrayList<>();
+
+ ResolvedLayoutPass reader = pass("reader", (graph, metadata) -> {
+ for (ResolvedLayoutAnchor anchor : metadata.anchors()) {
+ seenByThePass.add((FeatureOwner) anchor.id().groupKey());
+ }
+ return List.of();
+ });
+
+ // Per-page margins put this on the resolver's fixed point, so the document is
+ // compiled more than once and emitFragments builds fresh payloads each time. The
+ // id they carry comes from the semantic node, allocated once — that is the claim.
+ StringBuilder filler = new StringBuilder();
+ for (int i = 0; i < 20; i++) {
+ filler.append("Filler sentence ").append(i).append(" pushing content onto another page. ");
+ }
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(240, 200)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageMargins(List.of(PageMarginRule.page(1, DocumentInsets.of(30))));
+ session.registerLayoutPass(reader);
+ session.pageFlow()
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(owner, Kind.MARKER, 0), dot(8)))
+ .addParagraph(filler.toString())
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(owner, Kind.MARKER, 1), dot(8)))
+ .build();
+
+ LayoutGraph graph = session.layoutGraph();
+ assertThat(graph.totalPages()).as("the recompiling path, not the single-pass one").isGreaterThan(1);
+ }
+
+ assertThat(seenByThePass).as("both anchors reached the pass").hasSize(2);
+ assertThat(seenByThePass).allSatisfy(seen ->
+ assertThat(seen).as("the very object the DSL allocated, not a copy").isSameAs(owner));
+ assertThat(seenByThePass.get(0).railWidth)
+ .as("the pass reads the feature's configuration straight off its owner")
+ .isEqualTo(1.5, within(1e-9));
+ }
+
+ @Test
+ void aPassFindsNothingWhenTheDocumentHasNoneOfItsFeature() throws Exception {
+ FeatureOwner mine = new FeatureOwner(1.5);
+ FeatureOwner someoneElses = new FeatureOwner(3.0);
+ LayoutGraph graph = compile(flow -> flow
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(someoneElses, Kind.MARKER, 0), dot(8))),
+ List.of());
+
+ // How a built-in pass decides it has nothing to do: ask for its own owner and get
+ // back an empty list. No document inspection, no feature flag, no session help.
+ assertThat(ResolvedLayoutMetadata.from(graph).anchors(mine, Kind.MARKER)).isEmpty();
+ assertThat(ResolvedLayoutMetadata.from(graph).anchors(someoneElses, Kind.MARKER)).hasSize(1);
+ }
+
+ // --- guards --------------------------------------------------------------
+
+ @Test
+ void anIdentityKeyMayNotBeAStringOrABoxedNumber() {
+ // == decides whether two ids match, so a String key works or fails on interning:
+ // the same literal twice matches, the same text computed does not. Caught at the
+ // call site rather than as an anchor set that silently comes back empty.
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new LayoutAnchorId("timeline", Kind.MARKER, 0))
+ .withMessageContaining("identity key");
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new LayoutAnchorId(new Object(), 128, 0))
+ .withMessageContaining("identity key");
+ }
+
+ @Test
+ void aPassMayNotContributeANonFiniteCoordinate() {
+ ResolvedLayoutPass rogue = pass("rogue", (graph, metadata) -> List.of(
+ new ResolvedLayoutAddition(LayoutDepth.UNDER_BODY,
+ PlacedFragment.withZeroInsets("@x", 0, 0, Double.NaN, 0, 1, 1, "tag"))));
+
+ assertThatIllegalStateException()
+ .isThrownBy(() -> compile(flow -> flow.addParagraph("x"), List.of(rogue)))
+ .withMessageContaining("NaN");
+ }
+
+
+ @Test
+ void aPassMayNotInventAPage() {
+ ResolvedLayoutPass rogue = pass("rogue", (graph, metadata) -> List.of(
+ new ResolvedLayoutAddition(LayoutDepth.UNDER_BODY,
+ PlacedFragment.withZeroInsets("@x", 0, 7, 0, 0, 1, 1, "tag"))));
+
+ assertThatIllegalStateException()
+ .isThrownBy(() -> compile(flow -> flow.addParagraph("one page"), List.of(rogue)))
+ .withMessageContaining("may not add pages");
+ }
+
+ @Test
+ void aPassReturningNullIsRejectedByName() {
+ ResolvedLayoutPass broken = pass("broken", (graph, metadata) -> null);
+
+ assertThatIllegalStateException()
+ .isThrownBy(() -> compile(flow -> flow.addParagraph("x"), List.of(broken)))
+ .withMessageContaining("broken");
+ }
+
+ // --- helpers -------------------------------------------------------------
+
+ private static EllipseNode dot(double size) {
+ return new EllipseNode("dot", size, size, INK, null, null, null, null, null);
+ }
+
+ private static List nonAnchor(LayoutGraph graph) {
+ return graph.fragments().stream()
+ .filter(f -> !(f.payload() instanceof LayoutAnchorPayload))
+ .toList();
+ }
+
+ /** Everything that decides where ink lands, with the path left out. */
+ private static List geometry(List fragments) {
+ return fragments.stream()
+ .map(f -> "p%d (%.6f,%.6f %.6fx%.6f)".formatted(
+ f.pageIndex(), f.x(), f.y(), f.width(), f.height()))
+ .toList();
+ }
+
+ private static List
*
*
Lives in this {@code @Internal} package on purpose. Anchoring is engine plumbing that
* a built-in feature uses to reach its own resolved geometry; whether authors should ever
diff --git a/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutAnchor.java b/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutAnchor.java
index 8f02bd531..88b1f8722 100644
--- a/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutAnchor.java
+++ b/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutAnchor.java
@@ -9,16 +9,20 @@
* bottom-left of the page, y growing upwards. A pass reading these needs no conversion to
* emit a fragment beside them.
*
- *
The box is the anchored child's own, not its container's — see
- * {@code LayoutAnchorPayload}. That is what lets a caller treat this as the position of a
- * marker rather than of whatever cell or column happened to hold it.
+ *
The box is the wrapped node's border box: the box that node was laid out into,
+ * its own margin excluded and its padding included. Not its container's — that is
+ * what lets a caller treat this as the position of a marker rather than of
+ * whatever cell or column happened to hold it. Not its ink either: a stroke may paint
+ * outside it and a glyph need not fill it, so a consumer that wants the ink of one
+ * particular shape has to ask that shape. And it is the box of the node that was
+ * wrapped — anchor a container and the container is what comes back.
*
* @param id the anchor's identity
* @param pageIndex zero-based page the anchor landed on
* @param x left edge, page coordinates
* @param y bottom edge, page coordinates
- * @param width the anchored child's width
- * @param height the anchored child's height
+ * @param width the wrapped node's border-box width
+ * @param height the wrapped node's border-box height
* @author Artem Demchyshyn
* @since 2.4.0
*/
diff --git a/core/src/main/java/com/demcha/compose/document/layout/definitions/LayoutAnchorDefinition.java b/core/src/main/java/com/demcha/compose/document/layout/definitions/LayoutAnchorDefinition.java
index 408bd25d9..1decaf435 100644
--- a/core/src/main/java/com/demcha/compose/document/layout/definitions/LayoutAnchorDefinition.java
+++ b/core/src/main/java/com/demcha/compose/document/layout/definitions/LayoutAnchorDefinition.java
@@ -13,6 +13,7 @@
import com.demcha.compose.document.layout.PreparedNode;
import com.demcha.compose.document.layout.payloads.LayoutAnchorPayload;
import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.style.DocumentInsets;
import java.util.List;
@@ -25,6 +26,10 @@
* whole seam exists to avoid — an 8×8 marker in a 16pt table cell must anchor at 8×8, not
* at the cell.
*
+ *
What it reports is the child's border box: the box the child was laid out
+ * into, its own margin excluded and its padding included. Two boxes are in play and only
+ * one of them is useful to a consumer — see {@link #emitFragments}.
+ *
* @author Artem Demchyshyn
* @since 2.4.0
*/
@@ -69,17 +74,25 @@ public List children(LayoutAnchorNode node) {
public List emitFragments(PreparedNode prepared,
FragmentContext ctx,
FragmentPlacement placement) {
- // The size travels in the payload, taken from prepare()'s measurement of the
- // child. The fragment's own box is left at the placement so the marker sits where
- // the compiler put it; a reader that wants the child's extent reads the payload.
+ // The child's border box, which is not the wrapper's. prepare() measured the
+ // child's *margin* box, because that is the space the wrapper has to occupy for
+ // the surrounding flow to be right; reporting it would be wrong for the one thing
+ // this seam is for. A marker with margin(top 2, right 4, bottom 6, left 8) would
+ // have its anchor centre land 2pt off its own ink in both directions, and a rail
+ // drawn through that centre would visibly miss it. So the margin comes back off
+ // here, in the offset and in the size. Measured rather than assumed: the compiler
+ // seats the child at the anchor's bottom-left plus (left, bottom) — y grows up.
+ DocumentInsets margin = prepared.node().child().margin();
MeasureResult measured = prepared.measureResult();
+ double width = Math.max(0.0, measured.width() - margin.horizontal());
+ double height = Math.max(0.0, measured.height() - margin.vertical());
return List.of(new LayoutFragment(
placement.path(),
0,
- 0.0,
- 0.0,
- measured.width(),
- measured.height(),
- new LayoutAnchorPayload(prepared.node().id(), measured.width(), measured.height())));
+ margin.left(),
+ margin.bottom(),
+ width,
+ height,
+ new LayoutAnchorPayload(prepared.node().id(), width, height)));
}
}
diff --git a/core/src/main/java/com/demcha/compose/document/layout/payloads/LayoutAnchorPayload.java b/core/src/main/java/com/demcha/compose/document/layout/payloads/LayoutAnchorPayload.java
index 39b14c927..fcb43fbcc 100644
--- a/core/src/main/java/com/demcha/compose/document/layout/payloads/LayoutAnchorPayload.java
+++ b/core/src/main/java/com/demcha/compose/document/layout/payloads/LayoutAnchorPayload.java
@@ -16,13 +16,18 @@
* child's own measurement, never from the placement the anchor was handed. That
* distinction is the whole point: an 8×8 marker inside a table cell is placed in a
* 16pt-wide cell, and a payload built from {@code placement.width()} would report the
- * cell. A caller anchoring a marker drawn as five SVG fragments still gets one anchor with
- * one box, which is what makes the anchor a logical owner rather than a
- * particular draw fragment.
+ * cell. A marker drawn as three stacked shapes still gets one anchor with one box, which
+ * is what makes the anchor a logical owner rather than a particular draw
+ * fragment.
+ *
+ *
The box is the child's border box — its margin excluded, its padding included.
+ * {@code LayoutAnchorDefinition} takes the margin back off both the size and the offset,
+ * because the wrapper has to occupy the margin box for the flow to be right while
+ * a consumer drawing to the anchor means the ink.
*
* @param id the anchor's identity
- * @param width the anchored child's own measured width
- * @param height the anchored child's own measured height
+ * @param width the anchored child's border-box width
+ * @param height the anchored child's border-box height
* @author Artem Demchyshyn
* @since 2.4.0
*/
diff --git a/knowledge/api/backends.json b/knowledge/api/backends.json
index ee0c2d102..171320496 100644
--- a/knowledge/api/backends.json
+++ b/knowledge/api/backends.json
@@ -22,10 +22,10 @@
"graph-compose-testing:sources"
],
"counts": {
- "types": 71,
- "methods": 379,
+ "types": 69,
+ "methods": 371,
"constants": 18,
- "generated": 191
+ "generated": 189
},
"packages": [
{
@@ -1520,79 +1520,6 @@
}
]
},
- {
- "name": "PdfLayoutAnchorRenderHandler",
- "binaryName": "com.demcha.compose.document.backend.fixed.pdf.handlers.PdfLayoutAnchorRenderHandler",
- "kind": "class",
- "modifiers": [
- "final"
- ],
- "artifact": "graph-compose-render-pdf",
- "members": [
- {
- "kind": "constructor",
- "name": "PdfLayoutAnchorRenderHandler",
- "static": false,
- "origin": "source",
- "typeParameters": null,
- "returns": null,
- "params": []
- },
- {
- "kind": "method",
- "name": "payloadType",
- "static": false,
- "origin": "source",
- "typeParameters": null,
- "returns": "Class",
- "params": []
- },
- {
- "kind": "method",
- "name": "render",
- "static": false,
- "origin": "source",
- "typeParameters": null,
- "returns": "void",
- "params": [
- {
- "type": "PlacedFragment",
- "name": "fragment"
- },
- {
- "type": "LayoutAnchorPayload",
- "name": "payload"
- },
- {
- "type": "PdfRenderEnvironment",
- "name": "environment"
- }
- ]
- },
- {
- "kind": "method",
- "name": "render",
- "static": false,
- "origin": "generated",
- "typeParameters": null,
- "returns": "void",
- "params": [
- {
- "type": "PlacedFragment",
- "name": null
- },
- {
- "type": "Object",
- "name": null
- },
- {
- "type": "PdfRenderEnvironment",
- "name": null
- }
- ]
- }
- ]
- },
{
"name": "PdfLineFragmentRenderHandler",
"binaryName": "com.demcha.compose.document.backend.fixed.pdf.handlers.PdfLineFragmentRenderHandler",
@@ -5086,84 +5013,6 @@
}
]
},
- {
- "name": "PptxLayoutAnchorRenderHandler",
- "binaryName": "com.demcha.compose.document.backend.fixed.pptx.handlers.PptxLayoutAnchorRenderHandler",
- "kind": "class",
- "modifiers": [
- "final"
- ],
- "artifact": "graph-compose-render-pptx",
- "stability": "beta",
- "members": [
- {
- "kind": "constructor",
- "name": "PptxLayoutAnchorRenderHandler",
- "static": false,
- "origin": "source",
- "typeParameters": null,
- "returns": null,
- "params": [],
- "stability": "beta"
- },
- {
- "kind": "method",
- "name": "payloadType",
- "static": false,
- "origin": "source",
- "typeParameters": null,
- "returns": "Class",
- "params": [],
- "stability": "beta"
- },
- {
- "kind": "method",
- "name": "render",
- "static": false,
- "origin": "source",
- "typeParameters": null,
- "returns": "void",
- "params": [
- {
- "type": "PlacedFragment",
- "name": "fragment"
- },
- {
- "type": "LayoutAnchorPayload",
- "name": "payload"
- },
- {
- "type": "PptxRenderEnvironment",
- "name": "environment"
- }
- ],
- "stability": "beta"
- },
- {
- "kind": "method",
- "name": "render",
- "static": false,
- "origin": "generated",
- "typeParameters": null,
- "returns": "void",
- "params": [
- {
- "type": "PlacedFragment",
- "name": null
- },
- {
- "type": "Object",
- "name": null
- },
- {
- "type": "PptxRenderEnvironment",
- "name": null
- }
- ],
- "stability": "beta"
- }
- ]
- },
{
"name": "PptxLineFragmentRenderHandler",
"binaryName": "com.demcha.compose.document.backend.fixed.pptx.handlers.PptxLineFragmentRenderHandler",
diff --git a/knowledge/api/backends.md b/knowledge/api/backends.md
index a9b4dc4bc..0713aa562 100644
--- a/knowledge/api/backends.md
+++ b/knowledge/api/backends.md
@@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se
**GraphCompose version:** 2.4.0-SNAPSHOT
-Types: 71 · methods: 379 · constants: 18 · compiler-generated members: 191
+Types: 69 · methods: 371 · constants: 18 · compiler-generated members: 189
## com.demcha.compose.document.backend.fixed
@@ -157,12 +157,6 @@ Types: 71 · methods: 379 · constants: 18 · compiler-generated members: 191
- `void render(PlacedFragment fragment, ImageFragmentPayload payload, PdfRenderEnvironment environment)`
- `void render(PlacedFragment, Object, PdfRenderEnvironment)`
-### PdfLayoutAnchorRenderHandler (class)
-- `new PdfLayoutAnchorRenderHandler()`
-- `Class payloadType()`
-- `void render(PlacedFragment fragment, LayoutAnchorPayload payload, PdfRenderEnvironment environment)`
-- `void render(PlacedFragment, Object, PdfRenderEnvironment)`
-
### PdfLineFragmentRenderHandler (class)
- `new PdfLineFragmentRenderHandler()`
- `Class payloadType()`
@@ -472,12 +466,6 @@ Types: 71 · methods: 379 · constants: 18 · compiler-generated members: 191
- `void render(PlacedFragment fragment, ImageFragmentPayload payload, PptxRenderEnvironment environment) [beta]`
- `void render(PlacedFragment, Object, PptxRenderEnvironment) [beta]`
-### PptxLayoutAnchorRenderHandler (class) [beta]
-- `new PptxLayoutAnchorRenderHandler() [beta]`
-- `Class payloadType() [beta]`
-- `void render(PlacedFragment fragment, LayoutAnchorPayload payload, PptxRenderEnvironment environment) [beta]`
-- `void render(PlacedFragment, Object, PptxRenderEnvironment) [beta]`
-
### PptxLineFragmentRenderHandler (class) [beta]
- `new PptxLineFragmentRenderHandler() [beta]`
- `Class payloadType() [beta]`
diff --git a/qa/src/test/java/com/demcha/compose/document/api/ResolvedLayoutPassTest.java b/qa/src/test/java/com/demcha/compose/document/api/ResolvedLayoutPassTest.java
index 2878d8d0e..e7c44f1af 100644
--- a/qa/src/test/java/com/demcha/compose/document/api/ResolvedLayoutPassTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/api/ResolvedLayoutPassTest.java
@@ -11,8 +11,11 @@
import com.demcha.compose.document.layout.ResolvedLayoutMetadata;
import com.demcha.compose.document.layout.ResolvedLayoutPass;
import com.demcha.compose.document.layout.payloads.LayoutAnchorPayload;
+import com.demcha.compose.document.dsl.LayerStackBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.node.DocumentLinkTarget;
import com.demcha.compose.document.node.EllipseNode;
+import com.demcha.compose.document.node.LayerStackNode;
import com.demcha.compose.document.table.DocumentTableCell;
import com.demcha.compose.document.table.DocumentTableColumn;
import com.demcha.compose.document.style.DocumentColor;
@@ -122,9 +125,7 @@ void aResolvedAnchorReportsItsPageAndBox() throws Exception {
assertThat(anchor.width()).isEqualTo(8.0, within(1e-9));
assertThat(anchor.height()).isEqualTo(8.0, within(1e-9));
- PlacedFragment ellipse = graph.fragments().stream()
- .filter(f -> f.payload() != null && f.payload().getClass().getSimpleName().contains("Ellipse"))
- .findFirst().orElseThrow();
+ PlacedFragment ellipse = ellipses(graph).get(0);
assertThat(anchor.x()).as("the anchor lands on its child, not on its container")
.isEqualTo(ellipse.x(), within(1e-9));
assertThat(anchor.y()).isEqualTo(ellipse.y(), within(1e-9));
@@ -144,21 +145,100 @@ void anAnchorPointIsFractionsOfItsOwnBox() {
@Test
void oneAnchorPerWrapperHoweverManyFragmentsTheChildDraws() throws Exception {
+ // The case a custom marker actually is: three concentric dots in a layer stack —
+ // a ring, a disc and a pip — which the compiler emits as three separate ellipse
+ // fragments. The anchor has to stay one box, and the box of the marker as
+ // declared, not of any one of the shapes that make it up.
Object group = new Object();
- // A container drawing several shapes stands in for a custom marker; the anchor
- // must still be one box, and the child's, not the container's stretched width.
+ LayerStackNode marker = new LayerStackBuilder()
+ .name("marker")
+ .back(dot(16))
+ .center(dot(10))
+ .center(dot(4))
+ .build();
+
LayoutGraph graph = compile(flow -> flow
- .add(new LayoutAnchorNode("", new LayoutAnchorId(group, Kind.MARKER, 0), dot(12))),
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(group, Kind.MARKER, 0), marker)),
List.of());
+ List shapes = ellipses(graph);
+ assertThat(shapes)
+ .as("the premise: this marker really is drawn as several fragments")
+ .hasSize(3);
+ assertThat(shapes.stream().map(PlacedFragment::width))
+ .containsExactly(16.0, 10.0, 4.0);
+
+ PlacedFragment ring = shapes.get(0);
assertThat(ResolvedLayoutMetadata.from(graph).anchors(group, Kind.MARKER))
.singleElement()
.satisfies(a -> {
- assertThat(a.width()).isEqualTo(12.0, within(1e-9));
- assertThat(a.height()).isEqualTo(12.0, within(1e-9));
+ assertThat(a.width()).as("the declared marker box").isEqualTo(16.0, within(1e-9));
+ assertThat(a.height()).isEqualTo(16.0, within(1e-9));
+ assertThat(a.x()).isEqualTo(ring.x(), within(1e-9));
+ assertThat(a.y()).isEqualTo(ring.y(), within(1e-9));
+ // The pip is centred in the stack, so the marker's centre is the pip's
+ // centre. A consumer drawing a rail through the anchor hits the middle
+ // of the composed marker, not the middle of whichever shape drew first.
+ PlacedFragment pip = shapes.get(2);
+ assertThat(a.pointX(0.5)).isEqualTo(pip.x() + pip.width() / 2, within(1e-9));
+ assertThat(a.pointY(0.5)).isEqualTo(pip.y() + pip.height() / 2, within(1e-9));
});
}
+ @Test
+ void aMarkersOwnMarginIsNoPartOfItsAnchorBox() throws Exception {
+ // Two boxes are in play. The wrapper has to *occupy* the child's margin box or the
+ // surrounding flow is wrong, but a consumer drawing to the anchor means the ink.
+ // Deliberately asymmetric — a uniform margin hides the bug, since its margin-box
+ // centre and its border-box centre are the same point.
+ Object group = new Object();
+ DocumentInsets margin = new DocumentInsets(2, 4, 6, 8);
+ LayoutGraph graph = compile(flow -> flow
+ .addParagraph("Above")
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(group, Kind.MARKER, 0), dot(8, margin))),
+ List.of());
+
+ PlacedFragment ellipse = ellipses(graph).get(0);
+ ResolvedLayoutAnchor anchor =
+ ResolvedLayoutMetadata.from(graph).anchors(group, Kind.MARKER).get(0);
+
+ assertThat(anchor.width()).as("the marker, not the marker plus its spacing")
+ .isEqualTo(8.0, within(1e-9));
+ assertThat(anchor.height()).isEqualTo(8.0, within(1e-9));
+ assertThat(anchor.x()).isEqualTo(ellipse.x(), within(1e-9));
+ assertThat(anchor.y()).isEqualTo(ellipse.y(), within(1e-9));
+
+ // The number the margin box would have given: 20×16 at the wrapper's own origin,
+ // whose centre sits 2pt left of and 2pt below the ink in this case. That is what a
+ // rail through the anchor centre would have missed by.
+ assertThat(anchor.pointX(0.5)).isEqualTo(ellipse.x() + 4.0, within(1e-9));
+ assertThat(anchor.pointY(0.5)).isEqualTo(ellipse.y() + 4.0, within(1e-9));
+ }
+
+ @Test
+ void anchoringAContainerReportsTheContainerAndAnchoringTheMarkerReportsTheMarker() throws Exception {
+ // The answer to "which box is it": the one you wrapped. Same marker, same document,
+ // two anchors — one around the fixed-width container, one around the dot inside it.
+ Object group = new Object();
+ LayoutGraph graph = compile(flow -> flow
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(group, Kind.OTHER, 0),
+ new SectionBuilder()
+ .fixedWidth(60)
+ .add(new LayoutAnchorNode("", new LayoutAnchorId(group, Kind.MARKER, 0), dot(8)))
+ .build())),
+ List.of());
+
+ ResolvedLayoutMetadata metadata = ResolvedLayoutMetadata.from(graph);
+ assertThat(metadata.anchors(group, Kind.OTHER).get(0).width())
+ .as("wrapping the container measures the container")
+ .isEqualTo(60.0, within(1e-9));
+ assertThat(metadata.anchors(group, Kind.MARKER).get(0).width())
+ .as("wrapping the marker measures the marker, however wide its container is")
+ .isEqualTo(8.0, within(1e-9));
+ assertThat(metadata.anchors(group, Kind.MARKER).get(0).x())
+ .isEqualTo(ellipses(graph).get(0).x(), within(1e-9));
+ }
+
@Test
void insideATableCellTheAnchorIsTheMarkersBoxNotTheCells() throws Exception {
// The sharpest version of "logical owner, not draw fragment". A bare 8×8 ellipse
@@ -176,9 +256,7 @@ void insideATableCellTheAnchorIsTheMarkersBoxNotTheCells() throws Exception {
ResolvedLayoutAnchor anchor =
ResolvedLayoutMetadata.from(graph).anchors(group, Kind.MARKER).get(0);
- PlacedFragment ellipse = graph.fragments().stream()
- .filter(f -> f.payload() != null && f.payload().getClass().getSimpleName().contains("Ellipse"))
- .findFirst().orElseThrow();
+ PlacedFragment ellipse = ellipses(graph).get(0);
assertThat(anchor.width()).as("the marker's width, not the cell's").isEqualTo(8.0, within(1e-9));
assertThat(anchor.height()).isEqualTo(8.0, within(1e-9));
@@ -412,6 +490,19 @@ private static EllipseNode dot(double size) {
return new EllipseNode("dot", size, size, INK, null, null, null, null, null);
}
+ private static EllipseNode dot(double size, DocumentInsets margin) {
+ return new EllipseNode("dot", size, size, INK, null, (DocumentLinkTarget) null, null,
+ null, margin, null, null);
+ }
+
+ /** The ink the marker drew, in draw order. */
+ private static List ellipses(LayoutGraph graph) {
+ return graph.fragments().stream()
+ .filter(f -> f.payload() != null
+ && f.payload().getClass().getSimpleName().contains("Ellipse"))
+ .toList();
+ }
+
private static List nonAnchor(LayoutGraph graph) {
return graph.fragments().stream()
.filter(f -> !(f.payload() instanceof LayoutAnchorPayload))
diff --git a/qa/src/test/java/com/demcha/compose/document/backend/LayoutAnchorRendersNothingTest.java b/qa/src/test/java/com/demcha/compose/document/backend/LayoutAnchorRendersNothingTest.java
new file mode 100644
index 000000000..dd5baba55
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/backend/LayoutAnchorRendersNothingTest.java
@@ -0,0 +1,122 @@
+package com.demcha.compose.document.backend;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.layout.LayoutAnchorId;
+import com.demcha.compose.document.layout.LayoutAnchorNode;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.EllipseNode;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import org.apache.poi.xslf.usermodel.XMLSlideShow;
+import org.junit.jupiter.api.Test;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.util.List;
+import java.util.function.UnaryOperator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/**
+ * What each fixed backend does with an anchor fragment, asked of a real render.
+ *
+ *
The layout tests prove the anchor reports the right box. They cannot prove the thing
+ * the two no-op handlers exist for: a fixed backend refuses a fragment payload it has no
+ * handler for, so a document containing an anchor would fail at export — long
+ * after the layout everything else asserts. Registration is a line in a list, and a line
+ * in a list can be deleted without breaking a compile.
+ *
+ *
So each backend is asked twice, once with the marker wrapped and once bare, and the
+ * two are compared. That is stronger than "does not throw": it says the anchor reached the
+ * handler and the handler drew nothing, which is the whole of its contract. The
+ * PDF is compared as pixels because it is painted, and the deck by its shape count because
+ * a slide is a shape tree — an anchor that produced any ink or any shape moves one of the
+ * two numbers.
+ *
+ *
Proven fail-closed: removing either handler from its backend's {@code defaultHandlers}
+ * turns the matching test red with {@code UnsupportedNodeCapabilityException}.
+ */
+class LayoutAnchorRendersNothingTest {
+
+ private static final DocumentColor INK = DocumentColor.rgb(20, 60, 160);
+
+ private enum Kind { MARKER }
+
+ @Test
+ void aDocumentCarryingAnAnchorRendersToPdfExactlyAsItWouldWithout() throws Exception {
+ BufferedImage anchored = pdfPage(LayoutAnchorRendersNothingTest::anchor);
+ BufferedImage bare = pdfPage(UnaryOperator.identity());
+
+ assertThat(anchored.getWidth()).isEqualTo(bare.getWidth());
+ assertThat(anchored.getHeight()).isEqualTo(bare.getHeight());
+ assertThat(differingPixels(anchored, bare))
+ .as("an anchor is metadata; the page it is on must be the same page")
+ .isZero();
+ }
+
+ @Test
+ void aDocumentCarryingAnAnchorRendersToPptxExactlyAsItWouldWithout() throws Exception {
+ assertThat(slideShapeCount(LayoutAnchorRendersNothingTest::anchor))
+ .as("no shape stands for the anchor, and the deck is written")
+ .isEqualTo(slideShapeCount(UnaryOperator.identity()));
+ }
+
+ @Test
+ void bothBackendsAcceptTheAnchorRatherThanRefusingItsPayload() {
+ // The failure this guards is an export-time throw, not a wrong picture: each
+ // backend's handlerFor raises UnsupportedNodeCapabilityException on a payload class
+ // it does not know. Stated separately from the comparisons above so the message
+ // says which of the two things broke.
+ assertThatCode(() -> {
+ pdfPage(LayoutAnchorRendersNothingTest::anchor);
+ slideShapeCount(LayoutAnchorRendersNothingTest::anchor);
+ }).doesNotThrowAnyException();
+ }
+
+ private static DocumentNode anchor(DocumentNode marker) {
+ return new LayoutAnchorNode("", new LayoutAnchorId(new Object(), Kind.MARKER, 0), marker);
+ }
+
+ private static BufferedImage pdfPage(UnaryOperator wrap) throws Exception {
+ try (DocumentSession session = document(wrap)) {
+ List pages = session.toImages(72);
+ assertThat(pages).hasSize(1);
+ return pages.get(0);
+ }
+ }
+
+ private static int slideShapeCount(UnaryOperator wrap) throws Exception {
+ try (DocumentSession session = document(wrap)) {
+ try (XMLSlideShow deck = new XMLSlideShow(new ByteArrayInputStream(session.toPptxBytes()))) {
+ return deck.getSlides().get(0).getShapes().size();
+ }
+ }
+ }
+
+ /** One page, one paragraph and one marker — wrapped or not, by the caller's choice. */
+ private static DocumentSession document(UnaryOperator wrap) throws Exception {
+ DocumentSession session = GraphCompose.document()
+ .pageSize(240, 200)
+ .margin(DocumentInsets.of(20))
+ .create();
+ session.pageFlow()
+ .addParagraph("Beside the marker")
+ .add(wrap.apply(new EllipseNode("dot", 8, 8, INK, null, null, null, null, null)))
+ .build();
+ return session;
+ }
+
+ private static int differingPixels(BufferedImage left, BufferedImage right) {
+ int differing = 0;
+ for (int y = 0; y < left.getHeight(); y++) {
+ for (int x = 0; x < left.getWidth(); x++) {
+ if (left.getRGB(x, y) != right.getRGB(x, y)) {
+ differing++;
+ }
+ }
+ }
+ return differing;
+ }
+}
diff --git a/qa/src/test/java/com/demcha/documentation/ResolvedLayoutSeamStaysInternalTest.java b/qa/src/test/java/com/demcha/documentation/ResolvedLayoutSeamStaysInternalTest.java
index a2aafd83e..58e852c19 100644
--- a/qa/src/test/java/com/demcha/documentation/ResolvedLayoutSeamStaysInternalTest.java
+++ b/qa/src/test/java/com/demcha/documentation/ResolvedLayoutSeamStaysInternalTest.java
@@ -25,10 +25,12 @@
* being one, and the extension-SPI allow-list admits types out of exactly such a package
* by name. So this reads the generated surfaces and says it directly.
*
- *
A payload's name legitimately appears in a render handler's signature —
- * {@code payloadType()} returns {@code Class<…Payload>} — the way
- * {@code AnchorMarkerPayload} and {@code ShapeFragmentPayload} already do. What must not
- * happen is the type being admitted, which is what this checks.
+ *
The list includes the two backend handlers. Each fixed backend does need one — its
+ * {@code handlerFor} refuses a payload class it does not know — but a handler for an
+ * internal payload is not something a caller can usefully hold, and a public class is
+ * permanent. They are package-private, and sit beside their backend rather than in the
+ * {@code handlers} package whose members are public precisely because callers register
+ * them. The seam therefore adds nothing to the API.
*/
class ResolvedLayoutSeamStaysInternalTest {
@@ -42,7 +44,9 @@ class ResolvedLayoutSeamStaysInternalTest {
"ResolvedLayoutAnchor",
"ResolvedLayoutMetadata",
"ResolvedLayoutPass",
- "ResolvedLayoutPasses");
+ "ResolvedLayoutPasses",
+ "PdfLayoutAnchorRenderHandler",
+ "PptxLayoutAnchorRenderHandler");
@Test
void noSeamTypeIsAdmittedToAPublicSurface() throws IOException {
@@ -81,22 +85,20 @@ void theRegistrationMethodIsNotOnTheSession() throws IOException {
}
@Test
- void theTwoRenderHandlersAreTheOnlySurfaceThisAdds() throws IOException {
- // Named rather than left out. The seam is internal, but a fragment payload needs a
- // handler in each fixed backend — handlerFor throws on a payload class it does not
- // know — and handlers are public here, as every sibling is. So two public types do
- // ship. A guard that simply omitted them could not say whether that was intended or
- // an oversight; this says it is intended, and goes red if a third one appears.
- Path backends = RepoRoot.get().resolve("knowledge/api/backends.json");
- String json = Files.readString(backends);
-
- assertThat(json).as("the pdf no-op handler ships, deliberately")
- .contains("\"name\": \"PdfLayoutAnchorRenderHandler\"");
- assertThat(json).as("and its pptx twin")
- .contains("\"name\": \"PptxLayoutAnchorRenderHandler\"");
+ void theSeamAddsNoBackendSurfaceAtAll() throws IOException {
+ // Separate from the sweep above because this is the one that was nearly got wrong:
+ // a handler is the obvious place for internal machinery to leak, since every
+ // sibling in the handlers package is public and copying one is the natural move.
+ // The payload's own name would appear here too if a public handler declared
+ // payloadType() — AnchorMarkerPayload and ShapeFragmentPayload are in this file for
+ // exactly that reason — so its absence is the second signal that neither shipped.
+ String json = Files.readString(RepoRoot.get().resolve("knowledge/api/backends.json"));
assertThat(SEAM_TYPES.stream().filter(t -> json.contains("\"name\": \"" + t + "\"")).toList())
- .as("nothing else from the seam follows them out")
+ .as("no seam type is admitted to the backend surface")
.isEmpty();
+ assertThat(json)
+ .as("and no public signature mentions the anchor payload either")
+ .doesNotContain("LayoutAnchorPayload");
}
}
diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfLayoutAnchorRenderHandler.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfLayoutAnchorRenderHandler.java
similarity index 54%
rename from render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfLayoutAnchorRenderHandler.java
rename to render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfLayoutAnchorRenderHandler.java
index ffe1f69be..a9549e445 100644
--- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfLayoutAnchorRenderHandler.java
+++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfLayoutAnchorRenderHandler.java
@@ -1,7 +1,5 @@
-package com.demcha.compose.document.backend.fixed.pdf.handlers;
+package com.demcha.compose.document.backend.fixed.pdf;
-import com.demcha.compose.document.backend.fixed.pdf.PdfFragmentRenderHandler;
-import com.demcha.compose.document.backend.fixed.pdf.PdfRenderEnvironment;
import com.demcha.compose.document.layout.PlacedFragment;
import com.demcha.compose.document.layout.payloads.LayoutAnchorPayload;
@@ -10,20 +8,20 @@
*
*
The payload reports where an anchored subtree landed so a resolved-layout pass can
* read it; the ink is drawn by whatever the pass contributes, not here. A handler is
- * required all the same — {@code PdfFixedLayoutBackend.handlerFor} throws on a payload
- * class it does not know, so an unhandled marker would fail every render that contains
- * one.
+ * required all the same — {@link PdfFixedLayoutBackend}'s {@code handlerFor} throws on a
+ * payload class it does not know, so an unhandled anchor would fail every render that
+ * contains one.
+ *
+ *
Package-private, and here rather than in {@code ..pdf.handlers} for that reason: the
+ * resolved-layout seam is internal, and a public class is a permanent one. The siblings in
+ * that package are public because a caller can register them; nothing registers this.
The payload reports where an anchored subtree landed so a resolved-layout pass can
* read it; the ink is drawn by whatever the pass contributes, not here. A handler is
- * required all the same — {@code PptxFixedLayoutBackend.handlerFor} throws on a payload
- * class it does not know, so an unhandled marker would fail every render that contains
- * one.
+ * required all the same — {@link PptxFixedLayoutBackend}'s {@code handlerFor} throws on a
+ * payload class it does not know, so an unhandled anchor would fail every render that
+ * contains one.
+ *
+ *
Package-private, and here rather than in {@code ..pptx.handlers} for that reason: the
+ * resolved-layout seam is internal, and a public class is a permanent one. The siblings in
+ * that package are public because a caller can register them; nothing registers this.
*
* @author Artem Demchyshyn
* @since 2.4.0
*/
-public final class PptxLayoutAnchorRenderHandler
- implements PptxFragmentRenderHandler {
+final class PptxLayoutAnchorRenderHandler implements PptxFragmentRenderHandler {
- /**
- * Creates the layout-anchor handler.
- */
- public PptxLayoutAnchorRenderHandler() {
+ PptxLayoutAnchorRenderHandler() {
}
@Override