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