perf(text): index system fonts on demand and in parallel
Load indexed every installed font before the first frame: 5 s on a cold page cache with about 1000 fonts on an encrypted disk. The system catalog now indexes only files named after the requested and default families, and scans every font, with 32 parallel readers, only when a named family has no matching file or a glyph needs a fallback face. Faces keep the order of a full index.
- commit
- e40e7a189dfd4dfc45175f7251b03222f147e43a
- author
- brice · · 2026-10-09 10:10 UTC
- parent
- 438604c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +734 -49
M .mockery.yml +8 -0
| @@ -14,6 +14,14 @@ packages: | |||
| 14 | 14 | filename: '{{.InterfaceName}}_mock_test.go' | |
| 15 | 15 | interfaces: | |
| 16 | 16 | target: | |
| 17 | + | # Font source seam of the text catalog. | |
| 18 | + | github.com/bnema/nefergui/internal/text: | |
| 19 | + | config: | |
| 20 | + | dir: internal/text | |
| 21 | + | pkgname: text | |
| 22 | + | filename: '{{.InterfaceName}}_mock_test.go' | |
| 23 | + | interfaces: | |
| 24 | + | LazySource: | |
| 17 | 25 | # Editor ports: same-package mocks in test files. | |
| 18 | 26 | github.com/bnema/nefergui/internal/edit: | |
| 19 | 27 | config: | |
| @@ -14,6 +14,14 @@ packages: | |||||
| 14 | filename: '{{.InterfaceName}}_mock_test.go' | 14 | filename: '{{.InterfaceName}}_mock_test.go' | ||
| 15 | interfaces: | 15 | interfaces: | ||
| 16 | target: | 16 | target: | ||
| 17 | + | # Font source seam of the text catalog. | |||
| 18 | + | github.com/bnema/nefergui/internal/text: | |||
| 19 | + | config: | |||
| 20 | + | dir: internal/text | |||
| 21 | + | pkgname: text | |||
| 22 | + | filename: '{{.InterfaceName}}_mock_test.go' | |||
| 23 | + | interfaces: | |||
| 24 | + | LazySource: | |||
| 17 | # Editor ports: same-package mocks in test files. | 25 | # Editor ports: same-package mocks in test files. | ||
| 18 | github.com/bnema/nefergui/internal/edit: | 26 | github.com/bnema/nefergui/internal/edit: | ||
| 19 | config: | 27 | config: | ||
M docs/troubleshooting.md +1 -0
| @@ -7,3 +7,4 @@ | |||
| 7 | 7 | - Build with `-tags nefergui_debug` for identity diagnostics (duplicate keys, unkeyed type changes, stale handles). | |
| 8 | 8 | - To regenerate committed SPIR-V, install `glslc` and run `go generate ./...`; consumers do not need `glslc`. | |
| 9 | 9 | - If a requested font cannot be opened or parsed, text selection skips that face and tries the next matching face. Inspect catalog diagnostics for the failed path; system font discovery uses XDG font directories and does not require fontconfig. | |
| 10 | + | - System fonts are indexed on demand: at startup only files whose name matches a requested or default family (for example `NotoSans-Regular.ttf` for `Noto Sans`). A family that no file name reveals, or a glyph the named families lack, indexes every remaining font once, in parallel. Name a family that is installed under a matching file name to keep the first frame fast. | |
| @@ -7,3 +7,4 @@ | |||||
| 7 | - Build with `-tags nefergui_debug` for identity diagnostics (duplicate keys, unkeyed type changes, stale handles). | 7 | - Build with `-tags nefergui_debug` for identity diagnostics (duplicate keys, unkeyed type changes, stale handles). | ||
| 8 | - To regenerate committed SPIR-V, install `glslc` and run `go generate ./...`; consumers do not need `glslc`. | 8 | - To regenerate committed SPIR-V, install `glslc` and run `go generate ./...`; consumers do not need `glslc`. | ||
| 9 | - If a requested font cannot be opened or parsed, text selection skips that face and tries the next matching face. Inspect catalog diagnostics for the failed path; system font discovery uses XDG font directories and does not require fontconfig. | 9 | - If a requested font cannot be opened or parsed, text selection skips that face and tries the next matching face. Inspect catalog diagnostics for the failed path; system font discovery uses XDG font directories and does not require fontconfig. | ||
| 10 | + | - System fonts are indexed on demand: at startup only files whose name matches a requested or default family (for example `NotoSans-Regular.ttf` for `Noto Sans`). A family that no file name reveals, or a glyph the named families lack, indexes every remaining font once, in parallel. Name a family that is installed under a matching file name to keep the first frame fast. | |||
A internal/text/LazySource_mock_test.go +336 -0
| @@ -0,0 +1,336 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package text | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "io" | |
| 9 | + | ||
| 10 | + | mock "github.com/stretchr/testify/mock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // NewMockLazySource creates a new instance of MockLazySource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | + | // The first argument is typically a *testing.T value. | |
| 15 | + | func NewMockLazySource(t interface { | |
| 16 | + | mock.TestingT | |
| 17 | + | Cleanup(func()) | |
| 18 | + | }) *MockLazySource { | |
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | + | helper.Helper() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | mock := &MockLazySource{} | |
| 24 | + | mock.Mock.Test(t) | |
| 25 | + | ||
| 26 | + | t.Cleanup(func() { | |
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 28 | + | helper.Helper() | |
| 29 | + | } | |
| 30 | + | mock.AssertExpectations(t) | |
| 31 | + | }) | |
| 32 | + | ||
| 33 | + | return mock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // MockLazySource is an autogenerated mock type for the LazySource type | |
| 37 | + | type MockLazySource struct { | |
| 38 | + | mock.Mock | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type MockLazySource_Expecter struct { | |
| 42 | + | mock *mock.Mock | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (_m *MockLazySource) EXPECT() *MockLazySource_Expecter { | |
| 46 | + | return &MockLazySource_Expecter{mock: &_m.Mock} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Fonts provides a mock function for the type MockLazySource | |
| 50 | + | func (_mock *MockLazySource) Fonts() (map[string][]byte, error) { | |
| 51 | + | ret := _mock.Called() | |
| 52 | + | ||
| 53 | + | if len(ret) == 0 { | |
| 54 | + | panic("no return value specified for Fonts") | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | var r0 map[string][]byte | |
| 58 | + | var r1 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func() (map[string][]byte, error)); ok { | |
| 60 | + | return returnFunc() | |
| 61 | + | } | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func() map[string][]byte); ok { | |
| 63 | + | r0 = returnFunc() | |
| 64 | + | } else { | |
| 65 | + | if ret.Get(0) != nil { | |
| 66 | + | r0 = ret.Get(0).(map[string][]byte) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 70 | + | r1 = returnFunc() | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // MockLazySource_Fonts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Fonts' | |
| 78 | + | type MockLazySource_Fonts_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Fonts is a helper method to define mock.On call | |
| 83 | + | func (_e *MockLazySource_Expecter) Fonts() *MockLazySource_Fonts_Call { | |
| 84 | + | return &MockLazySource_Fonts_Call{Call: _e.mock.On("Fonts")} | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (_c *MockLazySource_Fonts_Call) Run(run func()) *MockLazySource_Fonts_Call { | |
| 88 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 89 | + | run() | |
| 90 | + | }) | |
| 91 | + | return _c | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (_c *MockLazySource_Fonts_Call) Return(stringToBytes map[string][]byte, err error) *MockLazySource_Fonts_Call { | |
| 95 | + | _c.Call.Return(stringToBytes, err) | |
| 96 | + | return _c | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (_c *MockLazySource_Fonts_Call) RunAndReturn(run func() (map[string][]byte, error)) *MockLazySource_Fonts_Call { | |
| 100 | + | _c.Call.Return(run) | |
| 101 | + | return _c | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Index provides a mock function for the type MockLazySource | |
| 105 | + | func (_mock *MockLazySource) Index() ([]FontFile, error) { | |
| 106 | + | ret := _mock.Called() | |
| 107 | + | ||
| 108 | + | if len(ret) == 0 { | |
| 109 | + | panic("no return value specified for Index") | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | var r0 []FontFile | |
| 113 | + | var r1 error | |
| 114 | + | if returnFunc, ok := ret.Get(0).(func() ([]FontFile, error)); ok { | |
| 115 | + | return returnFunc() | |
| 116 | + | } | |
| 117 | + | if returnFunc, ok := ret.Get(0).(func() []FontFile); ok { | |
| 118 | + | r0 = returnFunc() | |
| 119 | + | } else { | |
| 120 | + | if ret.Get(0) != nil { | |
| 121 | + | r0 = ret.Get(0).([]FontFile) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 125 | + | r1 = returnFunc() | |
| 126 | + | } else { | |
| 127 | + | r1 = ret.Error(1) | |
| 128 | + | } | |
| 129 | + | return r0, r1 | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // MockLazySource_Index_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Index' | |
| 133 | + | type MockLazySource_Index_Call struct { | |
| 134 | + | *mock.Call | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Index is a helper method to define mock.On call | |
| 138 | + | func (_e *MockLazySource_Expecter) Index() *MockLazySource_Index_Call { | |
| 139 | + | return &MockLazySource_Index_Call{Call: _e.mock.On("Index")} | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (_c *MockLazySource_Index_Call) Run(run func()) *MockLazySource_Index_Call { | |
| 143 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 144 | + | run() | |
| 145 | + | }) | |
| 146 | + | return _c | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (_c *MockLazySource_Index_Call) Return(fontFiles []FontFile, err error) *MockLazySource_Index_Call { | |
| 150 | + | _c.Call.Return(fontFiles, err) | |
| 151 | + | return _c | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (_c *MockLazySource_Index_Call) RunAndReturn(run func() ([]FontFile, error)) *MockLazySource_Index_Call { | |
| 155 | + | _c.Call.Return(run) | |
| 156 | + | return _c | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // IndexFiles provides a mock function for the type MockLazySource | |
| 160 | + | func (_mock *MockLazySource) IndexFiles(paths []string) ([]FontFile, error) { | |
| 161 | + | ret := _mock.Called(paths) | |
| 162 | + | ||
| 163 | + | if len(ret) == 0 { | |
| 164 | + | panic("no return value specified for IndexFiles") | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | var r0 []FontFile | |
| 168 | + | var r1 error | |
| 169 | + | if returnFunc, ok := ret.Get(0).(func([]string) ([]FontFile, error)); ok { | |
| 170 | + | return returnFunc(paths) | |
| 171 | + | } | |
| 172 | + | if returnFunc, ok := ret.Get(0).(func([]string) []FontFile); ok { | |
| 173 | + | r0 = returnFunc(paths) | |
| 174 | + | } else { | |
| 175 | + | if ret.Get(0) != nil { | |
| 176 | + | r0 = ret.Get(0).([]FontFile) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | if returnFunc, ok := ret.Get(1).(func([]string) error); ok { | |
| 180 | + | r1 = returnFunc(paths) | |
| 181 | + | } else { | |
| 182 | + | r1 = ret.Error(1) | |
| 183 | + | } | |
| 184 | + | return r0, r1 | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // MockLazySource_IndexFiles_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IndexFiles' | |
| 188 | + | type MockLazySource_IndexFiles_Call struct { | |
| 189 | + | *mock.Call | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // IndexFiles is a helper method to define mock.On call | |
| 193 | + | // - paths []string | |
| 194 | + | func (_e *MockLazySource_Expecter) IndexFiles(paths any) *MockLazySource_IndexFiles_Call { | |
| 195 | + | return &MockLazySource_IndexFiles_Call{Call: _e.mock.On("IndexFiles", paths)} | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func (_c *MockLazySource_IndexFiles_Call) Run(run func(paths []string)) *MockLazySource_IndexFiles_Call { | |
| 199 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 200 | + | var arg0 []string | |
| 201 | + | if args[0] != nil { | |
| 202 | + | arg0 = args[0].([]string) | |
| 203 | + | } | |
| 204 | + | run( | |
| 205 | + | arg0, | |
| 206 | + | ) | |
| 207 | + | }) | |
| 208 | + | return _c | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func (_c *MockLazySource_IndexFiles_Call) Return(fontFiles []FontFile, err error) *MockLazySource_IndexFiles_Call { | |
| 212 | + | _c.Call.Return(fontFiles, err) | |
| 213 | + | return _c | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func (_c *MockLazySource_IndexFiles_Call) RunAndReturn(run func(paths []string) ([]FontFile, error)) *MockLazySource_IndexFiles_Call { | |
| 217 | + | _c.Call.Return(run) | |
| 218 | + | return _c | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // Open provides a mock function for the type MockLazySource | |
| 222 | + | func (_mock *MockLazySource) Open(path string) (io.ReadCloser, error) { | |
| 223 | + | ret := _mock.Called(path) | |
| 224 | + | ||
| 225 | + | if len(ret) == 0 { | |
| 226 | + | panic("no return value specified for Open") | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | var r0 io.ReadCloser | |
| 230 | + | var r1 error | |
| 231 | + | if returnFunc, ok := ret.Get(0).(func(string) (io.ReadCloser, error)); ok { | |
| 232 | + | return returnFunc(path) | |
| 233 | + | } | |
| 234 | + | if returnFunc, ok := ret.Get(0).(func(string) io.ReadCloser); ok { | |
| 235 | + | r0 = returnFunc(path) | |
| 236 | + | } else { | |
| 237 | + | if ret.Get(0) != nil { | |
| 238 | + | r0 = ret.Get(0).(io.ReadCloser) | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 242 | + | r1 = returnFunc(path) | |
| 243 | + | } else { | |
| 244 | + | r1 = ret.Error(1) | |
| 245 | + | } | |
| 246 | + | return r0, r1 | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // MockLazySource_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open' | |
| 250 | + | type MockLazySource_Open_Call struct { | |
| 251 | + | *mock.Call | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // Open is a helper method to define mock.On call | |
| 255 | + | // - path string | |
| 256 | + | func (_e *MockLazySource_Expecter) Open(path any) *MockLazySource_Open_Call { | |
| 257 | + | return &MockLazySource_Open_Call{Call: _e.mock.On("Open", path)} | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (_c *MockLazySource_Open_Call) Run(run func(path string)) *MockLazySource_Open_Call { | |
| 261 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 262 | + | var arg0 string | |
| 263 | + | if args[0] != nil { | |
| 264 | + | arg0 = args[0].(string) | |
| 265 | + | } | |
| 266 | + | run( | |
| 267 | + | arg0, | |
| 268 | + | ) | |
| 269 | + | }) | |
| 270 | + | return _c | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func (_c *MockLazySource_Open_Call) Return(readCloser io.ReadCloser, err error) *MockLazySource_Open_Call { | |
| 274 | + | _c.Call.Return(readCloser, err) | |
| 275 | + | return _c | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func (_c *MockLazySource_Open_Call) RunAndReturn(run func(path string) (io.ReadCloser, error)) *MockLazySource_Open_Call { | |
| 279 | + | _c.Call.Return(run) | |
| 280 | + | return _c | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // Paths provides a mock function for the type MockLazySource | |
| 284 | + | func (_mock *MockLazySource) Paths() ([]string, error) { | |
| 285 | + | ret := _mock.Called() | |
| 286 | + | ||
| 287 | + | if len(ret) == 0 { | |
| 288 | + | panic("no return value specified for Paths") | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | var r0 []string | |
| 292 | + | var r1 error | |
| 293 | + | if returnFunc, ok := ret.Get(0).(func() ([]string, error)); ok { | |
| 294 | + | return returnFunc() | |
| 295 | + | } | |
| 296 | + | if returnFunc, ok := ret.Get(0).(func() []string); ok { | |
| 297 | + | r0 = returnFunc() | |
| 298 | + | } else { | |
| 299 | + | if ret.Get(0) != nil { | |
| 300 | + | r0 = ret.Get(0).([]string) | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 304 | + | r1 = returnFunc() | |
| 305 | + | } else { | |
| 306 | + | r1 = ret.Error(1) | |
| 307 | + | } | |
| 308 | + | return r0, r1 | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | // MockLazySource_Paths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Paths' | |
| 312 | + | type MockLazySource_Paths_Call struct { | |
| 313 | + | *mock.Call | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // Paths is a helper method to define mock.On call | |
| 317 | + | func (_e *MockLazySource_Expecter) Paths() *MockLazySource_Paths_Call { | |
| 318 | + | return &MockLazySource_Paths_Call{Call: _e.mock.On("Paths")} | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func (_c *MockLazySource_Paths_Call) Run(run func()) *MockLazySource_Paths_Call { | |
| 322 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 323 | + | run() | |
| 324 | + | }) | |
| 325 | + | return _c | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func (_c *MockLazySource_Paths_Call) Return(strings []string, err error) *MockLazySource_Paths_Call { | |
| 329 | + | _c.Call.Return(strings, err) | |
| 330 | + | return _c | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func (_c *MockLazySource_Paths_Call) RunAndReturn(run func() ([]string, error)) *MockLazySource_Paths_Call { | |
| 334 | + | _c.Call.Return(run) | |
| 335 | + | return _c | |
| 336 | + | } | |
| @@ -0,0 +1,336 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package text | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "io" | |||
| 9 | + | ||||
| 10 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // NewMockLazySource creates a new instance of MockLazySource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | + | // The first argument is typically a *testing.T value. | |||
| 15 | + | func NewMockLazySource(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *MockLazySource { | |||
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 20 | + | helper.Helper() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | mock := &MockLazySource{} | |||
| 24 | + | mock.Mock.Test(t) | |||
| 25 | + | ||||
| 26 | + | t.Cleanup(func() { | |||
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 28 | + | helper.Helper() | |||
| 29 | + | } | |||
| 30 | + | mock.AssertExpectations(t) | |||
| 31 | + | }) | |||
| 32 | + | ||||
| 33 | + | return mock | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // MockLazySource is an autogenerated mock type for the LazySource type | |||
| 37 | + | type MockLazySource struct { | |||
| 38 | + | mock.Mock | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type MockLazySource_Expecter struct { | |||
| 42 | + | mock *mock.Mock | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (_m *MockLazySource) EXPECT() *MockLazySource_Expecter { | |||
| 46 | + | return &MockLazySource_Expecter{mock: &_m.Mock} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Fonts provides a mock function for the type MockLazySource | |||
| 50 | + | func (_mock *MockLazySource) Fonts() (map[string][]byte, error) { | |||
| 51 | + | ret := _mock.Called() | |||
| 52 | + | ||||
| 53 | + | if len(ret) == 0 { | |||
| 54 | + | panic("no return value specified for Fonts") | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | var r0 map[string][]byte | |||
| 58 | + | var r1 error | |||
| 59 | + | if returnFunc, ok := ret.Get(0).(func() (map[string][]byte, error)); ok { | |||
| 60 | + | return returnFunc() | |||
| 61 | + | } | |||
| 62 | + | if returnFunc, ok := ret.Get(0).(func() map[string][]byte); ok { | |||
| 63 | + | r0 = returnFunc() | |||
| 64 | + | } else { | |||
| 65 | + | if ret.Get(0) != nil { | |||
| 66 | + | r0 = ret.Get(0).(map[string][]byte) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |||
| 70 | + | r1 = returnFunc() | |||
| 71 | + | } else { | |||
| 72 | + | r1 = ret.Error(1) | |||
| 73 | + | } | |||
| 74 | + | return r0, r1 | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // MockLazySource_Fonts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Fonts' | |||
| 78 | + | type MockLazySource_Fonts_Call struct { | |||
| 79 | + | *mock.Call | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Fonts is a helper method to define mock.On call | |||
| 83 | + | func (_e *MockLazySource_Expecter) Fonts() *MockLazySource_Fonts_Call { | |||
| 84 | + | return &MockLazySource_Fonts_Call{Call: _e.mock.On("Fonts")} | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (_c *MockLazySource_Fonts_Call) Run(run func()) *MockLazySource_Fonts_Call { | |||
| 88 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 89 | + | run() | |||
| 90 | + | }) | |||
| 91 | + | return _c | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (_c *MockLazySource_Fonts_Call) Return(stringToBytes map[string][]byte, err error) *MockLazySource_Fonts_Call { | |||
| 95 | + | _c.Call.Return(stringToBytes, err) | |||
| 96 | + | return _c | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (_c *MockLazySource_Fonts_Call) RunAndReturn(run func() (map[string][]byte, error)) *MockLazySource_Fonts_Call { | |||
| 100 | + | _c.Call.Return(run) | |||
| 101 | + | return _c | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // Index provides a mock function for the type MockLazySource | |||
| 105 | + | func (_mock *MockLazySource) Index() ([]FontFile, error) { | |||
| 106 | + | ret := _mock.Called() | |||
| 107 | + | ||||
| 108 | + | if len(ret) == 0 { | |||
| 109 | + | panic("no return value specified for Index") | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | var r0 []FontFile | |||
| 113 | + | var r1 error | |||
| 114 | + | if returnFunc, ok := ret.Get(0).(func() ([]FontFile, error)); ok { | |||
| 115 | + | return returnFunc() | |||
| 116 | + | } | |||
| 117 | + | if returnFunc, ok := ret.Get(0).(func() []FontFile); ok { | |||
| 118 | + | r0 = returnFunc() | |||
| 119 | + | } else { | |||
| 120 | + | if ret.Get(0) != nil { | |||
| 121 | + | r0 = ret.Get(0).([]FontFile) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |||
| 125 | + | r1 = returnFunc() | |||
| 126 | + | } else { | |||
| 127 | + | r1 = ret.Error(1) | |||
| 128 | + | } | |||
| 129 | + | return r0, r1 | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // MockLazySource_Index_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Index' | |||
| 133 | + | type MockLazySource_Index_Call struct { | |||
| 134 | + | *mock.Call | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // Index is a helper method to define mock.On call | |||
| 138 | + | func (_e *MockLazySource_Expecter) Index() *MockLazySource_Index_Call { | |||
| 139 | + | return &MockLazySource_Index_Call{Call: _e.mock.On("Index")} | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func (_c *MockLazySource_Index_Call) Run(run func()) *MockLazySource_Index_Call { | |||
| 143 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 144 | + | run() | |||
| 145 | + | }) | |||
| 146 | + | return _c | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (_c *MockLazySource_Index_Call) Return(fontFiles []FontFile, err error) *MockLazySource_Index_Call { | |||
| 150 | + | _c.Call.Return(fontFiles, err) | |||
| 151 | + | return _c | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func (_c *MockLazySource_Index_Call) RunAndReturn(run func() ([]FontFile, error)) *MockLazySource_Index_Call { | |||
| 155 | + | _c.Call.Return(run) | |||
| 156 | + | return _c | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // IndexFiles provides a mock function for the type MockLazySource | |||
| 160 | + | func (_mock *MockLazySource) IndexFiles(paths []string) ([]FontFile, error) { | |||
| 161 | + | ret := _mock.Called(paths) | |||
| 162 | + | ||||
| 163 | + | if len(ret) == 0 { | |||
| 164 | + | panic("no return value specified for IndexFiles") | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | var r0 []FontFile | |||
| 168 | + | var r1 error | |||
| 169 | + | if returnFunc, ok := ret.Get(0).(func([]string) ([]FontFile, error)); ok { | |||
| 170 | + | return returnFunc(paths) | |||
| 171 | + | } | |||
| 172 | + | if returnFunc, ok := ret.Get(0).(func([]string) []FontFile); ok { | |||
| 173 | + | r0 = returnFunc(paths) | |||
| 174 | + | } else { | |||
| 175 | + | if ret.Get(0) != nil { | |||
| 176 | + | r0 = ret.Get(0).([]FontFile) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | if returnFunc, ok := ret.Get(1).(func([]string) error); ok { | |||
| 180 | + | r1 = returnFunc(paths) | |||
| 181 | + | } else { | |||
| 182 | + | r1 = ret.Error(1) | |||
| 183 | + | } | |||
| 184 | + | return r0, r1 | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // MockLazySource_IndexFiles_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IndexFiles' | |||
| 188 | + | type MockLazySource_IndexFiles_Call struct { | |||
| 189 | + | *mock.Call | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // IndexFiles is a helper method to define mock.On call | |||
| 193 | + | // - paths []string | |||
| 194 | + | func (_e *MockLazySource_Expecter) IndexFiles(paths any) *MockLazySource_IndexFiles_Call { | |||
| 195 | + | return &MockLazySource_IndexFiles_Call{Call: _e.mock.On("IndexFiles", paths)} | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func (_c *MockLazySource_IndexFiles_Call) Run(run func(paths []string)) *MockLazySource_IndexFiles_Call { | |||
| 199 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 200 | + | var arg0 []string | |||
| 201 | + | if args[0] != nil { | |||
| 202 | + | arg0 = args[0].([]string) | |||
| 203 | + | } | |||
| 204 | + | run( | |||
| 205 | + | arg0, | |||
| 206 | + | ) | |||
| 207 | + | }) | |||
| 208 | + | return _c | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func (_c *MockLazySource_IndexFiles_Call) Return(fontFiles []FontFile, err error) *MockLazySource_IndexFiles_Call { | |||
| 212 | + | _c.Call.Return(fontFiles, err) | |||
| 213 | + | return _c | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func (_c *MockLazySource_IndexFiles_Call) RunAndReturn(run func(paths []string) ([]FontFile, error)) *MockLazySource_IndexFiles_Call { | |||
| 217 | + | _c.Call.Return(run) | |||
| 218 | + | return _c | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // Open provides a mock function for the type MockLazySource | |||
| 222 | + | func (_mock *MockLazySource) Open(path string) (io.ReadCloser, error) { | |||
| 223 | + | ret := _mock.Called(path) | |||
| 224 | + | ||||
| 225 | + | if len(ret) == 0 { | |||
| 226 | + | panic("no return value specified for Open") | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | var r0 io.ReadCloser | |||
| 230 | + | var r1 error | |||
| 231 | + | if returnFunc, ok := ret.Get(0).(func(string) (io.ReadCloser, error)); ok { | |||
| 232 | + | return returnFunc(path) | |||
| 233 | + | } | |||
| 234 | + | if returnFunc, ok := ret.Get(0).(func(string) io.ReadCloser); ok { | |||
| 235 | + | r0 = returnFunc(path) | |||
| 236 | + | } else { | |||
| 237 | + | if ret.Get(0) != nil { | |||
| 238 | + | r0 = ret.Get(0).(io.ReadCloser) | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |||
| 242 | + | r1 = returnFunc(path) | |||
| 243 | + | } else { | |||
| 244 | + | r1 = ret.Error(1) | |||
| 245 | + | } | |||
| 246 | + | return r0, r1 | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // MockLazySource_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open' | |||
| 250 | + | type MockLazySource_Open_Call struct { | |||
| 251 | + | *mock.Call | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // Open is a helper method to define mock.On call | |||
| 255 | + | // - path string | |||
| 256 | + | func (_e *MockLazySource_Expecter) Open(path any) *MockLazySource_Open_Call { | |||
| 257 | + | return &MockLazySource_Open_Call{Call: _e.mock.On("Open", path)} | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func (_c *MockLazySource_Open_Call) Run(run func(path string)) *MockLazySource_Open_Call { | |||
| 261 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 262 | + | var arg0 string | |||
| 263 | + | if args[0] != nil { | |||
| 264 | + | arg0 = args[0].(string) | |||
| 265 | + | } | |||
| 266 | + | run( | |||
| 267 | + | arg0, | |||
| 268 | + | ) | |||
| 269 | + | }) | |||
| 270 | + | return _c | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func (_c *MockLazySource_Open_Call) Return(readCloser io.ReadCloser, err error) *MockLazySource_Open_Call { | |||
| 274 | + | _c.Call.Return(readCloser, err) | |||
| 275 | + | return _c | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | func (_c *MockLazySource_Open_Call) RunAndReturn(run func(path string) (io.ReadCloser, error)) *MockLazySource_Open_Call { | |||
| 279 | + | _c.Call.Return(run) | |||
| 280 | + | return _c | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // Paths provides a mock function for the type MockLazySource | |||
| 284 | + | func (_mock *MockLazySource) Paths() ([]string, error) { | |||
| 285 | + | ret := _mock.Called() | |||
| 286 | + | ||||
| 287 | + | if len(ret) == 0 { | |||
| 288 | + | panic("no return value specified for Paths") | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | var r0 []string | |||
| 292 | + | var r1 error | |||
| 293 | + | if returnFunc, ok := ret.Get(0).(func() ([]string, error)); ok { | |||
| 294 | + | return returnFunc() | |||
| 295 | + | } | |||
| 296 | + | if returnFunc, ok := ret.Get(0).(func() []string); ok { | |||
| 297 | + | r0 = returnFunc() | |||
| 298 | + | } else { | |||
| 299 | + | if ret.Get(0) != nil { | |||
| 300 | + | r0 = ret.Get(0).([]string) | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |||
| 304 | + | r1 = returnFunc() | |||
| 305 | + | } else { | |||
| 306 | + | r1 = ret.Error(1) | |||
| 307 | + | } | |||
| 308 | + | return r0, r1 | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | // MockLazySource_Paths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Paths' | |||
| 312 | + | type MockLazySource_Paths_Call struct { | |||
| 313 | + | *mock.Call | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // Paths is a helper method to define mock.On call | |||
| 317 | + | func (_e *MockLazySource_Expecter) Paths() *MockLazySource_Paths_Call { | |||
| 318 | + | return &MockLazySource_Paths_Call{Call: _e.mock.On("Paths")} | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func (_c *MockLazySource_Paths_Call) Run(run func()) *MockLazySource_Paths_Call { | |||
| 322 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 323 | + | run() | |||
| 324 | + | }) | |||
| 325 | + | return _c | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func (_c *MockLazySource_Paths_Call) Return(strings []string, err error) *MockLazySource_Paths_Call { | |||
| 329 | + | _c.Call.Return(strings, err) | |||
| 330 | + | return _c | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func (_c *MockLazySource_Paths_Call) RunAndReturn(run func() ([]string, error)) *MockLazySource_Paths_Call { | |||
| 334 | + | _c.Call.Return(run) | |||
| 335 | + | return _c | |||
| 336 | + | } | |||
A internal/text/font_lazy_index_test.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 1 | + | package text | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "io" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "slices" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 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) { | |
| 18 | + | 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 | |
| 26 | + | src := NewMockLazySource(t) | |
| 27 | + | src.EXPECT().Paths().Return(paths, nil) | |
| 28 | + | src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(func(ps []string) ([]FontFile, error) { | |
| 29 | + | indexed = append(indexed, ps...) | |
| 30 | + | var real []string | |
| 31 | + | for _, p := range ps { | |
| 32 | + | if !slices.Contains(extra, p) { | |
| 33 | + | real = append(real, p) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | return SystemSource{}.IndexFiles(real) | |
| 37 | + | }).Maybe() | |
| 38 | + | src.EXPECT().Open(mock.Anything).RunAndReturn(func(p string) (io.ReadCloser, error) { | |
| 39 | + | b, err := os.ReadFile(p) | |
| 40 | + | return io.NopCloser(bytes.NewReader(b)), err | |
| 41 | + | }).Maybe() | |
| 42 | + | return src, &indexed | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func base(paths []string) []string { | |
| 46 | + | out := make([]string, len(paths)) | |
| 47 | + | for i, p := range paths { | |
| 48 | + | out[i] = filepath.Base(p) | |
| 49 | + | } | |
| 50 | + | slices.Sort(out) | |
| 51 | + | return out | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) { | |
| 55 | + | src, indexed := lazyFixture(t, "/fonts/Unrelated-Regular.ttf") | |
| 56 | + | c, err := Load(src) | |
| 57 | + | if err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | l, err := NewEngine(c).Measure("Hello", Request{Families: []string{"monospace"}, Size: 16}, 0) | |
| 61 | + | if err != nil || l.Width == 0 { | |
| 62 | + | t.Fatalf("measure: %v %v", err, c.Diagnostics) | |
| 63 | + | } | |
| 64 | + | // 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"} | |
| 66 | + | if got := base(*indexed); !slices.Equal(got, want) { | |
| 67 | + | t.Fatalf("indexed %v, want %v", got, want) | |
| 68 | + | } | |
| 69 | + | if c.complete { | |
| 70 | + | t.Fatal("catalog fully indexed for a covered request") | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestLazyIndexScansAllForUnknownFamily(t *testing.T) { | |
| 75 | + | src, indexed := lazyFixture(t, "/fonts/Unrelated-Regular.ttf") | |
| 76 | + | c, err := Load(src) | |
| 77 | + | if err != nil { | |
| 78 | + | t.Fatal(err) | |
| 79 | + | } | |
| 80 | + | // The file name does not reveal the family: only a full scan can find it. | |
| 81 | + | if _, err := NewEngine(c).Measure("Hello", Request{Families: []string{"Some Family"}, Size: 16}, 0); err != nil { | |
| 82 | + | t.Fatal(err) | |
| 83 | + | } | |
| 84 | + | if !c.complete || !slices.Contains(*indexed, "/fonts/Unrelated-Regular.ttf") { | |
| 85 | + | t.Fatalf("unknown family did not index every file: %v", *indexed) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestLazyIndexFallbackGlyph(t *testing.T) { | |
| 90 | + | src, _ := lazyFixture(t) | |
| 91 | + | c, err := Load(src) | |
| 92 | + | if err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | l, err := NewEngine(c).Measure("日本語", Request{Families: []string{"Adwaita Sans"}, Size: 20}, 0) | |
| 96 | + | if err != nil { | |
| 97 | + | t.Fatal(err) | |
| 98 | + | } | |
| 99 | + | run := l.Lines[0].Runs[0] | |
| 100 | + | if run.Face.Family != "IBM Plex Sans JP" || run.Glyphs[0].Missing { | |
| 101 | + | t.Fatalf("fallback face %q", run.Face.Family) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Indexing in steps keeps the catalog order of a full index. | |
| 106 | + | func TestLazyIndexKeepsPathOrder(t *testing.T) { | |
| 107 | + | src, _ := lazyFixture(t) | |
| 108 | + | c, err := Load(src) | |
| 109 | + | if err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | c.indexAll() | |
| 113 | + | paths, _ := src.Paths() | |
| 114 | + | full, err := SystemSource{}.IndexFiles(paths) | |
| 115 | + | if err != nil { | |
| 116 | + | t.Fatal(err) | |
| 117 | + | } | |
| 118 | + | if len(full) != len(c.Faces) { | |
| 119 | + | t.Fatalf("%d faces, want %d", len(c.Faces), len(full)) | |
| 120 | + | } | |
| 121 | + | for i, f := range c.Faces { | |
| 122 | + | if f.path != full[i].Path || f.index != full[i].Index { | |
| 123 | + | t.Fatalf("face %d is %s/%d, want %s/%d", i, f.path, f.index, full[i].Path, full[i].Index) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestFileStem(t *testing.T) { | |
| 129 | + | for path, want := range map[string]string{ | |
| 130 | + | "/f/NotoSans-Bold.ttf": "notosans", | |
| 131 | + | "/f/NotoSans[wght].ttf": "notosans", | |
| 132 | + | "/f/DejaVuSansMono.ttf": "dejavusansmono", | |
| 133 | + | "/f/IBMPlexMono_Regular.otf": "ibmplexmono", | |
| 134 | + | } { | |
| 135 | + | if got := fileStem(path); got != want { | |
| 136 | + | t.Errorf("%s: %q, want %q", path, got, want) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | } | |
| @@ -0,0 +1,139 @@ | |||||
| 1 | + | package text | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "io" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "slices" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 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) { | |||
| 18 | + | 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 | |||
| 26 | + | src := NewMockLazySource(t) | |||
| 27 | + | src.EXPECT().Paths().Return(paths, nil) | |||
| 28 | + | src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(func(ps []string) ([]FontFile, error) { | |||
| 29 | + | indexed = append(indexed, ps...) | |||
| 30 | + | var real []string | |||
| 31 | + | for _, p := range ps { | |||
| 32 | + | if !slices.Contains(extra, p) { | |||
| 33 | + | real = append(real, p) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | return SystemSource{}.IndexFiles(real) | |||
| 37 | + | }).Maybe() | |||
| 38 | + | src.EXPECT().Open(mock.Anything).RunAndReturn(func(p string) (io.ReadCloser, error) { | |||
| 39 | + | b, err := os.ReadFile(p) | |||
| 40 | + | return io.NopCloser(bytes.NewReader(b)), err | |||
| 41 | + | }).Maybe() | |||
| 42 | + | return src, &indexed | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func base(paths []string) []string { | |||
| 46 | + | out := make([]string, len(paths)) | |||
| 47 | + | for i, p := range paths { | |||
| 48 | + | out[i] = filepath.Base(p) | |||
| 49 | + | } | |||
| 50 | + | slices.Sort(out) | |||
| 51 | + | return out | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) { | |||
| 55 | + | src, indexed := lazyFixture(t, "/fonts/Unrelated-Regular.ttf") | |||
| 56 | + | c, err := Load(src) | |||
| 57 | + | if err != nil { | |||
| 58 | + | t.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | l, err := NewEngine(c).Measure("Hello", Request{Families: []string{"monospace"}, Size: 16}, 0) | |||
| 61 | + | if err != nil || l.Width == 0 { | |||
| 62 | + | t.Fatalf("measure: %v %v", err, c.Diagnostics) | |||
| 63 | + | } | |||
| 64 | + | // 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"} | |||
| 66 | + | if got := base(*indexed); !slices.Equal(got, want) { | |||
| 67 | + | t.Fatalf("indexed %v, want %v", got, want) | |||
| 68 | + | } | |||
| 69 | + | if c.complete { | |||
| 70 | + | t.Fatal("catalog fully indexed for a covered request") | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestLazyIndexScansAllForUnknownFamily(t *testing.T) { | |||
| 75 | + | src, indexed := lazyFixture(t, "/fonts/Unrelated-Regular.ttf") | |||
| 76 | + | c, err := Load(src) | |||
| 77 | + | if err != nil { | |||
| 78 | + | t.Fatal(err) | |||
| 79 | + | } | |||
| 80 | + | // The file name does not reveal the family: only a full scan can find it. | |||
| 81 | + | if _, err := NewEngine(c).Measure("Hello", Request{Families: []string{"Some Family"}, Size: 16}, 0); err != nil { | |||
| 82 | + | t.Fatal(err) | |||
| 83 | + | } | |||
| 84 | + | if !c.complete || !slices.Contains(*indexed, "/fonts/Unrelated-Regular.ttf") { | |||
| 85 | + | t.Fatalf("unknown family did not index every file: %v", *indexed) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestLazyIndexFallbackGlyph(t *testing.T) { | |||
| 90 | + | src, _ := lazyFixture(t) | |||
| 91 | + | c, err := Load(src) | |||
| 92 | + | if err != nil { | |||
| 93 | + | t.Fatal(err) | |||
| 94 | + | } | |||
| 95 | + | l, err := NewEngine(c).Measure("日本語", Request{Families: []string{"Adwaita Sans"}, Size: 20}, 0) | |||
| 96 | + | if err != nil { | |||
| 97 | + | t.Fatal(err) | |||
| 98 | + | } | |||
| 99 | + | run := l.Lines[0].Runs[0] | |||
| 100 | + | if run.Face.Family != "IBM Plex Sans JP" || run.Glyphs[0].Missing { | |||
| 101 | + | t.Fatalf("fallback face %q", run.Face.Family) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Indexing in steps keeps the catalog order of a full index. | |||
| 106 | + | func TestLazyIndexKeepsPathOrder(t *testing.T) { | |||
| 107 | + | src, _ := lazyFixture(t) | |||
| 108 | + | c, err := Load(src) | |||
| 109 | + | if err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | c.indexAll() | |||
| 113 | + | paths, _ := src.Paths() | |||
| 114 | + | full, err := SystemSource{}.IndexFiles(paths) | |||
| 115 | + | if err != nil { | |||
| 116 | + | t.Fatal(err) | |||
| 117 | + | } | |||
| 118 | + | if len(full) != len(c.Faces) { | |||
| 119 | + | t.Fatalf("%d faces, want %d", len(c.Faces), len(full)) | |||
| 120 | + | } | |||
| 121 | + | for i, f := range c.Faces { | |||
| 122 | + | if f.path != full[i].Path || f.index != full[i].Index { | |||
| 123 | + | t.Fatalf("face %d is %s/%d, want %s/%d", i, f.path, f.index, full[i].Path, full[i].Index) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestFileStem(t *testing.T) { | |||
| 129 | + | for path, want := range map[string]string{ | |||
| 130 | + | "/f/NotoSans-Bold.ttf": "notosans", | |||
| 131 | + | "/f/NotoSans[wght].ttf": "notosans", | |||
| 132 | + | "/f/DejaVuSansMono.ttf": "dejavusansmono", | |||
| 133 | + | "/f/IBMPlexMono_Regular.otf": "ibmplexmono", | |||
| 134 | + | } { | |||
| 135 | + | if got := fileStem(path); got != want { | |||
| 136 | + | t.Errorf("%s: %q, want %q", path, got, want) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | } | |||
M internal/text/fonts.go +250 -49
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "slices" | |
| 17 | 17 | "sort" | |
| 18 | 18 | "strings" | |
| 19 | + | "sync" | |
| 19 | 20 | ||
| 20 | 21 | "github.com/bnema/nefergui/internal/bidi" | |
| 21 | 22 | "github.com/go-text/typesetting/font" | |
| @@ -156,6 +157,16 @@ type IndexSource interface { | |||
| 156 | 157 | Open(path string) (io.ReadCloser, error) | |
| 157 | 158 | } | |
| 158 | 159 | ||
| 160 | + | // LazySource lists font files cheaply and indexes chosen ones on demand, so a | |
| 161 | + | // catalog reads only the files a request needs. Paths fixes the catalog order; | |
| 162 | + | // IndexFiles returns entries in the order of its paths. | |
| 163 | + | type LazySource interface { | |
| 164 | + | FontSource | |
| 165 | + | IndexSource | |
| 166 | + | Paths() ([]string, error) | |
| 167 | + | IndexFiles(paths []string) ([]FontFile, error) | |
| 168 | + | } | |
| 169 | + | ||
| 159 | 170 | type FontFile struct { | |
| 160 | 171 | Path string | |
| 161 | 172 | Family string | |
| @@ -195,59 +206,89 @@ func (m *metadataReader) Read(p []byte) (int, error) { | |||
| 195 | 206 | ||
| 196 | 207 | func (s SystemSource) Index() ([]FontFile, error) { | |
| 197 | 208 | paths, err := s.paths() | |
| 198 | - | var files []FontFile | |
| 199 | - | var buffer []byte | |
| 200 | - | for _, p := range paths { | |
| 201 | - | f, e := os.Open(p) | |
| 202 | - | if e != nil { | |
| 203 | - | err = errors.Join(err, fmt.Errorf("font %s: %w", p, e)) | |
| 204 | - | continue | |
| 205 | - | } | |
| 206 | - | info, statErr := f.Stat() | |
| 207 | - | if statErr != nil || info.Size() > maxFontBytes { | |
| 208 | - | if statErr == nil { | |
| 209 | - | statErr = fmt.Errorf("font exceeds %d byte limit", maxFontBytes) | |
| 210 | - | } | |
| 211 | - | err = errors.Join(err, fmt.Errorf("font %s: %w", p, statErr)) | |
| 212 | - | if closeErr := f.Close(); closeErr != nil { | |
| 213 | - | err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr)) | |
| 209 | + | files, e := s.IndexFiles(paths) | |
| 210 | + | return files, errors.Join(err, e) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // Paths lists the font files in catalog order without reading them. | |
| 214 | + | func (s SystemSource) Paths() ([]string, error) { return s.paths() } | |
| 215 | + | ||
| 216 | + | // indexWorkers bounds the files read at once. Parallel reads hide the latency | |
| 217 | + | // of a cold page cache, which dominates on encrypted disks. | |
| 218 | + | const indexWorkers = 32 | |
| 219 | + | ||
| 220 | + | // IndexFiles reads the metadata of paths in parallel and returns the entries | |
| 221 | + | // in path order. Every worker ends before it returns. | |
| 222 | + | func (SystemSource) IndexFiles(paths []string) ([]FontFile, error) { | |
| 223 | + | files := make([][]FontFile, len(paths)) | |
| 224 | + | errs := make([]error, len(paths)) | |
| 225 | + | next := make(chan int) | |
| 226 | + | var wg sync.WaitGroup | |
| 227 | + | for range min(indexWorkers, len(paths)) { | |
| 228 | + | wg.Add(1) | |
| 229 | + | go func() { | |
| 230 | + | defer wg.Done() | |
| 231 | + | var buffer []byte | |
| 232 | + | for i := range next { | |
| 233 | + | files[i], buffer, errs[i] = indexFile(paths[i], buffer) | |
| 214 | 234 | } | |
| 235 | + | }() | |
| 236 | + | } | |
| 237 | + | for i := range paths { | |
| 238 | + | next <- i | |
| 239 | + | } | |
| 240 | + | close(next) | |
| 241 | + | wg.Wait() | |
| 242 | + | return slices.Concat(files...), errors.Join(errs...) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // indexFile reads the metadata of every face in the file at p. buffer is | |
| 246 | + | // reusable scratch storage. | |
| 247 | + | func indexFile(p string, buffer []byte) (files []FontFile, _ []byte, err error) { | |
| 248 | + | f, e := os.Open(p) | |
| 249 | + | if e != nil { | |
| 250 | + | return nil, buffer, fmt.Errorf("font %s: %w", p, e) | |
| 251 | + | } | |
| 252 | + | defer func() { | |
| 253 | + | if closeErr := f.Close(); closeErr != nil { | |
| 254 | + | err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr)) | |
| 255 | + | } | |
| 256 | + | }() | |
| 257 | + | info, statErr := f.Stat() | |
| 258 | + | if statErr != nil || info.Size() > maxFontBytes { | |
| 259 | + | if statErr == nil { | |
| 260 | + | statErr = fmt.Errorf("font exceeds %d byte limit", maxFontBytes) | |
| 261 | + | } | |
| 262 | + | return nil, buffer, fmt.Errorf("font %s: %w", p, statErr) | |
| 263 | + | } | |
| 264 | + | reader := &metadataReader{File: f, remaining: maxMetadataBytes} | |
| 265 | + | loaders, e := ot.NewLoaders(reader) | |
| 266 | + | if e != nil { | |
| 267 | + | return nil, buffer, fmt.Errorf("font %s: %w", p, e) | |
| 268 | + | } | |
| 269 | + | for i, loader := range loaders { | |
| 270 | + | var desc font.Description | |
| 271 | + | // Describe ignores table read errors; a failed bounded read yields no | |
| 272 | + | // usable family and is excluded from the index. | |
| 273 | + | desc, buffer = font.Describe(loader, buffer) | |
| 274 | + | if reader.exceeded { | |
| 275 | + | return files, buffer, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i) | |
| 276 | + | } | |
| 277 | + | if desc.Family == "" { | |
| 215 | 278 | continue | |
| 216 | 279 | } | |
| 217 | - | reader := &metadataReader{File: f, remaining: maxMetadataBytes} | |
| 218 | - | loaders, e := ot.NewLoaders(reader) | |
| 219 | - | if e == nil { | |
| 220 | - | for i, loader := range loaders { | |
| 221 | - | var desc font.Description | |
| 222 | - | // Describe ignores table read errors; a failed bounded read | |
| 223 | - | // yields no usable family and is excluded from the index. | |
| 224 | - | desc, buffer = font.Describe(loader, buffer) | |
| 225 | - | if reader.exceeded { | |
| 226 | - | err = errors.Join(err, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i)) | |
| 227 | - | break | |
| 228 | - | } | |
| 229 | - | if desc.Family != "" { | |
| 230 | - | entry := FontFile{Path: p, Family: desc.Family, Aspect: desc.Aspect, Index: i} | |
| 231 | - | if raw, tableErr := loader.RawTable(ot.MustNewTag("OS/2")); tableErr == nil && len(raw) >= 58 { | |
| 232 | - | for j := range entry.UnicodeRanges { | |
| 233 | - | entry.UnicodeRanges[j] = binary.BigEndian.Uint32(raw[42+j*4:]) | |
| 234 | - | } | |
| 235 | - | } | |
| 236 | - | if reader.exceeded { | |
| 237 | - | err = errors.Join(err, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i)) | |
| 238 | - | break | |
| 239 | - | } | |
| 240 | - | files = append(files, entry) | |
| 241 | - | } | |
| 280 | + | entry := FontFile{Path: p, Family: desc.Family, Aspect: desc.Aspect, Index: i} | |
| 281 | + | if raw, tableErr := loader.RawTable(ot.MustNewTag("OS/2")); tableErr == nil && len(raw) >= 58 { | |
| 282 | + | for j := range entry.UnicodeRanges { | |
| 283 | + | entry.UnicodeRanges[j] = binary.BigEndian.Uint32(raw[42+j*4:]) | |
| 242 | 284 | } | |
| 243 | - | } else { | |
| 244 | - | err = errors.Join(err, fmt.Errorf("font %s: %w", p, e)) | |
| 245 | 285 | } | |
| 246 | - | if closeErr := f.Close(); closeErr != nil { | |
| 247 | - | err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr)) | |
| 286 | + | if reader.exceeded { | |
| 287 | + | return files, buffer, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i) | |
| 248 | 288 | } | |
| 289 | + | files = append(files, entry) | |
| 249 | 290 | } | |
| 250 | - | return files, err | |
| 291 | + | return files, buffer, nil | |
| 251 | 292 | } | |
| 252 | 293 | func (SystemSource) Open(path string) (io.ReadCloser, error) { return os.Open(path) } | |
| 253 | 294 | ||
| @@ -283,9 +324,17 @@ type Catalog struct { | |||
| 283 | 324 | Faces []*Face | |
| 284 | 325 | source IndexSource | |
| 285 | 326 | Diagnostics []error | |
| 286 | - | // ranked memoizes candidates per request; a face failure clears it, | |
| 287 | - | // because failed faces are the only catalog state match depends on. | |
| 327 | + | // ranked memoizes candidates per request; a face failure or newly | |
| 328 | + | // indexed faces clear it, because match depends only on them. | |
| 288 | 329 | ranked []rankedFaces | |
| 330 | + | ||
| 331 | + | // A LazySource catalog indexes files on demand. Faces stay in path order. | |
| 332 | + | lazy LazySource | |
| 333 | + | paths []string | |
| 334 | + | indexed []bool | |
| 335 | + | faceAt [][]*Face // faces of each path | |
| 336 | + | byStem map[string][]int // normalized file-name stem to path positions | |
| 337 | + | complete bool // every path is indexed | |
| 289 | 338 | } | |
| 290 | 339 | ||
| 291 | 340 | // maxRanked bounds the candidates memo; distinct font requests per | |
| @@ -299,7 +348,108 @@ type rankedFaces struct { | |||
| 299 | 348 | faces []*Face | |
| 300 | 349 | } | |
| 301 | 350 | ||
| 351 | + | // loadLazy lists the files and indexes only those named after the default | |
| 352 | + | // families, so a catalog is usable without reading every installed font. | |
| 353 | + | func loadLazy(source LazySource) (*Catalog, error) { | |
| 354 | + | paths, err := source.Paths() | |
| 355 | + | c := &Catalog{source: source, lazy: source, paths: paths, indexed: make([]bool, len(paths)), faceAt: make([][]*Face, len(paths)), byStem: map[string][]int{}} | |
| 356 | + | if err != nil { | |
| 357 | + | c.Diagnostics = append(c.Diagnostics, err) | |
| 358 | + | } | |
| 359 | + | for i, p := range paths { | |
| 360 | + | key := fileStem(p) | |
| 361 | + | c.byStem[key] = append(c.byStem[key], i) | |
| 362 | + | } | |
| 363 | + | c.indexStems(genericKeys["sans-serif"]) | |
| 364 | + | if len(c.Faces) == 0 { | |
| 365 | + | c.indexAll() | |
| 366 | + | } | |
| 367 | + | if len(c.Faces) == 0 { | |
| 368 | + | return nil, fmt.Errorf("no usable fonts: %w", errors.Join(c.Diagnostics...)) | |
| 369 | + | } | |
| 370 | + | return c, nil | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // fileStem is the normalized family part of a font file name: the name up to | |
| 374 | + | // the first '-', '[', '_' or '.', so NotoSans-Bold.ttf and NotoSans[wght].ttf | |
| 375 | + | // both give "notosans", the key of the "Noto Sans" family. | |
| 376 | + | func fileStem(path string) string { | |
| 377 | + | name := filepath.Base(path) | |
| 378 | + | if i := strings.IndexAny(name, "-[_."); i >= 0 { | |
| 379 | + | name = name[:i] | |
| 380 | + | } | |
| 381 | + | return font.NormalizeFamily(name) | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // indexStems indexes the files whose stem is one of keys. | |
| 385 | + | func (c *Catalog) indexStems(keys []string) { | |
| 386 | + | var todo []int | |
| 387 | + | for _, key := range keys { | |
| 388 | + | for _, i := range c.byStem[key] { | |
| 389 | + | if !c.indexed[i] && !slices.Contains(todo, i) { | |
| 390 | + | todo = append(todo, i) | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | c.indexPositions(todo) | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | // indexAll indexes every remaining file, once. | |
| 398 | + | func (c *Catalog) indexAll() { | |
| 399 | + | if c.complete { | |
| 400 | + | return | |
| 401 | + | } | |
| 402 | + | var todo []int | |
| 403 | + | for i, done := range c.indexed { | |
| 404 | + | if !done { | |
| 405 | + | todo = append(todo, i) | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | c.indexPositions(todo) | |
| 409 | + | c.complete = true | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | // indexPositions indexes the files at the given path positions and rebuilds | |
| 413 | + | // Faces in path order, keeping existing faces, so ties resolve as with a full | |
| 414 | + | // index. | |
| 415 | + | func (c *Catalog) indexPositions(todo []int) { | |
| 416 | + | if len(todo) == 0 { | |
| 417 | + | return | |
| 418 | + | } | |
| 419 | + | slices.Sort(todo) | |
| 420 | + | paths := make([]string, len(todo)) | |
| 421 | + | for j, i := range todo { | |
| 422 | + | paths[j] = c.paths[i] | |
| 423 | + | c.indexed[i] = true | |
| 424 | + | } | |
| 425 | + | entries, err := c.lazy.IndexFiles(paths) | |
| 426 | + | if err != nil { | |
| 427 | + | c.Diagnostics = append(c.Diagnostics, err) | |
| 428 | + | } | |
| 429 | + | position := make(map[string]int, len(todo)) | |
| 430 | + | for _, i := range todo { | |
| 431 | + | position[c.paths[i]] = i | |
| 432 | + | } | |
| 433 | + | for _, entry := range entries { | |
| 434 | + | i, ok := position[entry.Path] | |
| 435 | + | if !ok { | |
| 436 | + | continue | |
| 437 | + | } | |
| 438 | + | c.faceAt[i] = append(c.faceAt[i], c.newFace(entry)) | |
| 439 | + | } | |
| 440 | + | c.Faces = slices.Concat(c.faceAt...) | |
| 441 | + | clear(c.ranked) | |
| 442 | + | c.ranked = c.ranked[:0] | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func (c *Catalog) newFace(entry FontFile) *Face { | |
| 446 | + | return &Face{ID: fmt.Sprintf("%s/%d", entry.Path, entry.Index), Family: entry.Family, familyKey: font.NormalizeFamily(entry.Family), Aspect: entry.Aspect, path: entry.Path, index: entry.Index, unicodeRanges: entry.UnicodeRanges, catalog: c} | |
| 447 | + | } | |
| 448 | + | ||
| 302 | 449 | func Load(source FontSource) (*Catalog, error) { | |
| 450 | + | if lazy, ok := source.(LazySource); ok { | |
| 451 | + | return loadLazy(lazy) | |
| 452 | + | } | |
| 303 | 453 | if indexed, ok := source.(IndexSource); ok { | |
| 304 | 454 | entries, err := indexed.Index() | |
| 305 | 455 | c := &Catalog{source: indexed} | |
| @@ -307,7 +457,7 @@ func Load(source FontSource) (*Catalog, error) { | |||
| 307 | 457 | c.Diagnostics = append(c.Diagnostics, err) | |
| 308 | 458 | } | |
| 309 | 459 | for _, entry := range entries { | |
| 310 | - | c.Faces = append(c.Faces, &Face{ID: fmt.Sprintf("%s/%d", entry.Path, entry.Index), Family: entry.Family, familyKey: font.NormalizeFamily(entry.Family), Aspect: entry.Aspect, path: entry.Path, index: entry.Index, unicodeRanges: entry.UnicodeRanges, catalog: c}) | |
| 460 | + | c.Faces = append(c.Faces, c.newFace(entry)) | |
| 311 | 461 | } | |
| 312 | 462 | if len(c.Faces) == 0 { | |
| 313 | 463 | return nil, fmt.Errorf("no usable fonts: %w", err) | |
| @@ -544,6 +694,53 @@ func (c *Catalog) candidates(r Request) []*Face { | |||
| 544 | 694 | } | |
| 545 | 695 | ||
| 546 | 696 | func (c *Catalog) rank(r Request) []*Face { | |
| 697 | + | if c.lazy != nil && !c.complete { | |
| 698 | + | if out, ok := c.rankIndexed(r); ok { | |
| 699 | + | return out | |
| 700 | + | } | |
| 701 | + | c.indexAll() | |
| 702 | + | } | |
| 703 | + | return c.rankFaces(r) | |
| 704 | + | } | |
| 705 | + | ||
| 706 | + | // rankIndexed indexes the files named after the request's families and ranks | |
| 707 | + | // them. ok is false when the full index is needed: an explicitly named family | |
| 708 | + | // matches no file name, or nothing at all matches. | |
| 709 | + | func (c *Catalog) rankIndexed(r Request) (out []*Face, ok bool) { | |
| 710 | + | var keys []string | |
| 711 | + | explicit := map[string]bool{} | |
| 712 | + | for _, family := range r.Families { | |
| 713 | + | family = strings.Trim(family, " \t\"'") | |
| 714 | + | if alias, ok := genericKeys[strings.ToLower(family)]; ok { | |
| 715 | + | keys = append(keys, alias...) | |
| 716 | + | } else { | |
| 717 | + | key := font.NormalizeFamily(family) | |
| 718 | + | keys = append(keys, key) | |
| 719 | + | explicit[key] = true | |
| 720 | + | } | |
| 721 | + | } | |
| 722 | + | keys = append(keys, genericKeys["sans-serif"]...) | |
| 723 | + | keys = append(keys, scriptFallbackKeys...) | |
| 724 | + | c.indexStems(keys) | |
| 725 | + | for key := range explicit { | |
| 726 | + | if !c.hasFamily(key) { | |
| 727 | + | return nil, false | |
| 728 | + | } | |
| 729 | + | } | |
| 730 | + | out = c.rankFaces(r) | |
| 731 | + | return out, len(out) > 0 | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | func (c *Catalog) hasFamily(key string) bool { | |
| 735 | + | for _, f := range c.Faces { | |
| 736 | + | if f.familyKey == key { | |
| 737 | + | return true | |
| 738 | + | } | |
| 739 | + | } | |
| 740 | + | return false | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | func (c *Catalog) rankFaces(r Request) []*Face { | |
| 547 | 744 | seen := map[*Face]bool{} | |
| 548 | 745 | var out []*Face | |
| 549 | 746 | add := func(f *Face) { | |
| @@ -654,6 +851,10 @@ func namedKeys(r Request) map[string]bool { | |||
| 654 | 851 | // unknown range data remain eligible; loaded or failed state never moves the | |
| 655 | 852 | // window, so later measures cannot admit a different face. | |
| 656 | 853 | 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 | + | } | |
| 657 | 858 | named := namedKeys(r) | |
| 658 | 859 | out := make([]*Face, 0, maxFallbackFaces) | |
| 659 | 860 | for _, f := range c.Faces { | |
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | "slices" | 16 | "slices" | ||
| 17 | "sort" | 17 | "sort" | ||
| 18 | "strings" | 18 | "strings" | ||
| 19 | + | "sync" | |||
| 19 | 20 | ||||
| 20 | "github.com/bnema/nefergui/internal/bidi" | 21 | "github.com/bnema/nefergui/internal/bidi" | ||
| 21 | "github.com/go-text/typesetting/font" | 22 | "github.com/go-text/typesetting/font" | ||
| @@ -156,6 +157,16 @@ type IndexSource interface { | |||||
| 156 | Open(path string) (io.ReadCloser, error) | 157 | Open(path string) (io.ReadCloser, error) | ||
| 157 | } | 158 | } | ||
| 158 | 159 | ||||
| 160 | + | // LazySource lists font files cheaply and indexes chosen ones on demand, so a | |||
| 161 | + | // catalog reads only the files a request needs. Paths fixes the catalog order; | |||
| 162 | + | // IndexFiles returns entries in the order of its paths. | |||
| 163 | + | type LazySource interface { | |||
| 164 | + | FontSource | |||
| 165 | + | IndexSource | |||
| 166 | + | Paths() ([]string, error) | |||
| 167 | + | IndexFiles(paths []string) ([]FontFile, error) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 159 | type FontFile struct { | 170 | type FontFile struct { | ||
| 160 | Path string | 171 | Path string | ||
| 161 | Family string | 172 | Family string | ||
| @@ -195,59 +206,89 @@ func (m *metadataReader) Read(p []byte) (int, error) { | |||||
| 195 | 206 | ||||
| 196 | func (s SystemSource) Index() ([]FontFile, error) { | 207 | func (s SystemSource) Index() ([]FontFile, error) { | ||
| 197 | paths, err := s.paths() | 208 | paths, err := s.paths() | ||
| 198 | - | var files []FontFile | 209 | + | files, e := s.IndexFiles(paths) |
| 199 | - | var buffer []byte | 210 | + | return files, errors.Join(err, e) |
| 200 | - | for _, p := range paths { | 211 | + | } |
| 201 | - | f, e := os.Open(p) | 212 | + | |
| 202 | - | if e != nil { | 213 | + | // Paths lists the font files in catalog order without reading them. |
| 203 | - | err = errors.Join(err, fmt.Errorf("font %s: %w", p, e)) | 214 | + | func (s SystemSource) Paths() ([]string, error) { return s.paths() } |
| 204 | - | continue | 215 | + | |
| 205 | - | } | 216 | + | // indexWorkers bounds the files read at once. Parallel reads hide the latency |
| 206 | - | info, statErr := f.Stat() | 217 | + | // of a cold page cache, which dominates on encrypted disks. |
| 207 | - | if statErr != nil || info.Size() > maxFontBytes { | 218 | + | const indexWorkers = 32 |
| 208 | - | if statErr == nil { | 219 | + | |
| 209 | - | statErr = fmt.Errorf("font exceeds %d byte limit", maxFontBytes) | 220 | + | // IndexFiles reads the metadata of paths in parallel and returns the entries |
| 210 | - | } | 221 | + | // in path order. Every worker ends before it returns. |
| 211 | - | err = errors.Join(err, fmt.Errorf("font %s: %w", p, statErr)) | 222 | + | func (SystemSource) IndexFiles(paths []string) ([]FontFile, error) { |
| 212 | - | if closeErr := f.Close(); closeErr != nil { | 223 | + | files := make([][]FontFile, len(paths)) |
| 213 | - | err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr)) | 224 | + | errs := make([]error, len(paths)) |
| 225 | + | next := make(chan int) | |||
| 226 | + | var wg sync.WaitGroup | |||
| 227 | + | for range min(indexWorkers, len(paths)) { | |||
| 228 | + | wg.Add(1) | |||
| 229 | + | go func() { | |||
| 230 | + | defer wg.Done() | |||
| 231 | + | var buffer []byte | |||
| 232 | + | for i := range next { | |||
| 233 | + | files[i], buffer, errs[i] = indexFile(paths[i], buffer) | |||
| 214 | } | 234 | } | ||
| 235 | + | }() | |||
| 236 | + | } | |||
| 237 | + | for i := range paths { | |||
| 238 | + | next <- i | |||
| 239 | + | } | |||
| 240 | + | close(next) | |||
| 241 | + | wg.Wait() | |||
| 242 | + | return slices.Concat(files...), errors.Join(errs...) | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // indexFile reads the metadata of every face in the file at p. buffer is | |||
| 246 | + | // reusable scratch storage. | |||
| 247 | + | func indexFile(p string, buffer []byte) (files []FontFile, _ []byte, err error) { | |||
| 248 | + | f, e := os.Open(p) | |||
| 249 | + | if e != nil { | |||
| 250 | + | return nil, buffer, fmt.Errorf("font %s: %w", p, e) | |||
| 251 | + | } | |||
| 252 | + | defer func() { | |||
| 253 | + | if closeErr := f.Close(); closeErr != nil { | |||
| 254 | + | err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr)) | |||
| 255 | + | } | |||
| 256 | + | }() | |||
| 257 | + | info, statErr := f.Stat() | |||
| 258 | + | if statErr != nil || info.Size() > maxFontBytes { | |||
| 259 | + | if statErr == nil { | |||
| 260 | + | statErr = fmt.Errorf("font exceeds %d byte limit", maxFontBytes) | |||
| 261 | + | } | |||
| 262 | + | return nil, buffer, fmt.Errorf("font %s: %w", p, statErr) | |||
| 263 | + | } | |||
| 264 | + | reader := &metadataReader{File: f, remaining: maxMetadataBytes} | |||
| 265 | + | loaders, e := ot.NewLoaders(reader) | |||
| 266 | + | if e != nil { | |||
| 267 | + | return nil, buffer, fmt.Errorf("font %s: %w", p, e) | |||
| 268 | + | } | |||
| 269 | + | for i, loader := range loaders { | |||
| 270 | + | var desc font.Description | |||
| 271 | + | // Describe ignores table read errors; a failed bounded read yields no | |||
| 272 | + | // usable family and is excluded from the index. | |||
| 273 | + | desc, buffer = font.Describe(loader, buffer) | |||
| 274 | + | if reader.exceeded { | |||
| 275 | + | return files, buffer, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i) | |||
| 276 | + | } | |||
| 277 | + | if desc.Family == "" { | |||
| 215 | continue | 278 | continue | ||
| 216 | } | 279 | } | ||
| 217 | - | reader := &metadataReader{File: f, remaining: maxMetadataBytes} | 280 | + | entry := FontFile{Path: p, Family: desc.Family, Aspect: desc.Aspect, Index: i} |
| 218 | - | loaders, e := ot.NewLoaders(reader) | 281 | + | if raw, tableErr := loader.RawTable(ot.MustNewTag("OS/2")); tableErr == nil && len(raw) >= 58 { |
| 219 | - | if e == nil { | 282 | + | for j := range entry.UnicodeRanges { |
| 220 | - | for i, loader := range loaders { | 283 | + | entry.UnicodeRanges[j] = binary.BigEndian.Uint32(raw[42+j*4:]) |
| 221 | - | var desc font.Description | |||
| 222 | - | // Describe ignores table read errors; a failed bounded read | |||
| 223 | - | // yields no usable family and is excluded from the index. | |||
| 224 | - | desc, buffer = font.Describe(loader, buffer) | |||
| 225 | - | if reader.exceeded { | |||
| 226 | - | err = errors.Join(err, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i)) | |||
| 227 | - | break | |||
| 228 | - | } | |||
| 229 | - | if desc.Family != "" { | |||
| 230 | - | entry := FontFile{Path: p, Family: desc.Family, Aspect: desc.Aspect, Index: i} | |||
| 231 | - | if raw, tableErr := loader.RawTable(ot.MustNewTag("OS/2")); tableErr == nil && len(raw) >= 58 { | |||
| 232 | - | for j := range entry.UnicodeRanges { | |||
| 233 | - | entry.UnicodeRanges[j] = binary.BigEndian.Uint32(raw[42+j*4:]) | |||
| 234 | - | } | |||
| 235 | - | } | |||
| 236 | - | if reader.exceeded { | |||
| 237 | - | err = errors.Join(err, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i)) | |||
| 238 | - | break | |||
| 239 | - | } | |||
| 240 | - | files = append(files, entry) | |||
| 241 | - | } | |||
| 242 | } | 284 | } | ||
| 243 | - | } else { | |||
| 244 | - | err = errors.Join(err, fmt.Errorf("font %s: %w", p, e)) | |||
| 245 | } | 285 | } | ||
| 246 | - | if closeErr := f.Close(); closeErr != nil { | 286 | + | if reader.exceeded { |
| 247 | - | err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr)) | 287 | + | return files, buffer, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i) |
| 248 | } | 288 | } | ||
| 289 | + | files = append(files, entry) | |||
| 249 | } | 290 | } | ||
| 250 | - | return files, err | 291 | + | return files, buffer, nil |
| 251 | } | 292 | } | ||
| 252 | func (SystemSource) Open(path string) (io.ReadCloser, error) { return os.Open(path) } | 293 | func (SystemSource) Open(path string) (io.ReadCloser, error) { return os.Open(path) } | ||
| 253 | 294 | ||||
| @@ -283,9 +324,17 @@ type Catalog struct { | |||||
| 283 | Faces []*Face | 324 | Faces []*Face | ||
| 284 | source IndexSource | 325 | source IndexSource | ||
| 285 | Diagnostics []error | 326 | Diagnostics []error | ||
| 286 | - | // ranked memoizes candidates per request; a face failure clears it, | 327 | + | // ranked memoizes candidates per request; a face failure or newly |
| 287 | - | // because failed faces are the only catalog state match depends on. | 328 | + | // indexed faces clear it, because match depends only on them. |
| 288 | ranked []rankedFaces | 329 | ranked []rankedFaces | ||
| 330 | + | ||||
| 331 | + | // A LazySource catalog indexes files on demand. Faces stay in path order. | |||
| 332 | + | lazy LazySource | |||
| 333 | + | paths []string | |||
| 334 | + | indexed []bool | |||
| 335 | + | faceAt [][]*Face // faces of each path | |||
| 336 | + | byStem map[string][]int // normalized file-name stem to path positions | |||
| 337 | + | complete bool // every path is indexed | |||
| 289 | } | 338 | } | ||
| 290 | 339 | ||||
| 291 | // maxRanked bounds the candidates memo; distinct font requests per | 340 | // maxRanked bounds the candidates memo; distinct font requests per | ||
| @@ -299,7 +348,108 @@ type rankedFaces struct { | |||||
| 299 | faces []*Face | 348 | faces []*Face | ||
| 300 | } | 349 | } | ||
| 301 | 350 | ||||
| 351 | + | // loadLazy lists the files and indexes only those named after the default | |||
| 352 | + | // families, so a catalog is usable without reading every installed font. | |||
| 353 | + | func loadLazy(source LazySource) (*Catalog, error) { | |||
| 354 | + | paths, err := source.Paths() | |||
| 355 | + | c := &Catalog{source: source, lazy: source, paths: paths, indexed: make([]bool, len(paths)), faceAt: make([][]*Face, len(paths)), byStem: map[string][]int{}} | |||
| 356 | + | if err != nil { | |||
| 357 | + | c.Diagnostics = append(c.Diagnostics, err) | |||
| 358 | + | } | |||
| 359 | + | for i, p := range paths { | |||
| 360 | + | key := fileStem(p) | |||
| 361 | + | c.byStem[key] = append(c.byStem[key], i) | |||
| 362 | + | } | |||
| 363 | + | c.indexStems(genericKeys["sans-serif"]) | |||
| 364 | + | if len(c.Faces) == 0 { | |||
| 365 | + | c.indexAll() | |||
| 366 | + | } | |||
| 367 | + | if len(c.Faces) == 0 { | |||
| 368 | + | return nil, fmt.Errorf("no usable fonts: %w", errors.Join(c.Diagnostics...)) | |||
| 369 | + | } | |||
| 370 | + | return c, nil | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // fileStem is the normalized family part of a font file name: the name up to | |||
| 374 | + | // the first '-', '[', '_' or '.', so NotoSans-Bold.ttf and NotoSans[wght].ttf | |||
| 375 | + | // both give "notosans", the key of the "Noto Sans" family. | |||
| 376 | + | func fileStem(path string) string { | |||
| 377 | + | name := filepath.Base(path) | |||
| 378 | + | if i := strings.IndexAny(name, "-[_."); i >= 0 { | |||
| 379 | + | name = name[:i] | |||
| 380 | + | } | |||
| 381 | + | return font.NormalizeFamily(name) | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | // indexStems indexes the files whose stem is one of keys. | |||
| 385 | + | func (c *Catalog) indexStems(keys []string) { | |||
| 386 | + | var todo []int | |||
| 387 | + | for _, key := range keys { | |||
| 388 | + | for _, i := range c.byStem[key] { | |||
| 389 | + | if !c.indexed[i] && !slices.Contains(todo, i) { | |||
| 390 | + | todo = append(todo, i) | |||
| 391 | + | } | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | c.indexPositions(todo) | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | // indexAll indexes every remaining file, once. | |||
| 398 | + | func (c *Catalog) indexAll() { | |||
| 399 | + | if c.complete { | |||
| 400 | + | return | |||
| 401 | + | } | |||
| 402 | + | var todo []int | |||
| 403 | + | for i, done := range c.indexed { | |||
| 404 | + | if !done { | |||
| 405 | + | todo = append(todo, i) | |||
| 406 | + | } | |||
| 407 | + | } | |||
| 408 | + | c.indexPositions(todo) | |||
| 409 | + | c.complete = true | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | // indexPositions indexes the files at the given path positions and rebuilds | |||
| 413 | + | // Faces in path order, keeping existing faces, so ties resolve as with a full | |||
| 414 | + | // index. | |||
| 415 | + | func (c *Catalog) indexPositions(todo []int) { | |||
| 416 | + | if len(todo) == 0 { | |||
| 417 | + | return | |||
| 418 | + | } | |||
| 419 | + | slices.Sort(todo) | |||
| 420 | + | paths := make([]string, len(todo)) | |||
| 421 | + | for j, i := range todo { | |||
| 422 | + | paths[j] = c.paths[i] | |||
| 423 | + | c.indexed[i] = true | |||
| 424 | + | } | |||
| 425 | + | entries, err := c.lazy.IndexFiles(paths) | |||
| 426 | + | if err != nil { | |||
| 427 | + | c.Diagnostics = append(c.Diagnostics, err) | |||
| 428 | + | } | |||
| 429 | + | position := make(map[string]int, len(todo)) | |||
| 430 | + | for _, i := range todo { | |||
| 431 | + | position[c.paths[i]] = i | |||
| 432 | + | } | |||
| 433 | + | for _, entry := range entries { | |||
| 434 | + | i, ok := position[entry.Path] | |||
| 435 | + | if !ok { | |||
| 436 | + | continue | |||
| 437 | + | } | |||
| 438 | + | c.faceAt[i] = append(c.faceAt[i], c.newFace(entry)) | |||
| 439 | + | } | |||
| 440 | + | c.Faces = slices.Concat(c.faceAt...) | |||
| 441 | + | clear(c.ranked) | |||
| 442 | + | c.ranked = c.ranked[:0] | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func (c *Catalog) newFace(entry FontFile) *Face { | |||
| 446 | + | return &Face{ID: fmt.Sprintf("%s/%d", entry.Path, entry.Index), Family: entry.Family, familyKey: font.NormalizeFamily(entry.Family), Aspect: entry.Aspect, path: entry.Path, index: entry.Index, unicodeRanges: entry.UnicodeRanges, catalog: c} | |||
| 447 | + | } | |||
| 448 | + | ||||
| 302 | func Load(source FontSource) (*Catalog, error) { | 449 | func Load(source FontSource) (*Catalog, error) { | ||
| 450 | + | if lazy, ok := source.(LazySource); ok { | |||
| 451 | + | return loadLazy(lazy) | |||
| 452 | + | } | |||
| 303 | if indexed, ok := source.(IndexSource); ok { | 453 | if indexed, ok := source.(IndexSource); ok { | ||
| 304 | entries, err := indexed.Index() | 454 | entries, err := indexed.Index() | ||
| 305 | c := &Catalog{source: indexed} | 455 | c := &Catalog{source: indexed} | ||
| @@ -307,7 +457,7 @@ func Load(source FontSource) (*Catalog, error) { | |||||
| 307 | c.Diagnostics = append(c.Diagnostics, err) | 457 | c.Diagnostics = append(c.Diagnostics, err) | ||
| 308 | } | 458 | } | ||
| 309 | for _, entry := range entries { | 459 | for _, entry := range entries { | ||
| 310 | - | c.Faces = append(c.Faces, &Face{ID: fmt.Sprintf("%s/%d", entry.Path, entry.Index), Family: entry.Family, familyKey: font.NormalizeFamily(entry.Family), Aspect: entry.Aspect, path: entry.Path, index: entry.Index, unicodeRanges: entry.UnicodeRanges, catalog: c}) | 460 | + | c.Faces = append(c.Faces, c.newFace(entry)) |
| 311 | } | 461 | } | ||
| 312 | if len(c.Faces) == 0 { | 462 | if len(c.Faces) == 0 { | ||
| 313 | return nil, fmt.Errorf("no usable fonts: %w", err) | 463 | return nil, fmt.Errorf("no usable fonts: %w", err) | ||
| @@ -544,6 +694,53 @@ func (c *Catalog) candidates(r Request) []*Face { | |||||
| 544 | } | 694 | } | ||
| 545 | 695 | ||||
| 546 | func (c *Catalog) rank(r Request) []*Face { | 696 | func (c *Catalog) rank(r Request) []*Face { | ||
| 697 | + | if c.lazy != nil && !c.complete { | |||
| 698 | + | if out, ok := c.rankIndexed(r); ok { | |||
| 699 | + | return out | |||
| 700 | + | } | |||
| 701 | + | c.indexAll() | |||
| 702 | + | } | |||
| 703 | + | return c.rankFaces(r) | |||
| 704 | + | } | |||
| 705 | + | ||||
| 706 | + | // rankIndexed indexes the files named after the request's families and ranks | |||
| 707 | + | // them. ok is false when the full index is needed: an explicitly named family | |||
| 708 | + | // matches no file name, or nothing at all matches. | |||
| 709 | + | func (c *Catalog) rankIndexed(r Request) (out []*Face, ok bool) { | |||
| 710 | + | var keys []string | |||
| 711 | + | explicit := map[string]bool{} | |||
| 712 | + | for _, family := range r.Families { | |||
| 713 | + | family = strings.Trim(family, " \t\"'") | |||
| 714 | + | if alias, ok := genericKeys[strings.ToLower(family)]; ok { | |||
| 715 | + | keys = append(keys, alias...) | |||
| 716 | + | } else { | |||
| 717 | + | key := font.NormalizeFamily(family) | |||
| 718 | + | keys = append(keys, key) | |||
| 719 | + | explicit[key] = true | |||
| 720 | + | } | |||
| 721 | + | } | |||
| 722 | + | keys = append(keys, genericKeys["sans-serif"]...) | |||
| 723 | + | keys = append(keys, scriptFallbackKeys...) | |||
| 724 | + | c.indexStems(keys) | |||
| 725 | + | for key := range explicit { | |||
| 726 | + | if !c.hasFamily(key) { | |||
| 727 | + | return nil, false | |||
| 728 | + | } | |||
| 729 | + | } | |||
| 730 | + | out = c.rankFaces(r) | |||
| 731 | + | return out, len(out) > 0 | |||
| 732 | + | } | |||
| 733 | + | ||||
| 734 | + | func (c *Catalog) hasFamily(key string) bool { | |||
| 735 | + | for _, f := range c.Faces { | |||
| 736 | + | if f.familyKey == key { | |||
| 737 | + | return true | |||
| 738 | + | } | |||
| 739 | + | } | |||
| 740 | + | return false | |||
| 741 | + | } | |||
| 742 | + | ||||
| 743 | + | func (c *Catalog) rankFaces(r Request) []*Face { | |||
| 547 | seen := map[*Face]bool{} | 744 | seen := map[*Face]bool{} | ||
| 548 | var out []*Face | 745 | var out []*Face | ||
| 549 | add := func(f *Face) { | 746 | add := func(f *Face) { | ||
| @@ -654,6 +851,10 @@ func namedKeys(r Request) map[string]bool { | |||||
| 654 | // unknown range data remain eligible; loaded or failed state never moves the | 851 | // unknown range data remain eligible; loaded or failed state never moves the | ||
| 655 | // window, so later measures cannot admit a different face. | 852 | // window, so later measures cannot admit a different face. | ||
| 656 | func (c *Catalog) fallbackWindow(text []rune, r Request) []*Face { | 853 | 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 | + | } | |||
| 657 | named := namedKeys(r) | 858 | named := namedKeys(r) | ||
| 658 | out := make([]*Face, 0, maxFallbackFaces) | 859 | out := make([]*Face, 0, maxFallbackFaces) | ||
| 659 | for _, f := range c.Faces { | 860 | for _, f := range c.Faces { | ||