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
20 "github.com/bnema/nefergui/internal/bidi"
21 "github.com/go-text/typesetting/font"
22 ot "github.com/go-text/typesetting/font/opentype"
23 "golang.org/x/image/font/sfnt"
24)
25
26// FontSource is the only external boundary: it provides files, not font matching policy.
27// Implementations must return a stable snapshot. Corrupt files are reported, not silently accepted.
28type FontSource interface {
29 Fonts() (map[string][]byte, error)
30}
31
32// DirectorySource reads font files recursively; paths are sorted before parsing.
33type DirectorySource string
34
35func (d DirectorySource) Fonts() (map[string][]byte, error) {
36 out := map[string][]byte{}
37 var problems []error
38 _ = filepath.WalkDir(string(d), func(path string, entry fs.DirEntry, err error) error {
39 if errors.Is(err, os.ErrNotExist) {
40 return nil
41 }
42 if err != nil {
43 problems = append(problems, fmt.Errorf("%s: %w", path, err))
44 return nil
45 }
46 if entry.IsDir() {
47 return nil
48 }
49 if !isFontPath(path) {
50 return nil
51 }
52 b, e := os.ReadFile(path)
53 if e != nil {
54 problems = append(problems, fmt.Errorf("%s: %w", path, e))
55 return nil
56 }
57 out[path] = b
58 return nil
59 })
60 return out, errors.Join(problems...)
61}
62
63func isFontPath(path string) bool {
64 switch strings.ToLower(filepath.Ext(path)) {
65 case ".ttf", ".otf", ".ttc":
66 return true
67 }
68 return false
69}
70
71// SystemSource searches XDG user and system font directories without fontconfig/cgo.
72// Earlier locations win identical family/aspect ties.
73type SystemSource struct{}
74
75// Fonts remains available for callers that explicitly need byte snapshots; Load uses Index instead.
76func (s SystemSource) Fonts() (map[string][]byte, error) {
77 paths, err := s.paths()
78 out := make(map[string][]byte, len(paths))
79 for _, p := range paths {
80 b, e := os.ReadFile(p)
81 if e != nil {
82 err = errors.Join(err, e)
83 } else {
84 out[p] = b
85 }
86 }
87 return out, err
88}
89
90func (SystemSource) paths() ([]string, error) {
91 home, _ := os.UserHomeDir()
92 user := os.Getenv("XDG_DATA_HOME")
93 if user == "" {
94 user = filepath.Join(home, ".local/share")
95 }
96 dirs := []string{filepath.Join(user, "fonts"), filepath.Join(home, ".fonts")}
97 data := os.Getenv("XDG_DATA_DIRS")
98 if data == "" {
99 data = "/usr/local/share:/usr/share"
100 }
101 for _, d := range strings.Split(data, ":") {
102 if d != "" {
103 dirs = append(dirs, filepath.Join(d, "fonts"))
104 }
105 }
106 dirs = append(dirs, "/usr/share/fonts")
107 seen := map[string]bool{}
108 var paths []string
109 var problems []error
110 for _, d := range dirs {
111 resolved, e := filepath.EvalSymlinks(d)
112 if e != nil {
113 if !errors.Is(e, os.ErrNotExist) {
114 problems = append(problems, e)
115 }
116 continue
117 }
118 if seen[resolved] {
119 continue
120 }
121 seen[resolved] = true
122 var local []string
123 _ = filepath.WalkDir(resolved, func(p string, entry fs.DirEntry, err error) error {
124 if err != nil {
125 problems = append(problems, err)
126 return nil
127 }
128 if entry.IsDir() {
129 return nil
130 }
131 if isFontPath(p) {
132 local = append(local, p)
133 }
134 return nil
135 })
136 sort.Strings(local)
137 for _, p := range local {
138 resolvedPath, e := filepath.EvalSymlinks(p)
139 if e != nil {
140 problems = append(problems, e)
141 continue
142 }
143 if !seen[resolvedPath] {
144 seen[resolvedPath] = true
145 paths = append(paths, p)
146 }
147 }
148 }
149 return paths, errors.Join(problems...)
150}
151
152// IndexSource supplies metadata without retaining font bytes. Open is invoked only
153// when a face is selected. Sources must keep paths stable during the catalog lifetime.
154type IndexSource interface {
155 Index() ([]FontFile, error)
156 Open(path string) (io.ReadCloser, error)
157}
158
159type FontFile struct {
160 Path string
161 Family string
162 Aspect font.Aspect
163 Index int
164 UnicodeRanges [4]uint32 // OS/2 ulUnicodeRange; zero means unknown
165}
166
167const maxFontBytes = 64 << 20
168const maxMetadataBytes = 1 << 20
169
170// metadataReader limits total metadata I/O. Font files larger than the
171// selected-face cap are excluded before go-text examines their tables.
172type metadataReader struct {
173 *os.File
174 remaining int64
175 exceeded bool
176}
177
178func (m *metadataReader) ReadAt(p []byte, off int64) (int, error) {
179 if len(p) > maxMetadataBytes || int64(len(p)) > m.remaining {
180 m.exceeded = true
181 return 0, errors.New("font metadata limit exceeded")
182 }
183 m.remaining -= int64(len(p))
184 return m.File.ReadAt(p, off)
185}
186func (m *metadataReader) Read(p []byte) (int, error) {
187 if len(p) > maxMetadataBytes || int64(len(p)) > m.remaining {
188 m.exceeded = true
189 return 0, errors.New("font metadata limit exceeded")
190 }
191 n, err := m.File.Read(p)
192 m.remaining -= int64(n)
193 return n, err
194}
195
196func (s SystemSource) Index() ([]FontFile, error) {
197 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))
214 }
215 continue
216 }
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 }
242 }
243 } else {
244 err = errors.Join(err, fmt.Errorf("font %s: %w", p, e))
245 }
246 if closeErr := f.Close(); closeErr != nil {
247 err = errors.Join(err, fmt.Errorf("font %s close: %w", p, closeErr))
248 }
249 }
250 return files, err
251}
252func (SystemSource) Open(path string) (io.ReadCloser, error) { return os.Open(path) }
253
254// Face is a parsed font, paired with its bytes for static TrueType rasterization.
255type Face struct {
256 ID string
257 Family string
258 Aspect font.Aspect
259 Shape *font.Face
260 Raster *sfnt.Font
261 Data []byte
262 Variations []font.Variation // immutable after construction; use WithVariations to clone
263 Variable bool // fvar table present
264 path string
265 index int
266 unicodeRanges [4]uint32
267 catalog *Catalog
268 bad bool
269 familyKey string // font.NormalizeFamily(Family), computed once by Load
270}
271
272// WithVariations clones a face, preserving the font bytes and sfnt handle while
273// configuring go-text's variation-aware outline and shaping caches independently.
274func (f *Face) WithVariations(v []font.Variation) *Face {
275 clone := *f
276 clone.Shape = font.NewFace(f.Shape.Font)
277 clone.Shape.SetVariations(v)
278 clone.Variations = append([]font.Variation(nil), v...)
279 return &clone
280}
281
282type Catalog struct {
283 Faces []*Face
284 source IndexSource
285 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.
288 ranked []rankedFaces
289}
290
291// maxRanked bounds the candidates memo; distinct font requests per
292// application are few.
293const maxRanked = 64
294
295type rankedFaces struct {
296 families []string
297 weight, stretch float32
298 italic bool
299 faces []*Face
300}
301
302func Load(source FontSource) (*Catalog, error) {
303 if indexed, ok := source.(IndexSource); ok {
304 entries, err := indexed.Index()
305 c := &Catalog{source: indexed}
306 if err != nil {
307 c.Diagnostics = append(c.Diagnostics, err)
308 }
309 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})
311 }
312 if len(c.Faces) == 0 {
313 return nil, fmt.Errorf("no usable fonts: %w", err)
314 }
315 return c, nil
316 }
317
318 files, err := source.Fonts()
319 var problems []error
320 if err != nil {
321 problems = append(problems, err)
322 }
323 names := make([]string, 0, len(files))
324 for n := range files {
325 names = append(names, n)
326 }
327 sort.Strings(names)
328 c := &Catalog{}
329 for _, name := range names {
330 data := files[name]
331 loaders, e := ot.NewLoaders(bytes.NewReader(data))
332 if e != nil {
333 problems = append(problems, fmt.Errorf("font %s: %w", name, e))
334 continue
335 }
336 collection, e := sfnt.ParseCollection(data)
337 if e != nil {
338 problems = append(problems, fmt.Errorf("raster font %s: %w", name, e))
339 continue
340 }
341 if collection.NumFonts() != len(loaders) {
342 problems = append(problems, fmt.Errorf("font %s: mismatched collection length", name))
343 continue
344 }
345 sum := sha256.Sum256(data)
346 for index, loader := range loaders {
347 parsed, err := font.NewFont(loader)
348 if err != nil {
349 problems = append(problems, fmt.Errorf("font %s face %d: %w", name, index, err))
350 continue
351 }
352 raster, err := collection.Font(index)
353 if err != nil {
354 problems = append(problems, fmt.Errorf("raster font %s face %d: %w", name, index, err))
355 continue
356 }
357 shape := font.NewFace(parsed)
358 raw, tableErr := loader.RawTable(ot.MustNewTag("fvar"))
359 desc := shape.Describe()
360 id := fmt.Sprintf("%x/%d", sum, index)
361 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})
362 }
363 }
364 c.Diagnostics = problems
365 if len(c.Faces) == 0 {
366 return nil, fmt.Errorf("no usable fonts: %w", errors.Join(problems...))
367 }
368 return c, nil
369}
370
371// ready loads a selected face once. A failed face is permanently excluded from matching.
372func (f *Face) ready() bool {
373 if f == nil || f.bad {
374 return false
375 }
376 if f.Shape != nil {
377 return true
378 }
379 if f.catalog == nil {
380 return false
381 }
382 reader, err := f.catalog.source.Open(f.path)
383 if err != nil {
384 f.fail(err)
385 return false
386 }
387 data, err := io.ReadAll(io.LimitReader(reader, maxFontBytes+1))
388 if closeErr := reader.Close(); closeErr != nil {
389 f.catalog.Diagnostics = append(f.catalog.Diagnostics, fmt.Errorf("font %s close: %w", f.path, closeErr))
390 }
391 if err == nil && len(data) > maxFontBytes {
392 err = fmt.Errorf("font exceeds %d byte limit", maxFontBytes)
393 }
394 if err != nil {
395 f.fail(err)
396 return false
397 }
398 loaders, err := ot.NewLoaders(bytes.NewReader(data))
399 if err != nil {
400 f.fail(err)
401 return false
402 }
403 collection, err := sfnt.ParseCollection(data)
404 if err != nil {
405 f.fail(err)
406 return false
407 }
408 if f.index >= len(loaders) || f.index >= collection.NumFonts() {
409 f.fail(errors.New("face index out of range"))
410 return false
411 }
412 parsed, err := font.NewFont(loaders[f.index])
413 if err != nil {
414 f.fail(err)
415 return false
416 }
417 raster, err := collection.Font(f.index)
418 if err != nil {
419 f.fail(err)
420 return false
421 }
422 raw, tableErr := loaders[f.index].RawTable(ot.MustNewTag("fvar"))
423 f.Data, f.Shape, f.Raster = data, font.NewFace(parsed), raster
424 f.Variable = tableErr == nil && len(raw) > 0
425 return true
426}
427func (f *Face) fail(err error) {
428 f.bad = true
429 if f.catalog == nil {
430 return
431 }
432 clear(f.catalog.ranked)
433 f.catalog.ranked = f.catalog.ranked[:0]
434 f.catalog.Diagnostics = append(f.catalog.Diagnostics, fmt.Errorf("font %s face %d: %w", f.path, f.index, err))
435}
436
437// Request specifies CSS font selection. Weight defaults to 400, Stretch to 1, Style normal.
438type Request struct {
439 Direction bidi.BaseDirection // Auto (default), LTR, or RTL
440 Families []string
441 Weight float32
442 Stretch float32
443 Italic bool
444 Size float64
445 // LetterSpacing is the signed logical-pixel spacing between shaped clusters.
446 LetterSpacing float64
447 Variations []font.Variation
448}
449
450// match takes a family already normalized with font.NormalizeFamily.
451func (c *Catalog) match(family string, r Request) *Face {
452 var best *Face
453 score := math.Inf(1)
454 for _, f := range c.Faces {
455 if f.familyKey != family || f.bad {
456 continue
457 }
458 stretch := r.Stretch
459 if stretch == 0 {
460 stretch = 1
461 }
462 weight := r.Weight
463 if weight == 0 {
464 weight = 400
465 }
466 // CSS subset: stretch first, style second, then nearest weight. On ties use source path order.
467 s := math.Abs(float64(f.Aspect.Stretch)-float64(stretch)) * 1e6
468 if (f.Aspect.Style == font.StyleItalic) != r.Italic {
469 s += 1e4
470 }
471 diff := float64(f.Aspect.Weight) - float64(weight)
472 if weight >= 400 && weight <= 500 {
473 // CSS: [target..500], then below target, then above 500.
474 switch {
475 case diff >= 0 && float64(f.Aspect.Weight) <= 500:
476 s += diff
477 case diff < 0:
478 s += 1000 - diff
479 default:
480 s += 2000 + diff
481 }
482 } else if weight < 400 {
483 if diff > 0 {
484 s += 1000 + diff
485 } else {
486 s -= diff
487 }
488 } else {
489 if diff < 0 {
490 s += 1000 - diff
491 } else {
492 s += diff
493 }
494 }
495 if s < score {
496 best = f
497 score = s
498 }
499 }
500 return best
501}
502
503// Generic family aliases are ordered, hard-coded and independent of installed fontconfig rules.
504var generics = map[string][]string{
505 "sans-serif": {"Noto Sans", "Adwaita Sans", "DejaVu Sans"},
506 "system-ui": {"Adwaita Sans", "Noto Sans", "DejaVu Sans"},
507 "serif": {"Noto Serif", "DejaVu Serif"},
508 "monospace": {"Noto Sans Mono", "IBM Plex Mono", "DejaVu Sans Mono"},
509}
510
511// Normalized once so matching never allocates per face.
512var genericKeys = normalizeFamilies(generics)
513var scriptFallbackKeys = normalizeList([]string{"Noto Sans Arabic", "Noto Sans Devanagari", "IBM Plex Sans JP", "Noto Sans Symbols 2"})
514
515func normalizeList(names []string) []string {
516 out := make([]string, len(names))
517 for i, n := range names {
518 out[i] = font.NormalizeFamily(n)
519 }
520 return out
521}
522func normalizeFamilies(m map[string][]string) map[string][]string {
523 out := make(map[string][]string, len(m))
524 for k, v := range m {
525 out[k] = normalizeList(v)
526 }
527 return out
528}
529
530// candidates ranks the faces a request may use. The result is shared: callers
531// must not modify it.
532func (c *Catalog) candidates(r Request) []*Face {
533 for _, e := range c.ranked {
534 if e.weight == r.Weight && e.stretch == r.Stretch && e.italic == r.Italic && slices.Equal(e.families, r.Families) {
535 return e.faces
536 }
537 }
538 out := c.rank(r)
539 out = out[:len(out):len(out)] // an append by a caller cannot write into the memo
540 if len(c.ranked) < maxRanked {
541 c.ranked = append(c.ranked, rankedFaces{families: slices.Clone(r.Families), weight: r.Weight, stretch: r.Stretch, italic: r.Italic, faces: out})
542 }
543 return out
544}
545
546func (c *Catalog) rank(r Request) []*Face {
547 seen := map[*Face]bool{}
548 var out []*Face
549 add := func(f *Face) {
550 if f != nil && !seen[f] {
551 out = append(out, f)
552 seen[f] = true
553 }
554 }
555 for _, family := range r.Families {
556 family = strings.Trim(family, " \t\"'")
557 if alias, ok := genericKeys[strings.ToLower(family)]; ok {
558 for _, a := range alias {
559 add(c.match(a, r))
560 }
561 } else {
562 add(c.match(font.NormalizeFamily(family), r))
563 }
564 }
565 for _, a := range genericKeys["sans-serif"] {
566 add(c.match(a, r))
567 }
568 // Script fallback: deterministically prefer known Unicode families, never a color bitmap font.
569 for _, a := range scriptFallbackKeys {
570 add(c.match(a, r))
571 }
572 if c.source == nil {
573 for _, f := range c.Faces {
574 add(f)
575 }
576 }
577
578 return out
579}
580
581const maxFallbackFaces = 16
582
583// The OS/2 Unicode range bits are coarse hints, not glyph coverage. Unmapped
584// blocks and absent ranges remain eligible, so NominalGlyph decides coverage.
585func unicodeRangeBit(r rune) int {
586 switch {
587 case r <= 0x007f:
588 return 0 // Basic Latin
589 case r <= 0x00ff:
590 return 1 // Latin-1
591 case r >= 0x0100 && r <= 0x017f:
592 return 2
593 case r >= 0x0180 && r <= 0x024f:
594 return 3
595 case r >= 0x0300 && r <= 0x036f:
596 return 6
597 case r >= 0x0370 && r <= 0x03ff:
598 return 7
599 case r >= 0x0400 && r <= 0x052f:
600 return 9
601 case r >= 0x0590 && r <= 0x05ff:
602 return 11
603 case r >= 0x0600 && r <= 0x06ff:
604 return 13
605 case r >= 0x0900 && r <= 0x097f:
606 return 15
607 case r >= 0x3040 && r <= 0x309f:
608 return 49
609 case r >= 0x30a0 && r <= 0x30ff:
610 return 50
611 case r >= 0x4e00 && r <= 0x9fff:
612 return 59
613 case r >= 0xac00 && r <= 0xd7af:
614 return 56
615 case r >= 0x1f300 && r <= 0x1faff:
616 return 57 // non-BMP (OS/2 bit 57)
617 default:
618 return -1
619 }
620}
621func (f *Face) mayCover(r rune) bool {
622 bits := f.unicodeRanges
623 if bits == [4]uint32{} {
624 return true
625 }
626 bit := unicodeRangeBit(r)
627 return bit < 0 || bits[bit/32]&(1<<uint(bit%32)) != 0
628}
629
630// namedKeys lists every family key the request can name, in any aspect, so
631// the fallback window excludes them whether or not their faces loaded.
632func namedKeys(r Request) map[string]bool {
633 keys := map[string]bool{}
634 for _, family := range r.Families {
635 family = strings.Trim(family, " \t\"'")
636 if alias, ok := genericKeys[strings.ToLower(family)]; ok {
637 for _, a := range alias {
638 keys[a] = true
639 }
640 } else {
641 keys[font.NormalizeFamily(family)] = true
642 }
643 }
644 for _, a := range genericKeys["sans-serif"] {
645 keys[a] = true
646 }
647 for _, a := range scriptFallbackKeys {
648 keys[a] = true
649 }
650 return keys
651}
652
653// fallbackWindow is fixed by the request, text and metadata index. Faces with
654// unknown range data remain eligible; loaded or failed state never moves the
655// window, so later measures cannot admit a different face.
656func (c *Catalog) fallbackWindow(text []rune, r Request) []*Face {
657 named := namedKeys(r)
658 out := make([]*Face, 0, maxFallbackFaces)
659 for _, f := range c.Faces {
660 if named[f.familyKey] {
661 continue
662 }
663 for _, r := range text {
664 if r != 0x200d && (r < 0xfe00 || r > 0xfe0f) && f.mayCover(r) {
665 out = append(out, f)
666 break
667 }
668 }
669 if len(out) == maxFallbackFaces {
670 break
671 }
672 }
673 return out
674}
675
676// primary is the first loadable named candidate, else the first loadable face
677// of the fallback window. A failed face is marked bad, so each step advances.
678func (c *Catalog) primary(r Request) *Face {
679 for {
680 candidates := c.candidates(r)
681 if len(candidates) == 0 {
682 break
683 }
684 if candidates[0].ready() {
685 return candidates[0]
686 }
687 if !candidates[0].bad {
688 return nil
689 }
690 }
691 for _, f := range c.fallbackWindow([]rune{'A'}, r) {
692 if f.ready() {
693 return f
694 }
695 }
696 return nil
697}