1package text
2
3import (
4 "bytes"
5 "fmt"
6 "io"
7 "os"
8 "reflect"
9 "testing"
10
11 "github.com/go-text/typesetting/font"
12)
13
14type countingSource struct {
15 entries []FontFile
16 data map[string][]byte
17 reads map[string]int
18}
19
20func (s *countingSource) Fonts() (map[string][]byte, error) { panic("Load must use metadata index") }
21func (s *countingSource) Index() ([]FontFile, error) { return s.entries, nil }
22func (s *countingSource) Open(p string) (io.ReadCloser, error) {
23 s.reads[p]++
24 return io.NopCloser(bytes.NewReader(s.data[p])), nil
25}
26func TestLazyFontSelection(t *testing.T) {
27 b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
28 if err != nil {
29 t.Fatal(err)
30 }
31 s := &countingSource{data: map[string][]byte{"good": b, "bad": []byte("broken")}, reads: map[string]int{}}
32 s.entries = append(s.entries, FontFile{Path: "bad", Family: "Noto Sans", Aspect: font.Aspect{Weight: 400, Stretch: 1}})
33 s.entries = append(s.entries, FontFile{Path: "good", Family: "Noto Sans", Aspect: font.Aspect{Weight: 400, Stretch: 1}})
34 for i := 0; i < 2000; i++ {
35 s.entries = append(s.entries, FontFile{Path: fmt.Sprint(i), Family: fmt.Sprintf("unused%d", i)})
36 }
37 c, err := Load(s)
38 if err != nil || len(s.reads) != 0 {
39 t.Fatalf("index read font bytes: %v %v", err, s.reads)
40 }
41 e := NewEngine(c)
42 for i := 0; i < 2; i++ {
43 l, err := e.Measure("Hello", Request{Families: []string{"Noto Sans"}, Size: 16}, 0)
44 if err != nil || l.Width == 0 {
45 t.Fatalf("measure: %v diagnostics %v", err, c.Diagnostics)
46 }
47 e.EndFrame()
48 }
49 if len(s.reads) != 2 || s.reads["bad"] != 1 || s.reads["good"] != 1 || len(c.Diagnostics) != 1 {
50 t.Fatalf("reads: %v diagnostics: %v", s.reads, c.Diagnostics)
51 }
52}
53
54func TestUnknownFamilyFallback(t *testing.T) {
55 b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
56 if err != nil {
57 t.Fatal(err)
58 }
59 s := &countingSource{entries: []FontFile{{Path: "first", Family: "Unlisted Family", Aspect: font.Aspect{Weight: 400, Stretch: 1}}, {Path: "second", Family: "Other Family", Aspect: font.Aspect{Weight: 400, Stretch: 1}}}, data: map[string][]byte{"first": b, "second": b}, reads: map[string]int{}}
60 c, err := Load(s)
61 if err != nil {
62 t.Fatal(err)
63 }
64 l, err := NewEngine(c).Measure("Hello", Request{Families: []string{"sans-serif"}, Size: 16}, 0)
65 if err != nil || l.Width == 0 || len(s.reads) != 1 || s.reads["first"] != 1 {
66 t.Fatalf("fallback %v reads %v", err, s.reads)
67 }
68}
69
70func TestCollectionDiscovery(t *testing.T) {
71 if !isFontPath("font.TTC") || !isFontPath("font.otf") || isFontPath("font.txt") {
72 t.Fatal("font extension filter")
73 }
74 // A collection entry retains its individual face index in the catalog.
75 s := &countingSource{entries: []FontFile{{Path: "faces.ttc", Family: "Other Family", Index: 1}}, reads: map[string]int{}}
76 c, err := Load(s)
77 if err != nil || len(c.Faces) != 1 || c.Faces[0].index != 1 {
78 t.Fatalf("collection entry: %v %+v", err, c)
79 }
80}
81
82func TestOversizedSelectedFont(t *testing.T) {
83 b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
84 if err != nil {
85 t.Fatal(err)
86 }
87 s := &countingSource{entries: []FontFile{{Path: "large", Family: "Noto Sans"}, {Path: "good", Family: "Noto Sans"}}, data: map[string][]byte{"large": make([]byte, maxFontBytes+1), "good": b}, reads: map[string]int{}}
88 c, err := Load(s)
89 if err != nil {
90 t.Fatal(err)
91 }
92 e := NewEngine(c)
93 for i := 0; i < 2; i++ {
94 l, err := e.Measure("Hello", Request{Families: []string{"Noto Sans"}, Size: 16}, 0)
95 if err != nil || l.Width == 0 {
96 t.Fatalf("measure: %v", err)
97 }
98 e.EndFrame()
99 }
100 if s.reads["large"] != 1 || s.reads["good"] != 1 || len(c.Diagnostics) != 1 {
101 t.Fatalf("reads %v diagnostics %v", s.reads, c.Diagnostics)
102 }
103}
104
105func TestIndexedGlyphFallback(t *testing.T) {
106 latin, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
107 if err != nil {
108 t.Fatal(err)
109 }
110 japanese, err := os.ReadFile("../../testdata/fonts/IBMPlexSansJP-Regular.ttf")
111 if err != nil {
112 t.Fatal(err)
113 }
114 s := &countingSource{entries: []FontFile{{Path: "latin", Family: "Noto Sans"}, {Path: "japanese", Family: "Unlisted Japanese", UnicodeRanges: [4]uint32{0, 1 << (59 - 32)}}}, data: map[string][]byte{"latin": latin, "japanese": japanese}, reads: map[string]int{}}
115 c, err := Load(s)
116 if err != nil {
117 t.Fatal(err)
118 }
119 e := NewEngine(c)
120 var first *Face
121 for i := 0; i < 2; i++ {
122 l, err := e.Measure("日本語", Request{Families: []string{"Noto Sans"}, Size: 20}, 0)
123 if err != nil {
124 t.Fatal(err)
125 }
126 f := l.Lines[0].Runs[0].Face
127 if f.Family != "Unlisted Japanese" || len(l.Lines[0].Runs[0].Glyphs) == 0 || l.Lines[0].Runs[0].Glyphs[0].Missing {
128 t.Fatalf("wrong fallback: %+v", l.Lines[0].Runs)
129 }
130 if first != nil && f != first {
131 t.Fatal("selection changed")
132 }
133 first = f
134 e.EndFrame()
135 }
136 if s.reads["latin"] != 1 || s.reads["japanese"] != 1 {
137 t.Fatalf("reads %v", s.reads)
138 }
139}
140
141func TestIndexedFallbackCap(t *testing.T) {
142 b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
143 if err != nil {
144 t.Fatal(err)
145 }
146 s := &countingSource{data: map[string][]byte{}, reads: map[string]int{}}
147 for i := 0; i < 2000; i++ {
148 p := fmt.Sprint(i)
149 s.entries = append(s.entries, FontFile{Path: p, Family: fmt.Sprintf("Other %d", i)})
150 s.data[p] = b
151 }
152 c, err := Load(s)
153 if err != nil {
154 t.Fatal(err)
155 }
156 e := NewEngine(c)
157 // The unknown codepoint has no OS/2 mapping; every face is eligible.
158 for i := 0; i < 2; i++ {
159 l, err := e.Measure("\U0010ffff", Request{Families: []string{"sans-serif"}, Size: 20}, 0)
160 if err != nil {
161 t.Fatal(err)
162 }
163 if !l.Lines[0].Runs[0].Glyphs[0].Missing {
164 t.Fatal("expected tofu")
165 }
166 if len(s.reads) != maxFallbackFaces {
167 t.Fatalf("measure %d: %d reads, want %d", i, len(s.reads), maxFallbackFaces)
168 }
169 e.EndFrame()
170 }
171}
172
173func TestUnicodeRangeFilter(t *testing.T) {
174 f := &Face{unicodeRanges: [4]uint32{1, 0, 0, 0}}
175 if !f.mayCover('A') || f.mayCover('日') || !f.mayCover('\U0010ffff') {
176 t.Fatal("range filter")
177 }
178}
179
180func TestPriorityIndependentOfClusterOrder(t *testing.T) {
181 latin, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
182 if err != nil {
183 t.Fatal(err)
184 }
185 jp, err := os.ReadFile("../../testdata/fonts/IBMPlexSansJP-Regular.ttf")
186 if err != nil {
187 t.Fatal(err)
188 }
189 s := &countingSource{entries: []FontFile{{Path: "named", Family: "Noto Sans"}, {Path: "other", Family: "Other"}}, data: map[string][]byte{"named": latin, "other": jp}, reads: map[string]int{}}
190 c, err := Load(s)
191 if err != nil {
192 t.Fatal(err)
193 }
194 e := NewEngine(c)
195 r := Request{Families: []string{"Noto Sans"}, Size: 20}
196 for i := 0; i < 2; i++ {
197 joined, err := e.Measure("日A", r, 0)
198 if err != nil {
199 t.Fatal(err)
200 }
201 if len(joined.Lines[0].Runs) < 2 {
202 t.Fatalf("expected separate runs: %+v", joined.Lines[0].Runs)
203 }
204 left, err := e.Measure("日", r, 0)
205 if err != nil {
206 t.Fatal(err)
207 }
208 right, err := e.Measure("A", r, 0)
209 if err != nil {
210 t.Fatal(err)
211 }
212 runs := joined.Lines[0].Runs
213 if runs[0].Face != left.Lines[0].Runs[0].Face || runs[len(runs)-1].Face != right.Lines[0].Runs[0].Face || runs[len(runs)-1].Face.Family != "Noto Sans" {
214 t.Fatalf("priority changed: %+v", runs)
215 }
216 e.EndFrame()
217 }
218}
219
220func TestFallbackWindowAcrossManyBlocks(t *testing.T) {
221 b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
222 if err != nil {
223 t.Fatal(err)
224 }
225 s := &countingSource{data: map[string][]byte{}, reads: map[string]int{}}
226 for i := 0; i < 2000; i++ {
227 p := fmt.Sprint(i)
228 s.entries = append(s.entries, FontFile{Path: p, Family: fmt.Sprintf("Other %d", i)})
229 s.data[p] = b
230 }
231 c, err := Load(s)
232 if err != nil {
233 t.Fatal(err)
234 }
235 var sample []rune
236 for i := 0; i < 50; i++ {
237 sample = append(sample, rune(0x10000+i*0x500))
238 }
239 e := NewEngine(c)
240 r := Request{Families: []string{"sans-serif"}, Size: 16}
241 first, err := e.Measure(string(sample), r, 0)
242 if err != nil {
243 t.Fatal(err)
244 }
245 reads := len(s.reads)
246 if reads > maxFallbackFaces || reads == 0 {
247 t.Fatalf("first measure loaded %d", reads)
248 }
249 e.EndFrame()
250 second, err := e.Measure(string(sample), r, 0)
251 if err != nil {
252 t.Fatal(err)
253 }
254 if len(s.reads) != reads || !reflect.DeepEqual(first, second) {
255 t.Fatalf("unstable fallback: %d -> %d", reads, len(s.reads))
256 }
257}
258
259// A failed first fallback face must not stall primary (metrics) selection.
260func TestPrimarySkipsFailedFallbackFace(t *testing.T) {
261 b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
262 if err != nil {
263 t.Fatal(err)
264 }
265 s := &countingSource{entries: []FontFile{{Path: "broken", Family: "Unlisted A"}, {Path: "good", Family: "Unlisted B"}}, data: map[string][]byte{"broken": []byte("broken"), "good": b}, reads: map[string]int{}}
266 c, err := Load(s)
267 if err != nil {
268 t.Fatal(err)
269 }
270 r := Request{Families: []string{"sans-serif"}, Size: 16}
271 for i := 0; i < 2; i++ {
272 if f := c.primary(r); f == nil || f.path != "good" {
273 t.Fatalf("primary %+v", f)
274 }
275 }
276 e := NewEngine(c)
277 if l, err := e.Measure("Hello", r, 0); err != nil || l.Width == 0 {
278 t.Fatalf("measure: %v", err)
279 }
280 // Empty text has no missing runes but must still find a face for metrics.
281 if l, err := e.Measure("", r, 0); err != nil || l.LineHeight == 0 {
282 t.Fatalf("empty measure: %+v %v", l, err)
283 }
284 if s.reads["broken"] != 1 || s.reads["good"] != 1 {
285 t.Fatalf("reads %v", s.reads)
286 }
287}
288
289// A named face that fails to load must not let a new face into the window.
290func TestFallbackWindowIgnoresFailedNamedFaces(t *testing.T) {
291 s := &countingSource{data: map[string][]byte{"named": []byte("broken")}, reads: map[string]int{}}
292 s.entries = append(s.entries, FontFile{Path: "named", Family: "Noto Sans"})
293 for i := 0; i < maxFallbackFaces+4; i++ {
294 s.entries = append(s.entries, FontFile{Path: fmt.Sprint(i), Family: fmt.Sprintf("Other %d", i)})
295 }
296 c, err := Load(s)
297 if err != nil {
298 t.Fatal(err)
299 }
300 r := Request{Families: []string{"Noto Sans"}, Size: 16}
301 before := c.fallbackWindow([]rune{'A'}, r)
302 if c.primary(r) != nil && c.Faces[0].bad {
303 t.Fatal("broken named face selected")
304 }
305 if !c.Faces[0].bad {
306 t.Fatal("named face should have failed")
307 }
308 after := c.fallbackWindow([]rune{'A'}, r)
309 if !reflect.DeepEqual(before, after) || len(after) != maxFallbackFaces {
310 t.Fatalf("window moved after failure: %d -> %d", len(before), len(after))
311 }
312 for _, f := range after {
313 if f.path == "named" {
314 t.Fatal("named face entered the fallback window")
315 }
316 }
317}