detached e0abc49

fix(text): keep lazy font selection equal to a full index

Match file stems that add style words to a family name, such as
DejaVuSansCondensed.ttf for DejaVu Sans. Index every font before falling
back when a named family misses glyphs, so a named family under an
unrelated file name is found on the first measure. Turn a parser panic in
a worker into that file's error and report index entries for unrequested
paths.
commit
e0abc490de10fb3b2622057be938e133ab64275f
author
brice · · 2026-10-09 10:20 UTC
parent
e40e7a1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
tag: v0.7.1
3 files · +245 -56
M internal/text/font_lazy_index_test.go +161 -23
@@ -2,6 +2,8 @@ package text
22
33 import (
44 "bytes"
5+ "errors"
6+ "fmt"
57 "io"
68 "os"
79 "path/filepath"
@@ -11,38 +13,62 @@ import (
1113 "github.com/stretchr/testify/mock"
1214 )
1315
14-// lazyFixture is a LazySource over the test fonts, with extra paths listed
15-// before them that index to nothing. indexed records every path handed to
16-// IndexFiles.
17-func lazyFixture(t *testing.T, extra ...string) (*MockLazySource, *[]string) {
16+const 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.
22+func lazySource(t *testing.T, paths []string, files map[string]string) (*MockLazySource, *[]string) {
1823 t.Helper()
19- dir := "../../testdata/fonts"
20- real, err := filepath.Glob(filepath.Join(dir, "*.ttf"))
21- if err != nil || len(real) == 0 {
22- t.Fatalf("fixture fonts: %v", err)
23- }
24- paths := append(slices.Clone(extra), real...)
2524 var indexed []string
2625 src := NewMockLazySource(t)
2726 src.EXPECT().Paths().Return(paths, nil)
2827 src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(func(ps []string) ([]FontFile, error) {
2928 indexed = append(indexed, ps...)
30- var real []string
29+ var out []FontFile
3130 for _, p := range ps {
32- if !slices.Contains(extra, p) {
33- real = append(real, p)
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)
3442 }
3543 }
36- return SystemSource{}.IndexFiles(real)
44+ return out, nil
3745 }).Maybe()
3846 src.EXPECT().Open(mock.Anything).RunAndReturn(func(p string) (io.ReadCloser, error) {
39- b, err := os.ReadFile(p)
47+ b, err := os.ReadFile(filepath.Join(fixtureDir, files[p]))
4048 return io.NopCloser(bytes.NewReader(b)), err
4149 }).Maybe()
4250 return src, &indexed
4351 }
4452
45-func base(paths []string) []string {
53+// fixtureSource lists the fixture fonts under their own names, after extra
54+// paths that index to nothing.
55+func 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+
71+func bases(paths []string) []string {
4672 out := make([]string, len(paths))
4773 for i, p := range paths {
4874 out[i] = filepath.Base(p)
@@ -52,7 +78,7 @@ func base(paths []string) []string {
5278 }
5379
5480 func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) {
55- src, indexed := lazyFixture(t, "/fonts/Unrelated-Regular.ttf")
81+ src, indexed := fixtureSource(t, "/fonts/Unrelated-Regular.ttf")
5682 c, err := Load(src)
5783 if err != nil {
5884 t.Fatal(err)
@@ -63,7 +89,7 @@ func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) {
6389 }
6490 // monospace names no fixture family; the sans-serif and script fallbacks do.
6591 want := []string{"AdwaitaSans-Regular.ttf", "IBMPlexSansJP-Regular.ttf", "NotoSans-Regular.ttf", "NotoSansArabic-Regular.ttf", "NotoSansDevanagari-Regular.ttf", "NotoSansSymbols2-Regular.ttf"}
66- if got := base(*indexed); !slices.Equal(got, want) {
92+ if got := bases(*indexed); !slices.Equal(got, want) {
6793 t.Fatalf("indexed %v, want %v", got, want)
6894 }
6995 if c.complete {
@@ -72,7 +98,7 @@ func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) {
7298 }
7399
74100 func TestLazyIndexScansAllForUnknownFamily(t *testing.T) {
75- src, indexed := lazyFixture(t, "/fonts/Unrelated-Regular.ttf")
101+ src, indexed := fixtureSource(t, "/fonts/Unrelated-Regular.ttf")
76102 c, err := Load(src)
77103 if err != nil {
78104 t.Fatal(err)
@@ -87,7 +113,7 @@ func TestLazyIndexScansAllForUnknownFamily(t *testing.T) {
87113 }
88114
89115 func TestLazyIndexFallbackGlyph(t *testing.T) {
90- src, _ := lazyFixture(t)
116+ src, _ := fixtureSource(t)
91117 c, err := Load(src)
92118 if err != nil {
93119 t.Fatal(err)
@@ -102,16 +128,78 @@ func TestLazyIndexFallbackGlyph(t *testing.T) {
102128 }
103129 }
104130
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.
133+func 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.
155+func 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+
170+func 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+
105188 // Indexing in steps keeps the catalog order of a full index.
106189 func TestLazyIndexKeepsPathOrder(t *testing.T) {
107- src, _ := lazyFixture(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)
108197 c, err := Load(src)
109198 if err != nil {
110199 t.Fatal(err)
111200 }
112201 c.indexAll()
113- paths, _ := src.Paths()
114- full, err := SystemSource{}.IndexFiles(paths)
202+ full, err := SystemSource{}.IndexFiles(fonts)
115203 if err != nil {
116204 t.Fatal(err)
117205 }
@@ -125,6 +213,56 @@ func TestLazyIndexKeepsPathOrder(t *testing.T) {
125213 }
126214 }
127215
216+func 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.
235+func 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+
128266 func TestFileStem(t *testing.T) {
129267 for path, want := range map[string]string{
130268 "/f/NotoSans-Bold.ttf": "notosans",
@@ -2,6 +2,8 @@ package text
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "errors"
6+ "fmt"
5 "io"7 "io"
6 "os"8 "os"
7 "path/filepath"9 "path/filepath"
@@ -11,38 +13,62 @@ import (
11 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
12 )14 )
13 15
14-// lazyFixture is a LazySource over the test fonts, with extra paths listed16+const fixtureDir = "../../testdata/fonts"
15-// before them that index to nothing. indexed records every path handed to17+
16-// IndexFiles.18+// lazySource is a LazySource over paths. Each path indexes and opens as the
17-func lazyFixture(t *testing.T, extra ...string) (*MockLazySource, *[]string) {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.
22+func lazySource(t *testing.T, paths []string, files map[string]string) (*MockLazySource, *[]string) {
18 t.Helper()23 t.Helper()
19- dir := "../../testdata/fonts"
20- real, err := filepath.Glob(filepath.Join(dir, "*.ttf"))
21- if err != nil || len(real) == 0 {
22- t.Fatalf("fixture fonts: %v", err)
23- }
24- paths := append(slices.Clone(extra), real...)
25 var indexed []string24 var indexed []string
26 src := NewMockLazySource(t)25 src := NewMockLazySource(t)
27 src.EXPECT().Paths().Return(paths, nil)26 src.EXPECT().Paths().Return(paths, nil)
28 src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(func(ps []string) ([]FontFile, error) {27 src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(func(ps []string) ([]FontFile, error) {
29 indexed = append(indexed, ps...)28 indexed = append(indexed, ps...)
30- var real []string29+ var out []FontFile
31 for _, p := range ps {30 for _, p := range ps {
32- if !slices.Contains(extra, p) {31+ file, ok := files[p]
33- real = append(real, 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)
34 }42 }
35 }43 }
36- return SystemSource{}.IndexFiles(real)44+ return out, nil
37 }).Maybe()45 }).Maybe()
38 src.EXPECT().Open(mock.Anything).RunAndReturn(func(p string) (io.ReadCloser, error) {46 src.EXPECT().Open(mock.Anything).RunAndReturn(func(p string) (io.ReadCloser, error) {
39- b, err := os.ReadFile(p)47+ b, err := os.ReadFile(filepath.Join(fixtureDir, files[p]))
40 return io.NopCloser(bytes.NewReader(b)), err48 return io.NopCloser(bytes.NewReader(b)), err
41 }).Maybe()49 }).Maybe()
42 return src, &indexed50 return src, &indexed
43 }51 }
44 52
45-func base(paths []string) []string {53+// fixtureSource lists the fixture fonts under their own names, after extra
54+// paths that index to nothing.
55+func 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+
71+func bases(paths []string) []string {
46 out := make([]string, len(paths))72 out := make([]string, len(paths))
47 for i, p := range paths {73 for i, p := range paths {
48 out[i] = filepath.Base(p)74 out[i] = filepath.Base(p)
@@ -52,7 +78,7 @@ func base(paths []string) []string {
52 }78 }
53 79
54 func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) {80 func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) {
55- src, indexed := lazyFixture(t, "/fonts/Unrelated-Regular.ttf")81+ src, indexed := fixtureSource(t, "/fonts/Unrelated-Regular.ttf")
56 c, err := Load(src)82 c, err := Load(src)
57 if err != nil {83 if err != nil {
58 t.Fatal(err)84 t.Fatal(err)
@@ -63,7 +89,7 @@ func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) {
63 }89 }
64 // monospace names no fixture family; the sans-serif and script fallbacks do.90 // monospace names no fixture family; the sans-serif and script fallbacks do.
65 want := []string{"AdwaitaSans-Regular.ttf", "IBMPlexSansJP-Regular.ttf", "NotoSans-Regular.ttf", "NotoSansArabic-Regular.ttf", "NotoSansDevanagari-Regular.ttf", "NotoSansSymbols2-Regular.ttf"}91 want := []string{"AdwaitaSans-Regular.ttf", "IBMPlexSansJP-Regular.ttf", "NotoSans-Regular.ttf", "NotoSansArabic-Regular.ttf", "NotoSansDevanagari-Regular.ttf", "NotoSansSymbols2-Regular.ttf"}
66- if got := base(*indexed); !slices.Equal(got, want) {92+ if got := bases(*indexed); !slices.Equal(got, want) {
67 t.Fatalf("indexed %v, want %v", got, want)93 t.Fatalf("indexed %v, want %v", got, want)
68 }94 }
69 if c.complete {95 if c.complete {
@@ -72,7 +98,7 @@ func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) {
72 }98 }
73 99
74 func TestLazyIndexScansAllForUnknownFamily(t *testing.T) {100 func TestLazyIndexScansAllForUnknownFamily(t *testing.T) {
75- src, indexed := lazyFixture(t, "/fonts/Unrelated-Regular.ttf")101+ src, indexed := fixtureSource(t, "/fonts/Unrelated-Regular.ttf")
76 c, err := Load(src)102 c, err := Load(src)
77 if err != nil {103 if err != nil {
78 t.Fatal(err)104 t.Fatal(err)
@@ -87,7 +113,7 @@ func TestLazyIndexScansAllForUnknownFamily(t *testing.T) {
87 }113 }
88 114
89 func TestLazyIndexFallbackGlyph(t *testing.T) {115 func TestLazyIndexFallbackGlyph(t *testing.T) {
90- src, _ := lazyFixture(t)116+ src, _ := fixtureSource(t)
91 c, err := Load(src)117 c, err := Load(src)
92 if err != nil {118 if err != nil {
93 t.Fatal(err)119 t.Fatal(err)
@@ -102,16 +128,78 @@ func TestLazyIndexFallbackGlyph(t *testing.T) {
102 }128 }
103 }129 }
104 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.
133+func 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.
155+func 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+
170+func 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+
105 // Indexing in steps keeps the catalog order of a full index.188 // Indexing in steps keeps the catalog order of a full index.
106 func TestLazyIndexKeepsPathOrder(t *testing.T) {189 func TestLazyIndexKeepsPathOrder(t *testing.T) {
107- src, _ := lazyFixture(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)
108 c, err := Load(src)197 c, err := Load(src)
109 if err != nil {198 if err != nil {
110 t.Fatal(err)199 t.Fatal(err)
111 }200 }
112 c.indexAll()201 c.indexAll()
113- paths, _ := src.Paths()202+ full, err := SystemSource{}.IndexFiles(fonts)
114- full, err := SystemSource{}.IndexFiles(paths)
115 if err != nil {203 if err != nil {
116 t.Fatal(err)204 t.Fatal(err)
117 }205 }
@@ -125,6 +213,56 @@ func TestLazyIndexKeepsPathOrder(t *testing.T) {
125 }213 }
126 }214 }
127 215
216+func 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.
235+func 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+
128 func TestFileStem(t *testing.T) {266 func TestFileStem(t *testing.T) {
129 for path, want := range map[string]string{267 for path, want := range map[string]string{
130 "/f/NotoSans-Bold.ttf": "notosans",268 "/f/NotoSans-Bold.ttf": "notosans",
M internal/text/fonts.go +56 -13
@@ -230,7 +230,7 @@ func (SystemSource) IndexFiles(paths []string) ([]FontFile, error) {
230230 defer wg.Done()
231231 var buffer []byte
232232 for i := range next {
233- files[i], buffer, errs[i] = indexFile(paths[i], buffer)
233+ files[i], buffer, errs[i] = safeIndexFile(paths[i], buffer)
234234 }
235235 }()
236236 }
@@ -242,6 +242,17 @@ func (SystemSource) IndexFiles(paths []string) ([]FontFile, error) {
242242 return slices.Concat(files...), errors.Join(errs...)
243243 }
244244
245+// safeIndexFile is indexFile on a worker goroutine: a parser panic on a
246+// corrupt font becomes that file's error instead of ending the process.
247+func safeIndexFile(p string, buffer []byte) (files []FontFile, _ []byte, err error) {
248+ defer func() {
249+ if r := recover(); r != nil {
250+ files, buffer, err = nil, nil, fmt.Errorf("font %s: parser panic: %v", p, r)
251+ }
252+ }()
253+ return indexFile(p, buffer)
254+}
255+
245256 // indexFile reads the metadata of every face in the file at p. buffer is
246257 // reusable scratch storage.
247258 func indexFile(p string, buffer []byte) (files []FontFile, _ []byte, err error) {
@@ -381,12 +392,44 @@ func fileStem(path string) string {
381392 return font.NormalizeFamily(name)
382393 }
383394
384-// indexStems indexes the files whose stem is one of keys.
395+// styleWords may follow a family name inside a file-name stem, as in
396+// DejaVuSansCondensed.ttf, whose family is "DejaVu Sans".
397+var styleWords = []string{"ultracondensed", "extracondensed", "semicondensed", "condensed", "semiexpanded", "extraexpanded", "ultraexpanded", "expanded", "narrow", "wide",
398+ "hairline", "thin", "extralight", "ultralight", "light", "book", "regular", "normal", "medium", "semibold", "demibold", "bold", "extrabold", "ultrabold", "heavy", "black",
399+ "italic", "oblique"}
400+
401+// stemMatches reports whether a file stem names family key: the key itself,
402+// or the key followed only by style words.
403+func stemMatches(stem, key string) bool {
404+ rest, ok := strings.CutPrefix(stem, key)
405+ if !ok {
406+ return false
407+ }
408+ for rest != "" {
409+ word := ""
410+ for _, w := range styleWords {
411+ if strings.HasPrefix(rest, w) {
412+ word = w
413+ break
414+ }
415+ }
416+ if word == "" {
417+ return false
418+ }
419+ rest = rest[len(word):]
420+ }
421+ return true
422+}
423+
424+// indexStems indexes the files whose stem names one of keys.
385425 func (c *Catalog) indexStems(keys []string) {
386426 var todo []int
387- for _, key := range keys {
388- for _, i := range c.byStem[key] {
389- if !c.indexed[i] && !slices.Contains(todo, i) {
427+ for stem, positions := range c.byStem {
428+ if !slices.ContainsFunc(keys, func(key string) bool { return stemMatches(stem, key) }) {
429+ continue
430+ }
431+ for _, i := range positions {
432+ if !c.indexed[i] {
390433 todo = append(todo, i)
391434 }
392435 }
@@ -394,10 +437,11 @@ func (c *Catalog) indexStems(keys []string) {
394437 c.indexPositions(todo)
395438 }
396439
397-// indexAll indexes every remaining file, once.
398-func (c *Catalog) indexAll() {
399- if c.complete {
400- return
440+// indexAll indexes every remaining file of a lazy catalog, once. It reports
441+// whether it indexed anything.
442+func (c *Catalog) indexAll() bool {
443+ if c.lazy == nil || c.complete {
444+ return false
401445 }
402446 var todo []int
403447 for i, done := range c.indexed {
@@ -407,6 +451,7 @@ func (c *Catalog) indexAll() {
407451 }
408452 c.indexPositions(todo)
409453 c.complete = true
454+ return true
410455 }
411456
412457 // indexPositions indexes the files at the given path positions and rebuilds
@@ -433,6 +478,7 @@ func (c *Catalog) indexPositions(todo []int) {
433478 for _, entry := range entries {
434479 i, ok := position[entry.Path]
435480 if !ok {
481+ c.Diagnostics = append(c.Diagnostics, fmt.Errorf("font index entry for unrequested path %s", entry.Path))
436482 continue
437483 }
438484 c.faceAt[i] = append(c.faceAt[i], c.newFace(entry))
@@ -851,10 +897,7 @@ func namedKeys(r Request) map[string]bool {
851897 // unknown range data remain eligible; loaded or failed state never moves the
852898 // window, so later measures cannot admit a different face.
853899 func (c *Catalog) fallbackWindow(text []rune, r Request) []*Face {
854- if c.lazy != nil {
855- // Any installed face may cover a missing glyph.
856- c.indexAll()
857- }
900+ c.indexAll() // any installed face may cover a missing glyph
858901 named := namedKeys(r)
859902 out := make([]*Face, 0, maxFallbackFaces)
860903 for _, f := range c.Faces {
@@ -230,7 +230,7 @@ func (SystemSource) IndexFiles(paths []string) ([]FontFile, error) {
230 defer wg.Done()230 defer wg.Done()
231 var buffer []byte231 var buffer []byte
232 for i := range next {232 for i := range next {
233- files[i], buffer, errs[i] = indexFile(paths[i], buffer)233+ files[i], buffer, errs[i] = safeIndexFile(paths[i], buffer)
234 }234 }
235 }()235 }()
236 }236 }
@@ -242,6 +242,17 @@ func (SystemSource) IndexFiles(paths []string) ([]FontFile, error) {
242 return slices.Concat(files...), errors.Join(errs...)242 return slices.Concat(files...), errors.Join(errs...)
243 }243 }
244 244
245+// safeIndexFile is indexFile on a worker goroutine: a parser panic on a
246+// corrupt font becomes that file's error instead of ending the process.
247+func safeIndexFile(p string, buffer []byte) (files []FontFile, _ []byte, err error) {
248+ defer func() {
249+ if r := recover(); r != nil {
250+ files, buffer, err = nil, nil, fmt.Errorf("font %s: parser panic: %v", p, r)
251+ }
252+ }()
253+ return indexFile(p, buffer)
254+}
255+
245 // indexFile reads the metadata of every face in the file at p. buffer is256 // indexFile reads the metadata of every face in the file at p. buffer is
246 // reusable scratch storage.257 // reusable scratch storage.
247 func indexFile(p string, buffer []byte) (files []FontFile, _ []byte, err error) {258 func indexFile(p string, buffer []byte) (files []FontFile, _ []byte, err error) {
@@ -381,12 +392,44 @@ func fileStem(path string) string {
381 return font.NormalizeFamily(name)392 return font.NormalizeFamily(name)
382 }393 }
383 394
384-// indexStems indexes the files whose stem is one of keys.395+// styleWords may follow a family name inside a file-name stem, as in
396+// DejaVuSansCondensed.ttf, whose family is "DejaVu Sans".
397+var styleWords = []string{"ultracondensed", "extracondensed", "semicondensed", "condensed", "semiexpanded", "extraexpanded", "ultraexpanded", "expanded", "narrow", "wide",
398+ "hairline", "thin", "extralight", "ultralight", "light", "book", "regular", "normal", "medium", "semibold", "demibold", "bold", "extrabold", "ultrabold", "heavy", "black",
399+ "italic", "oblique"}
400+
401+// stemMatches reports whether a file stem names family key: the key itself,
402+// or the key followed only by style words.
403+func stemMatches(stem, key string) bool {
404+ rest, ok := strings.CutPrefix(stem, key)
405+ if !ok {
406+ return false
407+ }
408+ for rest != "" {
409+ word := ""
410+ for _, w := range styleWords {
411+ if strings.HasPrefix(rest, w) {
412+ word = w
413+ break
414+ }
415+ }
416+ if word == "" {
417+ return false
418+ }
419+ rest = rest[len(word):]
420+ }
421+ return true
422+}
423+
424+// indexStems indexes the files whose stem names one of keys.
385 func (c *Catalog) indexStems(keys []string) {425 func (c *Catalog) indexStems(keys []string) {
386 var todo []int426 var todo []int
387- for _, key := range keys {427+ for stem, positions := range c.byStem {
388- for _, i := range c.byStem[key] {428+ if !slices.ContainsFunc(keys, func(key string) bool { return stemMatches(stem, key) }) {
389- if !c.indexed[i] && !slices.Contains(todo, i) {429+ continue
430+ }
431+ for _, i := range positions {
432+ if !c.indexed[i] {
390 todo = append(todo, i)433 todo = append(todo, i)
391 }434 }
392 }435 }
@@ -394,10 +437,11 @@ func (c *Catalog) indexStems(keys []string) {
394 c.indexPositions(todo)437 c.indexPositions(todo)
395 }438 }
396 439
397-// indexAll indexes every remaining file, once.440+// indexAll indexes every remaining file of a lazy catalog, once. It reports
398-func (c *Catalog) indexAll() {441+// whether it indexed anything.
399- if c.complete {442+func (c *Catalog) indexAll() bool {
400- return443+ if c.lazy == nil || c.complete {
444+ return false
401 }445 }
402 var todo []int446 var todo []int
403 for i, done := range c.indexed {447 for i, done := range c.indexed {
@@ -407,6 +451,7 @@ func (c *Catalog) indexAll() {
407 }451 }
408 c.indexPositions(todo)452 c.indexPositions(todo)
409 c.complete = true453 c.complete = true
454+ return true
410 }455 }
411 456
412 // indexPositions indexes the files at the given path positions and rebuilds457 // indexPositions indexes the files at the given path positions and rebuilds
@@ -433,6 +478,7 @@ func (c *Catalog) indexPositions(todo []int) {
433 for _, entry := range entries {478 for _, entry := range entries {
434 i, ok := position[entry.Path]479 i, ok := position[entry.Path]
435 if !ok {480 if !ok {
481+ c.Diagnostics = append(c.Diagnostics, fmt.Errorf("font index entry for unrequested path %s", entry.Path))
436 continue482 continue
437 }483 }
438 c.faceAt[i] = append(c.faceAt[i], c.newFace(entry))484 c.faceAt[i] = append(c.faceAt[i], c.newFace(entry))
@@ -851,10 +897,7 @@ func namedKeys(r Request) map[string]bool {
851 // unknown range data remain eligible; loaded or failed state never moves the897 // unknown range data remain eligible; loaded or failed state never moves the
852 // window, so later measures cannot admit a different face.898 // window, so later measures cannot admit a different face.
853 func (c *Catalog) fallbackWindow(text []rune, r Request) []*Face {899 func (c *Catalog) fallbackWindow(text []rune, r Request) []*Face {
854- if c.lazy != nil {900+ c.indexAll() // any installed face may cover a missing glyph
855- // Any installed face may cover a missing glyph.
856- c.indexAll()
857- }
858 named := namedKeys(r)901 named := namedKeys(r)
859 out := make([]*Face, 0, maxFallbackFaces)902 out := make([]*Face, 0, maxFallbackFaces)
860 for _, f := range c.Faces {903 for _, f := range c.Faces {
M internal/text/shape.go +28 -20
@@ -188,33 +188,41 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
188188 // Load named faces only as required for coverage. A failing face is
189189 // excluded permanently and ranking is recomputed to admit its runner-up.
190190 // The scratch segmenter is free here: clusterFaces re-inits it later.
191- named := choices
191+ var named []*Face
192192 seg := &e.scratch.graphemes
193- seg.Init(text)
194- it := seg.GraphemeIterator()
195193 missing := e.scratch.missing[:0]
196- for it.Next() {
197- cluster := it.Grapheme().Text
198- covered := false
199- for {
200- named = e.catalog.candidates(r)
201- failed := false
202- for _, f := range named {
203- if !f.ready() {
204- failed = true
205- break
194+ for {
195+ seg.Init(text)
196+ it := seg.GraphemeIterator()
197+ missing = missing[:0]
198+ for it.Next() {
199+ cluster := it.Grapheme().Text
200+ covered := false
201+ for {
202+ named = e.catalog.candidates(r)
203+ failed := false
204+ for _, f := range named {
205+ if !f.ready() {
206+ failed = true
207+ break
208+ }
209+ if faceCovers(f, cluster) {
210+ covered = true
211+ break
212+ }
206213 }
207- if faceCovers(f, cluster) {
208- covered = true
214+ if covered || !failed {
209215 break
210216 }
211217 }
212- if covered || !failed {
213- break
218+ if !covered {
219+ missing = append(missing, cluster...)
214220 }
215221 }
216- if !covered {
217- missing = append(missing, cluster...)
222+ // A partial lazy index may lack faces of the named families:
223+ // index every font before falling back, and check coverage again.
224+ if len(missing) == 0 || !e.catalog.indexAll() {
225+ break
218226 }
219227 }
220228 // Rebuild after failed loads. The fallback window excludes named
@@ -235,7 +243,7 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
235243 choices = append(choices, f)
236244 complete := true
237245 seg.Init(missing)
238- it = seg.GraphemeIterator()
246+ it := seg.GraphemeIterator()
239247 for it.Next() {
240248 covered := false
241249 for _, candidate := range choices {
@@ -188,33 +188,41 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
188 // Load named faces only as required for coverage. A failing face is188 // Load named faces only as required for coverage. A failing face is
189 // excluded permanently and ranking is recomputed to admit its runner-up.189 // excluded permanently and ranking is recomputed to admit its runner-up.
190 // The scratch segmenter is free here: clusterFaces re-inits it later.190 // The scratch segmenter is free here: clusterFaces re-inits it later.
191- named := choices191+ var named []*Face
192 seg := &e.scratch.graphemes192 seg := &e.scratch.graphemes
193- seg.Init(text)
194- it := seg.GraphemeIterator()
195 missing := e.scratch.missing[:0]193 missing := e.scratch.missing[:0]
196- for it.Next() {194+ for {
197- cluster := it.Grapheme().Text195+ seg.Init(text)
198- covered := false196+ it := seg.GraphemeIterator()
199- for {197+ missing = missing[:0]
200- named = e.catalog.candidates(r)198+ for it.Next() {
201- failed := false199+ cluster := it.Grapheme().Text
202- for _, f := range named {200+ covered := false
203- if !f.ready() {201+ for {
204- failed = true202+ named = e.catalog.candidates(r)
205- break203+ failed := false
204+ for _, f := range named {
205+ if !f.ready() {
206+ failed = true
207+ break
208+ }
209+ if faceCovers(f, cluster) {
210+ covered = true
211+ break
212+ }
206 }213 }
207- if faceCovers(f, cluster) {214+ if covered || !failed {
208- covered = true
209 break215 break
210 }216 }
211 }217 }
212- if covered || !failed {218+ if !covered {
213- break219+ missing = append(missing, cluster...)
214 }220 }
215 }221 }
216- if !covered {222+ // A partial lazy index may lack faces of the named families:
217- missing = append(missing, cluster...)223+ // index every font before falling back, and check coverage again.
224+ if len(missing) == 0 || !e.catalog.indexAll() {
225+ break
218 }226 }
219 }227 }
220 // Rebuild after failed loads. The fallback window excludes named228 // Rebuild after failed loads. The fallback window excludes named
@@ -235,7 +243,7 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
235 choices = append(choices, f)243 choices = append(choices, f)
236 complete := true244 complete := true
237 seg.Init(missing)245 seg.Init(missing)
238- it = seg.GraphemeIterator()246+ it := seg.GraphemeIterator()
239 for it.Next() {247 for it.Next() {
240 covered := false248 covered := false
241 for _, candidate := range choices {249 for _, candidate := range choices {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close