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