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
45 changes: 45 additions & 0 deletions fixtures_inline_whitespace_test.go
Original file line number Diff line number Diff line change
@@ -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 := `<!DOCTYPE html><html><head><style>body{margin:0}h1{width:400px;font-size:20px;margin:0;line-height:26px}</style></head><body>` +
body + `<script>var h=document.querySelector('h1'),s=document.querySelector('span');` +
`document.title=h.offsetHeight+','+(s?s.getBoundingClientRect().height:0)</script></body></html>`
_, 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)", `<h1 style="white-space:nowrap">` + long + `</h1>`, "26,0"},
{"nowrap on an inline span", `<h1><span style="white-space:nowrap">` + long + `</span></h1>`, "26,26"},
{"pre on an inline span", `<h1><span style="white-space:pre">` + long + `</span></h1>`, "26,26"},
{"normal span still wraps (control)", `<h1><span>` + long + `</span></h1>`, "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", `<h1>Lead words <span style="white-space:nowrap">` + long + `</span></h1>`, "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)
}
})
}
}
10 changes: 8 additions & 2 deletions layout/box.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions layout/features_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<html><body style="margin:0"><p id="p" style="width:60px;margin:0">`+
`<span style="white-space:nowrap">aaa bbb ccc ddd eee fff</span> ggg</p></body></html>`, 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 := `<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
51 changes: 51 additions & 0 deletions layout/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -892,6 +935,7 @@ func (l *layouter) collectInlineFrom(nodes []*dom.Node, st *css.Style, pre bool,
}
}
resolveInlineEdges(items)
l.markNoWrap(items)
return items
}

Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 4 additions & 2 deletions layout/linebreak.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading