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