Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion css/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
20 changes: 20 additions & 0 deletions css/props_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions css/value.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> or a background-image) to a size other than its intrinsic one. It
// inherits (per CSS Images 3).
Expand Down
91 changes: 91 additions & 0 deletions fixtures_whitespace_values_test.go
Original file line number Diff line number Diff line change
@@ -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 := `<!DOCTYPE html><html><head><style>body{margin:0;font-size:16px;line-height:20px;font-family:monospace}` +
`div{width:100px}span{display:inline-block}</style></head><body>` + body +
`<script>document.title=[].map.call(document.querySelectorAll('[id]'),function(e){return e.id+'='+e.offsetWidth+'x'+e.offsetHeight}).join(' ')</script></body></html>`
_, 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(
`<div id="pre" style="white-space:pre">` + words + `</div>` +
`<div id="prewrap" style="white-space:pre-wrap">` + words + `</div>` +
`<div id="breakspaces" style="white-space:break-spaces">` + words + `</div>` +
`<div id="preline" style="white-space:pre-line">` + words + `</div>` +
`<span id="pw1" style="white-space:pre-wrap">a b</span>` +
`<span id="pw4" style="white-space:pre-wrap">a b</span>` +
`<span id="pl4" style="white-space:pre-line">a b</span>` +
`<span id="pwi" style="white-space:pre-wrap"> b</span>` +
`<span id="pwb" style="white-space:pre-wrap">b</span>` +
`<div id="pwnl" style="white-space:pre-wrap">a` + "\n" + `b</div>` +
`<div id="plnl" style="white-space:pre-line">a ` + "\n" + ` b</div>` +
`<div id="bsnl" style="white-space:break-spaces">a` + "\n" + `b</div>`))
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])
}
}
}
10 changes: 8 additions & 2 deletions layout/box.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions layout/features_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package layout

import (
"fmt"
"strconv"
"testing"

"github.com/go-webengine/engine/css"
Expand Down Expand Up @@ -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, `<html><body style="margin:0"><p id="p" style="margin:0;width:`+
strconv.Itoa(width)+`px;white-space:`+ws+`">`+text+`</p></body></html>`, 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, `<html><body style="margin:0"><pre id="p" style="margin:0">a <span style="white-space:normal">b c</span></pre></body></html>`, 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 := `<html><body style="margin:0"><div style="display:flex;flex-direction:column">` +
`<div style="height:30px">A</div><div style="height:40px">B</div></div></body></html>`
Expand Down
101 changes: 100 additions & 1 deletion layout/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pre> 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})
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion layout/linebreak.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion layout/mincontent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading