Skip to content

feat(layout): let a feature draw from geometry the layout has already resolved - #671

Merged
DemchaAV merged 2 commits into
developfrom
feat/resolved-layout-seam
Sep 9, 2026
Merged

DemchaAV merged 2 commits into
developfrom
feat/resolved-layout-seam

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Sep 9, 2026

Copy link
Copy Markdown
Owner

First of two. This is the engine seam alone; the Timeline rework that motivated it lands
separately on top, and no Timeline file is touched here.

Why

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 carries that back. NodeDefinition.emitFragments and
emitOverlayFragments receive only the node's own PreparedNode, FragmentContext and
FragmentPlacement; the positions its children resolved to go straight into the compiler's
fragments/nodes lists, and CompilerState is package-private.

Measured before designing anything: PlacedNode boxes and PlacedFragment boxes share one
coordinate space — bottom-left origin, y growing up — so a pass over the finished graph
needs no conversion.

What changed

  • ResolvedLayoutPass runs after compilation, over a finished LayoutGraph plus the
    anchors collected from it, and may only add fragments — never remove, reorder,
    mutate, add pages or touch the canvas.
  • LayoutAnchorNode wraps a subtree and emits one non-visual fragment saying where it
    landed. It measures to its child rather than to the width it is offered, which is what
    makes the anchor the marker's box rather than its container's.
  • The anchor is the wrapped node's border box — its own margin excluded, its padding
    included. Two boxes are in play: prepare has to measure the child's margin box or the
    surrounding flow is wrong, so emitFragments takes the margin back off, in the offset as
    well as the size. Reporting the margin box would put a marker's anchor centre off its own
    ink — 2pt in both directions for margin(top 2, right 4, bottom 6, left 8) — and a rail
    drawn through that centre would visibly miss.
  • LayoutAnchorId compares groupKey and kind with ==. No path, node name or
    string convention is involved — paths are the compiler's business and it renames them
    freely (LayoutCompiler.pathFor uses the node kind as the segment of an unnamed node).
    It refuses a String or a boxed number for the same reason it uses ==.
  • ResolvedLayoutPasses splices [under-body…, body…, over-body…] and runs before the
    page backgrounds. Order is not arbitrary: backgrounds prepend and zones append, so passes
    first gives background < pass-under < body < pass-over < zone chrome with no index
    arithmetic. A pass running after backgrounds would have its under-body fragment pushed to
    index 0, beneath an opaque fill, and never seen.
  • Two no-op render handlers, pdf and pptx, plus a PptxClipSafety entry. They draw
    nothing and exist because handlerFor throws on a payload class it does not know. Both
    are package-private, beside their backend rather than in the handlers package —
    whose members are public because a caller can register them, which is not true of a
    handler for an internal payload.
  • The compiler, the resolver and CompositeDecoration are not touched.

Verification

./mvnw -B -ntp clean verify -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-qa,:graph-compose-coverage -am
BUILD SUCCESS, 10 modules. 25 tests — 19 in ResolvedLayoutPassTest, 3 in
LayoutAnchorRendersNothingTest, 3 in ResolvedLayoutSeamStaysInternalTest.
javadoc:javadoc green; every knowledge gate green (surfaces current, stability doc,
classifier, claims, routes, bundle verify).

The zero-pass claim is asserted by reference, not by comparison. apply(graph, List.of())
must return the same object. An earlier version of that test compiled the same document
twice and compared the two graphs — which would have passed just as well if apply dropped
a fragment from both. That is the one assertion a consistent corruption cannot satisfy.

The anchor box is asserted where each naive form is known to be wrong. A bare 8×8
ellipse in a table cell reports a 16×8 fragment — the cell's; wrapped, the anchor reports
8×8 at the ellipse's own x. A marker carrying an asymmetric margin reports the ink, not the
ink plus its spacing. A composed marker — three concentric dots in a layer stack, which the
compiler emits as three separate ellipse fragments — reports one anchor at the declared
16×16 box, and its centre is the centre of the marker rather than of whichever shape drew
first. Anchor a container instead and the container is what comes back, which is the whole
of the rule: you get the box of the node you wrapped.

Both backends are asked at render time, not at registration. A handler is one line in a
list and deleting it breaks no compile, so LayoutAnchorRendersNothingTest renders a
document containing an anchor through each fixed backend and compares it to the same
document without the wrapper — the PDF page pixel for pixel, the deck by shape count.
Removing either handler from its defaultHandlers() turns it red with
UnsupportedNodeCapabilityException, checked by doing it.

The owner reaches the pass by reference, carrying its own configuration. That is the
shape a built-in feature needs and the reason nothing has to be registered from the DSL:
one object per logical instance, the feature's settings hanging off it, every anchor keyed
on it. Asserted through the resolver's recompiling path — per-page margins put the document
on the fixed point, so emitFragments builds fresh payloads on each pass while the id they
carry comes from the semantic node, allocated once. A pass asking for an owner the document
does not contain gets an empty list, which is how it decides it has nothing to do.

Making registerLayoutPass public turns the guard red, and so does making either
handler public — checked by doing both and watching
ResolvedLayoutSeamStaysInternalTest fail, not by reasoning about it.

Notes

  • An anchored subtree that spans pages reports one anchor per page, sharing an id and
    each carrying the subtree's whole height rather than the slice on that page. A composite
    emits its fragments once per page it occupies. Small atomic content — what anchors are
    for — never reaches this, and fixing it for a consumer that does not exist would be
    speculative, so it is pinned by a test and stated in the Javadoc instead.
  • The seam is internal, and adds nothing at all to the public API. Opening it would
    mean settling when passes run, in what order, what one sees of another, whether one may
    change nodes or add pages, resolver re-entry, a second layoutGraph() call, thread
    safety, determinism, duplicate ids, anchor lifecycle, pass failure and backend semantics
    — none of which the built-in case needs answered.

Lane: shared-engine — document.layout, the compiler's post-compilation splice chain,
and one handler per fixed backend.

… resolved

Some things cannot be drawn while laying out because they depend on where
other things ended up — a rail between markers, a bracket across sections, a
leader joining a callout to its subject. Nothing carried that back: a node
definition is handed its own box and nothing else, the positions its children
resolved to go straight into the compiler's lists, and CompilerState is
package-private.

A pass runs afterwards, over the finished graph, and may only add fragments.
Not remove, reorder, mutate, add pages or touch the canvas — so with nothing
registered the driver hands back the very graph it was given, asserted by
reference. Comparing two compiles to each other would have proved only that
the new path is deterministic: an apply() that dropped a fragment would drop
it from both and every assertion would still pass.

Anchors carry identity, not a name. LayoutAnchorId compares groupKey and kind
with ==, so two timelines on one page cannot collide and no path or node name
is ever parsed — paths are the compiler's business and it renames them freely.
It refuses a String or a boxed number for the same reason it uses ==: the same
literal twice is interned and matches, the same text computed does not, and a
caller who reached for a readable key would get an empty anchor set, nothing
drawn and no error to read.

LayoutAnchorNode measures to its child rather than to the width it is offered,
which is what makes the anchor the marker's box: dropped bare into a table cell
an 8x8 ellipse reports 16x8, and a centre taken from that lands 4pt out.
Wrapped, it reports 8x8 at the ellipse's own x, asserted in a cell. An anchored
subtree that spans pages reports one anchor per page, sharing an id and each
carrying the whole height — pinned by a test rather than left to be discovered,
since small atomic content, which anchors are for, never reaches it.

Metadata is collected once, before the first pass runs, and the same immutable
view goes to every pass, so nothing a pass contributes can seed an anchor for
the next one. Passes run in registration order; UNDER_BODY additions splice
ahead of the body and OVER_BODY behind it, because this engine has no z-index
and draw order is list order. They run before the page backgrounds for the same
reason: a background prepends, so a pass running after it would have its
under-body fragment pushed beneath an opaque fill and never seen.

Internal on purpose. Opening this would mean settling when passes run, what one
sees of another, whether one may change nodes or add pages, failure handling,
re-entrancy and thread safety — none of which the case that motivated it needs
answered. ResolvedLayoutSeamStaysInternalTest reads the generated surfaces and
says so; making registerLayoutPass public turns it red.

The two render handlers draw nothing and exist because handlerFor throws on a
payload class it does not know. They are public like every other handler, which
is the one piece of surface this adds — named in the guard rather than omitted
from it, so a third would go red.
@DemchaAV
DemchaAV force-pushed the feat/resolved-layout-seam branch from 2ac0626 to cad9c68 Compare September 9, 2026 15:22
…he ink

The seam adds nothing public now, not even a render handler. Each fixed
backend does need one — handlerFor refuses a payload class it does not know —
but a handler for an internal payload is not something a caller can hold, and
a public class is permanent. Both are package-private and sit beside their
backend rather than in the handlers package, whose members are public
precisely because a caller can register those. The guard says the seam admits
nothing, instead of naming two allowed exceptions; making either one public
turns it red.

An anchor now reports the wrapped node's border box, its own margin excluded.
Two boxes are in play: prepare() has to measure the child's margin box or the
surrounding flow is wrong, so emitFragments takes the margin back off, in the
offset as well as the size. Reporting the margin box put a marker's centre off
its own ink — 2pt each way for margin(2, 4, 6, 8) on an 8x8 dot — and a rail
drawn through that centre would visibly miss. Which box it is was measured
before it was changed: the compiler seats the child at the anchor's
bottom-left plus (left, bottom), y growing up.

The multi-fragment case is now multi-fragment. It claimed a composed marker
and passed a single ellipse, so it asserted nothing the plain case did not
already cover. It is three concentric dots in a layer stack, which the
compiler emits as three ellipse fragments; the test asserts that premise
before asserting one anchor at the declared 16x16 box, and that the anchor's
centre is the marker's centre rather than the first shape's.

Registration is proven at render time, not at compile time. A handler is one
line in a list and deleting it breaks nothing that compiles, so a document
carrying an anchor is now rendered through both fixed backends and compared to
the same document without the wrapper — the PDF page pixel for pixel, the deck
by shape count. Removing either handler turns that red with
UnsupportedNodeCapabilityException, which is the failure it exists to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@DemchaAV
DemchaAV merged commit bc49015 into develop Sep 9, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feat/resolved-layout-seam branch September 9, 2026 16:15
DemchaAV added a commit that referenced this pull request Sep 12, 2026
Brings the 2.4.0 engine work onto the promotion branch: native letter
spacing (#676), opt-in list hanging indent (#674), the resolved timeline
rail (#671-#673), the row-child margin fix, the RTL documentation
corrections (#679, #680) and the templates japicmp gate (#681).

Eight files conflicted. CHANGELOG.md is a union of both v2.4.0 sections,
with the branch-local "### Deprecated" folded into the house heading
"### Deprecations" and the sections ordered the way released entries are.
The other seven are generated and were regenerated from the merged source
rather than resolved by side: knowledge/api/templates.json and .md through
extract-api --from-reactor, and the five cv preview PDFs by re-rendering
their example classes.

Five qa baselines moved, all from f75def6, which stops a row child's
horizontal margin being taken off twice. Each of the four layout snapshots
changes by exactly one node's own horizontal margin - HeadingRule_EXPERIENCE
+9.0, EducationHeadingRule +11.285, FooterDueIcon -3.479 (a negative margin)
and FooterSite +1.693 (a right margin) - with startPage and endPage
unchanged, so no page ownership moved. cobalt_rota keeps its geometry
snapshot and moves only in pixels, inside composed table cells, which emit
fragments rather than PlacedNodes and so cannot appear in a layout snapshot;
the changed region is the day-header and note cells. One of 126 pixel
baselines changed, verified by checksum before and after.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant