1package text
2
3import (
4 "bytes"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "slices"
11 "testing"
12
13 "github.com/stretchr/testify/mock"
14)
15
16const fixtureDir = "../../testdata/fonts"
17
18// lazySource is a LazySource over paths. Each path indexes and opens as the
19// fixture file named by files (a missing entry indexes to nothing), so a test
20// controls file names independently of font families. indexed records every
21// path handed to IndexFiles.
22func lazySource(t *testing.T, paths []string, files map[string]string) (*MockLazySource, *[]string) {
23 t.Helper()
24 var indexed []string
25 src := NewMockLazySource(t)
26 src.EXPECT().Paths().Return(paths, nil)
27 src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(func(ps []string) ([]FontFile, error) {
28 indexed = append(indexed, ps...)
29 var out []FontFile
30 for _, p := range ps {
31 file, ok := files[p]
32 if !ok {
33 continue
34 }
35 entries, err := SystemSource{}.IndexFiles([]string{filepath.Join(fixtureDir, file)})
36 if err != nil {
37 return nil, err
38 }
39 for _, e := range entries {
40 e.Path = p
41 out = append(out, e)
42 }
43 }
44 return out, nil
45 }).Maybe()
46 src.EXPECT().Open(mock.Anything).RunAndReturn(func(p string) (io.ReadCloser, error) {
47 b, err := os.ReadFile(filepath.Join(fixtureDir, files[p]))
48 return io.NopCloser(bytes.NewReader(b)), err
49 }).Maybe()
50 return src, &indexed
51}
52
53// fixtureSource lists the fixture fonts under their own names, after extra
54// paths that index to nothing.
55func fixtureSource(t *testing.T, extra ...string) (*MockLazySource, *[]string) {
56 t.Helper()
57 fonts, err := filepath.Glob(filepath.Join(fixtureDir, "*.ttf"))
58 if err != nil || len(fonts) == 0 {
59 t.Fatalf("fixture fonts: %v", err)
60 }
61 paths := slices.Clone(extra)
62 files := map[string]string{}
63 for _, f := range fonts {
64 p := "/fonts/" + filepath.Base(f)
65 paths = append(paths, p)
66 files[p] = filepath.Base(f)
67 }
68 return lazySource(t, paths, files)
69}
70
71func bases(paths []string) []string {
72 out := make([]string, len(paths))
73 for i, p := range paths {
74 out[i] = filepath.Base(p)
75 }
76 slices.Sort(out)
77 return out
78}
79
80func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) {
81 src, indexed := fixtureSource(t, "/fonts/Unrelated-Regular.ttf")
82 c, err := Load(src)
83 if err != nil {
84 t.Fatal(err)
85 }
86 l, err := NewEngine(c).Measure("Hello", Request{Families: []string{"monospace"}, Size: 16}, 0)
87 if err != nil || l.Width == 0 {
88 t.Fatalf("measure: %v %v", err, c.Diagnostics)
89 }
90 // monospace names no fixture family; the sans-serif and script fallbacks do.
91 want := []string{"AdwaitaSans-Regular.ttf", "IBMPlexSansJP-Regular.ttf", "NotoSans-Regular.ttf", "NotoSansArabic-Regular.ttf", "NotoSansDevanagari-Regular.ttf", "NotoSansSymbols2-Regular.ttf"}
92 if got := bases(*indexed); !slices.Equal(got, want) {
93 t.Fatalf("indexed %v, want %v", got, want)
94 }
95 if c.complete {
96 t.Fatal("catalog fully indexed for a covered request")
97 }
98}
99
100func TestLazyIndexScansAllForUnknownFamily(t *testing.T) {
101 src, indexed := fixtureSource(t, "/fonts/Unrelated-Regular.ttf")
102 c, err := Load(src)
103 if err != nil {
104 t.Fatal(err)
105 }
106 // The file name does not reveal the family: only a full scan can find it.
107 if _, err := NewEngine(c).Measure("Hello", Request{Families: []string{"Some Family"}, Size: 16}, 0); err != nil {
108 t.Fatal(err)
109 }
110 if !c.complete || !slices.Contains(*indexed, "/fonts/Unrelated-Regular.ttf") {
111 t.Fatalf("unknown family did not index every file: %v", *indexed)
112 }
113}
114
115func TestLazyIndexFallbackGlyph(t *testing.T) {
116 src, _ := fixtureSource(t)
117 c, err := Load(src)
118 if err != nil {
119 t.Fatal(err)
120 }
121 l, err := NewEngine(c).Measure("日本語", Request{Families: []string{"Adwaita Sans"}, Size: 20}, 0)
122 if err != nil {
123 t.Fatal(err)
124 }
125 run := l.Lines[0].Runs[0]
126 if run.Face.Family != "IBM Plex Sans JP" || run.Glyphs[0].Missing {
127 t.Fatalf("fallback face %q", run.Face.Family)
128 }
129}
130
131// A named script fallback family stored under an unrelated file name is found
132// on the first measure, not only after another request indexed every file.
133func TestLazyIndexFirstMeasureFindsHiddenNamedFamily(t *testing.T) {
134 src, _ := lazySource(t, []string{"/fonts/AdwaitaSans-Regular.ttf", "/fonts/Arabic.ttf"},
135 map[string]string{"/fonts/AdwaitaSans-Regular.ttf": "AdwaitaSans-Regular.ttf", "/fonts/Arabic.ttf": "NotoSansArabic-Regular.ttf"})
136 c, err := Load(src)
137 if err != nil {
138 t.Fatal(err)
139 }
140 e := NewEngine(c)
141 for i := range 2 {
142 l, err := e.Measure("سلام", Request{Families: []string{"Adwaita Sans"}, Size: 20}, 0)
143 if err != nil {
144 t.Fatal(err)
145 }
146 run := l.Lines[0].Runs[0]
147 if run.Face.Family != "Noto Sans Arabic" || run.Glyphs[0].Missing {
148 t.Fatalf("measure %d: face %q missing %v", i, run.Face.Family, run.Glyphs[0].Missing)
149 }
150 e.EndFrame()
151 }
152}
153
154// A file whose stem adds style words to the family name belongs to it.
155func TestLazyIndexFindsStyleSuffixedFiles(t *testing.T) {
156 src, indexed := lazySource(t, []string{"/fonts/NotoSans-Regular.ttf", "/fonts/NotoSansCondensed.ttf", "/fonts/NotoSansArabic-Regular.ttf"},
157 map[string]string{"/fonts/NotoSans-Regular.ttf": "NotoSans-Regular.ttf", "/fonts/NotoSansCondensed.ttf": "NotoSans-Regular.ttf", "/fonts/NotoSansArabic-Regular.ttf": "NotoSansArabic-Regular.ttf"})
158 c, err := Load(src)
159 if err != nil {
160 t.Fatal(err)
161 }
162 if !slices.Contains(*indexed, "/fonts/NotoSansCondensed.ttf") || slices.Contains(*indexed, "/fonts/NotoSansArabic-Regular.ttf") {
163 t.Fatalf("indexed %v", *indexed)
164 }
165 if c.complete {
166 t.Fatal("catalog fully indexed at load")
167 }
168}
169
170func TestStemMatches(t *testing.T) {
171 for _, tc := range []struct {
172 stem, key string
173 want bool
174 }{
175 {"notosans", "notosans", true},
176 {"dejavusanscondensed", "dejavusans", true},
177 {"dejavusansboldoblique", "dejavusans", true},
178 {"dejavusansmono", "dejavusans", false},
179 {"notosansarabic", "notosans", false},
180 {"noto", "notosans", false},
181 } {
182 if got := stemMatches(tc.stem, tc.key); got != tc.want {
183 t.Errorf("stemMatches(%q, %q) = %v", tc.stem, tc.key, got)
184 }
185 }
186}
187
188// Indexing in steps keeps the catalog order of a full index.
189func TestLazyIndexKeepsPathOrder(t *testing.T) {
190 fonts, err := filepath.Glob(filepath.Join(fixtureDir, "*.ttf"))
191 if err != nil {
192 t.Fatal(err)
193 }
194 src := NewMockLazySource(t)
195 src.EXPECT().Paths().Return(fonts, nil)
196 src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(SystemSource{}.IndexFiles)
197 c, err := Load(src)
198 if err != nil {
199 t.Fatal(err)
200 }
201 c.indexAll()
202 full, err := SystemSource{}.IndexFiles(fonts)
203 if err != nil {
204 t.Fatal(err)
205 }
206 if len(full) != len(c.Faces) {
207 t.Fatalf("%d faces, want %d", len(c.Faces), len(full))
208 }
209 for i, f := range c.Faces {
210 if f.path != full[i].Path || f.index != full[i].Index {
211 t.Fatalf("face %d is %s/%d, want %s/%d", i, f.path, f.index, full[i].Path, full[i].Index)
212 }
213 }
214}
215
216func TestLazyLoadReportsPathsError(t *testing.T) {
217 fonts, err := filepath.Glob(filepath.Join(fixtureDir, "NotoSans-*.ttf"))
218 if err != nil {
219 t.Fatal(err)
220 }
221 listing := errors.New("listing failed")
222 src := NewMockLazySource(t)
223 src.EXPECT().Paths().Return(fonts, listing)
224 src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(SystemSource{}.IndexFiles)
225 c, err := Load(src)
226 if err != nil {
227 t.Fatal(err)
228 }
229 if len(c.Diagnostics) == 0 || !errors.Is(c.Diagnostics[0], listing) {
230 t.Fatalf("diagnostics %v", c.Diagnostics)
231 }
232}
233
234// More files than workers come back in path order, and failures are joined.
235func TestIndexFilesOrderAndErrors(t *testing.T) {
236 data, err := os.ReadFile(filepath.Join(fixtureDir, "NotoSans-Regular.ttf"))
237 if err != nil {
238 t.Fatal(err)
239 }
240 dir := t.TempDir()
241 var paths []string
242 for i := range indexWorkers + 8 {
243 p := filepath.Join(dir, fmt.Sprintf("font%02d.ttf", i))
244 if err := os.WriteFile(p, data, 0o600); err != nil {
245 t.Fatal(err)
246 }
247 paths = append(paths, p)
248 }
249 missing := filepath.Join(dir, "missing.ttf")
250 paths = slices.Insert(paths, 3, missing)
251 files, err := SystemSource{}.IndexFiles(paths)
252 if err == nil || !errors.Is(err, os.ErrNotExist) {
253 t.Fatalf("error %v", err)
254 }
255 if len(files) != len(paths)-1 {
256 t.Fatalf("%d entries, want %d", len(files), len(paths)-1)
257 }
258 want := slices.DeleteFunc(slices.Clone(paths), func(p string) bool { return p == missing })
259 for i, f := range files {
260 if f.Path != want[i] {
261 t.Fatalf("entry %d is %s, want %s", i, f.Path, want[i])
262 }
263 }
264}
265
266func TestFileStem(t *testing.T) {
267 for path, want := range map[string]string{
268 "/f/NotoSans-Bold.ttf": "notosans",
269 "/f/NotoSans[wght].ttf": "notosans",
270 "/f/DejaVuSansMono.ttf": "dejavusansmono",
271 "/f/IBMPlexMono_Regular.otf": "ibmplexmono",
272 } {
273 if got := fileStem(path); got != want {
274 t.Errorf("%s: %q, want %q", path, got, want)
275 }
276 }
277}