Files
gio-patched/text/shaper_test.go
T
Chris Waldon 43c47f0883 go.*,text,font{,/opentype},app,gpu,widget{,/material}: [API] load system fonts
This commit updates the text package to be able to load system fonts. As a consequence,
application authors may choose to provide no fonts manually, and it's
also possible that the system provides none (WASM, for instance, currently provides no
system fonts). As such, the text stack needed some minor tweaks to handle this case by
displaying blank spaces where text should be rather than crashing when no faces are
available.

Internally, we are dropping the old method of choosing faces and instead relying solely
on the new font matching logic in go-text. I chose to do this because maintaining two
different sets of logic with a hierarchical relationship proved to be really complex,
and also the go-text logic seems to produce higher-quality choices.

The breaking API change from this commit is the new way of constructing a text shaper
using text.ShaperOptions. Providing no options will result in a shaper that uses solely
system fonts. The various options can be used to disable system font loading and to
provide an already-parsed collection of fonts as per Gio's old API.

The material.NewTheme function now accepts no arguments instead of a font collection.
Users wanting to provide a collection can simply provide a new shaper configured how
they would like:

    theme := material.NewTheme()
	theme.Shaper = text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Regular()))

This commit touches many packages to fix up their construction of text shapers, mostly in
test code. The changes to the tests in package widget deserve special note:
Changing our font resolution logic caused the tofu characters within the
test strings to use a different font's tofu. This isn't a problem, but shifted
the layout of the shaped text a little bit. I've updated the numbers to expect
the new glyph positions.

Fixes: https://todo.sr.ht/~eliasnaur/gio/309
Fixes: https://todo.sr.ht/~eliasnaur/gio/184
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-19 10:01:51 +02:00

503 lines
16 KiB
Go

package text
import (
"fmt"
"strings"
"testing"
nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
"gioui.org/font/gofont"
"gioui.org/font/opentype"
"gioui.org/io/system"
"golang.org/x/exp/slices"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/math/fixed"
)
// TestWrappingTruncation checks that the line wrapper's truncation features
// behave as expected.
func TestWrappingTruncation(t *testing.T) {
// Use a test string containing multiple newlines to ensure that they are shaped
// as separate paragraphs.
textInput := "Lorem ipsum dolor sit amet, consectetur adipiscing elit,\nsed do eiusmod tempor incididunt ut labore et\ndolore magna aliqua.\n"
ltrFace, _ := opentype.Parse(goregular.TTF)
collection := []FontFace{{Face: ltrFace}}
cache := NewShaper(NoSystemFonts(), WithCollection(collection))
cache.LayoutString(Parameters{
Alignment: Middle,
PxPerEm: fixed.I(10),
MinWidth: 200,
MaxWidth: 200,
Locale: english,
}, textInput)
untruncatedCount := len(cache.txt.lines)
for i := untruncatedCount + 1; i > 0; i-- {
t.Run(fmt.Sprintf("truncated to %d/%d lines", i, untruncatedCount), func(t *testing.T) {
cache.LayoutString(Parameters{
Alignment: Middle,
PxPerEm: fixed.I(10),
MaxLines: i,
MinWidth: 200,
MaxWidth: 200,
Locale: english,
}, textInput)
lineCount := 0
lastGlyphWasLineBreak := false
glyphs := []Glyph{}
untruncatedRunes := 0
truncatedRunes := 0
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
glyphs = append(glyphs, g)
if g.Flags&FlagTruncator != 0 && g.Flags&FlagClusterBreak != 0 {
truncatedRunes += g.Runes
} else {
untruncatedRunes += g.Runes
}
if g.Flags&FlagLineBreak != 0 {
lineCount++
lastGlyphWasLineBreak = true
} else {
lastGlyphWasLineBreak = false
}
}
if lastGlyphWasLineBreak && truncatedRunes == 0 {
// There was no actual line of text following this break.
lineCount--
}
if i <= untruncatedCount {
if lineCount != i {
t.Errorf("expected %d lines, got %d", i, lineCount)
}
} else if i > untruncatedCount {
if lineCount != untruncatedCount {
t.Errorf("expected %d lines, got %d", untruncatedCount, lineCount)
}
}
if expected := len([]rune(textInput)); truncatedRunes+untruncatedRunes != expected {
t.Errorf("expected %d total runes, got %d (%d truncated)", expected, truncatedRunes+untruncatedRunes, truncatedRunes)
}
})
}
}
// TestWrappingForcedTruncation checks that the line wrapper's truncation features
// activate correctly on multi-paragraph text when later paragraphs are truncated.
func TestWrappingForcedTruncation(t *testing.T) {
// Use a test string containing multiple newlines to ensure that they are shaped
// as separate paragraphs.
textInput := "Lorem ipsum\ndolor sit\namet"
ltrFace, _ := opentype.Parse(goregular.TTF)
collection := []FontFace{{Face: ltrFace}}
cache := NewShaper(NoSystemFonts(), WithCollection(collection))
cache.LayoutString(Parameters{
Alignment: Middle,
PxPerEm: fixed.I(10),
MinWidth: 200,
MaxWidth: 200,
Locale: english,
}, textInput)
untruncatedCount := len(cache.txt.lines)
for i := untruncatedCount + 1; i > 0; i-- {
t.Run(fmt.Sprintf("truncated to %d/%d lines", i, untruncatedCount), func(t *testing.T) {
cache.LayoutString(Parameters{
Alignment: Middle,
PxPerEm: fixed.I(10),
MaxLines: i,
MinWidth: 200,
MaxWidth: 200,
Locale: english,
}, textInput)
lineCount := 0
glyphs := []Glyph{}
untruncatedRunes := 0
truncatedRunes := 0
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
glyphs = append(glyphs, g)
if g.Flags&FlagTruncator != 0 && g.Flags&FlagClusterBreak != 0 {
truncatedRunes += g.Runes
} else {
untruncatedRunes += g.Runes
}
if g.Flags&FlagLineBreak != 0 {
lineCount++
}
}
expectedTruncated := false
expectedLines := 0
if i < untruncatedCount {
expectedLines = i
expectedTruncated = true
} else if i == untruncatedCount {
expectedLines = i
expectedTruncated = false
} else if i > untruncatedCount {
expectedLines = untruncatedCount
expectedTruncated = false
}
if lineCount != expectedLines {
t.Errorf("expected %d lines, got %d", expectedLines, lineCount)
}
if truncatedRunes > 0 != expectedTruncated {
t.Errorf("expected expectedTruncated=%v, truncatedRunes=%d", expectedTruncated, truncatedRunes)
}
if expected := len([]rune(textInput)); truncatedRunes+untruncatedRunes != expected {
t.Errorf("expected %d total runes, got %d (%d truncated)", expected, truncatedRunes+untruncatedRunes, truncatedRunes)
}
})
}
}
// TestShapingNewlineHandling checks that the shaper's newline splitting behaves
// consistently and does not create spurious lines of text.
func TestShapingNewlineHandling(t *testing.T) {
type testcase struct {
textInput string
expectedLines int
expectedGlyphs int
}
for _, tc := range []testcase{
{textInput: "a\n", expectedLines: 1, expectedGlyphs: 3},
{textInput: "a\nb", expectedLines: 2, expectedGlyphs: 3},
{textInput: "", expectedLines: 1, expectedGlyphs: 1},
} {
t.Run(fmt.Sprintf("%q", tc.textInput), func(t *testing.T) {
ltrFace, _ := opentype.Parse(goregular.TTF)
collection := []FontFace{{Face: ltrFace}}
cache := NewShaper(NoSystemFonts(), WithCollection(collection))
checkGlyphs := func() {
glyphs := []Glyph{}
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
glyphs = append(glyphs, g)
}
if len(glyphs) != tc.expectedGlyphs {
t.Errorf("expected %d glyphs, got %d", tc.expectedGlyphs, len(glyphs))
}
findBreak := func(g Glyph) bool {
return g.Flags&FlagParagraphBreak != 0
}
found := 0
for idx := slices.IndexFunc(glyphs, findBreak); idx != -1; idx = slices.IndexFunc(glyphs, findBreak) {
found++
breakGlyph := glyphs[idx]
startGlyph := glyphs[idx+1]
glyphs = glyphs[idx+1:]
if flags := breakGlyph.Flags; flags&FlagParagraphBreak == 0 {
t.Errorf("expected newline glyph to have P flag, got %s", flags)
}
if flags := startGlyph.Flags; flags&FlagParagraphStart == 0 {
t.Errorf("expected newline glyph to have S flag, got %s", flags)
}
breakX, breakY := breakGlyph.X, breakGlyph.Y
startX, startY := startGlyph.X, startGlyph.Y
if breakX == startX {
t.Errorf("expected paragraph start glyph to have cursor x")
}
if breakY == startY {
t.Errorf("expected paragraph start glyph to have cursor y")
}
}
if count := strings.Count(tc.textInput, "\n"); found != count {
t.Errorf("expected %d paragraph breaks, found %d", count, found)
}
}
cache.LayoutString(Parameters{
Alignment: Middle,
PxPerEm: fixed.I(10),
MinWidth: 200,
MaxWidth: 200,
Locale: english,
}, tc.textInput)
if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines {
t.Errorf("shaping string %q created %d lines", tc.textInput, lineCount)
}
checkGlyphs()
cache.Layout(Parameters{
Alignment: Middle,
PxPerEm: fixed.I(10),
MinWidth: 200,
MaxWidth: 200,
Locale: english,
}, strings.NewReader(tc.textInput))
if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines {
t.Errorf("shaping reader %q created %d lines", tc.textInput, lineCount)
}
checkGlyphs()
})
}
}
// TestCacheEmptyString ensures that shaping the empty string returns a
// single synthetic glyph with ascent/descent info.
func TestCacheEmptyString(t *testing.T) {
ltrFace, _ := opentype.Parse(goregular.TTF)
collection := []FontFace{{Face: ltrFace}}
cache := NewShaper(NoSystemFonts(), WithCollection(collection))
cache.LayoutString(Parameters{
Alignment: Middle,
PxPerEm: fixed.I(10),
MinWidth: 200,
MaxWidth: 200,
Locale: english,
}, "")
glyphs := make([]Glyph, 0, 1)
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
glyphs = append(glyphs, g)
}
if len(glyphs) != 1 {
t.Errorf("expected %d glyphs, got %d", 1, len(glyphs))
}
glyph := glyphs[0]
checkFlag(t, true, FlagClusterBreak, glyph, 0)
checkFlag(t, true, FlagRunBreak, glyph, 0)
checkFlag(t, true, FlagLineBreak, glyph, 0)
checkFlag(t, false, FlagParagraphBreak, glyph, 0)
if glyph.Ascent == 0 {
t.Errorf("expected non-zero ascent")
}
if glyph.Descent == 0 {
t.Errorf("expected non-zero descent")
}
if glyph.Y == 0 {
t.Errorf("expected non-zero y offset")
}
if glyph.X == 0 {
t.Errorf("expected non-zero x offset")
}
}
// TestCacheAlignment ensures that shaping with different alignments or dominant
// text directions results in different X offsets.
func TestCacheAlignment(t *testing.T) {
ltrFace, _ := opentype.Parse(goregular.TTF)
collection := []FontFace{{Face: ltrFace}}
cache := NewShaper(NoSystemFonts(), WithCollection(collection))
params := Parameters{
Alignment: Start,
PxPerEm: fixed.I(10),
MinWidth: 200,
MaxWidth: 200,
Locale: english,
}
cache.LayoutString(params, "A")
glyph, _ := cache.NextGlyph()
startX := glyph.X
params.Alignment = Middle
cache.LayoutString(params, "A")
glyph, _ = cache.NextGlyph()
middleX := glyph.X
params.Alignment = End
cache.LayoutString(params, "A")
glyph, _ = cache.NextGlyph()
endX := glyph.X
if startX == middleX || startX == endX || endX == middleX {
t.Errorf("[LTR] shaping with with different alignments should not produce the same X, start %d, middle %d, end %d", startX, middleX, endX)
}
params.Locale = arabic
params.Alignment = Start
cache.LayoutString(params, "A")
glyph, _ = cache.NextGlyph()
rtlStartX := glyph.X
params.Alignment = Middle
cache.LayoutString(params, "A")
glyph, _ = cache.NextGlyph()
rtlMiddleX := glyph.X
params.Alignment = End
cache.LayoutString(params, "A")
glyph, _ = cache.NextGlyph()
rtlEndX := glyph.X
if rtlStartX == rtlMiddleX || rtlStartX == rtlEndX || rtlEndX == rtlMiddleX {
t.Errorf("[RTL] shaping with with different alignments should not produce the same X, start %d, middle %d, end %d", rtlStartX, rtlMiddleX, rtlEndX)
}
if startX == rtlStartX || endX == rtlEndX {
t.Errorf("shaping with with different dominant text directions and the same alignment should not produce the same X unless it's middle-aligned")
}
}
func TestCacheGlyphConverstion(t *testing.T) {
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
collection := []FontFace{{Face: ltrFace}, {Face: rtlFace}}
type testcase struct {
name string
text string
locale system.Locale
expected []Glyph
}
for _, tc := range []testcase{
{
name: "bidi ltr",
text: "The quick سماء שלום لا fox تمط שלום\nغير the\nlazy dog.",
locale: english,
},
{
name: "bidi rtl",
text: "الحب سماء brown привет fox تمط jumps\nпривет over\nغير الأحلام.",
locale: arabic,
},
} {
t.Run(tc.name, func(t *testing.T) {
cache := NewShaper(NoSystemFonts(), WithCollection(collection))
cache.LayoutString(Parameters{
PxPerEm: fixed.I(10),
MaxWidth: 200,
Locale: tc.locale,
}, tc.text)
doc := cache.txt
glyphs := make([]Glyph, 0, len(tc.expected))
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
glyphs = append(glyphs, g)
}
glyphCursor := 0
for _, line := range doc.lines {
for runIdx, run := range line.runs {
lastRun := runIdx == len(line.runs)-1
start := 0
end := len(run.Glyphs) - 1
inc := 1
towardOrigin := false
if run.Direction.Progression() == system.TowardOrigin {
start = len(run.Glyphs) - 1
end = 0
inc = -1
towardOrigin = true
}
for glyphIdx := start; ; glyphIdx += inc {
endOfRun := glyphIdx == end
glyph := run.Glyphs[glyphIdx]
endOfCluster := glyphIdx == end || run.Glyphs[glyphIdx+inc].clusterIndex != glyph.clusterIndex
actual := glyphs[glyphCursor]
if actual.ID != glyph.id {
t.Errorf("glyphs[%d] expected id %d, got id %d", glyphCursor, glyph.id, actual.ID)
}
// Synthetic glyphs should only ever show up at the end of lines.
endOfLine := lastRun && endOfRun
synthetic := glyph.glyphCount == 0 && endOfLine
checkFlag(t, endOfLine, FlagLineBreak, actual, glyphCursor)
checkFlag(t, endOfRun, FlagRunBreak, actual, glyphCursor)
checkFlag(t, towardOrigin, FlagTowardOrigin, actual, glyphCursor)
checkFlag(t, synthetic, FlagParagraphBreak, actual, glyphCursor)
checkFlag(t, endOfCluster, FlagClusterBreak, actual, glyphCursor)
glyphCursor++
if glyphIdx == end {
break
}
}
}
}
printLinePositioning(t, doc.lines, glyphs)
})
}
}
func checkFlag(t *testing.T, shouldHave bool, flag Flags, actual Glyph, glyphCursor int) {
t.Helper()
if shouldHave && actual.Flags&flag == 0 {
t.Errorf("glyphs[%d] should have %s set", glyphCursor, flag)
} else if !shouldHave && actual.Flags&flag != 0 {
t.Errorf("glyphs[%d] should not have %s set", glyphCursor, flag)
}
}
func printLinePositioning(t *testing.T, lines []line, glyphs []Glyph) {
t.Helper()
glyphCursor := 0
for i, line := range lines {
t.Logf("line %d, dir %s, width %d, visual %v, runeCount: %d", i, line.direction, line.width, line.visualOrder, line.runeCount)
for k, run := range line.runs {
t.Logf("run: %d, dir %s, width %d, runes {count: %d, offset: %d}", k, run.Direction, run.Advance, run.Runes.Count, run.Runes.Offset)
start := 0
end := len(run.Glyphs) - 1
inc := 1
if run.Direction.Progression() == system.TowardOrigin {
start = len(run.Glyphs) - 1
end = 0
inc = -1
}
for g := start; ; g += inc {
glyph := run.Glyphs[g]
if glyphCursor < len(glyphs) {
t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - glyphs[%2d] flags %s", g, glyph.xAdvance, glyph.runeCount, glyph.glyphCount, glyphCursor, glyphs[glyphCursor].Flags)
t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - n/a", g, glyph.xAdvance, glyph.runeCount, glyph.glyphCount)
}
glyphCursor++
if g == end {
break
}
}
}
}
}
// TestShapeStringRuneAccounting tries shaping the same string/parameter combinations with both
// shaping methods and ensures that the resulting glyph stream always has the right number of
// runes accounted for.
func TestShapeStringRuneAccounting(t *testing.T) {
type testcase struct {
name string
input string
params Parameters
}
type setup struct {
kind string
do func(*Shaper, Parameters, string)
}
for _, tc := range []testcase{
{
name: "simple truncated",
input: "abc",
params: Parameters{
PxPerEm: fixed.Int26_6(16),
MaxWidth: 100,
MaxLines: 1,
},
},
{
name: "simple",
input: "abc",
params: Parameters{
PxPerEm: fixed.Int26_6(16),
MaxWidth: 100,
},
},
} {
t.Run(tc.name, func(t *testing.T) {
for _, setup := range []setup{
{
kind: "LayoutString",
do: func(shaper *Shaper, params Parameters, input string) {
shaper.LayoutString(params, input)
},
},
{
kind: "Layout",
do: func(shaper *Shaper, params Parameters, input string) {
shaper.Layout(params, strings.NewReader(input))
},
},
} {
t.Run(setup.kind, func(t *testing.T) {
shaper := NewShaper(NoSystemFonts(), WithCollection(gofont.Collection()))
setup.do(shaper, tc.params, tc.input)
glyphs := []Glyph{}
for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() {
glyphs = append(glyphs, g)
}
totalRunes := 0
for _, g := range glyphs {
totalRunes += g.Runes
}
if inputRunes := len([]rune(tc.input)); totalRunes != inputRunes {
t.Errorf("input contained %d runes, but glyphs contained %d", inputRunes, totalRunes)
}
})
}
})
}
}