fix(layout): take a margin off once between measure and placement - #666
Merged
Merged
Conversation
A row band is sized in two passes over the same children. NodeDefinitionSupport.measureRow prepares each child inside its slot less that child's own margin and takes the tallest result as the band height. The placement pass in LayoutCompiler then prepares the child a second time to place it -- and handed prepareForRegionWidth a width the margin had already been taken out of. That helper subtracts the margin itself, so placement ran at slot - 2 * margin against a band measured from slot - margin. A left or right inset on a row child was the whole trigger. With enough text to wrap, the second subtraction bought an extra line and the band's own guard rejected the document it had just measured: on a 400x300 page with 20pt page margins, a section carrying a 20pt margin each side was measured at 140 and laid out at 100, and threw "child 'SectionNode' measured height 77.7 exceeds row inner height". Only the page-level band throws. The other two symptoms were silent. The same seating loop exists a second time in placeRowBandInFixedSlot, which seats a row nested in a LayerStack layer or a composed table cell, and there is no inner-height guard there -- so the tallest child came out taller than the band sized for it, the cross-axis slack went negative, and a non-TOP verticalAlign seated the child above its band and spilled it through the top. That slack going negative contradicts the invariant the code comment beside it already asserts. And compileNodeInFixedSlot always derived its own region as childAvailableWidth(slotWidth, node), so a composite row child reported one width while its own children were laid out in another. Both sites now pass the whole slot and let childAvailableWidth perform the single subtraction, which is the contract the other five callers of prepareForRegionWidth already used -- each passes a region with only the parent's margin and padding removed, never the child's own. The two helpers now say so in Javadoc; the undocumented contract is what let one bug exist in two places. Clamping is unchanged: the removed Math.max at the call sites is the same clamp childAvailableWidth still applies, and a margin wider than its slot still yields zero. This changes output only for a row child carrying a left or right margin, which now occupies its slot less that margin rather than less twice it. A row child without one is untouched -- the arithmetic is identical at zero. Tests: RowChildMarginWidthTest, five cases. Two read the width base back out of the placed node at each site, using a CENTER-aligned paragraph because a LEFT one shrink-wraps to its text and would hide the base. One pins a composite child's own width against the region its children were given, which is the only assertion that catches that third symptom -- an earlier version asserted on the inner paragraph instead and passed on the unfixed code, since the inner region was always right. One pins the fixed-slot child inside its band, and one is the reported repro. Verified fails-closed per hunk: reverting the page-level site alone turns three red, reverting the fixed-slot site turns the other two red. Reactor gate green, 2023 tests. No layout snapshot, pixel baseline or committed preview moves, and neither gate could have caught this: nothing in the templates, the examples or the fixtures puts a horizontal margin on a row child. Every such margin in examples/ is a page margin, a section child, or a CanvasChild layer, none of which routes through the row band. Knowledge-pack surfaces unchanged -- LayoutCompiler is package-@internal and excluded from them.
…asses A cell reserves a box for the node handed to DocumentTableCell.node(...), then the fragment pass lays that node out inside the box. The two started from different geometry. Horizontally, the cell was measured at its full inner width with the node's margin left in, while the fixed-box walk that places a composite removes that margin itself. The content re-wrapped one margin narrower than the row had been sized for: measured on a 260pt column, a section with a 20pt side margin measured 3 lines (h=38.85) and drew 4 (h=51.80), so its text painted 8.95pt below the table and its own box, still carrying the wider measured width but shifted right by margin.left, reached 295.34 against a cell edge of 280. Vertically it failed the other way round: naturalCellHeight added the cell's padding but not the node's margin, while placement still applied margin.top. A 20pt vertical margin left the row height unchanged and dropped the content 20pt, spilling 16.00pt below the table. On a leaf child -- a bare paragraph or image, which goes straight to its own emitFragments rather than the fixed-box walk -- the margin was discarded altogether, and the geometry was identical to no margin at all. A cell's inner box is now the content's margin box, which is the contract a row slot already uses: prepareComposedCells measures inside the margin, naturalCellHeight reserves it, and emitComposedCellFragments places the content one margin in on both axes. That leaves the seam that allowed it. A FragmentPlacement is a CONTENT box -- both callers, the composed cell and ChartDefinition, build one at the exact rectangle the content should occupy and pass Margin.zero() -- but emitCompositeSubtree handed it to compileFixedBoxSubtree, which reads a MARGIN box and subtracts the node's margin from it. The bridge now converts between the two, so a composite child is laid out in the rectangle its owner reserved rather than one margin inside it. Every conversion is an identity at zero margin, so nothing that ships today moves a point. This changes output for composed cell content carrying a margin, which now sits inside the room the cell reserves for it instead of spilling out of it. Tests: ComposedCellMarginTest, three cases -- a horizontal margin must not push the content below its own table, a vertical margin must be reserved by the cell, and a leaf child's margin must not be discarded. Each asserts containment rather than an absolute coordinate, so none depends on a font metric. All three fail on the unfixed engine; a fourth case asserting the box overhang was written and dropped because it passed either way -- it read the shrink-wrapped text fragment rather than the section box, so it would have been decoration. Reactor gate green, 2026 tests. No layout snapshot, pixel baseline or committed preview moves: every composed cell in the templates and the examples uses zero margins, which is also why neither gate caught this.
DemchaAV
force-pushed
the
fix/row-child-margin-double-subtraction
branch
from
September 8, 2026 07:52
4efd0f8 to
78872ff
Compare
Abneco
pushed a commit
to Abneco/graphcompose
that referenced
this pull request
Sep 15, 2026
The row child's horizontal margin is taken off once now, so a card with a 20pt side margin and `fixedWidth(120)` in a 180pt slot of a 400pt-wide page is placed at 120, not at the 100 the double subtraction produced. The test covering that shape asserted only that the content stayed inside the painted box — deliberately, because the width was wrong at the time — so nothing held the number itself. It asserts the width now; against pre-DemchaAV#666 core it fails at 100. The other side of the clamp gets a test too: 160 plus the same 40pt margin does not fit the slot, and resolves to the 140 the margin leaves rather than overflowing the row. The changelog's note that this configuration is knowingly still wrong goes with it.
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.
Why
A node's margin was subtracted twice between the two passes that lay it out, in two unrelated places, because the seam between them was never written down.
A row band is sized by
NodeDefinitionSupport.measureRow, which prepares each child inside its slot less that child's own margin, then placed byLayoutCompiler, which prepares it again. The placement pass handedprepareForRegionWidtha width the margin had already been taken out of — and that helper subtracts the margin itself. The child was laid out atslot - 2 * marginagainst a band measured fromslot - margin, wrapped into a line the band had no room for, and the row's own guard rejected the document it had just measured:Only that one path throws. The same disagreement is silent in
placeRowBandInFixedSlot(a row in a LayerStack layer or a composed table cell), which has no inner-height guard: the tallest child is seated above its band and spills through the top under a non-TOPverticalAlign. AndcompileNodeInFixedSlotalways derived its own region asslot - margin, so a composite row child reported one width while its own children were laid out in another.A multi-angle review of that fix found the same defect class one layer away, in composed table cells, and it is fixed in the second commit.
DocumentTableCell.node(...)measured its content at the cell's full inner width with the margin left in; the fixed-box walk then removed it. On a 260pt column a section with a 20pt side margin measured 3 lines and drew 4, painting 8.95pt below its own table while its box, still carrying the wider measured width but shifted right bymargin.left, reached 295.34 against a cell edge of 280. Vertically it failed the other way: the cell height ignored the margin outright while placement still appliedmargin.top, dropping the content 16.00pt through the cell floor. On a leaf child the margin was discarded entirely.What changed
LayoutCompiler— both row-seating loops pass the whole slot toprepareForRegionWidthand letchildAvailableWidthperform the single subtraction. That is the contract the other five callers already used: each passes a region with only the parent's margin and padding removed. The clamp is unchanged — the removedMath.maxat the call sites is the same one the helper still applies.prepareForRegionWidth/childAvailableWidth— now carry Javadoc stating that the argument is the region the node's margin box occupies. The undocumented contract is what let one bug exist in two places.TableLayoutSupport— a cell's inner box is the content's margin box, the same contract a row slot uses:prepareComposedCellContentsmeasures inside the margin andnaturalCellHeightreserves it on the vertical axis.NodeDefinitionSupport.emitComposedCellFragments— places the content one margin in on both axes, and computes the anchor slack against the content's outer height.DocumentLayoutPassContext.emitCompositeSubtree— aFragmentPlacementis a content box (both callers, the composed cell andChartDefinition, build one at the exact rectangle and passMargin.zero()), but the fixed-box walk reads a margin box. The bridge now converts between the two. Every conversion is an identity at zero margin, so nothing shipped today moves a point.Behaviour change. Output moves only for a row child, or composed cell content, that carries a margin — it now occupies the room reserved for it instead of a smaller box. Content without a margin is untouched; the arithmetic is identical at zero.
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, 2043 tests, 0 failures, on this branch rebased onto currentdevelop. Knowledge surfaces, claims and routes all current.RowChildMarginWidthTest(5) — the width base read back out of the placed node at each row-seating site; a composite row child's own width against the region its children were given; the fixed-slot child kept inside its band; and the reported repro. Verified fails-closed per hunk: reverting the page-level site alone turns three red, reverting the fixed-slot site turns the other two red.ComposedCellMarginTest(3) — a horizontal margin must not push content below its own table, a vertical margin must be reserved by the cell, and a leaf child's margin must not be discarded. All three fail on the unfixed engine.Assertions are containment and derived widths rather than absolute coordinates, so none depends on a font metric.
No layout snapshot, pixel baseline or committed preview moves. Independently established three ways: 154 row children across the 64 committed layout snapshots, 124 across all 33 shipped presets, and a full
PlacedNode/PlacedFragmentdump of those presets under both engines that diffs empty. Nothing in the templates, the examples or the fixtures puts a horizontal margin on a row child, or a margin on composed cell content — which is also why neither gate caught either bug.Lane: shared-engine — layout compiler and table measurement; no public API surface changed (
document.layoutis@Internalat package level, andLayoutCompileris excluded from the knowledge surfaces).Known gaps, deliberately not in this PR
placeRowBandInFixedSlotstill has no inner-height guard. Adding the throw is a behaviour change for documents that currently overflow silently, and the state it would catch was only reachable through the composed-cell bug fixed here.placeRowBandInFixedSlot— it was created by copying that loop, carrying the double subtraction with it, which is why this one-line fix had to be applied twice. Extracting it is the durable fix and belongs in its own PR.