From b6205309082f2e1889d7563558b1a6925dc8773a Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 18 Oct 2026 21:13:46 +0200 Subject: [PATCH] fix(layout): honour white-space:nowrap on inline elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the block container's white-space decided whether a line could wrap, so … inside a normal heading still wrapped at every space. CSS Text 3 §3: white-space applies to all elements; §5.5: the white-space property on the nearest common ancestor of the two characters controls breaking at their boundary. After collection, each item whose common ancestor with the previous item is nowrap/pre gets NoWrapBefore: glueRun keeps such items in one run (counting the space between them), and forceOne places a whole nowrap run that is wider than the line as one overflowing piece instead of tearing it apart one word per line. Boundaries around the span still wrap. Co-Authored-By: Claude Opus 5.5 --- fixtures_inline_whitespace_test.go | 45 ++++++++++++++++++++++++++ layout/box.go | 10 ++++-- layout/features_test.go | 18 +++++++++++ layout/layout.go | 51 ++++++++++++++++++++++++++++++ layout/linebreak.go | 6 ++-- 5 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 fixtures_inline_whitespace_test.go diff --git a/fixtures_inline_whitespace_test.go b/fixtures_inline_whitespace_test.go new file mode 100644 index 0000000..a818976 --- /dev/null +++ b/fixtures_inline_whitespace_test.go @@ -0,0 +1,45 @@ +// Copyright (c) the go-webengine/engine authors. +// SPDX-License-Identifier: BSD-3-Clause + +package engine + +import ( + "context" + "image" + "testing" +) + +// TestInlineWhiteSpaceNowrap: white-space applies to inline elements too +// (CSS Text 3 §3). A soft wrap opportunity between two characters is +// controlled by the white-space of their nearest common ancestor (§5.5), so +// the spaces inside a nowrap/pre span offer no break, while the boundaries +// around the span still do. +func TestInlineWhiteSpaceNowrap(t *testing.T) { + measure := func(body string) string { + t.Helper() + page := `` + + body + `` + _, info, err := New().RenderHTML(context.Background(), page, "https://demo.test/", image.Rect(0, 0, 800, 200)) + if err != nil { + t.Fatal(err) + } + return info.Title + } + const long = "A rather long heading that would wrap over two lines otherwise" + for _, tc := range []struct{ name, body, want string }{ + {"nowrap on the block (control)", `

` + long + `

`, "26,0"}, + {"nowrap on an inline span", `

` + long + `

`, "26,26"}, + {"pre on an inline span", `

` + long + `

`, "26,26"}, + {"normal span still wraps (control)", `

` + long + `

`, "52,52"}, + // The boundary before the nowrap span is a wrap opportunity: the + // span moves to the next line as a whole. + {"break before a nowrap span", `

Lead words ` + long + `

`, "52,26"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := measure(tc.body); got != tc.want { + t.Errorf("h1 height, span height = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/layout/box.go b/layout/box.go index 765f8aa..4543e83 100644 --- a/layout/box.go +++ b/layout/box.go @@ -85,8 +85,14 @@ type InlineItem struct { Width float64 // advance of the word / DISPLAY width of the image SpaceBefore float64 // width of a space in this item's font - Ascent float64 - LineHeight float64 + + // NoWrapBefore suppresses the soft wrap opportunity between the previous + // item and this one even when SpaceBefore > 0: the white-space of their + // nearest common ancestor forbids wrapping (CSS Text 3 §5.5) — the spaces + // inside an inline `white-space:nowrap` span. See markNoWrap. + NoWrapBefore bool + Ascent float64 + LineHeight float64 // BaselineShift moves this item DOWN (positive) from where plain // baseline alignment (Y = line top + line baseline - Ascent) would place diff --git a/layout/features_test.go b/layout/features_test.go index 916f7d6..44711a8 100644 --- a/layout/features_test.go +++ b/layout/features_test.go @@ -438,6 +438,24 @@ func TestPercentageHeight(t *testing.T) { assertF(t, "d.H", findBoxByID(root, "d").H, 30) } +// TestInlineNowrapSpan: the spaces inside an inline white-space:nowrap span +// offer no wrap opportunity (CSS Text 3 §5.5), and a nowrap run wider than +// the line overflows as one piece instead of one word per line. +func TestInlineNowrapSpan(t *testing.T) { + root := layoutHTML(t, `

`+ + `aaa bbb ccc ddd eee fff ggg

`, 300) + p := findBoxByID(root, "p") + if len(p.Lines) != 2 { + t.Fatalf("lines = %d, want 2 (the nowrap run, then ggg)", len(p.Lines)) + } + if n := len(p.Lines[0].Items); n != 6 { + t.Errorf("first line holds %d items, want the 6 words of the nowrap span", n) + } + if commonAncestor(p.Node, p.Node) != p.Node { + t.Error("commonAncestor(a, a) != a") + } +} + func TestFlexColumn(t *testing.T) { src := `
` + `
A
B
` diff --git a/layout/layout.go b/layout/layout.go index 15380b6..e3925e5 100644 --- a/layout/layout.go +++ b/layout/layout.go @@ -874,9 +874,52 @@ func (l *layouter) collectInline(node *dom.Node, st *css.Style, pre bool, cw flo l.beginInlineContext() l.appendInline(node, st, &items, pre, cw) resolveInlineEdges(items) + l.markNoWrap(items) return items } +// markNoWrap sets NoWrapBefore on every item whose break opportunity with the +// previous item is suppressed by white-space: per CSS Text 3 §5.5 the +// white-space property on the nearest common ancestor of the two characters +// controls breaking at their boundary, and white-space applies to — and is +// inherited through — inline elements (§3). So the spaces inside an inline +// `white-space:nowrap` (or pre) span break nothing, while the boundaries +// around the span, whose common ancestor is the wrapping parent, still do. +// (The block-level case is the layouter's own nowrap flag.) +func (l *layouter) markNoWrap(items []*InlineItem) { + for i := 1; i < len(items); i++ { + a, b := items[i-1].Node, items[i].Node + if a == nil || b == nil { + continue + } + anc := commonAncestor(a, b) + if anc == nil { + continue + } + if st := l.sm[anc]; st != nil && (st.WhiteSpace == css.WSNoWrap || st.WhiteSpace == css.WSPre) { + items[i].NoWrapBefore = true + } + } +} + +// commonAncestor returns the nearest element that is a or b or an ancestor +// of both (nil when they share none). +func commonAncestor(a, b *dom.Node) *dom.Node { + if a == b { + return a + } + seen := map[*dom.Node]bool{} + for n := a; n != nil; n = n.Parent { + seen[n] = true + } + for n := b; n != nil; n = n.Parent { + if seen[n] { + return n + } + } + return nil +} + func (l *layouter) collectInlineFrom(nodes []*dom.Node, st *css.Style, pre bool, cw float64) []*InlineItem { var items []*InlineItem l.beginInlineContext() @@ -892,6 +935,7 @@ func (l *layouter) collectInlineFrom(nodes []*dom.Node, st *css.Style, pre bool, } } resolveInlineEdges(items) + l.markNoWrap(items) return items } @@ -1767,6 +1811,13 @@ func forceOne(items []*InlineItem) (*LineBox, int) { if i < len(items) { line.Items = append(line.Items, items[i]) i++ + // Items white-space forbids breaking between (NoWrapBefore) stay + // together: a nowrap run overflows the line as a whole, as in a + // browser, rather than being torn apart one word per line. + for i < len(items) && !items[i].LineBreak && items[i].NoWrapBefore { + line.Items = append(line.Items, items[i]) + i++ + } } return line, i } diff --git a/layout/linebreak.go b/layout/linebreak.go index 1e4e54a..321e65b 100644 --- a/layout/linebreak.go +++ b/layout/linebreak.go @@ -21,8 +21,10 @@ package layout func glueRun(items []*InlineItem, i int) (j int, runW float64) { j = i + 1 runW = items[i].padLead + items[i].Width + items[i].padTrail - for j < len(items) && !items[j].LineBreak && items[j].SpaceBefore == 0 { - runW += items[j].padLead + items[j].Width + items[j].padTrail + for j < len(items) && !items[j].LineBreak && (items[j].SpaceBefore == 0 || items[j].NoWrapBefore) { + // A space that offers no wrap opportunity (NoWrapBefore) still + // occupies its width inside the run. + runW += items[j].SpaceBefore + items[j].padLead + items[j].Width + items[j].padTrail j++ } return j, runW