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