Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public final class DocumentSession implements AutoCloseable {
private boolean markdown;
private DocumentDebugOptions debug = DocumentDebugOptions.none();
private List<PageBackgroundFill> pageBackgrounds = List.of();
private List<ResolvedLayoutPass> layoutPasses = List.of();
private List<PageMarginRule> pageMargins = List.of();
private MeasurementResources measurementResources;
private boolean closed;
Expand Down Expand Up @@ -434,6 +435,31 @@ public DocumentSession pageBackgrounds(List<PageBackgroundFill> 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.
*
* <p>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.</p>
*
* @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<ResolvedLayoutPass> 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
Expand Down Expand Up @@ -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<DocumentPageZone> 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>The order matters and is not arbitrary. Backgrounds prepend their fragments and zones
* append theirs, so running passes first yields, with no index arithmetic:</p>
*
* <pre>
* background &lt; pass-under &lt; body &lt; pass-over &lt; zone chrome
* </pre>
*
* <p>A pass running after backgrounds would have its under-body fragment prepended to
* index 0 — beneath an opaque page background, and invisible.</p>
*
* @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<ResolvedLayoutPass> 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<PlacedFragment> under = new ArrayList<>();
List<PlacedFragment> over = new ArrayList<>();
for (ResolvedLayoutPass pass : passes) {
List<ResolvedLayoutAddition> 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<PlacedFragment> 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.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*
* @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.
*
* <p>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.</p>
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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
* <em>marker</em> 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.</p>
*
* <p>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.</p>
*
* @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<DocumentNode> children() {
return List.of(child);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.demcha.compose.document.layout;

/**
* Where a pass's fragment sits relative to the document body.
*
* <p>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.</p>
*
* @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
}
Loading
Loading