feat(layout): let a feature draw from geometry the layout has already resolved - #671
Merged
Merged
Conversation
… 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
force-pushed
the
feat/resolved-layout-seam
branch
from
September 9, 2026 15:22
2ac0626 to
cad9c68
Compare
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.emitFragmentsandemitOverlayFragmentsreceive only the node's ownPreparedNode,FragmentContextandFragmentPlacement; the positions its children resolved to go straight into the compiler'sfragments/nodeslists, andCompilerStateis package-private.Measured before designing anything:
PlacedNodeboxes andPlacedFragmentboxes share onecoordinate space — bottom-left origin, y growing up — so a pass over the finished graph
needs no conversion.
What changed
ResolvedLayoutPassruns after compilation, over a finishedLayoutGraphplus theanchors collected from it, and may only add fragments — never remove, reorder,
mutate, add pages or touch the canvas.
LayoutAnchorNodewraps a subtree and emits one non-visual fragment saying where itlanded. 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.
included. Two boxes are in play:
preparehas to measure the child's margin box or thesurrounding flow is wrong, so
emitFragmentstakes the margin back off, in the offset aswell 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 raildrawn through that centre would visibly miss.
LayoutAnchorIdcomparesgroupKeyandkindwith==. No path, node name orstring convention is involved — paths are the compiler's business and it renames them
freely (
LayoutCompiler.pathForuses the node kind as the segment of an unnamed node).It refuses a
Stringor a boxed number for the same reason it uses==.ResolvedLayoutPassessplices[under-body…, body…, over-body…]and runs before thepage backgrounds. Order is not arbitrary: backgrounds prepend and zones append, so passes
first gives
background < pass-under < body < pass-over < zone chromewith no indexarithmetic. A pass running after backgrounds would have its under-body fragment pushed to
index 0, beneath an opaque fill, and never seen.
PptxClipSafetyentry. They drawnothing and exist because
handlerForthrows on a payload class it does not know. Bothare package-private, beside their backend rather than in the
handlerspackage —whose members are public because a caller can register them, which is not true of a
handler for an internal payload.
CompositeDecorationare 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 inLayoutAnchorRendersNothingTest, 3 inResolvedLayoutSeamStaysInternalTest.javadoc:javadocgreen; 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
applydroppeda 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
LayoutAnchorRendersNothingTestrenders adocument 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 withUnsupportedNodeCapabilityException, 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
emitFragmentsbuilds fresh payloads on each pass while the id theycarry 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
registerLayoutPasspublic turns the guard red, and so does making eitherhandler public — checked by doing both and watching
ResolvedLayoutSeamStaysInternalTestfail, not by reasoning about it.Notes
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.
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, threadsafety, 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.