package text import ( "math" "reflect" "testing" nsareg "eliasnaur.com/font/noto/sans/arabic/regular" "github.com/go-text/typesetting/shaping" "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 { shaper := shaperImpl{} for _, face := range faces { shaper.Load(FontFace{Face: face}) } 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] exp := fixed.Rectangle26_6{ Min: fixed.Point26_6{ Y: fixed.Int26_6(-12094), }, Max: fixed.Point26_6{ Y: fixed.Int26_6(2700), }, } if got := l.bounds; got != exp { t.Errorf("got bounds %+v for empty string; expected %+v", got, exp) } } 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, } } // 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(shaper.orderer.sortedFacesForStyle(giofont.Font{}), Parameters{ PxPerEm: fixed.I(fontSize), MaxWidth: lineWidth, Locale: locale, }, []rune(simpleSource)) complexText, _ := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(giofont.Font{}), Parameters{ PxPerEm: fixed.I(fontSize), MaxWidth: lineWidth, Locale: locale, }, []rune(complexSource)) 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.orderer, input, tc.dir) if output.bounds.Min == (fixed.Point26_6{}) { t.Errorf("line %d: Bounds.Min not populated", i) } if output.bounds.Max == (fixed.Point26_6{}) { t.Errorf("line %d: Bounds.Max not populated", i) } 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, uint8(10), uint16(200)) shaper := testShaper(ltrFace, rtlFace) f.Fuzz(func(t *testing.T, txt string, rtl bool, fontSize uint8, width uint16) { locale := system.Locale{ Direction: system.LTR, } if rtl { locale.Direction = system.RTL } if fontSize < 1 { fontSize = 1 } lines := shaper.LayoutRunes(Parameters{ PxPerEm: fixed.I(int(fontSize)), MaxWidth: int(width), 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 { if line.bounds.Min == (fixed.Point26_6{}) { t.Errorf("line %d: Bounds.Min not populated", i) } if line.bounds.Max == (fixed.Point26_6{}) { t.Errorf("line %d: Bounds.Max not populated", i) } 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 TestClosestFontByWeight(t *testing.T) { const ( testTF1 giofont.Typeface = "MockFace" testTF2 giofont.Typeface = "TestFace" testTF3 giofont.Typeface = "AnotherFace" ) fonts := []giofont.Font{ {Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Normal}, {Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Light}, {Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Bold}, {Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Thin}, } weightOnlyTests := []struct { Lookup giofont.Weight Expected giofont.Weight }{ // Test for existing weights. {Lookup: giofont.Normal, Expected: giofont.Normal}, {Lookup: giofont.Light, Expected: giofont.Light}, {Lookup: giofont.Bold, Expected: giofont.Bold}, // Test for missing weights. {Lookup: giofont.Thin, Expected: giofont.Light}, {Lookup: giofont.ExtraLight, Expected: giofont.Light}, {Lookup: giofont.Medium, Expected: giofont.Normal}, {Lookup: giofont.SemiBold, Expected: giofont.Bold}, {Lookup: giofont.ExtraBold, Expected: giofont.Bold}, } for _, test := range weightOnlyTests { got, ok := closestFont(giofont.Font{Typeface: testTF1, Weight: test.Lookup}, fonts) if !ok { t.Errorf("expected closest font for %v to exist", test.Lookup) } if got.Weight != test.Expected { t.Errorf("got weight %v, expected %v", got.Weight, test.Expected) } } fonts = []giofont.Font{ {Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Light}, {Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Bold}, {Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Normal}, {Typeface: testTF3, Style: giofont.Italic, Weight: giofont.Bold}, } otherTests := []struct { Lookup giofont.Font Expected giofont.Font ExpectedToFail bool }{ // Test for existing fonts. { Lookup: giofont.Font{Typeface: testTF1, Weight: giofont.Light}, Expected: giofont.Font{Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Light}, }, { Lookup: giofont.Font{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Normal}, Expected: giofont.Font{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Normal}, }, // Test for missing fonts. { Lookup: giofont.Font{Typeface: testTF1, Weight: giofont.Normal}, Expected: giofont.Font{Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Light}, }, { Lookup: giofont.Font{Typeface: testTF3, Style: giofont.Italic, Weight: giofont.Normal}, Expected: giofont.Font{Typeface: testTF3, Style: giofont.Italic, Weight: giofont.Bold}, }, { Lookup: giofont.Font{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Thin}, Expected: giofont.Font{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Normal}, }, { Lookup: giofont.Font{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Bold}, Expected: giofont.Font{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Normal}, }, { Lookup: giofont.Font{Typeface: testTF2, Weight: giofont.Normal}, ExpectedToFail: true, }, { Lookup: giofont.Font{Typeface: testTF2, Style: giofont.Italic, Weight: giofont.Normal}, ExpectedToFail: true, }, } for _, test := range otherTests { got, ok := closestFont(test.Lookup, fonts) if test.ExpectedToFail { if ok { t.Errorf("expected closest font for %v to not exist", test.Lookup) } else { continue } } if !ok { t.Errorf("expected closest font for %v to exist", test.Lookup) } if got != test.Expected { t.Errorf("got %v, expected %v", got, test.Expected) } } }