v0.1.0 47aaa0f

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	"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}

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