1package text
2
3import (
4 "crypto/sha256"
5 "fmt"
6 "image"
7 "math"
8 "os"
9 "path/filepath"
10 "reflect"
11 "strings"
12 "testing"
13
14 "github.com/go-text/typesetting/di"
15 "github.com/go-text/typesetting/font"
16 ot "github.com/go-text/typesetting/font/opentype"
17)
18
19func fixture(t testing.TB) *Catalog {
20 t.Helper()
21 c, e := Load(DirectorySource("../../testdata/fonts"))
22 if e != nil {
23 t.Fatal(e)
24 }
25 return c
26}
27func TestScriptsAndMetrics(t *testing.T) {
28 c := fixture(t)
29 e := NewEngine(c)
30 r := Request{Families: []string{"Noto Sans"}, Size: 20}
31 cases := []struct {
32 label, text, family string
33 rtl bool
34 }{
35 {"Latin", "office fi", "Noto Sans", false}, {"Arabic", "سلام", "Noto Sans Arabic", true}, {"Devanagari", "किरण", "Noto Sans Devanagari", false}, {"CJK", "日本語", "IBM Plex Sans JP", false}, {"combining", "a\u0301", "Noto Sans", false}, {"bidi", "abc سلام 123", "", true}, {"tofu", "\U0010ffff", "Noto Sans", false},
36 }
37 for _, tc := range cases {
38 t.Run(tc.label, func(t *testing.T) {
39 l, err := e.Measure(tc.text, r, 0)
40 if err != nil {
41 t.Fatal(err)
42 }
43 if len(l.Lines) == 0 || l.Width <= 0 || l.Height <= 0 {
44 t.Fatalf("bad metrics: %+v", l)
45 }
46 found, rtl, missing := false, false, false
47 for _, line := range l.Lines {
48 for _, run := range line.Runs {
49 if run.Face == nil {
50 t.Fatal("nil face")
51 }
52 if tc.family == run.Face.Family {
53 found = true
54 }
55 if run.Direction == di.DirectionRTL {
56 rtl = true
57 }
58 sum := 0.0
59 for _, g := range run.Glyphs {
60 sum += g.Advance
61 missing = missing || g.Missing
62 }
63 if math.Abs(sum-run.Advance) > 0.02 {
64 t.Fatalf("advance mismatch %f %f", sum, run.Advance)
65 }
66 }
67 }
68 if tc.family != "" && !found {
69 t.Errorf("missing fallback %s; got %+v", tc.family, l.Lines[0].Runs)
70 }
71 if tc.rtl && !rtl {
72 t.Error("missing RTL run")
73 }
74 if tc.label == "tofu" && !missing {
75 t.Error("missing tofu flag")
76 }
77 again, err := e.Measure(tc.text, r, 0)
78 if err != nil || !reflect.DeepEqual(l, again) {
79 t.Error("nondeterministic output")
80 }
81 })
82 }
83 a, _ := e.Measure("office fi", r, 0)
84 glyphs := 0
85 for _, run := range a.Lines[0].Runs {
86 glyphs += len(run.Glyphs)
87 }
88 if glyphs >= len([]rune("office fi")) {
89 t.Error("expected fi ligature")
90 }
91 wide, _ := e.Measure("one two three four five", r, 220)
92 narrow, _ := e.Measure("one two three four five", r, 60)
93 if narrow.Height < wide.Height || len(narrow.Lines) <= len(wide.Lines) {
94 t.Errorf("wrap monotonicity: %+v %+v", narrow, wide)
95 }
96}
97
98func TestCorruptAndMatching(t *testing.T) {
99 if _, err := Load(sourceMap{"broken.ttf": []byte("not a font")}); err == nil {
100 t.Fatal("accepted corrupt font")
101 }
102 c := fixture(t)
103 if f := c.primary(Request{Families: []string{"missing", "sans-serif"}}); f.Family != "Noto Sans" {
104 t.Fatalf("generic mismatch %s", f.Family)
105 }
106 if f := c.primary(Request{Families: []string{"IBM Plex Sans JP"}}); f.Family != "IBM Plex Sans JP" {
107 t.Fatalf("family mismatch %s", f.Family)
108 }
109 if _, err := Load(sourceMap{}); err == nil {
110 t.Fatal("accepted empty catalog")
111 }
112}
113
114type sourceMap map[string][]byte
115
116func (s sourceMap) Fonts() (map[string][]byte, error) { return s, nil }
117func TestRasterCoverage(t *testing.T) {
118 c := fixture(t)
119 f := c.primary(Request{Families: []string{"Noto Sans"}})
120 gid, ok := f.Shape.NominalGlyph('H')
121 if !ok {
122 t.Fatal("no H")
123 }
124 key, err := GlyphKey(f, gid, 32, 0, nil)
125 if err != nil {
126 t.Fatal(err)
127 }
128 mask, err := Rasterize(f, key)
129 if err != nil {
130 t.Fatal(err)
131 }
132 if mask.Alpha.Rect.Dx() == 0 {
133 t.Fatal("empty mask")
134 }
135 coverage := 0.0
136 for _, v := range mask.Alpha.Pix {
137 coverage += float64(v) / 255
138 }
139 // Independent outline area oracle: flatten Beziers, sum signed contour areas.
140 // Uses go-text font-unit outlines; rasterizer uses sfnt 26.6 outlines.
141 outline, ok := f.Shape.GlyphData(gid).(font.GlyphOutline)
142 if !ok {
143 t.Fatal("outline")
144 }
145 area := 0.0
146 var start, prev [2]float64
147 contour := [][2]float64{}
148 flush := func() {
149 if len(contour) < 2 {
150 return
151 }
152 for i, p := range contour {
153 q := contour[(i+1)%len(contour)]
154 area += p[0]*q[1] - q[0]*p[1]
155 }
156 contour = nil
157 }
158 for _, s := range outline.Segments {
159 p := s.ArgsSlice()
160 conv := func(pt ot.SegmentPoint) [2]float64 {
161 return [2]float64{float64(pt.X) * 32 / float64(f.Shape.Upem()), float64(pt.Y) * 32 / float64(f.Shape.Upem())}
162 }
163 switch s.Op {
164 case ot.SegmentOpMoveTo:
165 flush()
166 start = conv(p[0])
167 prev = start
168 contour = append(contour, start)
169 case ot.SegmentOpLineTo:
170 prev = conv(p[0])
171 contour = append(contour, prev)
172 case ot.SegmentOpQuadTo:
173 ctrl, end := conv(p[0]), conv(p[1])
174 from := prev
175 for i := 1; i <= 24; i++ {
176 v := float64(i) / 24
177 point := [2]float64{(1-v)*(1-v)*from[0] + 2*(1-v)*v*ctrl[0] + v*v*end[0], (1-v)*(1-v)*from[1] + 2*(1-v)*v*ctrl[1] + v*v*end[1]}
178 contour = append(contour, point)
179 }
180 prev = end
181 case ot.SegmentOpCubeTo:
182 a, b, end := conv(p[0]), conv(p[1]), conv(p[2])
183 from := prev
184 for i := 1; i <= 24; i++ {
185 v := float64(i) / 24
186 u := 1 - v
187 contour = append(contour, [2]float64{u*u*u*from[0] + 3*u*u*v*a[0] + 3*u*v*v*b[0] + v*v*v*end[0], u*u*u*from[1] + 3*u*u*v*a[1] + 3*u*v*v*b[1] + v*v*v*end[1]})
188 }
189 prev = end
190 }
191 _ = start
192 }
193 flush()
194 area = math.Abs(area) / 2
195 if math.Abs(coverage-area) > area*0.14+2 {
196 t.Fatalf("coverage %.2f outline area %.2f", coverage, area)
197 }
198 for _, phase := range []float64{0, 0.26, 0.51, 0.76} {
199 k, _ := GlyphKey(f, gid, 32, phase, nil)
200 m, err := Rasterize(f, k)
201 if err != nil || m.Alpha.Rect.Dx() == 0 {
202 t.Fatalf("phase %v: %v", phase, err)
203 }
204 }
205 if _, err := Rasterize(f, Key{}); err == nil {
206 t.Fatal("invalid key accepted")
207 }
208}
209func TestAtlas(t *testing.T) {
210 a, _ := NewAtlas(32, 2)
211 m := Mask{Alpha: image.NewAlpha(image.Rect(0, 0, 11, 11))}
212 seen := map[int][]image.Rectangle{}
213 for i := 0; i < 8; i++ {
214 p, err := a.Insert(Key{FaceID: string(rune('a' + i))}, m)
215 if err != nil {
216 t.Fatal(err)
217 }
218 for _, rect := range seen[p.Page] {
219 if p.Rect.Overlaps(rect) {
220 t.Fatalf("overlap %v %v", p.Rect, rect)
221 }
222 }
223 seen[p.Page] = append(seen[p.Page], p.Rect)
224 }
225 if _, err := a.Insert(Key{FaceID: "overflow"}, m); err == nil {
226 t.Error("evicted in active frame")
227 }
228 if up := a.Uploads(); len(up) != 2 {
229 t.Fatalf("coalesced uploads %d", len(up))
230 }
231 a.BeginFrame()
232 p, err := a.Insert(Key{FaceID: "new"}, m)
233 if err != nil || p.Page < 0 {
234 t.Fatalf("eviction: %v", err)
235 }
236 up := a.Uploads()
237 if len(up) != 1 || up[0].Rect != image.Rect(0, 0, 32, 32) {
238 t.Fatalf("missing coalesced clear: %+v", up)
239 }
240 if len(up[0].Bytes) > 32*32 {
241 t.Fatal("upload exceeded page size")
242 }
243}
244func TestVariableFixture(t *testing.T) {
245 c := fixture(t)
246 f := c.primary(Request{Families: []string{"Adwaita Sans"}})
247 r := Request{Families: []string{"Adwaita Sans"}, Size: 20, Variations: []font.Variation{{Tag: ot.MustNewTag("wght"), Value: 700}}}
248 l, err := NewEngine(c).Measure("Hello", r, 0)
249 if err != nil || len(l.Lines) == 0 {
250 t.Fatalf("variable shaping: %v", err)
251 }
252 if len(l.Lines[0].Runs) == 0 || len(l.Lines[0].Runs[0].Face.Shape.Coords()) == 0 {
253 t.Fatal("variation axis not active")
254 }
255 glyph := l.Lines[0].Runs[0].Glyphs[0]
256 variable := l.Lines[0].Runs[0].Face
257 k, _ := GlyphKey(variable, glyph.ID, 20, 0, r.Variations)
258 if k.Variations == VariationHash(nil) {
259 t.Fatal("lost variation identity")
260 }
261 m, err := Rasterize(variable, k)
262 if err != nil || m.Alpha.Rect.Dx() == 0 {
263 t.Fatalf("variable raster: %v", err)
264 }
265 if _, err := Rasterize(f, k); err == nil {
266 t.Fatal("default face accepted non-default key")
267 }
268}
269
270// ShapeParagraph measures uncached shaping; ShapeParagraphCached measures a
271// Measure call that hits the per-frame cache.
272func BenchmarkShapeParagraph(b *testing.B) {
273 c := fixture(b)
274 e := NewEngine(c)
275 r := Request{Families: []string{"Noto Sans"}, Size: 16}
276 s := strings.Repeat("Hello سلام कक्षा 日本語! ", 8)
277 b.ReportAllocs()
278 b.ResetTimer()
279 for i := 0; i < b.N; i++ {
280 if _, err := e.measure(s, r, 240); err != nil {
281 b.Fatal(err)
282 }
283 }
284}
285func BenchmarkShapeParagraphCached(b *testing.B) {
286 e := NewEngine(fixture(b))
287 r := Request{Families: []string{"Noto Sans"}, Size: 16}
288 s := strings.Repeat("Hello سلام कक्षा 日本語! ", 8)
289 if _, err := e.Measure(s, r, 240); err != nil {
290 b.Fatal(err)
291 }
292 b.ReportAllocs()
293 b.ResetTimer()
294 for i := 0; i < b.N; i++ {
295 if _, err := e.Measure(s, r, 240); err != nil {
296 b.Fatal(err)
297 }
298 }
299}
300func BenchmarkAtlasInsert(b *testing.B) {
301 a, _ := NewAtlas(1024, 4)
302 m := Mask{Alpha: image.NewAlpha(image.Rect(0, 0, 12, 16))}
303 b.ResetTimer()
304 for i := 0; i < b.N; i++ {
305 a.BeginFrame()
306 k := Key{Glyph: font.GID(i)}
307 if _, err := a.Insert(k, m); err != nil {
308 b.Fatal(err)
309 }
310 a.Uploads()
311 }
312}
313func TestFixtureHashes(t *testing.T) {
314 expected := map[string]string{"NotoSans-Regular.ttf": "478c558ea716033cd60c03438f628dfa75694dcf6b5f6d505a2f05fd2b4f3823", "NotoSansArabic-Regular.ttf": "bdff3e5659d67e67def05b33f749683b9376ae819d65d3dd62ac4640b3aaef48", "NotoSansDevanagari-Regular.ttf": "306b53ecfb182a504dd8a7446093c316387d2fd8dc350d0792ed1753fe0996cd", "NotoSansSymbols2-Regular.ttf": "c4a0a80f0041ce4be81e2478faad22776d23edb98ae3f0d19bd37044820ecf9d", "IBMPlexSansJP-Regular.ttf": "25d96fe620f12fba6cf09158807117ec13d1e7c9debff488117cf21dc8844688", "AdwaitaSans-Regular.ttf": "8381c33b9a44f066f2b99dba3d416a2342891e28c956a35dfd8d16ee2987e6d4"}
315 for name, hash := range expected {
316 b, err := os.ReadFile(filepath.Join("../../testdata/fonts", name))
317 if err != nil {
318 t.Fatal(err)
319 }
320 sum := sha256.Sum256(b)
321 if fmt.Sprintf("%x", sum) != hash {
322 t.Errorf("fixture %s hash mismatch", name)
323 }
324 }
325}
326
327func TestLetterSpacingWrap(t *testing.T) {
328 e := NewEngine(fixture(t))
329 r := Request{Families: []string{"Noto Sans"}, Size: 16}
330 plain, err := e.Measure("hello world", r, 1000)
331 if err != nil {
332 t.Fatal(err)
333 }
334 r.LetterSpacing = 4
335 spaced, err := e.Measure("hello world", r, 1000)
336 if err != nil {
337 t.Fatal(err)
338 }
339 if spaced.Width <= plain.Width+10 {
340 t.Fatalf("spacing width: %v -> %v", plain.Width, spaced.Width)
341 }
342 width := (plain.Width + spaced.Width) / 2
343 r.LetterSpacing = 0
344 unwrapped, err := e.Measure("hello world", r, width)
345 if err != nil {
346 t.Fatal(err)
347 }
348 r.LetterSpacing = 4
349 wrapped, err := e.Measure("hello world", r, width)
350 if err != nil {
351 t.Fatal(err)
352 }
353 if len(unwrapped.Lines) != 1 || len(wrapped.Lines) < 2 {
354 t.Fatalf("spacing wrap: %d -> %d at %v", len(unwrapped.Lines), len(wrapped.Lines), width)
355 }
356 r.LetterSpacing = math.NaN()
357 if _, err = e.Measure("x", r, width); err == nil {
358 t.Fatal("accepted NaN spacing")
359 }
360}
361
362func TestLetterSpacingClusterGeometry(t *testing.T) {
363 e := NewEngine(fixture(t))
364 r := Request{Families: []string{"Noto Sans"}, Size: 20}
365 for _, tc := range []struct {
366 name, input string
367 first, second int
368 }{{"rtl", "سلام", 0, 1}, {"combining", "a\u0301b", 0, 2}, {"ligature", "fib", 0, 2}} {
369 t.Run(tc.name, func(t *testing.T) {
370 r.LetterSpacing = 0
371 plain, err := e.Measure(tc.input, r, 0)
372 if err != nil {
373 t.Fatal(err)
374 }
375 r.LetterSpacing = 6
376 spaced, err := e.Measure(tc.input, r, 0)
377 if err != nil {
378 t.Fatal(err)
379 }
380 if len(plain.Lines) != 1 || len(spaced.Lines) != 1 {
381 t.Fatalf("lines: %d %d", len(plain.Lines), len(spaced.Lines))
382 }
383 if tc.name == "rtl" && math.Abs(spaced.Width-plain.Width-6*float64(len([]rune(tc.input))-1)) > 0.1 {
384 t.Fatalf("RTL spacing width: %v -> %v", plain.Width, spaced.Width)
385 }
386 // Compare visually adjacent cluster origins rather than rune order: RTL is
387 // painted in visual order. Glyphs in the same cluster must not gain space.
388 clusters := func(l Layout) map[int][]Glyph {
389 m := map[int][]Glyph{}
390 for _, run := range l.Lines[0].Runs {
391 for _, g := range run.Glyphs {
392 m[g.Cluster] = append(m[g.Cluster], g)
393 }
394 }
395 return m
396 }
397 a, b := clusters(plain), clusters(spaced)
398 if len(b) <= 1 || len(a) != len(b) || spaced.Width <= plain.Width+2 {
399 t.Fatalf("clusters/width: %v -> %v", a, b)
400 }
401 if tc.name == "combining" || tc.name == "ligature" {
402 if _, ok := b[tc.second]; !ok {
403 t.Fatalf("missing next cluster %d: %v", tc.second, b)
404 }
405 if _, ok := b[tc.first]; !ok {
406 t.Fatalf("missing first cluster %d: %v", tc.first, b)
407 }
408 if math.Abs((b[tc.second][0].X-b[tc.first][0].X)-(a[tc.second][0].X-a[tc.first][0].X)-6) > 0.1 {
409 t.Fatalf("spacing not between clusters: %v -> %v", a, b)
410 }
411 }
412 if tc.name == "ligature" && len(b[0]) != 1 {
413 t.Fatalf("ligature split by spacing: %v", b[0])
414 }
415 if tc.name == "rtl" {
416 moved := false
417 for cluster, old := range a {
418 if math.Abs(b[cluster][0].X-old[0].X) > 1 {
419 moved = true
420 }
421 }
422 if !moved {
423 t.Fatalf("RTL glyph positions unchanged: %v -> %v", a, b)
424 }
425 }
426 for _, run := range spaced.Lines[0].Runs {
427 sum := 0.0
428 for _, g := range run.Glyphs {
429 sum += g.Advance
430 }
431 if math.Abs(sum-run.Advance) > .02 {
432 t.Fatalf("advance mismatch %v %v", sum, run.Advance)
433 }
434 }
435 })
436 }
437}
438
439// BenchmarkMeasureMissShort measures an uncached short label, the common
440// per-frame miss when a counter or clock changes.
441func BenchmarkMeasureMissShort(b *testing.B) {
442 e := NewEngine(fixture(b))
443 r := Request{Families: []string{"Noto Sans"}, Size: 16}
444 b.ReportAllocs()
445 for b.Loop() {
446 if _, err := e.measure("Value: 42", r, 0); err != nil {
447 b.Fatal(err)
448 }
449 }
450}
451
452// allocBaselineMeasureMiss is the measured allocation count of an uncached
453// short label; most of the remainder is inside go-text segmentation and
454// wrapping. Lower it with each optimization.
455const allocBaselineMeasureMiss = 20
456
457// TestAllocMeasureMissShort covers both catalog kinds: an eager directory
458// catalog and a lazy indexed one, like the system catalog the renderer uses,
459// which runs the on-demand coverage pass.
460func TestAllocMeasureMissShort(t *testing.T) {
461 if raceEnabled {
462 t.Skip("race instrumentation changes allocation counts")
463 }
464 b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
465 if err != nil {
466 t.Fatal(err)
467 }
468 lazy, err := Load(&countingSource{entries: []FontFile{{Path: "noto", Family: "Noto Sans", Aspect: font.Aspect{Weight: 400, Stretch: 1}}}, data: map[string][]byte{"noto": b}, reads: map[string]int{}})
469 if err != nil {
470 t.Fatal(err)
471 }
472 for name, catalog := range map[string]*Catalog{"directory": fixture(t), "indexed": lazy} {
473 t.Run(name, func(t *testing.T) {
474 e := NewEngine(catalog)
475 r := Request{Families: []string{"Noto Sans"}, Size: 16}
476 measure := func() {
477 if _, err := e.measure("Value: 42", r, 0); err != nil {
478 t.Fatal(err)
479 }
480 }
481 measure() // load fonts and fill scratch buffers
482 if got := testing.AllocsPerRun(50, measure); got > allocBaselineMeasureMiss {
483 t.Fatalf("allocs per uncached measure = %v, baseline %v", got, allocBaselineMeasureMiss)
484 }
485 })
486 }
487}