Files
gio-patched/text/gotext_test.go
Chris Waldon d414116990 text: update fuzzer to sometimes truncate
This commit updates the shaper fuzzer to try truncating the text, exposing
new edge cases.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-02 10:44:15 -04:00

712 lines
20 KiB
Go

package text
import (
"fmt"
"math"
"reflect"
"strconv"
"testing"
nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
"github.com/go-text/typesetting/font"
"github.com/go-text/typesetting/shaping"
"golang.org/x/exp/slices"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/math/fixed"
giofont "gioui.org/font"
"gioui.org/font/opentype"
"gioui.org/io/system"
)
var english = system.Locale{
Language: "EN",
Direction: system.LTR,
}
var arabic = system.Locale{
Language: "AR",
Direction: system.RTL,
}
func testShaper(faces ...giofont.Face) *shaperImpl {
ff := make([]FontFace, 0, len(faces))
for _, face := range faces {
ff = append(ff, FontFace{Face: face})
}
shaper := newShaperImpl(false, ff)
return shaper
}
func TestEmptyString(t *testing.T) {
ppem := fixed.I(200)
ltrFace, _ := opentype.Parse(goregular.TTF)
shaper := testShaper(ltrFace)
lines := shaper.LayoutRunes(Parameters{
PxPerEm: ppem,
MaxWidth: 2000,
Locale: english,
}, []rune{})
if len(lines.lines) == 0 {
t.Fatalf("Layout returned no lines for empty string; expected 1")
}
l := lines.lines[0]
if expected := fixed.Int26_6(12094); l.ascent != expected {
t.Errorf("unexpected ascent for empty string: %v, expected %v", l.ascent, expected)
}
if expected := fixed.Int26_6(2700); l.descent != expected {
t.Errorf("unexpected descent for empty string: %v, expected %v", l.descent, expected)
}
}
func TestNoFaces(t *testing.T) {
ppem := fixed.I(200)
shaper := testShaper()
// Ensure shaping text with no faces does not panic.
shaper.LayoutRunes(Parameters{
PxPerEm: ppem,
MaxWidth: 2000,
Locale: english,
}, []rune("✨ⷽℎ↞⋇ⱜ⪫⢡⽛⣦␆Ⱨⳏ⳯⒛⭣╎⌞⟻⢇┃➡⬎⩱⸇ⷎ⟅▤⼶⇺⩳⎏⤬⬞ⴈ⋠⿶⢒₍☟⽂ⶦ⫰⭢⌹∼▀⾯⧂❽⩏ⓖ⟅⤔⍇␋⽓ₑ⢳⠑❂⊪⢘⽨⃯▴ⷿ"))
}
func TestAlignWidth(t *testing.T) {
lines := []line{
{width: fixed.I(50)},
{width: fixed.I(75)},
{width: fixed.I(25)},
}
for _, minWidth := range []int{0, 50, 100} {
width := alignWidth(minWidth, lines)
if width < minWidth {
t.Errorf("expected width >= %d, got %d", minWidth, width)
}
}
}
func TestShapingAlignWidth(t *testing.T) {
ppem := fixed.I(10)
ltrFace, _ := opentype.Parse(goregular.TTF)
shaper := testShaper(ltrFace)
type testcase struct {
name string
minWidth, maxWidth int
expected int
str string
}
for _, tc := range []testcase{
{
name: "zero min",
maxWidth: 100,
str: "a\nb\nc",
expected: 22,
},
{
name: "min == max",
minWidth: 100,
maxWidth: 100,
str: "a\nb\nc",
expected: 100,
},
{
name: "min < max",
minWidth: 50,
maxWidth: 100,
str: "a\nb\nc",
expected: 50,
},
{
name: "min < max, text > min",
minWidth: 50,
maxWidth: 100,
str: "aphabetic\nb\nc",
expected: 60,
},
} {
t.Run(tc.name, func(t *testing.T) {
lines := shaper.LayoutString(Parameters{PxPerEm: ppem,
MinWidth: tc.minWidth,
MaxWidth: tc.maxWidth,
Locale: english,
}, tc.str)
if lines.alignWidth != tc.expected {
t.Errorf("expected line alignWidth to be %d, got %d", tc.expected, lines.alignWidth)
}
})
}
}
// TestNewlineSynthesis ensures that the shaper correctly inserts synthetic glyphs
// representing newline runes.
func TestNewlineSynthesis(t *testing.T) {
ppem := fixed.I(10)
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
shaper := testShaper(ltrFace, rtlFace)
type testcase struct {
name string
locale system.Locale
txt string
}
for _, tc := range []testcase{
{
name: "ltr bidi newline in rtl segment",
locale: english,
txt: "The quick سماء שלום لا fox تمط שלום\n",
},
{
name: "ltr bidi newline in ltr segment",
locale: english,
txt: "The quick سماء שלום لا fox\n",
},
{
name: "rtl bidi newline in ltr segment",
locale: arabic,
txt: "الحب سماء brown привет fox تمط jumps\n",
},
{
name: "rtl bidi newline in rtl segment",
locale: arabic,
txt: "الحب سماء brown привет fox تمط\n",
},
} {
t.Run(tc.name, func(t *testing.T) {
doc := shaper.LayoutRunes(Parameters{
PxPerEm: ppem,
MaxWidth: 200,
Locale: tc.locale,
}, []rune(tc.txt))
for lineIdx, line := range doc.lines {
lastRunIdx := len(line.runs) - 1
lastRun := line.runs[lastRunIdx]
lastGlyphIdx := len(lastRun.Glyphs) - 1
if lastRun.Direction.Progression() == system.TowardOrigin {
lastGlyphIdx = 0
}
glyph := lastRun.Glyphs[lastGlyphIdx]
if glyph.glyphCount != 0 {
t.Errorf("expected synthetic newline on line %d, run %d, glyph %d", lineIdx, lastRunIdx, lastGlyphIdx)
}
for runIdx, run := range line.runs {
for glyphIdx, glyph := range run.Glyphs {
if runIdx == lastRunIdx && glyphIdx == lastGlyphIdx {
continue
}
if glyph.glyphCount == 0 {
t.Errorf("found invalid synthetic newline on line %d, run %d, glyph %d", lineIdx, runIdx, glyphIdx)
}
}
}
}
if t.Failed() {
printLinePositioning(t, doc.lines, nil)
}
})
}
}
// simpleGlyph returns a simple square glyph with the provided cluster
// value.
func simpleGlyph(cluster int) shaping.Glyph {
return complexGlyph(cluster, 1, 1)
}
// ligatureGlyph returns a simple square glyph with the provided cluster
// value and number of runes.
func ligatureGlyph(cluster, runes int) shaping.Glyph {
return complexGlyph(cluster, runes, 1)
}
// expansionGlyph returns a simple square glyph with the provided cluster
// value and number of glyphs.
func expansionGlyph(cluster, glyphs int) shaping.Glyph {
return complexGlyph(cluster, 1, glyphs)
}
// complexGlyph returns a simple square glyph with the provided cluster
// value, number of associated runes, and number of glyphs in the cluster.
func complexGlyph(cluster, runes, glyphs int) shaping.Glyph {
return shaping.Glyph{
Width: fixed.I(10),
Height: fixed.I(10),
XAdvance: fixed.I(10),
YAdvance: fixed.I(10),
YBearing: fixed.I(10),
ClusterIndex: cluster,
GlyphCount: glyphs,
RuneCount: runes,
}
}
// copyLines performs a deep copy of the provided lines. This is necessary if you
// want to use the line wrapper again while also using the lines.
func copyLines(lines []shaping.Line) []shaping.Line {
out := make([]shaping.Line, len(lines))
for lineIdx, line := range lines {
lineCopy := make([]shaping.Output, len(line))
for runIdx, run := range line {
lineCopy[runIdx] = run
lineCopy[runIdx].Glyphs = slices.Clone(run.Glyphs)
}
out[lineIdx] = lineCopy
}
return out
}
// makeTestText creates a simple and complex(bidi) sample of shaped text at the given
// font size and wrapped to the given line width. The runeLimit, if nonzero,
// truncates the sample text to ensure shorter output for expensive tests.
func makeTestText(shaper *shaperImpl, primaryDir system.TextDirection, fontSize, lineWidth, runeLimit int) (simpleSample, complexSample []shaping.Line) {
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
if shaper == nil {
shaper = testShaper(ltrFace, rtlFace)
}
ltrSource := "The quick brown fox jumps over the lazy dog."
rtlSource := "الحب سماء لا تمط غير الأحلام"
// bidiSource is crafted to contain multiple consecutive RTL runs (by
// changing scripts within the RTL).
bidiSource := "The quick سماء שלום لا fox تمط שלום غير the lazy dog."
// bidi2Source is crafted to contain multiple consecutive LTR runs (by
// changing scripts within the LTR).
bidi2Source := "الحب سماء brown привет fox تمط jumps привет over غير الأحلام"
locale := english
simpleSource := ltrSource
complexSource := bidiSource
if primaryDir == system.RTL {
simpleSource = rtlSource
complexSource = bidi2Source
locale = arabic
}
if runeLimit != 0 {
simpleRunes := []rune(simpleSource)
complexRunes := []rune(complexSource)
if runeLimit < len(simpleRunes) {
ltrSource = string(simpleRunes[:runeLimit])
}
if runeLimit < len(complexRunes) {
rtlSource = string(complexRunes[:runeLimit])
}
}
simpleText, _ := shaper.shapeAndWrapText(Parameters{
PxPerEm: fixed.I(fontSize),
MaxWidth: lineWidth,
Locale: locale,
}, []rune(simpleSource))
simpleText = copyLines(simpleText)
complexText, _ := shaper.shapeAndWrapText(Parameters{
PxPerEm: fixed.I(fontSize),
MaxWidth: lineWidth,
Locale: locale,
}, []rune(complexSource))
complexText = copyLines(complexText)
testShaper(rtlFace, ltrFace)
return simpleText, complexText
}
func fixedAbs(a fixed.Int26_6) fixed.Int26_6 {
if a < 0 {
a = -a
}
return a
}
func TestToLine(t *testing.T) {
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
shaper := testShaper(ltrFace, rtlFace)
ltr, bidi := makeTestText(shaper, system.LTR, 16, 100, 0)
rtl, bidi2 := makeTestText(shaper, system.RTL, 16, 100, 0)
_, bidiWide := makeTestText(shaper, system.LTR, 16, 200, 0)
_, bidi2Wide := makeTestText(shaper, system.RTL, 16, 200, 0)
type testcase struct {
name string
lines []shaping.Line
// Dominant text direction.
dir system.TextDirection
}
for _, tc := range []testcase{
{
name: "ltr",
lines: ltr,
dir: system.LTR,
},
{
name: "rtl",
lines: rtl,
dir: system.RTL,
},
{
name: "bidi",
lines: bidi,
dir: system.LTR,
},
{
name: "bidi2",
lines: bidi2,
dir: system.RTL,
},
{
name: "bidi_wide",
lines: bidiWide,
dir: system.LTR,
},
{
name: "bidi2_wide",
lines: bidi2Wide,
dir: system.RTL,
},
} {
t.Run(tc.name, func(t *testing.T) {
// We expect:
// - Line dimensions to be populated.
// - Line direction to be populated.
// - Runs to be ordered from lowest runes first.
// - Runs to have widths matching the input.
// - Runs to have the same total number of glyphs/runes as the input.
runesSeen := Range{}
shaper := testShaper(ltrFace, rtlFace)
for i, input := range tc.lines {
seenRun := make([]bool, len(input))
inputLowestRuneOffset := math.MaxInt
totalInputGlyphs := 0
totalInputRunes := 0
for _, run := range input {
if run.Runes.Offset < inputLowestRuneOffset {
inputLowestRuneOffset = run.Runes.Offset
}
totalInputGlyphs += len(run.Glyphs)
totalInputRunes += run.Runes.Count
}
output := toLine(shaper.faceToIndex, input, tc.dir)
if output.direction != tc.dir {
t.Errorf("line %d: expected direction %v, got %v", i, tc.dir, output.direction)
}
totalRunWidth := fixed.I(0)
totalLineGlyphs := 0
totalLineRunes := 0
for k, run := range output.runs {
seenRun[run.VisualPosition] = true
if output.visualOrder[run.VisualPosition] != k {
t.Errorf("line %d, run %d: run.VisualPosition=%d, but line.VisualOrder[%d]=%d(should be %d)", i, k, run.VisualPosition, run.VisualPosition, output.visualOrder[run.VisualPosition], k)
}
if run.Runes.Offset != totalLineRunes {
t.Errorf("line %d, run %d: expected Runes.Offset to be %d, got %d", i, k, totalLineRunes, run.Runes.Offset)
}
runGlyphCount := len(run.Glyphs)
if inputGlyphs := len(input[k].Glyphs); runGlyphCount != inputGlyphs {
t.Errorf("line %d, run %d: expected %d glyphs, found %d", i, k, inputGlyphs, runGlyphCount)
}
runRuneCount := 0
currentCluster := -1
for _, g := range run.Glyphs {
if g.clusterIndex != currentCluster {
runRuneCount += g.runeCount
currentCluster = g.clusterIndex
}
}
if run.Runes.Count != runRuneCount {
t.Errorf("line %d, run %d: expected %d runes, counted %d", i, k, run.Runes.Count, runRuneCount)
}
runesSeen.Count += run.Runes.Count
totalRunWidth += fixedAbs(run.Advance)
totalLineGlyphs += len(run.Glyphs)
totalLineRunes += run.Runes.Count
}
if output.runeCount != totalInputRunes {
t.Errorf("line %d: input had %d runes, only counted %d", i, totalInputRunes, output.runeCount)
}
if totalLineGlyphs != totalInputGlyphs {
t.Errorf("line %d: input had %d glyphs, only counted %d", i, totalInputRunes, totalLineGlyphs)
}
if totalRunWidth != output.width {
t.Errorf("line %d: expected width %d, got %d", i, totalRunWidth, output.width)
}
for runIndex, seen := range seenRun {
if !seen {
t.Errorf("line %d, run %d missing from runs VisualPosition fields", i, runIndex)
}
}
}
lastLine := tc.lines[len(tc.lines)-1]
maxRunes := 0
for _, run := range lastLine {
if run.Runes.Count+run.Runes.Offset > maxRunes {
maxRunes = run.Runes.Count + run.Runes.Offset
}
}
if runesSeen.Count != maxRunes {
t.Errorf("input covered %d runes, output only covers %d", maxRunes, runesSeen.Count)
}
})
}
}
func TestComputeVisualOrder(t *testing.T) {
type testcase struct {
name string
input line
expectedVisualOrder []int
}
for _, tc := range []testcase{
{
name: "ltr",
input: line{
direction: system.LTR,
runs: []runLayout{
{Direction: system.LTR},
{Direction: system.LTR},
{Direction: system.LTR},
},
},
expectedVisualOrder: []int{0, 1, 2},
},
{
name: "rtl",
input: line{
direction: system.RTL,
runs: []runLayout{
{Direction: system.RTL},
{Direction: system.RTL},
{Direction: system.RTL},
},
},
expectedVisualOrder: []int{2, 1, 0},
},
{
name: "bidi-ltr",
input: line{
direction: system.LTR,
runs: []runLayout{
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.RTL},
{Direction: system.RTL},
{Direction: system.LTR},
},
},
expectedVisualOrder: []int{0, 3, 2, 1, 4},
},
{
name: "bidi-ltr-complex",
input: line{
direction: system.LTR,
runs: []runLayout{
{Direction: system.RTL},
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.RTL},
},
},
expectedVisualOrder: []int{1, 0, 2, 4, 3, 5, 7, 6, 8, 10, 9},
},
{
name: "bidi-rtl",
input: line{
direction: system.RTL,
runs: []runLayout{
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.LTR},
{Direction: system.LTR},
{Direction: system.RTL},
},
},
expectedVisualOrder: []int{4, 1, 2, 3, 0},
},
{
name: "bidi-rtl-complex",
input: line{
direction: system.RTL,
runs: []runLayout{
{Direction: system.LTR},
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.LTR},
},
},
expectedVisualOrder: []int{9, 10, 8, 6, 7, 5, 3, 4, 2, 0, 1},
},
} {
t.Run(tc.name, func(t *testing.T) {
computeVisualOrder(&tc.input)
if !reflect.DeepEqual(tc.input.visualOrder, tc.expectedVisualOrder) {
t.Errorf("expected visual order %v, got %v", tc.expectedVisualOrder, tc.input.visualOrder)
}
for i, visualIndex := range tc.input.visualOrder {
if pos := tc.input.runs[visualIndex].VisualPosition; pos != i {
t.Errorf("line.VisualOrder[%d]=%d, but line.Runs[%d].VisualPosition=%d", i, visualIndex, visualIndex, pos)
}
}
})
}
}
func FuzzLayout(f *testing.F) {
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
f.Add("د عرمثال dstي met لم aqل جدmوpمg lرe dرd لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ.", true, false, uint8(10), uint16(200))
shaper := testShaper(ltrFace, rtlFace)
f.Fuzz(func(t *testing.T, txt string, rtl bool, truncate bool, fontSize uint8, width uint16) {
locale := system.Locale{
Direction: system.LTR,
}
if rtl {
locale.Direction = system.RTL
}
if fontSize < 1 {
fontSize = 1
}
maxLines := 0
if truncate {
maxLines = 1
}
lines := shaper.LayoutRunes(Parameters{
PxPerEm: fixed.I(int(fontSize)),
MaxWidth: int(width),
MaxLines: maxLines,
Locale: locale,
}, []rune(txt))
validateLines(t, lines.lines, len([]rune(txt)))
})
}
func validateLines(t *testing.T, lines []line, expectedRuneCount int) {
t.Helper()
runesSeen := 0
for i, line := range lines {
totalRunWidth := fixed.I(0)
totalLineGlyphs := 0
lineRunesSeen := 0
for k, run := range line.runs {
if line.visualOrder[run.VisualPosition] != k {
t.Errorf("line %d, run %d: run.VisualPosition=%d, but line.VisualOrder[%d]=%d(should be %d)", i, k, run.VisualPosition, run.VisualPosition, line.visualOrder[run.VisualPosition], k)
}
if run.Runes.Offset != lineRunesSeen {
t.Errorf("line %d, run %d: expected Runes.Offset to be %d, got %d", i, k, lineRunesSeen, run.Runes.Offset)
}
runRuneCount := 0
currentCluster := -1
for _, g := range run.Glyphs {
if g.clusterIndex != currentCluster {
runRuneCount += g.runeCount
currentCluster = g.clusterIndex
}
}
if run.Runes.Count != runRuneCount {
t.Errorf("line %d, run %d: expected %d runes, counted %d", i, k, run.Runes.Count, runRuneCount)
}
lineRunesSeen += run.Runes.Count
totalRunWidth += fixedAbs(run.Advance)
totalLineGlyphs += len(run.Glyphs)
}
if totalRunWidth != line.width {
t.Errorf("line %d: expected width %d, got %d", i, line.width, totalRunWidth)
}
runesSeen += lineRunesSeen
}
if runesSeen != expectedRuneCount {
t.Errorf("input covered %d runes, output only covers %d", expectedRuneCount, runesSeen)
}
}
// TestTextAppend ensures that appending two texts together correctly updates the new lines'
// y offsets.
func TestTextAppend(t *testing.T) {
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
shaper := testShaper(ltrFace, rtlFace)
text1 := shaper.LayoutString(Parameters{
PxPerEm: fixed.I(14),
MaxWidth: 200,
Locale: english,
}, "د عرمثال dstي met لم aqل جدmوpمg lرe dرd لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ.")
text2 := shaper.LayoutString(Parameters{
PxPerEm: fixed.I(14),
MaxWidth: 200,
Locale: english,
}, "د عرمثال dstي met لم aqل جدmوpمg lرe dرd لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ.")
text1.append(text2)
curY := math.MinInt
for lineNum, line := range text1.lines {
yOff := line.yOffset
if yOff <= curY {
t.Errorf("lines[%d] has y offset %d, <= to previous %d", lineNum, yOff, curY)
}
curY = yOff
}
}
func TestGlyphIDPacking(t *testing.T) {
const maxPPem = fixed.Int26_6((1 << sizebits) - 1)
type testcase struct {
name string
ppem fixed.Int26_6
faceIndex int
gid font.GID
expected GlyphID
}
for _, tc := range []testcase{
{
name: "zero value",
},
{
name: "10 ppem faceIdx 1 GID 5",
ppem: fixed.I(10),
faceIndex: 1,
gid: 5,
expected: 284223755780101,
},
{
name: maxPPem.String() + " ppem faceIdx " + strconv.Itoa(math.MaxUint16) + " GID " + fmt.Sprintf("%d", int64(math.MaxUint32)),
ppem: maxPPem,
faceIndex: math.MaxUint16,
gid: math.MaxUint32,
expected: 18446744073709551615,
},
} {
t.Run(tc.name, func(t *testing.T) {
actual := newGlyphID(tc.ppem, tc.faceIndex, tc.gid)
if actual != tc.expected {
t.Errorf("expected %d, got %d", tc.expected, actual)
}
actualPPEM, actualFaceIdx, actualGID := splitGlyphID(actual)
if actualPPEM != tc.ppem {
t.Errorf("expected ppem %d, got %d", tc.ppem, actualPPEM)
}
if actualFaceIdx != tc.faceIndex {
t.Errorf("expected faceIdx %d, got %d", tc.faceIndex, actualFaceIdx)
}
if actualGID != tc.gid {
t.Errorf("expected gid %d, got %d", tc.gid, actualGID)
}
})
}
}