Files
gio/text/shaper_test.go
2026-05-18 14:30:25 -04:00

584 lines
19 KiB
Go

package text
import (
"fmt"
"slices"
"strings"
"testing"
nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
"gioui.org/font"
"gioui.org/font/gofont"
"gioui.org/font/opentype"
"gioui.org/io/system"
"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 += int(g.Runes)
} else {
untruncatedRunes += int(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 += int(g.Runes)
} else {
untruncatedRunes += int(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
maxLines int
expectedTruncated int
}
for _, tc := range []testcase{
{textInput: "a\n", expectedLines: 1, expectedGlyphs: 3},
{textInput: "a\nb", expectedLines: 2, expectedGlyphs: 3},
{textInput: "", expectedLines: 1, expectedGlyphs: 1},
{textInput: "\n", expectedLines: 1, expectedGlyphs: 2},
{textInput: "\n\n", expectedLines: 2, expectedGlyphs: 3},
{textInput: "\n\n\n", expectedLines: 3, expectedGlyphs: 4},
{textInput: "\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 1, expectedTruncated: 1},
{textInput: "\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 1, expectedTruncated: 2},
{textInput: "\n\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 1, expectedTruncated: 3},
{textInput: "a\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 2, expectedTruncated: 1},
{textInput: "a\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 2, expectedTruncated: 2},
{textInput: "a\n\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 2, expectedTruncated: 3},
{textInput: "\n", expectedLines: 1, maxLines: 2, expectedGlyphs: 2},
{textInput: "\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 2, expectedTruncated: 1},
{textInput: "\n\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 2, expectedTruncated: 2},
{textInput: "a\n", expectedLines: 1, maxLines: 2, expectedGlyphs: 3},
{textInput: "a\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 3, expectedTruncated: 1},
{textInput: "a\n\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 3, expectedTruncated: 2},
} {
t.Run(fmt.Sprintf("%q-maxLines%d", tc.textInput, tc.maxLines), func(t *testing.T) {
ltrFace, _ := opentype.Parse(goregular.TTF)
collection := []FontFace{{Face: ltrFace}}
cache := NewShaper(NoSystemFonts(), WithCollection(collection))
checkGlyphs := func() {
glyphs := []Glyph{}
runes := 0
truncated := 0
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
glyphs = append(glyphs, g)
if g.Flags&FlagTruncator == 0 {
runes += int(g.Runes)
} else {
truncated += int(g.Runes)
}
}
if expected := len([]rune(tc.textInput)) - tc.expectedTruncated; expected != runes {
t.Errorf("expected %d runes, got %d", expected, runes)
}
if truncated != tc.expectedTruncated {
t.Errorf("expected %d truncated runes, got %d", tc.expectedTruncated, truncated)
}
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 && idx != 0 {
t.Errorf("expected paragraph start glyph to have cursor x, got %v", startX)
}
if breakY == startY {
t.Errorf("expected paragraph start glyph to have cursor y")
}
}
if count := strings.Count(tc.textInput, "\n"); found != count && tc.maxLines == 0 {
t.Errorf("expected %d paragraph breaks, found %d", count, found)
} else if tc.maxLines > 0 && found > tc.maxLines {
t.Errorf("expected %d paragraph breaks due to truncation, found %d", tc.maxLines, found)
}
}
params := Parameters{
Alignment: Middle,
PxPerEm: fixed.I(10),
MinWidth: 200,
MaxWidth: 200,
Locale: english,
MaxLines: tc.maxLines,
}
cache.LayoutString(params, 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(params, 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.advance, glyph.runeCount, glyph.glyphCount, glyphCursor, glyphs[glyphCursor].Flags)
t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - n/a", g, glyph.advance, 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,
},
},
{
name: "newline regression",
input: "\n",
params: Parameters{
Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal},
Alignment: Start,
PxPerEm: 768,
MaxLines: 1,
Truncator: "\u200b",
WrapPolicy: WrapHeuristically,
MaxWidth: 999929,
},
},
{
name: "newline zero-width regression",
input: "\n",
params: Parameters{
Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal},
Alignment: Start,
PxPerEm: 768,
MaxLines: 1,
Truncator: "\u200b",
WrapPolicy: WrapHeuristically,
MaxWidth: 0,
},
},
{
name: "double newline regression",
input: "\n\n",
params: Parameters{
Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal},
Alignment: Start,
PxPerEm: 768,
MaxLines: 1,
Truncator: "\u200b",
WrapPolicy: WrapHeuristically,
MaxWidth: 1000,
},
},
{
name: "triple newline regression",
input: "\n\n\n",
params: Parameters{
Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal},
Alignment: Start,
PxPerEm: 768,
MaxLines: 1,
Truncator: "\u200b",
WrapPolicy: WrapHeuristically,
MaxWidth: 1000,
},
},
} {
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 += int(g.Runes)
}
if inputRunes := len([]rune(tc.input)); totalRunes != inputRunes {
t.Errorf("input contained %d runes, but glyphs contained %d", inputRunes, totalRunes)
}
})
}
})
}
}