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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 5 | 7 | "io" | |
| 6 | 8 | "os" | |
| 7 | 9 | "path/filepath" | |
| @@ -11,38 +13,62 @@ import ( | |||
| 11 | 13 | "github.com/stretchr/testify/mock" | |
| 12 | 14 | ) | |
| 13 | 15 | ||
| 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) { | |
| 18 | 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 | 24 | var indexed []string | |
| 26 | 25 | src := NewMockLazySource(t) | |
| 27 | 26 | src.EXPECT().Paths().Return(paths, nil) | |
| 28 | 27 | src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(func(ps []string) ([]FontFile, error) { | |
| 29 | 28 | indexed = append(indexed, ps...) | |
| 30 | - | var real []string | |
| 29 | + | var out []FontFile | |
| 31 | 30 | 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) | |
| 34 | 42 | } | |
| 35 | 43 | } | |
| 36 | - | return SystemSource{}.IndexFiles(real) | |
| 44 | + | return out, nil | |
| 37 | 45 | }).Maybe() | |
| 38 | 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 | 48 | return io.NopCloser(bytes.NewReader(b)), err | |
| 41 | 49 | }).Maybe() | |
| 42 | 50 | 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 | 72 | out := make([]string, len(paths)) | |
| 47 | 73 | for i, p := range paths { | |
| 48 | 74 | out[i] = filepath.Base(p) | |
| @@ -52,7 +78,7 @@ func base(paths []string) []string { | |||
| 52 | 78 | } | |
| 53 | 79 | ||
| 54 | 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 | 82 | c, err := Load(src) | |
| 57 | 83 | if err != nil { | |
| 58 | 84 | t.Fatal(err) | |
| @@ -63,7 +89,7 @@ func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) { | |||
| 63 | 89 | } | |
| 64 | 90 | // monospace names no fixture family; the sans-serif and script fallbacks do. | |
| 65 | 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 | 93 | t.Fatalf("indexed %v, want %v", got, want) | |
| 68 | 94 | } | |
| 69 | 95 | if c.complete { | |
| @@ -72,7 +98,7 @@ func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) { | |||
| 72 | 98 | } | |
| 73 | 99 | ||
| 74 | 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 | 102 | c, err := Load(src) | |
| 77 | 103 | if err != nil { | |
| 78 | 104 | t.Fatal(err) | |
| @@ -87,7 +113,7 @@ func TestLazyIndexScansAllForUnknownFamily(t *testing.T) { | |||
| 87 | 113 | } | |
| 88 | 114 | ||
| 89 | 115 | func TestLazyIndexFallbackGlyph(t *testing.T) { | |
| 90 | - | src, _ := lazyFixture(t) | |
| 116 | + | src, _ := fixtureSource(t) | |
| 91 | 117 | c, err := Load(src) | |
| 92 | 118 | if err != nil { | |
| 93 | 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 | 188 | // Indexing in steps keeps the catalog order of a full index. | |
| 106 | 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 | 197 | c, err := Load(src) | |
| 109 | 198 | if err != nil { | |
| 110 | 199 | t.Fatal(err) | |
| 111 | 200 | } | |
| 112 | 201 | c.indexAll() | |
| 113 | - | paths, _ := src.Paths() | |
| 114 | - | full, err := SystemSource{}.IndexFiles(paths) | |
| 202 | + | full, err := SystemSource{}.IndexFiles(fonts) | |
| 115 | 203 | if err != nil { | |
| 116 | 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 | 266 | func TestFileStem(t *testing.T) { | |
| 129 | 267 | for path, want := range map[string]string{ | |
| 130 | 268 | "/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 listed | 16 | + | const fixtureDir = "../../testdata/fonts" |
| 15 | - | // before them that index to nothing. indexed records every path handed to | 17 | + | |
| 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 []string | 24 | 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 []string | 29 | + | 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)), err | 48 | return io.NopCloser(bytes.NewReader(b)), err | ||
| 41 | }).Maybe() | 49 | }).Maybe() | ||
| 42 | return src, &indexed | 50 | 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) { | |||
| 230 | 230 | defer wg.Done() | |
| 231 | 231 | var buffer []byte | |
| 232 | 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 | 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 | 256 | // indexFile reads the metadata of every face in the file at p. buffer is | |
| 246 | 257 | // reusable scratch storage. | |
| 247 | 258 | func indexFile(p string, buffer []byte) (files []FontFile, _ []byte, err error) { | |
| @@ -381,12 +392,44 @@ func fileStem(path string) string { | |||
| 381 | 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 | 425 | func (c *Catalog) indexStems(keys []string) { | |
| 386 | 426 | 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] { | |
| 390 | 433 | todo = append(todo, i) | |
| 391 | 434 | } | |
| 392 | 435 | } | |
| @@ -394,10 +437,11 @@ func (c *Catalog) indexStems(keys []string) { | |||
| 394 | 437 | c.indexPositions(todo) | |
| 395 | 438 | } | |
| 396 | 439 | ||
| 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 | |
| 401 | 445 | } | |
| 402 | 446 | var todo []int | |
| 403 | 447 | for i, done := range c.indexed { | |
| @@ -407,6 +451,7 @@ func (c *Catalog) indexAll() { | |||
| 407 | 451 | } | |
| 408 | 452 | c.indexPositions(todo) | |
| 409 | 453 | c.complete = true | |
| 454 | + | return true | |
| 410 | 455 | } | |
| 411 | 456 | ||
| 412 | 457 | // indexPositions indexes the files at the given path positions and rebuilds | |
| @@ -433,6 +478,7 @@ func (c *Catalog) indexPositions(todo []int) { | |||
| 433 | 478 | for _, entry := range entries { | |
| 434 | 479 | i, ok := position[entry.Path] | |
| 435 | 480 | if !ok { | |
| 481 | + | c.Diagnostics = append(c.Diagnostics, fmt.Errorf("font index entry for unrequested path %s", entry.Path)) | |
| 436 | 482 | continue | |
| 437 | 483 | } | |
| 438 | 484 | c.faceAt[i] = append(c.faceAt[i], c.newFace(entry)) | |
| @@ -851,10 +897,7 @@ func namedKeys(r Request) map[string]bool { | |||
| 851 | 897 | // unknown range data remain eligible; loaded or failed state never moves the | |
| 852 | 898 | // window, so later measures cannot admit a different face. | |
| 853 | 899 | 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 | |
| 858 | 901 | named := namedKeys(r) | |
| 859 | 902 | out := make([]*Face, 0, maxFallbackFaces) | |
| 860 | 903 | 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 []byte | 231 | 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 is | 256 | // 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 []int | 426 | 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 | - | return | 443 | + | if c.lazy == nil || c.complete { |
| 444 | + | return false | |||
| 401 | } | 445 | } | ||
| 402 | var todo []int | 446 | 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 = true | 453 | c.complete = true | ||
| 454 | + | return true | |||
| 410 | } | 455 | } | ||
| 411 | 456 | ||||
| 412 | // indexPositions indexes the files at the given path positions and rebuilds | 457 | // 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 | continue | 482 | 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 the | 897 | // 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 [] | |||
| 188 | 188 | // Load named faces only as required for coverage. A failing face is | |
| 189 | 189 | // excluded permanently and ranking is recomputed to admit its runner-up. | |
| 190 | 190 | // The scratch segmenter is free here: clusterFaces re-inits it later. | |
| 191 | - | named := choices | |
| 191 | + | var named []*Face | |
| 192 | 192 | seg := &e.scratch.graphemes | |
| 193 | - | seg.Init(text) | |
| 194 | - | it := seg.GraphemeIterator() | |
| 195 | 193 | 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 | + | } | |
| 206 | 213 | } | |
| 207 | - | if faceCovers(f, cluster) { | |
| 208 | - | covered = true | |
| 214 | + | if covered || !failed { | |
| 209 | 215 | break | |
| 210 | 216 | } | |
| 211 | 217 | } | |
| 212 | - | if covered || !failed { | |
| 213 | - | break | |
| 218 | + | if !covered { | |
| 219 | + | missing = append(missing, cluster...) | |
| 214 | 220 | } | |
| 215 | 221 | } | |
| 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 | |
| 218 | 226 | } | |
| 219 | 227 | } | |
| 220 | 228 | // 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 | 243 | choices = append(choices, f) | |
| 236 | 244 | complete := true | |
| 237 | 245 | seg.Init(missing) | |
| 238 | - | it = seg.GraphemeIterator() | |
| 246 | + | it := seg.GraphemeIterator() | |
| 239 | 247 | for it.Next() { | |
| 240 | 248 | covered := false | |
| 241 | 249 | 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 is | 188 | // 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 := choices | 191 | + | var named []*Face |
| 192 | seg := &e.scratch.graphemes | 192 | 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().Text | 195 | + | seg.Init(text) |
| 198 | - | covered := false | 196 | + | it := seg.GraphemeIterator() |
| 199 | - | for { | 197 | + | missing = missing[:0] |
| 200 | - | named = e.catalog.candidates(r) | 198 | + | for it.Next() { |
| 201 | - | failed := false | 199 | + | cluster := it.Grapheme().Text |
| 202 | - | for _, f := range named { | 200 | + | covered := false |
| 203 | - | if !f.ready() { | 201 | + | for { |
| 204 | - | failed = true | 202 | + | named = e.catalog.candidates(r) |
| 205 | - | break | 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 | + | } | |||
| 206 | } | 213 | } | ||
| 207 | - | if faceCovers(f, cluster) { | 214 | + | if covered || !failed { |
| 208 | - | covered = true | |||
| 209 | break | 215 | break | ||
| 210 | } | 216 | } | ||
| 211 | } | 217 | } | ||
| 212 | - | if covered || !failed { | 218 | + | if !covered { |
| 213 | - | break | 219 | + | 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 named | 228 | // 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 := true | 244 | 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 := false | 248 | covered := false | ||
| 241 | for _, candidate := range choices { | 249 | for _, candidate := range choices { | ||