diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c71338d..a3f00f19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,22 @@ 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, and adds nothing at all to the public API. Opening it 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. Each fixed backend does need a + handler for the anchor's payload, since it refuses a payload class it has no handler + for; both are package-private and draw nothing. + - **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..6f5a562c5 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/LayoutAnchorNode.java @@ -0,0 +1,57 @@ +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. What comes back is the child's border + * box — margin excluded, padding included; see + * {@link ResolvedLayoutAnchor} for the whole contract. Wrap the node you want to measure: + * anchor a marker and you get the marker, anchor the container it sits in and you get the + * container.

+ * + *

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..88b1f8722 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/ResolvedLayoutAnchor.java @@ -0,0 +1,70 @@ +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 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 wrapped node's border-box width + * @param height the wrapped node's border-box 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..1decaf435 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/definitions/LayoutAnchorDefinition.java @@ -0,0 +1,98 @@ +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 com.demcha.compose.document.style.DocumentInsets; + +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.

+ * + *

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 + */ +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 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, + 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 new file mode 100644 index 000000000..fcb43fbcc --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/payloads/LayoutAnchorPayload.java @@ -0,0 +1,54 @@ +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 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 border-box width + * @param height the anchored child's border-box height + * @author Artem Demchyshyn + * @since 2.4.0 + */ +public record LayoutAnchorPayload(LayoutAnchorId id, double width, double height) { + + /** + * Validates the payload. + * + * @throws NullPointerException if {@code id} is null + * @throws IllegalArgumentException if a dimension is negative or non-finite + */ + public LayoutAnchorPayload { + Objects.requireNonNull(id, "id"); + requireSize(width, "width"); + requireSize(height, "height"); + } + + private static void requireSize(double value, String name) { + if (!Double.isFinite(value) || value < 0.0) { + throw new IllegalArgumentException( + "Anchor " + name + " must be finite and non-negative, was " + value + "."); + } + } +} diff --git a/knowledge/api/excluded.json b/knowledge/api/excluded.json index 0b66ccd2b..bf81274b3 100644 --- a/knowledge/api/excluded.json +++ b/knowledge/api/excluded.json @@ -3,7 +3,7 @@ "verifiedAgainst": "2.4.0-SNAPSHOT", "generator": "knowledge/tools/api-surface/extract-api.mjs", "note": "Public types and members deliberately kept out of every surface. An exclusion nobody can see is indistinguishable from a bug, so each one records why.", - "count": 164, + "count": 173, "excluded": [ { "binaryName": "com.demcha.compose.document.backend.fixed.pptx.handlers.PptxChromeRenderer", @@ -208,6 +208,13 @@ "artifact": "graph-compose-core", "reason": "package @Internal (com.demcha.compose.document.layout.definitions)" }, + { + "binaryName": "com.demcha.compose.document.layout.definitions.LayoutAnchorDefinition", + "package": "com.demcha.compose.document.layout.definitions", + "kind": "class", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout.definitions)" + }, { "binaryName": "com.demcha.compose.document.layout.definitions.LineDefinition", "package": "com.demcha.compose.document.layout.definitions", @@ -355,6 +362,20 @@ "artifact": "graph-compose-core", "reason": "package @Internal (com.demcha.compose.document.layout)" }, + { + "binaryName": "com.demcha.compose.document.layout.LayoutAnchorId", + "package": "com.demcha.compose.document.layout", + "kind": "record", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout)" + }, + { + "binaryName": "com.demcha.compose.document.layout.LayoutAnchorNode", + "package": "com.demcha.compose.document.layout", + "kind": "record", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout)" + }, { "binaryName": "com.demcha.compose.document.layout.LayoutCanvas", "package": "com.demcha.compose.document.layout", @@ -369,6 +390,13 @@ "artifact": "graph-compose-core", "reason": "package @Internal (com.demcha.compose.document.layout)" }, + { + "binaryName": "com.demcha.compose.document.layout.LayoutDepth", + "package": "com.demcha.compose.document.layout", + "kind": "enum", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout)" + }, { "binaryName": "com.demcha.compose.document.layout.LayoutFragment", "package": "com.demcha.compose.document.layout", @@ -488,6 +516,13 @@ "artifact": "graph-compose-core", "reason": "package @Internal (com.demcha.compose.document.layout.payloads)" }, + { + "binaryName": "com.demcha.compose.document.layout.payloads.LayoutAnchorPayload", + "package": "com.demcha.compose.document.layout.payloads", + "kind": "record", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout.payloads)" + }, { "binaryName": "com.demcha.compose.document.layout.payloads.LineFragmentPayload", "package": "com.demcha.compose.document.layout.payloads", @@ -712,6 +747,34 @@ "artifact": "graph-compose-core", "reason": "package @Internal (com.demcha.compose.document.layout)" }, + { + "binaryName": "com.demcha.compose.document.layout.ResolvedLayoutAddition", + "package": "com.demcha.compose.document.layout", + "kind": "record", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout)" + }, + { + "binaryName": "com.demcha.compose.document.layout.ResolvedLayoutAnchor", + "package": "com.demcha.compose.document.layout", + "kind": "record", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout)" + }, + { + "binaryName": "com.demcha.compose.document.layout.ResolvedLayoutMetadata", + "package": "com.demcha.compose.document.layout", + "kind": "class", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout)" + }, + { + "binaryName": "com.demcha.compose.document.layout.ResolvedLayoutPass", + "package": "com.demcha.compose.document.layout", + "kind": "interface", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout)" + }, { "binaryName": "com.demcha.compose.document.layout.SplitRequest", "package": "com.demcha.compose.document.layout", 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 new file mode 100644 index 000000000..e7c44f1af --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/ResolvedLayoutPassTest.java @@ -0,0 +1,580 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.layout.LayoutAnchorId; +import com.demcha.compose.document.layout.LayoutAnchorNode; +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.ResolvedLayoutAnchor; +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; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.within; + +/** + * The resolved-layout seam: anchors report where a subtree landed, and passes add + * fragments derived from that once the layout is settled. + * + *

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 = 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)); + } + + @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 { + // 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(); + 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), 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()).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 + // 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 = 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)); + 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 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)) + .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 payloadsOf(List fragments) { + return fragments.stream().map(PlacedFragment::payload).toList(); + } + + private static int indexOfFirstNonTag(LayoutGraph graph) { + List fragments = graph.fragments(); + for (int i = 0; i < fragments.size(); i++) { + if (!(fragments.get(i).payload() instanceof String)) { + return i; + } + } + return fragments.size(); + } + + private static LayoutGraph compile(Consumer spec, + List passes) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(240, 200) + .margin(DocumentInsets.of(20)) + .create()) { + for (ResolvedLayoutPass pass : passes) { + session.registerLayoutPass(pass); + } + var flow = session.pageFlow(); + spec.accept(flow); + flow.build(); + return session.layoutGraph(); + } + } + + private interface Contribution { + List apply(LayoutGraph graph, ResolvedLayoutMetadata metadata); + } + + private static ResolvedLayoutPass pass(String id, Contribution contribution) { + return new ResolvedLayoutPass() { + @Override + public String id() { + return id; + } + + @Override + public List contribute(LayoutGraph graph, ResolvedLayoutMetadata metadata) { + return contribution.apply(graph, metadata); + } + }; + } + + /** Contributes one tagged fragment per name, so ordering is readable in an assertion. */ + private record RecordingPass(String id, LayoutDepth depth, String... tags) implements ResolvedLayoutPass { + @Override + public List contribute(LayoutGraph graph, ResolvedLayoutMetadata metadata) { + List additions = new ArrayList<>(); + for (String tag : tags) { + additions.add(new ResolvedLayoutAddition(depth, + PlacedFragment.withZeroInsets("@" + tag, 0, 0, 0.0, 0.0, 1.0, 1.0, tag))); + } + return additions; + } + } +} 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 new file mode 100644 index 000000000..58e852c19 --- /dev/null +++ b/qa/src/test/java/com/demcha/documentation/ResolvedLayoutSeamStaysInternalTest.java @@ -0,0 +1,104 @@ +package com.demcha.documentation; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Keeps the resolved-layout seam off the public surface. + * + *

The seam exists so a built-in feature can draw from geometry it could not know during + * layout. Making it public would mean settling, permanently, when passes run, in what + * order, what one may see of another, whether one may change nodes or add pages, what + * happens on failure, and how any of it behaves on a second compile. None of that is + * needed by the feature that motivated it, and answering it by accident is worse than + * leaving it open.

+ * + *

Nothing enforces that on its own. The types sit in {@code @Internal} packages, which + * excludes them today, but an {@code @Internal} package is one annotation away from not + * 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.

+ * + *

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 { + + private static final List SEAM_TYPES = List.of( + "LayoutAnchorId", + "LayoutAnchorNode", + "LayoutAnchorPayload", + "LayoutAnchorDefinition", + "LayoutDepth", + "ResolvedLayoutAddition", + "ResolvedLayoutAnchor", + "ResolvedLayoutMetadata", + "ResolvedLayoutPass", + "ResolvedLayoutPasses", + "PdfLayoutAnchorRenderHandler", + "PptxLayoutAnchorRenderHandler"); + + @Test + void noSeamTypeIsAdmittedToAPublicSurface() throws IOException { + Path apiDir = RepoRoot.get().resolve("knowledge/api"); + assertThat(apiDir).as("the knowledge pack must be present to check against").exists(); + + List admitted = new ArrayList<>(); + try (var files = Files.list(apiDir)) { + for (Path surface : files.filter(p -> p.getFileName().toString().endsWith(".json")).toList()) { + if (surface.getFileName().toString().equals("excluded.json")) { + continue; + } + String json = Files.readString(surface); + for (String type : SEAM_TYPES) { + // A type entry, not a mention in a signature: the surfaces write an + // admitted type as "name": "Foo". + if (json.contains("\"name\": \"" + type + "\"")) { + admitted.add(surface.getFileName() + " admits " + type); + } + } + } + } + + assertThat(admitted) + .as("the resolved-layout seam is internal; promoting it needs its own design, " + + "not a side effect of the feature that first used it") + .isEmpty(); + } + + @Test + void theRegistrationMethodIsNotOnTheSession() throws IOException { + Path authoring = RepoRoot.get().resolve("knowledge/api/authoring.json"); + assertThat(Files.readString(authoring)) + .as("registerLayoutPass is package-private and must not reach the authoring surface") + .doesNotContain("registerLayoutPass"); + } + + @Test + 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("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/PdfFixedLayoutBackend.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java index e0b792406..84f787150 100644 --- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java +++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java @@ -139,6 +139,7 @@ private static List> defaultHandlers() { new PdfTransformBeginRenderHandler(), new PdfTransformEndRenderHandler(), new PdfAnchorMarkerRenderHandler(), + new PdfLayoutAnchorRenderHandler(), new PdfBookmarkMarkerRenderHandler()); } diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfLayoutAnchorRenderHandler.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfLayoutAnchorRenderHandler.java new file mode 100644 index 000000000..a9549e445 --- /dev/null +++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfLayoutAnchorRenderHandler.java @@ -0,0 +1,38 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.LayoutAnchorPayload; + +/** + * Draws nothing for a {@link LayoutAnchorPayload} fragment. + * + *

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 — {@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.

+ * + * @author Artem Demchyshyn + * @since 2.4.0 + */ +final class PdfLayoutAnchorRenderHandler implements PdfFragmentRenderHandler { + + PdfLayoutAnchorRenderHandler() { + } + + @Override + public Class payloadType() { + return LayoutAnchorPayload.class; + } + + @Override + public void render(PlacedFragment fragment, + LayoutAnchorPayload payload, + PdfRenderEnvironment environment) { + // Intentionally empty: an anchor is metadata, not ink. + } +} diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java index cc4255b6f..13cb1e790 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java @@ -2,6 +2,7 @@ import com.demcha.compose.document.layout.PlacedFragment; import com.demcha.compose.document.layout.payloads.AnchorMarkerPayload; +import com.demcha.compose.document.layout.payloads.LayoutAnchorPayload; import com.demcha.compose.document.layout.payloads.BookmarkMarkerPayload; import com.demcha.compose.document.layout.payloads.BarcodeFragmentPayload; import com.demcha.compose.document.layout.payloads.EllipseFragmentPayload; @@ -118,7 +119,8 @@ static boolean clipCannotRemoveInk(ShapeClipBeginPayload clip, if (payload instanceof TransformEndPayload || payload instanceof ShapeClipEndPayload || payload instanceof AnchorMarkerPayload - || payload instanceof BookmarkMarkerPayload) { + || payload instanceof BookmarkMarkerPayload + || payload instanceof LayoutAnchorPayload) { // Markers draw nothing themselves. continue; } diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackend.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackend.java index a788ccddf..e797b234e 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackend.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackend.java @@ -195,6 +195,7 @@ private static List> defaultHandlers() { new PptxShapeClipBeginRenderHandler(), new PptxShapeClipEndRenderHandler(), new PptxAnchorMarkerRenderHandler(), + new PptxLayoutAnchorRenderHandler(), new PptxBookmarkMarkerRenderHandler()); } diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxLayoutAnchorRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxLayoutAnchorRenderHandler.java new file mode 100644 index 000000000..7b80d3934 --- /dev/null +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxLayoutAnchorRenderHandler.java @@ -0,0 +1,38 @@ +package com.demcha.compose.document.backend.fixed.pptx; + +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.LayoutAnchorPayload; + +/** + * Draws nothing for a {@link LayoutAnchorPayload} fragment. + * + *

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 — {@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 + */ +final class PptxLayoutAnchorRenderHandler implements PptxFragmentRenderHandler { + + PptxLayoutAnchorRenderHandler() { + } + + @Override + public Class payloadType() { + return LayoutAnchorPayload.class; + } + + @Override + public void render(PlacedFragment fragment, + LayoutAnchorPayload payload, + PptxRenderEnvironment environment) { + // Intentionally empty: an anchor is metadata, not ink. + } +}