v0.5.1 657f658

fonts.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close