Give an inline element a real box: background, border and padding, fragmented per line - #128
Merged
Merged
Conversation
…order and padding, fragmented per line
An inline-level element — a styled <span> label, a bordered <code>, an <a>
with a highlight, the near-universal pill/chip/badge pattern — had no
representation in the layout tree at all. collectInline flattened it into a
flat list of InlineItem words carrying only the innermost element's Style
pointer, and nothing recorded that a run of those words belonged to a box
with edges.
Two failures followed from that one root cause. Nothing reserved the
horizontal space CSS gives an inline box (border-left+padding-left before its
first content, border-right+padding-right after its last), so text after a
padded <span> sat exactly where that padding should have been. And nothing
painted the decoration: paintBoxContent only paints a *layout.Box, and an
inline element never gets one. A narrow stopgap existed for the background
alone (paintInlineBackground, a per-item fill keyed on the item's Style
pointer) but it could reach only the INNERMOST element — a <b> inside a
styled <span> hid the span's background from it entirely — it painted
interleaved with the glyphs, and it knew nothing of borders, padding, or an
element split across lines. Borders on an inline element simply never
appeared.
This models what CSS specifies: an inline box FRAGMENTS, one fragment per
line box it spans, with box-decoration-break: slice (the CSS default) putting
its leading edge on the first fragment only and its trailing edge on the last
only, while top and bottom paint on every one.
Layout carries a per-item chain of the inline ancestors that generate a box
(newInlineDecor: a background to paint, or an edge to reserve — a <b>, <em>,
<a> or UA-default <code> with only typographic style generates nothing and
costs nothing, and the chain is shared per element, not copied per word, so
plain text allocates none of it). resolveInlineEdges derives each item's
first/last-of-ancestor depths and the edges it therefore reserves from where
its chain diverges from its neighbour's; those lengths enter the line advance
through the same wrapOneLine/WrapItems/lineMetrics/preferredWidth machinery
round 44's inline margins already use — deliberately NOT via SpaceBefore,
which a line start legitimately drops. One new positioning loop, placeLine,
replaces the two layoutInline carried and builds the fragments as it goes.
Vertical padding/border do not grow the line; they overflow it, as CSS says.
Exported for a downstream painter (a PDF writer) to consume without
re-deriving any of it: layout.InlineFragment{Node, Style, X, Y, W, H, First,
Last} in absolute document pixels describing the border box, listed
outermost-first in LineBox.Inlines.
Paint replaces paintInlineBackground with paintInlineFragment (background,
honouring border-radius, then border) and refactors paintBorders into a
shared paintEdges(..., left, right bool, ...) so a block box and an inline
fragment stroke their edges through one code path — a block passes true/true,
a fragment passes its own First/Last. A line's fragments now all paint before
its items, outermost first, so an enclosing background lands under a nested
one and no background paints over an already-drawn glyph.
Out of scope, documented in FIDELITY.md: box-decoration-break: clone,
background-image/gradient, box-shadow, outline and filter on an inline
element, RTL, and the fragment's content height still being the item's line
box rather than the font's own em box.
Every one of the twelve existing committed goldens regenerates byte-identical;
layout and paint both hold 100% statement coverage.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.
The gap
An inline-level element — a styled
<span>label, a bordered<code>, an<a>with a highlight, the near-universal pill/chip/badge pattern — had no representation in the layout tree at all.collectInlineflattened it into a flat list ofInlineItemwords carrying only the innermost element'sStylepointer; nothing recorded that a run of those words belonged to a box with edges.Two failures followed from that one root cause:
border-left + padding-leftbefore its first content andborder-right + padding-rightafter its last. The engine reserved neither, so text after a padded<span>sat exactly where that padding should have been, and a shrink-to-fit container sized to the text alone clipped the padding off.paintBoxContentpaints backgrounds/borders for a*layout.Boxand an inline element never gets one. A stopgap existed for the background alone (paintInlineBackground, a per-item fill keyed on the item'sStylepointer) but it reached only the innermost element — a<b>inside a styled<span>hid the span's own background from it entirely — painted interleaved with the glyphs, and knew nothing of borders, padding, or an element split across lines. Borders on an inline element simply never appeared.The CSS rule now honoured
An inline box fragments: one fragment per line box it spans, with
box-decoration-break: slice(the CSS default) putting the leading edge on the first fragment only and the trailing edge on the last only, while top and bottom borders paint on every one. Vertical padding/border do not grow the line — they overflow it, as CSS specifies.Layout
newInlineDecordecides whether an inline element generates a box (a background to paint, or an edge to reserve) — a<b>,<em>,<a>or UA-default<code>with only typographic style generates nothing and costs nothing. The chain of such ancestors is shared per element, not copied per word, so plain text allocates none of it.resolveInlineEdgesderives each item's first/last-of-ancestor depths, and the edges it therefore reserves, from where its ancestor chain diverges from its neighbour's; those lengths enter the line advance through the samewrapOneLine/WrapItems/lineMetrics/preferredWidthmachinery #124's inline margins already use — deliberately not viaSpaceBefore, which a line start legitimately drops. One new positioning loop,placeLine, replaces the twolayoutInlinecarried and builds the fragments as it goes.Exported API
For a downstream painter (a PDF writer) to consume without re-deriving anything:
Iterate
for _, line := range box.Lines { for _, fr := range line.Inlines { … } }and paint in that order. Fragments translate with their box, so a flex/grid/float-repositioned subtree's decoration follows its words.Paint
paintInlineBackgroundis replaced bypaintInlineFragment(background, honouringborder-radius, then border).paintBordersis refactored into a sharedpaintEdges(…, left, right bool, …)so a block box and an inline fragment stroke their edges through one code path — a block passes true/true, a fragment passes its ownFirst/Last. A line's fragments now all paint before its items, outermost first, so an enclosing background lands under a nested one and no background paints over an already-drawn glyph.Proven
TestInlineDecorationReservesEdges,TestInlineDecorationFragmentsPerLineBox,TestNestedInlineDecorationOutermostFirst,TestAdjacentInlineDecorationsAreSeparateFragments,TestInlineDecorationSpansAForcedBreak,TestInlineDecorationCountsTowardMaxContentWidth,TestInlineDecorationInPreformattedText,TestBorderStyleNoneReservesNothing,TestInlineDecorationTranslatesWithItsBox,TestUndecoratedInlineElementsMakeNoFragment.TestPaintInlineFragmentBackground,TestPaintInlineFragmentBorderSlice,TestPaintInlineFragmentRounded,TestPaintInlineFragmentDegenerate.TestInlineDecorationGoldenasserts the final pixels oftestdata/inline_decoration.html— background on both lines, left border on the first fragment only, right border on the last only, none between, the nested<b>'s green over its ancestor's blue, and not one decoration pixel on the undecorated<span>'s rows.Every one of the twelve existing committed goldens regenerates byte-identical (
UPDATE_GOLDEN=1 go test -short ./...leavestestdata/golden/clean).layoutandpaintboth hold 100% statement coverage.Out of scope (documented in FIDELITY.md round 48)
box-decoration-break: clone;background-image/gradients,box-shadow,outlineandfilteron an inline element; RTL; and the fragment's content height still being the item's line box rather than the font's own em box.🤖 Generated with Claude Code