1// Package text implements CPU-side font selection, shaping, measurement and grayscale glyph preparation.
2// Catalog and Engine are not safe for concurrent use (go-text faces cache mutable state).
3package text
4
5import (
6 "bytes"
7 "crypto/sha256"
8 "encoding/binary"
9 "errors"
10 "fmt"
11 "io"
12 "io/fs"
13 "math"
14 "os"
15 "path/filepath"
16 "slices"
17 "sort"
18 "strings"
19 "sync"
20
21 "github.com/bnema/nefergui/internal/bidi"
22 "github.com/go-text/typesetting/font"
23 ot "github.com/go-text/typesetting/font/opentype"
24 "golang.org/x/image/font/sfnt"
25)
26
27// FontSource is the only external boundary: it provides files, not font matching policy.
28// Implementations must return a stable snapshot. Corrupt files are reported, not silently accepted.
29type FontSource interface {
30 Fonts() (map[string][]byte, error)
31}
32
33// DirectorySource reads font files recursively; paths are sorted before parsing.
34type DirectorySource string
35
36func (d DirectorySource) Fonts() (map[string][]byte, error) {
37 out := map[string][]byte{}
38 var problems []error
39 _ = filepath.WalkDir(string(d), func(path string, entry fs.DirEntry, err error) error {
40 if errors.Is(err, os.ErrNotExist) {
41 return nil
42 }
43 if err != nil {
44 problems = append(problems, fmt.Errorf("%s: %w", path, err))
45 return nil
46 }
47 if entry.IsDir() {
48 return nil
49 }
50 if !isFontPath(path) {
51 return nil
52 }
53 b, e := os.ReadFile(path)
54 if e != nil {
55 problems = append(problems, fmt.Errorf("%s: %w", path, e))
56 return nil
57 }
58 out[path] = b
59 return nil
60 })
61 return out, errors.Join(problems...)
62}
63
64func isFontPath(path string) bool {
65 switch strings.ToLower(filepath.Ext(path)) {
66 case ".ttf", ".otf", ".ttc":
67 return true
68 }
69 return false
70}
71
72// SystemSource searches XDG user and system font directories without fontconfig/cgo.
73// Earlier locations win identical family/aspect ties.
74type SystemSource struct{}
75
76// Fonts remains available for callers that explicitly need byte snapshots; Load uses Index instead.
77func (s SystemSource) Fonts() (map[string][]byte, error) {
78 paths, err := s.paths()
79 out := make(map[string][]byte, len(paths))
80 for _, p := range paths {
81 b, e := os.ReadFile(p)
82 if e != nil {
83 err = errors.Join(err, e)
84 } else {
85 out[p] = b
86 }
87 }
88 return out, err
89}
90
91func (SystemSource) paths() ([]string, error) {
92 home, _ := os.UserHomeDir()
93 user := os.Getenv("XDG_DATA_HOME")
94 if user == "" {
95 user = filepath.Join(home, ".local/share")
96 }
97 dirs := []string{filepath.Join(user, "fonts"), filepath.Join(home, ".fonts")}
98 data := os.Getenv("XDG_DATA_DIRS")
99 if data == "" {
100 data = "/usr/local/share:/usr/share"
101 }
102 for _, d := range strings.Split(data, ":") {
103 if d != "" {
104 dirs = append(dirs, filepath.Join(d, "fonts"))
105 }
106 }
107 dirs = append(dirs, "/usr/share/fonts")
108 seen := map[string]bool{}
109 var paths []string
110 var problems []error
111 for _, d := range dirs {
112 resolved, e := filepath.EvalSymlinks(d)
113 if e != nil {
114 if !errors.Is(e, os.ErrNotExist) {
115 problems = append(problems, e)
116 }
117 continue
118 }
119 if seen[resolved] {
120 continue
121 }
122 seen[resolved] = true
123 var local []string
124 _ = filepath.WalkDir(resolved, func(p string, entry fs.DirEntry, err error) error {
125 if err != nil {
126 problems = append(problems, err)
127 return nil
128 }
129 if entry.IsDir() {
130 return nil
131 }
132 if isFontPath(p) {
133 local = append(local, p)
134 }
135 return nil
136 })
137 sort.Strings(local)
138 for _, p := range local {
139 resolvedPath, e := filepath.EvalSymlinks(p)
140 if e != nil {
141 problems = append(problems, e)
142 continue
143 }
144 if !seen[resolvedPath] {
145 seen[resolvedPath] = true
146 paths = append(paths, p)
147 }
148 }
149 }
150 return paths, errors.Join(problems...)
151}
152
153// IndexSource supplies metadata without retaining font bytes. Open is invoked only
154// when a face is selected. Sources must keep paths stable during the catalog lifetime.
155type IndexSource interface {
156 Index() ([]FontFile, error)
157 Open(path string) (io.ReadCloser, error)
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.
163type LazySource interface {
164 FontSource
165 IndexSource
166 Paths() ([]string, error)
167 IndexFiles(paths []string) ([]FontFile, error)
168}
169
170type FontFile struct {
171 Path string
172 Family string
173 Aspect font.Aspect
174 Index int
175 UnicodeRanges [4]uint32 // OS/2 ulUnicodeRange; zero means unknown
176}
177
178const maxFontBytes = 64 << 20
179const maxMetadataBytes = 1 << 20
180
181// metadataReader limits total metadata I/O. Font files larger than the
182// selected-face cap are excluded before go-text examines their tables.
183type metadataReader struct {
184 *os.File
185 remaining int64
186 exceeded bool
187}
188
189func (m *metadataReader) ReadAt(p []byte, off int64) (int, error) {
190 if len(p) > maxMetadataBytes || int64(len(p)) > m.remaining {
191 m.exceeded = true
192 return 0, errors.New("font metadata limit exceeded")
193 }
194 m.remaining -= int64(len(p))
195 return m.File.ReadAt(p, off)
196}
197func (m *metadataReader) Read(p []byte) (int, error) {
198 if len(p) > maxMetadataBytes || int64(len(p)) > m.remaining {
199 m.exceeded = true
200 return 0, errors.New("font metadata limit exceeded")
201 }
202 n, err := m.File.Read(p)
203 m.remaining -= int64(n)
204 return n, err
205}
206
207func (s SystemSource) Index() ([]FontFile, error) {
208 paths, err := s.paths()
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.
214func (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.
218const 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.
222func (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] = safeIndexFile(paths[i], buffer)
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// 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.
247func 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
256// indexFile reads the metadata of every face in the file at p. buffer is
257// reusable scratch storage.
258func indexFile(p string, buffer []byte) (files []FontFile, _ []byte, err error) {
259 f, e := os.Open(p)
260 if e != nil {
261 return nil, buffer, fmt.Errorf("font %s: %w", p, e)
262 }
263 defer func() {
264 if closeErr := f.Close(); closeErr != nil {
265 err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr))
266 }
267 }()
268 info, statErr := f.Stat()
269 if statErr != nil || info.Size() > maxFontBytes {
270 if statErr == nil {
271 statErr = fmt.Errorf("font exceeds %d byte limit", maxFontBytes)
272 }
273 return nil, buffer, fmt.Errorf("font %s: %w", p, statErr)
274 }
275 reader := &metadataReader{File: f, remaining: maxMetadataBytes}
276 loaders, e := ot.NewLoaders(reader)
277 if e != nil {
278 return nil, buffer, fmt.Errorf("font %s: %w", p, e)
279 }
280 for i, loader := range loaders {
281 var desc font.Description
282 // Describe ignores table read errors; a failed bounded read yields no
283 // usable family and is excluded from the index.
284 desc, buffer = font.Describe(loader, buffer)
285 if reader.exceeded {
286 return files, buffer, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i)
287 }
288 if desc.Family == "" {
289 continue
290 }
291 entry := FontFile{Path: p, Family: desc.Family, Aspect: desc.Aspect, Index: i}
292 if raw, tableErr := loader.RawTable(ot.MustNewTag("OS/2")); tableErr == nil && len(raw) >= 58 {
293 for j := range entry.UnicodeRanges {
294 entry.UnicodeRanges[j] = binary.BigEndian.Uint32(raw[42+j*4:])
295 }
296 }
297 if reader.exceeded {
298 return files, buffer, fmt.Errorf("font %s face %d: metadata limit exceeded", p, i)
299 }
300 files = append(files, entry)
301 }
302 return files, buffer, nil
303}
304func (SystemSource) Open(path string) (io.ReadCloser, error) { return os.Open(path) }
305
306// Face is a parsed font, paired with its bytes for static TrueType rasterization.
307type Face struct {
308 ID string
309 Family string
310 Aspect font.Aspect
311 Shape *font.Face
312 Raster *sfnt.Font
313 Data []byte
314 Variations []font.Variation // immutable after construction; use WithVariations to clone
315 Variable bool // fvar table present
316 path string
317 index int
318 unicodeRanges [4]uint32
319 catalog *Catalog
320 bad bool
321 familyKey string // font.NormalizeFamily(Family), computed once by Load
322}
323
324// WithVariations clones a face, preserving the font bytes and sfnt handle while
325// configuring go-text's variation-aware outline and shaping caches independently.
326func (f *Face) WithVariations(v []font.Variation) *Face {
327 clone := *f
328 clone.Shape = font.NewFace(f.Shape.Font)
329 clone.Shape.SetVariations(v)
330 clone.Variations = append([]font.Variation(nil), v...)
331 return &clone
332}
333
334type Catalog struct {
335 Faces []*Face
336 source IndexSource
337 Diagnostics []error
338 // ranked memoizes candidates per request; a face failure or newly
339 // indexed faces clear it, because match depends only on them.
340 ranked []rankedFaces
341
342 // A LazySource catalog indexes files on demand. Faces stay in path order.
343 lazy LazySource
344 paths []string
345 indexed []bool
346 faceAt [][]*Face // faces of each path
347 byStem map[string][]int // normalized file-name stem to path positions
348 complete bool // every path is indexed
349}
350
351// maxRanked bounds the candidates memo; distinct font requests per
352// application are few.
353const maxRanked = 64
354
355type rankedFaces struct {
356 families []string
357 weight, stretch float32
358 italic bool
359 faces []*Face
360}
361
362// loadLazy lists the files and indexes only those named after the default
363// families, so a catalog is usable without reading every installed font.
364func loadLazy(source LazySource) (*Catalog, error) {
365 paths, err := source.Paths()
366 c := &Catalog{source: source, lazy: source, paths: paths, indexed: make([]bool, len(paths)), faceAt: make([][]*Face, len(paths)), byStem: map[string][]int{}}
367 if err != nil {
368 c.Diagnostics = append(c.Diagnostics, err)
369 }
370 for i, p := range paths {
371 key := fileStem(p)
372 c.byStem[key] = append(c.byStem[key], i)
373 }
374 c.indexStems(genericKeys["sans-serif"])
375 if len(c.Faces) == 0 {
376 c.indexAll()
377 }
378 if len(c.Faces) == 0 {
379 return nil, fmt.Errorf("no usable fonts: %w", errors.Join(c.Diagnostics...))
380 }
381 return c, nil
382}
383
384// fileStem is the normalized family part of a font file name: the name up to
385// the first '-', '[', '_' or '.', so NotoSans-Bold.ttf and NotoSans[wght].ttf
386// both give "notosans", the key of the "Noto Sans" family.
387func fileStem(path string) string {
388 name := filepath.Base(path)
389 if i := strings.IndexAny(name, "-[_."); i >= 0 {
390 name = name[:i]
391 }
392 return font.NormalizeFamily(name)
393}
394
395// styleWords may follow a family name inside a file-name stem, as in
396// DejaVuSansCondensed.ttf, whose family is "DejaVu Sans".
397var 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.
403func 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.
425func (c *Catalog) indexStems(keys []string) {
426 var todo []int
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] {
433 todo = append(todo, i)
434 }
435 }
436 }
437 c.indexPositions(todo)
438}
439
440// indexAll indexes every remaining file of a lazy catalog, once. It reports
441// whether it indexed anything.
442func (c *Catalog) indexAll() bool {
443 if c.lazy == nil || c.complete {
444 return false
445 }
446 var todo []int
447 for i, done := range c.indexed {
448 if !done {
449 todo = append(todo, i)
450 }
451 }
452 c.indexPositions(todo)
453 c.complete = true
454 return true
455}
456
457// indexPositions indexes the files at the given path positions and rebuilds
458// Faces in path order, keeping existing faces, so ties resolve as with a full
459// index.
460func (c *Catalog) indexPositions(todo []int) {
461 if len(todo) == 0 {
462 return
463 }
464 slices.Sort(todo)
465 paths := make([]string, len(todo))
466 for j, i := range todo {
467 paths[j] = c.paths[i]
468 c.indexed[i] = true
469 }
470 entries, err := c.lazy.IndexFiles(paths)
471 if err != nil {
472 c.Diagnostics = append(c.Diagnostics, err)
473 }
474 position := make(map[string]int, len(todo))
475 for _, i := range todo {
476 position[c.paths[i]] = i
477 }
478 for _, entry := range entries {
479 i, ok := position[entry.Path]
480 if !ok {
481 c.Diagnostics = append(c.Diagnostics, fmt.Errorf("font index entry for unrequested path %s", entry.Path))
482 continue
483 }
484 c.faceAt[i] = append(c.faceAt[i], c.newFace(entry))
485 }
486 c.Faces = slices.Concat(c.faceAt...)
487 clear(c.ranked)
488 c.ranked = c.ranked[:0]
489}
490
491func (c *Catalog) newFace(entry FontFile) *Face {
492 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}
493}
494
495func Load(source FontSource) (*Catalog, error) {
496 if lazy, ok := source.(LazySource); ok {
497 return loadLazy(lazy)
498 }
499 if indexed, ok := source.(IndexSource); ok {
500 entries, err := indexed.Index()
501 c := &Catalog{source: indexed}
502 if err != nil {
503 c.Diagnostics = append(c.Diagnostics, err)
504 }
505 for _, entry := range entries {
506 c.Faces = append(c.Faces, c.newFace(entry))
507 }
508 if len(c.Faces) == 0 {
509 return nil, fmt.Errorf("no usable fonts: %w", err)
510 }
511 return c, nil
512 }
513
514 files, err := source.Fonts()
515 var problems []error
516 if err != nil {
517 problems = append(problems, err)
518 }
519 names := make([]string, 0, len(files))
520 for n := range files {
521 names = append(names, n)
522 }
523 sort.Strings(names)
524 c := &Catalog{}
525 for _, name := range names {
526 data := files[name]
527 loaders, e := ot.NewLoaders(bytes.NewReader(data))
528 if e != nil {
529 problems = append(problems, fmt.Errorf("font %s: %w", name, e))
530 continue
531 }
532 collection, e := sfnt.ParseCollection(data)
533 if e != nil {
534 problems = append(problems, fmt.Errorf("raster font %s: %w", name, e))
535 continue
536 }
537 if collection.NumFonts() != len(loaders) {
538 problems = append(problems, fmt.Errorf("font %s: mismatched collection length", name))
539 continue
540 }
541 sum := sha256.Sum256(data)
542 for index, loader := range loaders {
543 parsed, err := font.NewFont(loader)
544 if err != nil {
545 problems = append(problems, fmt.Errorf("font %s face %d: %w", name, index, err))
546 continue
547 }
548 raster, err := collection.Font(index)
549 if err != nil {
550 problems = append(problems, fmt.Errorf("raster font %s face %d: %w", name, index, err))
551 continue
552 }
553 shape := font.NewFace(parsed)
554 raw, tableErr := loader.RawTable(ot.MustNewTag("fvar"))
555 desc := shape.Describe()
556 id := fmt.Sprintf("%x/%d", sum, index)
557 c.Faces = append(c.Faces, &Face{ID: id, Family: desc.Family, familyKey: font.NormalizeFamily(desc.Family), Aspect: desc.Aspect, Shape: shape, Raster: raster, Data: data, Variable: tableErr == nil && len(raw) > 0})
558 }
559 }
560 c.Diagnostics = problems
561 if len(c.Faces) == 0 {
562 return nil, fmt.Errorf("no usable fonts: %w", errors.Join(problems...))
563 }
564 return c, nil
565}
566
567// ready loads a selected face once. A failed face is permanently excluded from matching.
568func (f *Face) ready() bool {
569 if f == nil || f.bad {
570 return false
571 }
572 if f.Shape != nil {
573 return true
574 }
575 if f.catalog == nil {
576 return false
577 }
578 reader, err := f.catalog.source.Open(f.path)
579 if err != nil {
580 f.fail(err)
581 return false
582 }
583 data, err := io.ReadAll(io.LimitReader(reader, maxFontBytes+1))
584 if closeErr := reader.Close(); closeErr != nil {
585 f.catalog.Diagnostics = append(f.catalog.Diagnostics, fmt.Errorf("font %s close: %w", f.path, closeErr))
586 }
587 if err == nil && len(data) > maxFontBytes {
588 err = fmt.Errorf("font exceeds %d byte limit", maxFontBytes)
589 }
590 if err != nil {
591 f.fail(err)
592 return false
593 }
594 loaders, err := ot.NewLoaders(bytes.NewReader(data))
595 if err != nil {
596 f.fail(err)
597 return false
598 }
599 collection, err := sfnt.ParseCollection(data)
600 if err != nil {
601 f.fail(err)
602 return false
603 }
604 if f.index >= len(loaders) || f.index >= collection.NumFonts() {
605 f.fail(errors.New("face index out of range"))
606 return false
607 }
608 parsed, err := font.NewFont(loaders[f.index])
609 if err != nil {
610 f.fail(err)
611 return false
612 }
613 raster, err := collection.Font(f.index)
614 if err != nil {
615 f.fail(err)
616 return false
617 }
618 raw, tableErr := loaders[f.index].RawTable(ot.MustNewTag("fvar"))
619 f.Data, f.Shape, f.Raster = data, font.NewFace(parsed), raster
620 f.Variable = tableErr == nil && len(raw) > 0
621 return true
622}
623func (f *Face) fail(err error) {
624 f.bad = true
625 if f.catalog == nil {
626 return
627 }
628 clear(f.catalog.ranked)
629 f.catalog.ranked = f.catalog.ranked[:0]
630 f.catalog.Diagnostics = append(f.catalog.Diagnostics, fmt.Errorf("font %s face %d: %w", f.path, f.index, err))
631}
632
633// Request specifies CSS font selection. Weight defaults to 400, Stretch to 1, Style normal.
634type Request struct {
635 Direction bidi.BaseDirection // Auto (default), LTR, or RTL
636 Families []string
637 Weight float32
638 Stretch float32
639 Italic bool
640 Size float64
641 // LetterSpacing is the signed logical-pixel spacing between shaped clusters.
642 LetterSpacing float64
643 Variations []font.Variation
644}
645
646// match takes a family already normalized with font.NormalizeFamily.
647func (c *Catalog) match(family string, r Request) *Face {
648 var best *Face
649 score := math.Inf(1)
650 for _, f := range c.Faces {
651 if f.familyKey != family || f.bad {
652 continue
653 }
654 stretch := r.Stretch
655 if stretch == 0 {
656 stretch = 1
657 }
658 weight := r.Weight
659 if weight == 0 {
660 weight = 400
661 }
662 // CSS subset: stretch first, style second, then nearest weight. On ties use source path order.
663 s := math.Abs(float64(f.Aspect.Stretch)-float64(stretch)) * 1e6
664 if (f.Aspect.Style == font.StyleItalic) != r.Italic {
665 s += 1e4
666 }
667 diff := float64(f.Aspect.Weight) - float64(weight)
668 if weight >= 400 && weight <= 500 {
669 // CSS: [target..500], then below target, then above 500.
670 switch {
671 case diff >= 0 && float64(f.Aspect.Weight) <= 500:
672 s += diff
673 case diff < 0:
674 s += 1000 - diff
675 default:
676 s += 2000 + diff
677 }
678 } else if weight < 400 {
679 if diff > 0 {
680 s += 1000 + diff
681 } else {
682 s -= diff
683 }
684 } else {
685 if diff < 0 {
686 s += 1000 - diff
687 } else {
688 s += diff
689 }
690 }
691 if s < score {
692 best = f
693 score = s
694 }
695 }
696 return best
697}
698
699// Generic family aliases are ordered, hard-coded and independent of installed fontconfig rules.
700var generics = map[string][]string{
701 "sans-serif": {"Noto Sans", "Adwaita Sans", "DejaVu Sans"},
702 "system-ui": {"Adwaita Sans", "Noto Sans", "DejaVu Sans"},
703 "serif": {"Noto Serif", "DejaVu Serif"},
704 "monospace": {"Noto Sans Mono", "IBM Plex Mono", "DejaVu Sans Mono"},
705}
706
707// Normalized once so matching never allocates per face.
708var genericKeys = normalizeFamilies(generics)
709var scriptFallbackKeys = normalizeList([]string{"Noto Sans Arabic", "Noto Sans Devanagari", "IBM Plex Sans JP", "Noto Sans Symbols 2"})
710
711func normalizeList(names []string) []string {
712 out := make([]string, len(names))
713 for i, n := range names {
714 out[i] = font.NormalizeFamily(n)
715 }
716 return out
717}
718func normalizeFamilies(m map[string][]string) map[string][]string {
719 out := make(map[string][]string, len(m))
720 for k, v := range m {
721 out[k] = normalizeList(v)
722 }
723 return out
724}
725
726// candidates ranks the faces a request may use. The result is shared: callers
727// must not modify it.
728func (c *Catalog) candidates(r Request) []*Face {
729 for _, e := range c.ranked {
730 if e.weight == r.Weight && e.stretch == r.Stretch && e.italic == r.Italic && slices.Equal(e.families, r.Families) {
731 return e.faces
732 }
733 }
734 out := c.rank(r)
735 out = out[:len(out):len(out)] // an append by a caller cannot write into the memo
736 if len(c.ranked) < maxRanked {
737 c.ranked = append(c.ranked, rankedFaces{families: slices.Clone(r.Families), weight: r.Weight, stretch: r.Stretch, italic: r.Italic, faces: out})
738 }
739 return out
740}
741
742func (c *Catalog) rank(r Request) []*Face {
743 if c.lazy != nil && !c.complete {
744 if out, ok := c.rankIndexed(r); ok {
745 return out
746 }
747 c.indexAll()
748 }
749 return c.rankFaces(r)
750}
751
752// rankIndexed indexes the files named after the request's families and ranks
753// them. ok is false when the full index is needed: an explicitly named family
754// matches no file name, or nothing at all matches.
755func (c *Catalog) rankIndexed(r Request) (out []*Face, ok bool) {
756 var keys []string
757 explicit := map[string]bool{}
758 for _, family := range r.Families {
759 family = strings.Trim(family, " \t\"'")
760 if alias, ok := genericKeys[strings.ToLower(family)]; ok {
761 keys = append(keys, alias...)
762 } else {
763 key := font.NormalizeFamily(family)
764 keys = append(keys, key)
765 explicit[key] = true
766 }
767 }
768 keys = append(keys, genericKeys["sans-serif"]...)
769 keys = append(keys, scriptFallbackKeys...)
770 c.indexStems(keys)
771 for key := range explicit {
772 if !c.hasFamily(key) {
773 return nil, false
774 }
775 }
776 out = c.rankFaces(r)
777 return out, len(out) > 0
778}
779
780func (c *Catalog) hasFamily(key string) bool {
781 for _, f := range c.Faces {
782 if f.familyKey == key {
783 return true
784 }
785 }
786 return false
787}
788
789func (c *Catalog) rankFaces(r Request) []*Face {
790 seen := map[*Face]bool{}
791 var out []*Face
792 add := func(f *Face) {
793 if f != nil && !seen[f] {
794 out = append(out, f)
795 seen[f] = true
796 }
797 }
798 for _, family := range r.Families {
799 family = strings.Trim(family, " \t\"'")
800 if alias, ok := genericKeys[strings.ToLower(family)]; ok {
801 for _, a := range alias {
802 add(c.match(a, r))
803 }
804 } else {
805 add(c.match(font.NormalizeFamily(family), r))
806 }
807 }
808 for _, a := range genericKeys["sans-serif"] {
809 add(c.match(a, r))
810 }
811 // Script fallback: deterministically prefer known Unicode families, never a color bitmap font.
812 for _, a := range scriptFallbackKeys {
813 add(c.match(a, r))
814 }
815 if c.source == nil {
816 for _, f := range c.Faces {
817 add(f)
818 }
819 }
820
821 return out
822}
823
824const maxFallbackFaces = 16
825
826// The OS/2 Unicode range bits are coarse hints, not glyph coverage. Unmapped
827// blocks and absent ranges remain eligible, so NominalGlyph decides coverage.
828func unicodeRangeBit(r rune) int {
829 switch {
830 case r <= 0x007f:
831 return 0 // Basic Latin
832 case r <= 0x00ff:
833 return 1 // Latin-1
834 case r >= 0x0100 && r <= 0x017f:
835 return 2
836 case r >= 0x0180 && r <= 0x024f:
837 return 3
838 case r >= 0x0300 && r <= 0x036f:
839 return 6
840 case r >= 0x0370 && r <= 0x03ff:
841 return 7
842 case r >= 0x0400 && r <= 0x052f:
843 return 9
844 case r >= 0x0590 && r <= 0x05ff:
845 return 11
846 case r >= 0x0600 && r <= 0x06ff:
847 return 13
848 case r >= 0x0900 && r <= 0x097f:
849 return 15
850 case r >= 0x3040 && r <= 0x309f:
851 return 49
852 case r >= 0x30a0 && r <= 0x30ff:
853 return 50
854 case r >= 0x4e00 && r <= 0x9fff:
855 return 59
856 case r >= 0xac00 && r <= 0xd7af:
857 return 56
858 case r >= 0x1f300 && r <= 0x1faff:
859 return 57 // non-BMP (OS/2 bit 57)
860 default:
861 return -1
862 }
863}
864func (f *Face) mayCover(r rune) bool {
865 bits := f.unicodeRanges
866 if bits == [4]uint32{} {
867 return true
868 }
869 bit := unicodeRangeBit(r)
870 return bit < 0 || bits[bit/32]&(1<<uint(bit%32)) != 0
871}
872
873// namedKeys lists every family key the request can name, in any aspect, so
874// the fallback window excludes them whether or not their faces loaded.
875func namedKeys(r Request) map[string]bool {
876 keys := map[string]bool{}
877 for _, family := range r.Families {
878 family = strings.Trim(family, " \t\"'")
879 if alias, ok := genericKeys[strings.ToLower(family)]; ok {
880 for _, a := range alias {
881 keys[a] = true
882 }
883 } else {
884 keys[font.NormalizeFamily(family)] = true
885 }
886 }
887 for _, a := range genericKeys["sans-serif"] {
888 keys[a] = true
889 }
890 for _, a := range scriptFallbackKeys {
891 keys[a] = true
892 }
893 return keys
894}
895
896// fallbackWindow is fixed by the request, text and metadata index. Faces with
897// unknown range data remain eligible; loaded or failed state never moves the
898// window, so later measures cannot admit a different face.
899func (c *Catalog) fallbackWindow(text []rune, r Request) []*Face {
900 c.indexAll() // any installed face may cover a missing glyph
901 named := namedKeys(r)
902 out := make([]*Face, 0, maxFallbackFaces)
903 for _, f := range c.Faces {
904 if named[f.familyKey] {
905 continue
906 }
907 for _, r := range text {
908 if r != 0x200d && (r < 0xfe00 || r > 0xfe0f) && f.mayCover(r) {
909 out = append(out, f)
910 break
911 }
912 }
913 if len(out) == maxFallbackFaces {
914 break
915 }
916 }
917 return out
918}
919
920// primary is the first loadable named candidate, else the first loadable face
921// of the fallback window. A failed face is marked bad, so each step advances.
922func (c *Catalog) primary(r Request) *Face {
923 for {
924 candidates := c.candidates(r)
925 if len(candidates) == 0 {
926 break
927 }
928 if candidates[0].ready() {
929 return candidates[0]
930 }
931 if !candidates[0].bad {
932 return nil
933 }
934 }
935 for _, f := range c.fallbackWindow([]rune{'A'}, r) {
936 if f.ready() {
937 return f
938 }
939 }
940 return nil
941}