From d3206485df0b5b21b12f45398d0e4132f9bebc1d Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 18 Oct 2026 21:22:19 +0200 Subject: [PATCH] fix: white-space pre-wrap, break-spaces and pre-line wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pre-wrap and pre-line were parsed as pre and break-spaces not at all, so a block using them never wrapped - Marp themes set white-space:pre-wrap on code blocks. Per the CSS Text 3 §3 table: - pre-wrap: spaces and newlines preserved, wraps; a run of spaces is a soft wrap opportunity at its end and hangs at the end of a line (§4.1.2) - leading indentation stays part of the first word, inner runs become the next word's SpaceBefore at their real width; - break-spaces: preserved and wraps, but spaces never hang: they stay in the previous word's width and the next word carries a new BreakBefore wrap opportunity; - pre-line: spaces collapse as in normal, newlines are forced breaks. The text's own element now decides (its inherited white-space), so a white-space:normal span inside a
 collapses as in a browser. Min-content
treats only pre and nowrap as unbreakable.

Co-Authored-By: Claude Opus 5.5 
---
 css/parse.go                       |   8 ++-
 css/props_test.go                  |  20 ++++++
 css/value.go                       |  14 ++++
 fixtures_whitespace_values_test.go |  91 ++++++++++++++++++++++++++
 layout/box.go                      |  10 ++-
 layout/features_test.go            |  63 ++++++++++++++++++
 layout/layout.go                   | 101 ++++++++++++++++++++++++++++-
 layout/linebreak.go                |   2 +-
 layout/mincontent.go               |   2 +-
 9 files changed, 305 insertions(+), 6 deletions(-)
 create mode 100644 fixtures_whitespace_values_test.go

diff --git a/css/parse.go b/css/parse.go
index ab3fee9..7299a52 100644
--- a/css/parse.go
+++ b/css/parse.go
@@ -786,8 +786,14 @@ func (s *Style) apply(d Declaration, emRef float64, parent *Style) {
 		}
 	case "white-space":
 		switch lv {
-		case "pre", "pre-wrap", "pre-line":
+		case "pre":
 			s.WhiteSpace = WSPre
+		case "pre-wrap":
+			s.WhiteSpace = WSPreWrap
+		case "break-spaces":
+			s.WhiteSpace = WSBreakSpaces
+		case "pre-line":
+			s.WhiteSpace = WSPreLine
 		case "nowrap":
 			s.WhiteSpace = WSNoWrap
 		case "normal":
diff --git a/css/props_test.go b/css/props_test.go
index 409e5d0..00a8fd3 100644
--- a/css/props_test.go
+++ b/css/props_test.go
@@ -8,6 +8,26 @@ import (
 	"testing"
 )
 
+// TestWhiteSpaceValues: each white-space keyword keeps its own value (CSS
+// Text 3 §3); pre-wrap/pre-line used to collapse into pre.
+func TestWhiteSpaceValues(t *testing.T) {
+	for kw, want := range map[string]WhiteSpace{
+		"normal": WSNormal, "pre": WSPre, "nowrap": WSNoWrap,
+		"pre-wrap": WSPreWrap, "break-spaces": WSBreakSpaces, "pre-line": WSPreLine,
+	} {
+		s := newStyle()
+		applyOn(s, "white-space", kw, 16)
+		if s.WhiteSpace != want {
+			t.Errorf("white-space:%s = %v, want %v", kw, s.WhiteSpace, want)
+		}
+	}
+	for ws, want := range map[WhiteSpace]bool{WSNormal: false, WSPre: true, WSNoWrap: false, WSPreWrap: true, WSBreakSpaces: true, WSPreLine: false} {
+		if ws.PreservesSpaces() != want {
+			t.Errorf("%v.PreservesSpaces() = %v", ws, !want)
+		}
+	}
+}
+
 func newStyle() *Style {
 	s := initialStyle()
 	return &s
diff --git a/css/value.go b/css/value.go
index 58fc948..ff2dd09 100644
--- a/css/value.go
+++ b/css/value.go
@@ -531,8 +531,22 @@ const (
 	// WSNoWrap collapses whitespace like WSNormal but never wraps: the
 	// block's content stays on one line, however wide.
 	WSNoWrap
+	// WSPreWrap preserves spaces and newlines and wraps; preserved spaces at
+	// the end of a line hang (CSS Text 3 §3, §4.1.2).
+	WSPreWrap
+	// WSBreakSpaces is WSPreWrap except that preserved spaces never hang:
+	// they take room and a line may break after any of them.
+	WSBreakSpaces
+	// WSPreLine collapses spaces like WSNormal but preserves newlines (as
+	// forced breaks) and wraps.
+	WSPreLine
 )
 
+// PreservesSpaces reports whether spaces and tabs are kept as written.
+func (w WhiteSpace) PreservesSpaces() bool {
+	return w == WSPre || w == WSPreWrap || w == WSBreakSpaces
+}
+
 // ImageRendering selects the resampling filter used to scale a raster image
 // (an  or a background-image) to a size other than its intrinsic one. It
 // inherits (per CSS Images 3).
diff --git a/fixtures_whitespace_values_test.go b/fixtures_whitespace_values_test.go
new file mode 100644
index 0000000..1e799e0
--- /dev/null
+++ b/fixtures_whitespace_values_test.go
@@ -0,0 +1,91 @@
+// Copyright (c) the go-webengine/engine authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package engine
+
+import (
+	"context"
+	"image"
+	"strings"
+	"testing"
+)
+
+// TestWhiteSpaceWrappingValues pins the white-space values that preserve
+// some white space AND wrap (CSS Text 3 §3 table, §4.1):
+//
+//	pre-wrap:     newlines and spaces preserved, wraps, trailing spaces hang
+//	break-spaces: newlines and spaces preserved, wraps, spaces take room
+//	pre-line:     newlines preserved, spaces collapse, wraps
+//
+// They were all parsed as `pre` (or not at all), so such a block never
+// wrapped. Marp themes set `white-space: pre-wrap` on code blocks.
+func TestWhiteSpaceWrappingValues(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, 400, 400))
+		if err != nil {
+			t.Fatal(err)
+		}
+		return info.Title
+	}
+	dims := func(s string) map[string][2]int {
+		out := map[string][2]int{}
+		for _, f := range strings.Fields(s) {
+			k, v, _ := strings.Cut(f, "=")
+			var w, h int
+			for i, p := range strings.Split(v, "x") {
+				n := 0
+				for _, c := range p {
+					n = n*10 + int(c-'0')
+				}
+				if i == 0 {
+					w = n
+				} else {
+					h = n
+				}
+			}
+			out[k] = [2]int{w, h}
+		}
+		return out
+	}
+	const words = "aaa bbb ccc ddd eee fff ggg"
+	got := dims(measure(
+		`
` + words + `
` + + `
` + words + `
` + + `
` + words + `
` + + `
` + words + `
` + + `a b` + + `a b` + + `a b` + + ` b` + + `b` + + `
a` + "\n" + `b
` + + `
a ` + "\n" + ` b
` + + `
a` + "\n" + `b
`)) + t.Logf("%v", got) + if got["pre"][1] != 20 { + t.Errorf("pre: height %d, want one 20px line (control)", got["pre"][1]) + } + for _, id := range []string{"prewrap", "breakspaces", "preline"} { + if got[id][1] <= 20 { + t.Errorf("%s: height %d, want the 27-char line to wrap in 100px", id, got[id][1]) + } + } + if got["pw4"][0] <= got["pw1"][0] { + t.Errorf("pre-wrap must preserve spaces: 'a b' %d wide vs 'a b' %d", got["pw4"][0], got["pw1"][0]) + } + if got["pl4"][0] != got["pw1"][0] { + t.Errorf("pre-line must collapse spaces: 'a b' %d wide, want %d ('a b')", got["pl4"][0], got["pw1"][0]) + } + if got["pwi"][0] <= got["pwb"][0] { + t.Errorf("pre-wrap must keep leading indentation: ' b' %d wide vs 'b' %d", got["pwi"][0], got["pwb"][0]) + } + for _, id := range []string{"pwnl", "plnl", "bsnl"} { + if got[id][1] != 40 { + t.Errorf("%s: height %d, want 40 (the newline is preserved)", id, got[id][1]) + } + } +} diff --git a/layout/box.go b/layout/box.go index 4543e83..a3b2924 100644 --- a/layout/box.go +++ b/layout/box.go @@ -91,8 +91,14 @@ type InlineItem struct { // 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 + + // BreakBefore is a soft wrap opportunity before this item even though + // SpaceBefore is 0: under white-space:break-spaces the preserved spaces + // are part of the previous item's own width (they take room) and the + // line may break right after them (CSS Text 3 §3, §4.1.2). + BreakBefore 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 44711a8..947e112 100644 --- a/layout/features_test.go +++ b/layout/features_test.go @@ -5,6 +5,7 @@ package layout import ( "fmt" + "strconv" "testing" "github.com/go-webengine/engine/css" @@ -456,6 +457,68 @@ func TestInlineNowrapSpan(t *testing.T) { } } +// TestWhiteSpaceWrappingValues: pre-wrap / break-spaces preserve spaces and +// newlines and wrap; pre-line collapses spaces, keeps newlines and wraps +// (CSS Text 3 §3 table). +func TestWhiteSpaceWrappingValues(t *testing.T) { + lines := func(ws, text string, width int) []*LineBox { + t.Helper() + root := layoutHTML(t, `

`+text+`

`, 400) + return findBoxByID(root, "p").Lines + } + texts := func(ls []*LineBox) []string { + var out []string + for _, l := range ls { + s := "" + for _, it := range l.Items { + s += "[" + it.Text + "]" + } + out = append(out, s) + } + return out + } + // pre-wrap: indentation kept, inner runs become SpaceBefore, trailing + // spaces hang (dropped), a tab expands, a blank line is a line. + pw := lines("pre-wrap", " ab cd \n\n\tx", 400) + if got := texts(pw); len(got) != 3 || got[0] != "[ ab][cd]" || got[1] != "" || got[2] != "[ x]" { + t.Errorf("pre-wrap lines = %q", got) + } + if sb := pw[0].Items[1].SpaceBefore; sb <= 0 { + t.Errorf("pre-wrap inner run SpaceBefore = %v, want the run's width", sb) + } + // pre-wrap wraps at its space runs. + if n := len(lines("pre-wrap", "aaaa bbbb cccc dddd", 30)); n < 2 { + t.Errorf("pre-wrap did not wrap: %d line(s)", n) + } + // break-spaces: spaces stay with the word before them, next word breaks. + bs := lines("break-spaces", "ab cd", 400) + if got := texts(bs); len(got) != 1 || got[0] != "[ab ][cd]" || !bs[0].Items[1].BreakBefore { + t.Errorf("break-spaces lines = %q", got) + } + if n := len(lines("break-spaces", "aaaa bbbb cccc dddd", 30)); n < 2 { + t.Errorf("break-spaces did not wrap: %d line(s)", n) + } + // pre-line: spaces collapse, newline breaks, spaces around it vanish. + if got := texts(lines("pre-line", "a b \n c", 400)); len(got) != 2 || got[0] != "[a][b]" || got[1] != "[c]" { + t.Errorf("pre-line lines = %q", got) + } + // The text's own element decides: a white-space:normal span inside pre + // collapses its spaces. + root := layoutHTML(t, `
a  b    c
`, 400) + if got := texts(findBoxByID(root, "p").Lines); len(got) != 1 || got[0] != "[a ][b][c]" { + t.Errorf("normal span in pre = %q", got) + } + // A line of spaces only: they hang under pre-wrap (no item), take room + // under break-spaces. + if got := texts(lines("pre-wrap", "a\n \nb", 400)); len(got) != 3 || got[1] != "" { + t.Errorf("pre-wrap blank-ish line = %q", got) + } + if got := texts(lines("break-spaces", "a\n \nb", 400)); len(got) != 3 || got[1] != "[ ]" { + t.Errorf("break-spaces blank-ish line = %q", got) + } +} + func TestFlexColumn(t *testing.T) { src := `
` + `
A
B
` diff --git a/layout/layout.go b/layout/layout.go index e3925e5..6b91b08 100644 --- a/layout/layout.go +++ b/layout/layout.go @@ -1162,7 +1162,30 @@ func (l *layouter) appendWords(text string, st *css.Style, items *[]*InlineItem, // downstream then see one and the same string. text = applyTextTransform(text, st) asc, lh := l.lineMetricsFor(st) - if pre { + // The white-space value governing this text (CSS Text 3 §3) is that of + // the element directly containing it, already inherited through its + // ancestors — so a white-space:normal span inside a
 collapses, as
+	// in a browser. (pre, the enclosing block's flag, no longer decides here;
+	// it still drives the block's own no-wrap placement.)
+	ws := st.WhiteSpace
+	switch ws {
+	case css.WSPreWrap, css.WSBreakSpaces:
+		l.appendPreservedWrapping(text, st, items, origin, asc, lh, ws == css.WSBreakSpaces)
+		return
+	case css.WSPreLine:
+		// Spaces collapse as in normal, but every newline is a forced break
+		// (§4.1.1/§4.1.2: segment breaks are preserved). Spaces around the
+		// break are removed: the next line starts afresh.
+		for i, seg := range strings.Split(text, "\n") {
+			if i > 0 {
+				*items = append(*items, &InlineItem{LineBreak: true, Style: st, Node: origin})
+				l.wsPending = false
+			}
+			l.appendCollapsed(seg, st, items, origin, asc, lh)
+		}
+		return
+	}
+	if ws == css.WSPre {
 		for i, seg := range strings.Split(text, "\n") {
 			if i > 0 {
 				*items = append(*items, &InlineItem{LineBreak: true, Style: st, Node: origin})
@@ -1189,6 +1212,82 @@ func (l *layouter) appendWords(text string, st *css.Style, items *[]*InlineItem,
 		}
 		return
 	}
+	l.appendCollapsed(text, st, items, origin, asc, lh)
+}
+
+// appendPreservedWrapping appends text whose spaces and newlines are
+// preserved but which may wrap (CSS Text 3 §3: pre-wrap, break-spaces).
+// Each newline is a forced break; within a line, leading spaces stay part of
+// the first word (indentation survives a wrap-free line start), and each
+// following run of spaces is a soft wrap opportunity at its end (§5.1).
+// Under pre-wrap the run becomes the next word's SpaceBefore — measured at
+// its real width, and, like any SpaceBefore, not counted at a line start or
+// end, which is how preserved spaces at the end of a line hang (§4.1.2).
+// Under break-spaces the spaces never hang: they stay in the previous word's
+// width and the next word carries BreakBefore. (Breaking between two spaces
+// of one run, which break-spaces also allows, is not modelled.)
+func (l *layouter) appendPreservedWrapping(text string, st *css.Style, items *[]*InlineItem, origin *dom.Node, asc, lh float64, breakSpaces bool) {
+	measure := func(s string) float64 {
+		return l.m.Measure(s, st.FontFamily, st.FontSize, st.FontWeight, st.Italic) + letterSpacingWidth(s, st)
+	}
+	isBlank := func(c byte) bool { return c == ' ' || c == '\t' }
+	for li, seg := range strings.Split(text, "\n") {
+		if li > 0 {
+			*items = append(*items, &InlineItem{LineBreak: true, Style: st, Node: origin})
+			l.preCol = 0
+		}
+		seg = l.expandTabs(seg, st.TabSize)
+		i, first := 0, true
+		for i < len(seg) {
+			// [spaces][word]
+			s := i
+			for i < len(seg) && isBlank(seg[i]) {
+				i++
+			}
+			spaces := seg[s:i]
+			w := i
+			for i < len(seg) && !isBlank(seg[i]) {
+				i++
+			}
+			word := seg[w:i]
+			if breakSpaces {
+				// The spaces after the word belong to it.
+				e := i
+				for e < len(seg) && isBlank(seg[e]) {
+					e++
+				}
+				word, i = seg[w:e], e
+			}
+			it := &InlineItem{Style: st, Node: origin, Ascent: asc, LineHeight: lh, decor: l.decor}
+			switch {
+			case first && word == "" && !breakSpaces:
+				continue // a line of only spaces: they all hang (pre-wrap)
+			case first:
+				it.Text = spaces + word // leading indentation is content
+				it.SpaceBefore = l.takeMargin()
+			case breakSpaces:
+				it.Text = word
+				it.BreakBefore = true
+			default:
+				if word == "" {
+					continue // trailing spaces of the line hang
+				}
+				it.Text = word
+				it.SpaceBefore = measure(spaces)
+			}
+			it.Width = measure(it.Text)
+			*items = append(*items, it)
+			first = false
+		}
+	}
+	l.wsEmitted = true
+	l.wsPending = false
+}
+
+// appendCollapsed appends text under white-space collapsing (normal, nowrap,
+// and each line of pre-line): runs of white space become one space between
+// words, which is a soft wrap opportunity.
+func (l *layouter) appendCollapsed(text string, st *css.Style, items *[]*InlineItem, origin *dom.Node, asc, lh float64) {
 	space := l.m.Measure(" ", st.FontFamily, st.FontSize, st.FontWeight, st.Italic)
 	// Words split on collapsible whitespace ONLY (isSpace) — never on
 	// strings.Fields' unicode.IsSpace, which also splits on the no-break
diff --git a/layout/linebreak.go b/layout/linebreak.go
index 321e65b..821b506 100644
--- a/layout/linebreak.go
+++ b/layout/linebreak.go
@@ -21,7 +21,7 @@ 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 || items[j].NoWrapBefore) {
+	for j < len(items) && !items[j].LineBreak && ((items[j].SpaceBefore == 0 && !items[j].BreakBefore) || 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
diff --git a/layout/mincontent.go b/layout/mincontent.go
index f3c222b..ae9f9b6 100644
--- a/layout/mincontent.go
+++ b/layout/mincontent.go
@@ -42,7 +42,7 @@ func (l *layouter) minContentWidth(node *dom.Node, st *css.Style) float64 {
 	}
 	// Content that never wraps (white-space: pre / nowrap) has no unit
 	// smaller than its longest line: min-content equals max-content.
-	if st.WhiteSpace != css.WSNormal {
+	if st.WhiteSpace == css.WSPre || st.WhiteSpace == css.WSNoWrap {
 		return l.preferredWidth(node, st)
 	}
 	// A single-line flex row keeps its items side by side, so its min-content