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 := ``
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