Skip to content

Give an inline element a real box: background, border and padding, fragmented per line - #128

Merged
tannevaled merged 1 commit into
mainfrom
inline-box-decoration
Sep 6, 2026
Merged

tannevaled merged 1 commit into
mainfrom
inline-box-decoration

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

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. collectInline flattened it into a flat list of InlineItem words carrying only the innermost element's Style pointer; 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 reserves an inline box's border-left + padding-left before its first content and border-right + padding-right after 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.
  • Nothing painted the decoration. paintBoxContent paints backgrounds/borders for a *layout.Box and an inline element never gets one. A stopgap existed for the background alone (paintInlineBackground, a per-item fill keyed on the item's Style pointer) 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

newInlineDecor decides 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. resolveInlineEdges derives 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 same wrapOneLine/WrapItems/lineMetrics/preferredWidth machinery #124'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.

Exported API

For a downstream painter (a PDF writer) to consume without re-deriving anything:

type InlineFragment struct {
	Node        *dom.Node   // the inline element this is a piece of
	Style       *css.Style  // its computed style
	X, Y, W, H  float64     // absolute document px, BORDER box
	First, Last bool        // box-decoration-break: slice
}

type LineBox struct {
	// …
	Inlines []InlineFragment // outermost first
}

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

paintInlineBackground is replaced by paintInlineFragment (background, honouring border-radius, then border). paintBorders is refactored 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.

Proven

  • layout (fake measurer, exact coordinates): TestInlineDecorationReservesEdges, TestInlineDecorationFragmentsPerLineBox, TestNestedInlineDecorationOutermostFirst, TestAdjacentInlineDecorationsAreSeparateFragments, TestInlineDecorationSpansAForcedBreak, TestInlineDecorationCountsTowardMaxContentWidth, TestInlineDecorationInPreformattedText, TestBorderStyleNoneReservesNothing, TestInlineDecorationTranslatesWithItsBox, TestUndecoratedInlineElementsMakeNoFragment.
  • paint: TestPaintInlineFragmentBackground, TestPaintInlineFragmentBorderSlice, TestPaintInlineFragmentRounded, TestPaintInlineFragmentDegenerate.
  • end to end: TestInlineDecorationGolden asserts the final pixels of testdata/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 ./... leaves testdata/golden/ clean). layout and paint both hold 100% statement coverage.

Out of scope (documented in FIDELITY.md round 48)

box-decoration-break: clone; background-image/gradients, 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.

🤖 Generated with Claude Code

…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>
@tannevaled
tannevaled merged commit fa7a628 into main Sep 6, 2026
7 checks passed
@tannevaled
tannevaled deleted the inline-box-decoration branch September 6, 2026 08:08
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