Skip to content

fix(layout): take a margin off once between measure and placement - #666

Merged
DemchaAV merged 2 commits into
developfrom
fix/row-child-margin-double-subtraction
Sep 8, 2026
Merged

DemchaAV merged 2 commits into
developfrom
fix/row-child-margin-double-subtraction

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Sep 8, 2026

Copy link
Copy Markdown
Owner

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 by LayoutCompiler, which prepares it again. The placement pass handed prepareForRegionWidth a width the margin had already been taken out of — and that helper subtracts the margin itself. The child was laid out at slot - 2 * margin against a band measured from slot - margin, wrapped into a line the band had no room for, and the row's own guard rejected the document it had just measured:

IllegalStateException: Row 'ContainerNode[0]/Band[0]' child 'SectionNode'
measured height 77.7000023983419 exceeds row inner height.

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-TOP verticalAlign. And compileNodeInFixedSlot always derived its own region as slot - 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 by margin.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 applied margin.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 to prepareForRegionWidth and let childAvailableWidth perform 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 removed Math.max at 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: prepareComposedCellContents measures inside the margin and naturalCellHeight reserves 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 — a FragmentPlacement is a content box (both callers, the composed cell and ChartDefinition, build one at the exact rectangle and pass Margin.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 -amBUILD SUCCESS, 2043 tests, 0 failures, on this branch rebased onto current develop. 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/PlacedFragment dump 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.layout is @Internal at package level, and LayoutCompiler is excluded from the knowledge surfaces).

Known gaps, deliberately not in this PR

  • placeRowBandInFixedSlot still 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.
  • The row seating loop is duplicated between the page-level path and 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.

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
DemchaAV force-pushed the fix/row-child-margin-double-subtraction branch from 4efd0f8 to 78872ff Compare September 8, 2026 07:52
@DemchaAV
DemchaAV merged commit 8a63711 into develop Sep 8, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the fix/row-child-margin-double-subtraction branch September 8, 2026 08:01
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.
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