diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d3b83d..ced56d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`New`, `NewSplit` and `NewDiffView` work without fontconfig**, on Windows, + macOS and a Linux that lacks it: they read the fonts in the platform's font + directories (`%WINDIR%\Fonts` and the user's on Windows; `/System/Library/Fonts`, + `/Library/Fonts` and `~/Library/Fonts` on macOS; the XDG ones on Linux), match + the family by the name each font carries, and fall back to those fonts for a + character the family lacks, CJK included. `"monospace"` is the platform's + own (Cascadia Mono or Consolas on Windows, Menlo on macOS). Where fontconfig + is installed nothing changes. +- **`ErrFontNotFound`**, what `New` fails with, without fontconfig, when no + installed font is of the family asked for. + +### Changed + +- **`New` no longer fails with `ErrNoFontconfig`** where fontconfig is missing. + A caller that tested for it to fall back to `NewFromFonts` tests for + `ErrFontNotFound` instead. + +### Deprecated + +- **`ErrNoFontconfig`**: `New` never returns it. + +### Fixed + +- **A family whose styles live in one collection file (`.ttc`) draws each + style from its own font.** fontconfig names a font in a collection by file + and index, and the index was dropped: every style came from the file's first + font, so bold or italic text could come out regular, or in another family + the collection holds. + ## [0.7.1] - 2026-10-17 ### Fixed diff --git a/README.md b/README.md index e9e7501..8b63a60 100644 --- a/README.md +++ b/README.md @@ -185,10 +185,12 @@ and reports frame times. - **Gio's system libraries** for your platform (on Linux: Wayland or X11, `libxkbcommon`, EGL and Vulkan headers), as listed in [Gio's install guide](https://gioui.org/doc/install). -- **fontconfig** (`fc-match`, `fc-list`) to resolve font families and fall back - for missing characters. This is what keeps godemirror Linux-first today. - Without it, `New` fails with `ErrNoFontconfig`; `NewFromFonts` takes the font - files' contents instead and draws a character they lack as a box. +- **fontconfig** (`fc-match`, `fc-list`), where it is installed, to resolve font + families and fall back for missing characters. Without it (stock Windows and + macOS, a bare Linux), `New` reads the fonts in the platform's font directories + instead and matches the family by the name each font carries, failing with + `ErrFontNotFound` when none has it; `NewFromFonts` takes the font files' + contents and draws a character they lack as a box. ## Build from source diff --git a/SECURITY.md b/SECURITY.md index 26332ae..41a9273 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -29,8 +29,9 @@ nothing anywhere, so the interesting boundaries are the inputs it reads: it, or makes it use memory out of proportion to the input is a vulnerability. - **Font files.** `NewFromFonts` parses font data it is handed, and `New` loads - the files fontconfig names. The same applies: a crafted font that crashes or - hangs the process belongs here. + the fonts fontconfig names or, without fontconfig, reads every font in the + platform's font directories. The same applies: a crafted font that crashes + or hangs the process belongs here. - **fontconfig.** `New` runs `fc-match` and `fc-list` with fixed arguments, never through a shell. Getting them, or anything else, run with arguments of an attacker's choosing is a vulnerability. diff --git a/cmd/godemirror-demo/main.go b/cmd/godemirror-demo/main.go index 2f4b9a0..6ed3c54 100644 --- a/cmd/godemirror-demo/main.go +++ b/cmd/godemirror-demo/main.go @@ -35,7 +35,7 @@ var ( flagSearch = flag.String("search", "", "highlight the matches of this text, and select the first one") flagLight = flag.Bool("light", false, "use godemirror.LightTheme") flagTheme = flag.String("theme", "", "use a ported CodeMirror theme: onedark, dracula, githublight, githubdark or nord") - flagFont = flag.String("font", "JetBrainsMono Nerd Font", "font family, resolved to files by fc-match") + flagFont = flag.String("font", "JetBrainsMono Nerd Font", "font family, resolved to files as godemirror.New does") flagSize = flag.Float64("size", 13, "font size in sp") flagWidth = flag.Int("width", 1200, "window width in px") flagHeight = flag.Int("height", 800, "window height in px") diff --git a/diffview.go b/diffview.go index d29c944..826386d 100644 --- a/diffview.go +++ b/diffview.go @@ -85,8 +85,8 @@ type DiffView struct { leftSide, rightSide side } -// NewDiffView builds a diff view drawing family (a fontconfig name) at size. -// It fails as New does. +// NewDiffView builds a diff view drawing family at size, resolved as New +// resolves it. It fails as New does. func NewDiffView(family string, size unit.Sp, opts DiffOptions) (*DiffView, error) { r, err := glyphs.New(family) if err != nil { @@ -95,8 +95,8 @@ func NewDiffView(family string, size unit.Sp, opts DiffOptions) (*DiffView, erro return newDiffView(r, size, opts), nil } -// NewDiffViewFromFonts builds a diff view drawing fonts at size, with no -// fontconfig. It fails as NewFromFonts does. +// NewDiffViewFromFonts builds a diff view drawing fonts at size, without +// looking for installed fonts. It fails as NewFromFonts does. func NewDiffViewFromFonts(fonts Fonts, size unit.Sp, opts DiffOptions) (*DiffView, error) { r, err := rasterizerOf(fonts) if err != nil { diff --git a/docs/ceilings.md b/docs/ceilings.md index 7c72a73..7c75abb 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -4,14 +4,24 @@ Deliberate limits and shortcuts, and the traps they set. A bullet earns its plac work when nobody knows it and that the call site never shows. The mechanism stays in the code and its godoc; this file is the trap alone. -- **Fonts resolve through fontconfig** (`glyphs`): `New` and `NewSplit` run `fc-match` for the family's four - styles, and a character the family lacks is looked up with `fc-list :charset=X` and ranked by - `fc-match -s monospace`, a ranking read once per process. Gio itself cannot resolve a family like a Nerd +- **Fonts resolve through fontconfig where it is installed** (`glyphs`): `New` and `NewSplit` run `fc-match` for + the family's four styles, and a character the family lacks is looked up with `fc-list :charset=X` and ranked + by `fc-match -s monospace`, a ranking read once per process. Gio itself cannot resolve a family like a Nerd Font by name. A view tries the fallbacks it already loaded before looking up again, so a character two - fallbacks cover is drawn from the one loaded first, not the one fontconfig ranks closer. Font files are read - once per process and kept until it exits. A machine without fontconfig (stock macOS, Windows) gets - `ErrNoFontconfig` from `New`; `NewFromFonts` takes the font files instead and has no fallback at all, so a - character those files lack is drawn as a box. + fallbacks cover is drawn from the one loaded first, not the one fontconfig ranks closer. Each font is read + once per process and kept until it exits; two fonts of one collection read its file twice. `NewFromFonts` + takes the font files instead and has no fallback at all, so a character those files lack is drawn as a box. +- **Without fontconfig, fonts are found by scanning their directories** (`glyphs`, Windows and macOS always, Linux + when `fc-match` is missing): the first `New` of a process reads every font in the platform's font directories + (0.1 s for 1,400 fonts), and a font installed afterwards is not found until the process restarts. A family + matches only the exact name a font carries (case aside), with no fuzzy match: where `fc-match` always picks + something, an unknown family is `ErrFontNotFound`. `monospace` is a fixed per-platform list (Cascadia Mono, + Consolas, Courier New on Windows; Menlo, Monaco, Courier New on macOS). A style outside regular, bold, + italic and bold italic (Light, SemiBold) is never picked, and a variable font draws its default instance for + every style. A missing character is looked up across the fonts in a fixed order (the monospace list, then a + per-platform CJK and symbol list, then the rest): Han characters take the first CJK family installed + (Microsoft YaHei, PingFang SC), whatever the text's language. A character no font covers reads every font + once per process, about 0.1 s for 1,400 fonts. - **A missing glyph is a box, and the miss sticks**: a character no font covers, or whose fallback lookup fails (fontconfig gone mid-session, a broken font file), is drawn as a box and never fails `Layout`. The miss is cached per character until the size changes, so a font installed while the view is open shows only after diff --git a/example_test.go b/example_test.go index 8cf654e..79b9ea7 100644 --- a/example_test.go +++ b/example_test.go @@ -16,8 +16,8 @@ import ( func ExampleView() { view, err := godemirror.New("monospace", unit.Sp(13)) - if errors.Is(err, godemirror.ErrNoFontconfig) { - // Without fontconfig, hand the view the font files instead. + if errors.Is(err, godemirror.ErrFontNotFound) { + // Without an installed monospace font, hand the view the font files instead. view, err = godemirror.NewFromFonts(godemirror.Fonts{Regular: gomono.TTF}, unit.Sp(13)) } if err != nil { @@ -52,7 +52,7 @@ func ExampleView() { func ExampleView_SetSearch() { view, err := godemirror.New("monospace", unit.Sp(13)) - if errors.Is(err, godemirror.ErrNoFontconfig) { + if errors.Is(err, godemirror.ErrFontNotFound) { view, err = godemirror.NewFromFonts(godemirror.Fonts{Regular: gomono.TTF}, unit.Sp(13)) } if err != nil { diff --git a/fonts.go b/fonts.go index 58c03ba..d448281 100644 --- a/fonts.go +++ b/fonts.go @@ -8,11 +8,18 @@ import ( "github.com/lichdotdev/godemirror/internal/glyphs" ) -// ErrNoFontconfig is what New and NewSplit fail with on a machine without -// fontconfig's fc-match, where a family name cannot be resolved to font -// files. NewFromFonts needs no fontconfig. +// ErrNoFontconfig was what New failed with on a machine without fontconfig. +// +// Deprecated: New resolves a family without fontconfig and never fails with +// it; test for ErrFontNotFound instead. var ErrNoFontconfig = glyphs.ErrNoFontconfig +// ErrFontNotFound is what New fails with, on a machine without fontconfig, +// when no font in the platform's font directories is of the family it was +// asked for. Where fontconfig is installed, fc-match picks its closest font +// instead. +var ErrFontNotFound = glyphs.ErrFontNotFound + // Fonts are the contents of a monospace family's font files (TrueType, // OpenType or a collection, whose first font is used). Regular is required; // a style left nil is drawn with Regular. @@ -20,9 +27,9 @@ type Fonts struct { Regular, Bold, Italic, BoldItalic []byte } -// NewFromFonts builds a view drawing fonts at size, with no fontconfig. A -// character the fonts do not cover is drawn as a box, since there is no -// installed font to fall back to. It fails when Regular is nil or a style is +// NewFromFonts builds a view drawing fonts at size, without looking for +// installed fonts. A character the fonts do not cover is drawn as a box, since +// there is no installed font to fall back to. It fails when Regular is nil or a style is // not a font. func NewFromFonts(fonts Fonts, size unit.Sp) (*View, error) { r, err := rasterizerOf(fonts) diff --git a/fonts_test.go b/fonts_test.go index 204ad78..495080a 100644 --- a/fonts_test.go +++ b/fonts_test.go @@ -3,6 +3,9 @@ package godemirror import ( "errors" "image" + "os" + "path/filepath" + "runtime" "testing" "time" @@ -27,11 +30,60 @@ func withoutFontconfig(t *testing.T) { t.Setenv("PATH", t.TempDir()) } -func TestNewWithoutFontconfigIsErrNoFontconfig(t *testing.T) { +// installFonts puts fonts' files in the current user's font directory, which +// is a fresh one for the rest of the test. +func installFonts(t *testing.T, fonts Fonts) { + t.Helper() + home := t.TempDir() + var dir string + switch runtime.GOOS { + case "windows": + t.Setenv("LOCALAPPDATA", home) + dir = filepath.Join(home, "Microsoft", "Windows", "Fonts") + case "darwin": + t.Setenv("HOME", home) + dir = filepath.Join(home, "Library", "Fonts") + default: + t.Setenv("XDG_DATA_HOME", home) + dir = filepath.Join(home, "fonts") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + files := map[string][]byte{ + "Regular.ttf": fonts.Regular, "Bold.ttf": fonts.Bold, + "Italic.ttf": fonts.Italic, "BoldItalic.ttf": fonts.BoldItalic, + } + for name, data := range files { + if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil { + t.Fatal(err) + } + } +} + +func TestNewWithoutFontconfigFindsAnInstalledFamily(t *testing.T) { + withoutFontconfig(t) + installFonts(t, goMono) + v, err := New("Go Mono", 12) + if err != nil { + t.Fatal(err) + } + v.SetContent(Content{Text: uncovered}) + h := &harness{t: t, v: v, size: image.Pt(400, 200), now: time.Unix(1, 0)} + h.frame() + h.click(h.at(0, 0), 0) + h.click(h.at(0, 0), 0) + if got := v.SelectedText(); got != "a" { + t.Fatalf("selected %q, want %q", got, "a") + } +} + +func TestNewWithoutFontconfigIsErrFontNotFoundForAFamilyNotInstalled(t *testing.T) { withoutFontconfig(t) - _, err := New("monospace", 12) - if !errors.Is(err, ErrNoFontconfig) { - t.Fatalf("err = %v, want ErrNoFontconfig", err) + installFonts(t, goMono) + _, err := New("No Such Family", 12) + if !errors.Is(err, ErrFontNotFound) { + t.Fatalf("err = %v, want ErrFontNotFound", err) } } diff --git a/internal/glyphs/files.go b/internal/glyphs/files.go index f2a283a..95f11ad 100644 --- a/internal/glyphs/files.go +++ b/internal/glyphs/files.go @@ -9,7 +9,7 @@ import ( "golang.org/x/image/font/sfnt" ) -// fontFile is a parsed font file, the first face of a collection. A file on +// fontFile is a parsed font file, or one face of a collection. A font on // disk is read once per process and shared by every Rasterizer: each view has // its own, a diff has a view per file, and a fallback such as a CJK collection // is tens of megabytes. sfnt keeps reading from the file's bytes, and a Font @@ -18,36 +18,43 @@ type fontFile struct { sf *sfnt.Font } +// fontRef names one font on disk: index picks it in a collection, and is 0 +// for a file holding a single font. +type fontRef struct { + path string + index int +} + var fontFiles = struct { sync.Mutex - byPath map[string]*fontFile -}{byPath: map[string]*fontFile{}} + byRef map[fontRef]*fontFile +}{byRef: map[fontRef]*fontFile{}} -func openFontFile(path string) (*fontFile, error) { +func openFontFile(ref fontRef) (*fontFile, error) { fontFiles.Lock() defer fontFiles.Unlock() - if f, ok := fontFiles.byPath[path]; ok { + if f, ok := fontFiles.byRef[ref]; ok { return f, nil } - data, err := os.ReadFile(path) + data, err := os.ReadFile(ref.path) if err != nil { return nil, err } - f, err := parse(data) + f, err := parse(data, ref.index) if err != nil { - return nil, fmt.Errorf("%s: %w", path, err) + return nil, fmt.Errorf("%s: %w", ref.path, err) } - fontFiles.byPath[path] = f + fontFiles.byRef[ref] = f return f, nil } -// parse reads a font file, or the first font of a collection. -func parse(data []byte) (*fontFile, error) { +// parse reads a font file, or the font at index in a collection. +func parse(data []byte, index int) (*fontFile, error) { coll, err := opentype.ParseCollection(data) if err != nil { return nil, fmt.Errorf("parse font: %w", err) } - sf, err := coll.Font(0) + sf, err := coll.Font(index) if err != nil { return nil, fmt.Errorf("parse font: %w", err) } diff --git a/internal/glyphs/fontconfig_test.go b/internal/glyphs/fontconfig_test.go new file mode 100644 index 0000000..33baaee --- /dev/null +++ b/internal/glyphs/fontconfig_test.go @@ -0,0 +1,66 @@ +package glyphs + +import ( + "encoding/binary" + "os" + "path/filepath" + "testing" + + "golang.org/x/image/font/gofont/gomono" + "golang.org/x/image/font/gofont/gomonobold" +) + +// A family's bold in a collection is a font of its own there: drawn from the +// collection's first font instead, bold text came out regular. +func TestFontconfigResolvesAStyleToItsFontInACollection(t *testing.T) { + dir := t.TempDir() + ttc := filepath.Join(dir, "GoMono.ttc") + if err := os.WriteFile(ttc, collection(gomono.TTF, gomonobold.TTF), 0o644); err != nil { + t.Fatal(err) + } + onlyFontconfigDir(t, dir) + refs, err := fontconfigStyles("Go Mono") + if err != nil { + t.Fatal(err) + } + if want := (fontRef{path: ttc, index: 1}); refs[styleIndex(Key{Bold: true})] != want { + t.Fatalf("bold = %+v, want %+v", refs[styleIndex(Key{Bold: true})], want) + } +} + +// onlyFontconfigDir points fontconfig at dir alone for the rest of the test. +func onlyFontconfigDir(t *testing.T, dir string) { + t.Helper() + conf := filepath.Join(t.TempDir(), "fonts.conf") + xml := `` + dir + `` + t.TempDir() + `` + if err := os.WriteFile(conf, []byte(xml), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("FONTCONFIG_FILE", conf) +} + +// collection is a TrueType collection of fonts, in order. A collection's table +// offsets count from the start of the file, so each font's are moved by +// where it lands. +func collection(fonts ...[]byte) []byte { + const headerSize, recordSize, firstRecord = 12, 16, 12 + out := make([]byte, headerSize+4*len(fonts)) + copy(out, "ttcf") + binary.BigEndian.PutUint32(out[4:], 0x00010000) + binary.BigEndian.PutUint32(out[8:], uint32(len(fonts))) + for i, font := range fonts { + for len(out)%4 != 0 { + out = append(out, 0) + } + at := uint32(len(out)) + binary.BigEndian.PutUint32(out[headerSize+4*i:], at) + font = append([]byte(nil), font...) + numTables := int(binary.BigEndian.Uint16(font[4:])) + for r := range numTables { + offset := font[firstRecord+r*recordSize+8:] + binary.BigEndian.PutUint32(offset, binary.BigEndian.Uint32(offset)+at) + } + out = append(out, font...) + } + return out +} diff --git a/internal/glyphs/fontdirs_darwin.go b/internal/glyphs/fontdirs_darwin.go new file mode 100644 index 0000000..ec4f616 --- /dev/null +++ b/internal/glyphs/fontdirs_darwin.go @@ -0,0 +1,21 @@ +package glyphs + +import ( + "os" + "path/filepath" +) + +const usesFontconfig = false + +var ( + monospaceFamilies = []string{"Menlo", "Monaco", "Courier New"} + fallbackFamilies = []string{"PingFang SC", "Hiragino Sans", "Apple SD Gothic Neo", "Apple Symbols"} +) + +func fontDirs() []string { + dirs := []string{"/System/Library/Fonts", "/Library/Fonts"} + if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, filepath.Join(home, "Library", "Fonts")) + } + return dirs +} diff --git a/internal/glyphs/fontdirs_other.go b/internal/glyphs/fontdirs_other.go new file mode 100644 index 0000000..a2bd817 --- /dev/null +++ b/internal/glyphs/fontdirs_other.go @@ -0,0 +1,35 @@ +//go:build !windows && !darwin + +package glyphs + +import ( + "os" + "path/filepath" +) + +// usesFontconfig: fc-match resolves a family where it is installed, and the +// font directories are scanned where it is not. +const usesFontconfig = true + +var ( + monospaceFamilies = []string{"DejaVu Sans Mono", "Noto Sans Mono", "Liberation Mono"} + fallbackFamilies = []string{"DejaVu Sans", "Noto Sans CJK SC", "Noto Sans"} +) + +// fontDirs are fontconfig's default directories, as the XDG base directory +// spec places the user's. +func fontDirs() []string { + dirs := []string{"/usr/share/fonts", "/usr/local/share/fonts"} + dataHome := os.Getenv("XDG_DATA_HOME") + home, err := os.UserHomeDir() + if dataHome == "" && err == nil { + dataHome = filepath.Join(home, ".local", "share") + } + if dataHome != "" { + dirs = append(dirs, filepath.Join(dataHome, "fonts")) + } + if err == nil { + dirs = append(dirs, filepath.Join(home, ".fonts")) + } + return dirs +} diff --git a/internal/glyphs/fontdirs_windows.go b/internal/glyphs/fontdirs_windows.go new file mode 100644 index 0000000..dfa9915 --- /dev/null +++ b/internal/glyphs/fontdirs_windows.go @@ -0,0 +1,26 @@ +package glyphs + +import ( + "os" + "path/filepath" +) + +const usesFontconfig = false + +var ( + monospaceFamilies = []string{"Cascadia Mono", "Consolas", "Courier New"} + fallbackFamilies = []string{"Microsoft YaHei", "Yu Gothic", "Malgun Gothic", "Segoe UI Symbol", "Segoe UI Emoji"} +) + +// fontDirs are where Windows installs fonts for every user and for the +// current one. +func fontDirs() []string { + var dirs []string + if windir := os.Getenv("WINDIR"); windir != "" { + dirs = append(dirs, filepath.Join(windir, "Fonts")) + } + if local := os.Getenv("LOCALAPPDATA"); local != "" { + dirs = append(dirs, filepath.Join(local, "Microsoft", "Windows", "Fonts")) + } + return dirs +} diff --git a/internal/glyphs/fontscan.go b/internal/glyphs/fontscan.go new file mode 100644 index 0000000..40b605c --- /dev/null +++ b/internal/glyphs/fontscan.go @@ -0,0 +1,237 @@ +package glyphs + +import ( + "cmp" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "sync" + + "golang.org/x/image/font/sfnt" +) + +// ErrFontNotFound is what New fails with, where fontconfig is not installed, +// when no font directory holds the family it was asked for. +var ErrFontNotFound = errors.New("font family not found") + +// installedFont is one font found in a font directory. +type installedFont struct { + ref fontRef + family string // lower case + style int // styleIndex, or -1 for a style outside the four +} + +// fontIndex is every font in a set of font directories, the platform's +// monospace families first, then its fallback families, regular faces ahead +// of the other styles of each. +type fontIndex struct { + fonts []installedFont + + mu sync.Mutex + // coveringByRune is every answer covering gave, misses too: a miss reads + // every font in the index. + coveringByRune map[rune]fontRef +} + +// fontIndexes are scanned once per process for each set of directories. A +// font installed since is not found until the process restarts. +var fontIndexes = struct { + sync.Mutex + byDirs map[string]*fontIndex +}{byDirs: map[string]*fontIndex{}} + +func installedFonts(dirs []string) *fontIndex { + key := strings.Join(dirs, string(os.PathListSeparator)) + fontIndexes.Lock() + defer fontIndexes.Unlock() + if idx, ok := fontIndexes.byDirs[key]; ok { + return idx + } + idx := scanFonts(dirs) + fontIndexes.byDirs[key] = idx + return idx +} + +// scanFonts keeps a font's names alone: keeping its parsed tables to answer +// covering faster held about 100 MB for 1,400 fonts. +// +// ponytail: every font file is read on each process's first New, 0.1 s for +// 1,400 fonts; a persistent on-disk index, as fontconfig keeps, if that ever +// shows. +func scanFonts(dirs []string) *fontIndex { + var fonts []installedFont + for _, dir := range dirs { + // A directory that is missing or unreadable, and a file that is not a + // font, are skipped: the fonts found elsewhere are still worth using. + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || !isFontFile(d) { + return nil + } + fonts = append(fonts, readFonts(path)...) + return nil + }) + } + slices.SortStableFunc(fonts, func(a, b installedFont) int { + return cmp.Or(cmp.Compare(familyRank(a.family), familyRank(b.family)), cmp.Compare(styleRank(a.style), styleRank(b.style))) + }) + return &fontIndex{fonts: fonts, coveringByRune: map[rune]fontRef{}} +} + +func isFontFile(d fs.DirEntry) bool { + switch strings.ToLower(filepath.Ext(d.Name())) { + case ".ttf", ".otf", ".ttc", ".otc": + return !d.IsDir() + } + return false +} + +// readFonts is every font in the file at path, or none when it cannot be +// read as one. +func readFonts(path string) []installedFont { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + coll, err := sfnt.ParseCollectionReaderAt(f) + if err != nil { + return nil + } + var fonts []installedFont + var buf sfnt.Buffer + for i := range coll.NumFonts() { + sf, err := coll.Font(i) + if err != nil { + continue + } + family := nameOf(sf, &buf, sfnt.NameIDTypographicFamily, sfnt.NameIDFamily) + if family == "" { + continue + } + fonts = append(fonts, installedFont{ + ref: fontRef{path: path, index: i}, + family: strings.ToLower(family), + style: styleOf(nameOf(sf, &buf, sfnt.NameIDTypographicSubfamily, sfnt.NameIDSubfamily)), + }) + } + return fonts +} + +// nameOf is the first of ids the font has a name for, or "". +func nameOf(sf *sfnt.Font, buf *sfnt.Buffer, ids ...sfnt.NameID) string { + for _, id := range ids { + if s, err := sf.Name(buf, id); err == nil && s != "" { + return s + } + } + return "" +} + +func styleOf(subfamily string) int { + switch strings.ToLower(subfamily) { + case "regular", "book", "normal", "roman": + return 0 + case "bold": + return 1 + case "italic", "oblique": + return 2 + case "bold italic", "bold oblique": + return 3 + } + return -1 +} + +// styleRank puts a family's regular face first and a style outside the four +// last. +func styleRank(style int) int { + if style < 0 { + return len(fontStyles) + } + return style +} + +func familyRank(family string) int { + preferred := slices.Concat(monospaceFamilies, fallbackFamilies) + i := slices.IndexFunc(preferred, func(f string) bool { return strings.EqualFold(f, family) }) + if i < 0 { + return len(preferred) + } + return i +} + +// family names the fonts of family's four styles, as fontconfig would: a +// style the family lacks is drawn with its regular face. "monospace" is the +// first of the platform's monospace families that is installed. +func (idx *fontIndex) family(family string) ([4]fontRef, error) { + candidates := []string{family} + if strings.EqualFold(family, "monospace") { + candidates = monospaceFamilies + } + for _, name := range candidates { + if refs, ok := idx.styles(strings.ToLower(name)); ok { + return refs, nil + } + } + return [4]fontRef{}, fmt.Errorf("%w: %s", ErrFontNotFound, family) +} + +// styles is family's fonts by styleIndex, the family's first face standing +// in for a style it lacks; ok is false when no font is of family. +func (idx *fontIndex) styles(family string) (refs [4]fontRef, ok bool) { + var found [4]bool + for _, f := range idx.fonts { + if f.family != family { + continue + } + if !ok { + refs, ok = [4]fontRef{f.ref, f.ref, f.ref, f.ref}, true + } + if f.style >= 0 && !found[f.style] { + refs[f.style], found[f.style] = f.ref, true + } + } + return refs, ok +} + +// covering names the first font in the index with a glyph for c, or none. +func (idx *fontIndex) covering(c rune) (fontRef, error) { + idx.mu.Lock() + defer idx.mu.Unlock() + if ref, ok := idx.coveringByRune[c]; ok { + return ref, nil + } + var ref fontRef + var buf sfnt.Buffer + for _, f := range idx.fonts { + if fontCovers(f.ref, c, &buf) { + ref = f.ref + break + } + } + idx.coveringByRune[c] = ref + return ref, nil +} + +// fontCovers reads whether the font at ref has a glyph for c. A font that +// cannot be read covers nothing, as when the index was built. +func fontCovers(ref fontRef, c rune, buf *sfnt.Buffer) bool { + f, err := os.Open(ref.path) + if err != nil { + return false + } + defer f.Close() + coll, err := sfnt.ParseCollectionReaderAt(f) + if err != nil { + return false + } + sf, err := coll.Font(ref.index) + if err != nil { + return false + } + gi, err := sf.GlyphIndex(buf, c) + return err == nil && gi != 0 +} diff --git a/internal/glyphs/fontscan_test.go b/internal/glyphs/fontscan_test.go new file mode 100644 index 0000000..e990486 --- /dev/null +++ b/internal/glyphs/fontscan_test.go @@ -0,0 +1,62 @@ +package glyphs + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/image/font/gofont/gomono" + "golang.org/x/image/font/gofont/gomonobold" + "golang.org/x/image/font/gofont/gomonobolditalic" + "golang.org/x/image/font/gofont/gomonoitalic" +) + +// fontDir is a fresh directory holding files, by name. +func fontDir(t *testing.T, files map[string][]byte) string { + t.Helper() + dir := t.TempDir() + for name, data := range files { + if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +func TestAScannedFamilyTakesEachStyleFromItsOwnFile(t *testing.T) { + dir := fontDir(t, map[string][]byte{ + "a.ttf": gomonobolditalic.TTF, "b.ttf": gomonoitalic.TTF, + "c.ttf": gomonobold.TTF, "d.ttf": gomono.TTF, "notes.txt": []byte("not a font"), + }) + refs, err := installedFonts([]string{dir}).family("GO MONO") + if err != nil { + t.Fatal(err) + } + want := [4]string{"d.ttf", "c.ttf", "b.ttf", "a.ttf"} + for i, ref := range refs { + if got := filepath.Base(ref.path); got != want[i] { + t.Errorf("%s from %s, want %s", fontStyles[i], got, want[i]) + } + } +} + +func TestAStyleTheScannedFamilyLacksIsDrawnWithItsRegular(t *testing.T) { + dir := fontDir(t, map[string][]byte{"bold.ttf": gomonobold.TTF, "regular.ttf": gomono.TTF}) + refs, err := installedFonts([]string{dir}).family("Go Mono") + if err != nil { + t.Fatal(err) + } + if got := filepath.Base(refs[styleIndex(Key{Italic: true})].path); got != "regular.ttf" { + t.Errorf("italic from %s, want regular.ttf", got) + } +} + +func TestAScannedFallbackIsTheFirstFontCoveringTheCharacter(t *testing.T) { + idx := installedFonts([]string{fontDir(t, map[string][]byte{"regular.ttf": gomono.TTF})}) + if ref, _ := idx.covering('é'); filepath.Base(ref.path) != "regular.ttf" { + t.Errorf("é covered by %q, want regular.ttf", ref.path) + } + if ref, _ := idx.covering(0x50000); ref.path != "" { + t.Errorf("an unassigned character covered by %q", ref.path) + } +} diff --git a/internal/glyphs/raster.go b/internal/glyphs/raster.go index a0f1a73..82870af 100644 --- a/internal/glyphs/raster.go +++ b/internal/glyphs/raster.go @@ -11,6 +11,7 @@ import ( "image/draw" "os/exec" "slices" + "strconv" "strings" "sync" @@ -20,7 +21,7 @@ import ( "golang.org/x/image/math/fixed" ) -// ErrNoFontconfig is what New fails with when fontconfig's fc-match is not +// ErrNoFontconfig is what fcMatch fails with when fontconfig's fc-match is not // installed. var ErrNoFontconfig = errors.New("fontconfig not found: fc-match is not on PATH") @@ -61,12 +62,12 @@ type Mask struct { // Rasterizer turns characters into coverage masks on the CPU, once each, for // the renderers that keep glyphs as images instead of Gio text paths. Built by // New, it also owns the fallback Gio lacks: a character the family does not -// cover is drawn from whichever installed font fontconfig says covers it. A +// cover is drawn from whichever installed font covers it. A // character nothing covers, or whose lookup fails, is drawn as a box (tofu). type Rasterizer struct { px float64 styles [4]*fontFace - byFile map[string]*fontFace + byFile map[fontRef]*fontFace byRune map[rune]*fontFace masks map[Key]*Mask buf sfnt.Buffer @@ -75,58 +76,99 @@ type Rasterizer struct { ascent int underline, strikethrough Rule - // fcLookup names a font file covering a character; nil looks nothing up. - fcLookup func(r rune) (string, error) - // fallbacks are the faces fcLookup picked so far, in the order it picked - // them, tried before asking it again: each ask is a subprocess, and CJK - // text brings thousands of characters one face covers. + // lookup names a font covering a character, or none with an empty path; + // nil looks nothing up. + lookup func(r rune) (fontRef, error) + // fallbacks are the faces lookup picked so far, in the order it picked + // them, tried before asking it again: with fontconfig each ask is a + // subprocess, and CJK text brings thousands of characters one face covers. fallbacks []*fontFace } -// New loads the four styles fontconfig resolves for family. Call SetSize -// before Mask or Cell. It fails with ErrNoFontconfig when fc-match is not -// installed. +// New loads the four styles of family. Where fontconfig is installed it +// resolves family as fc-match does; elsewhere family must be the name of an +// installed font (or "monospace") in the platform's font directories. Call +// SetSize before Mask or Cell. It fails with ErrFontNotFound when no font +// directory holds family. func New(family string) (*Rasterizer, error) { - r := &Rasterizer{byFile: map[string]*fontFace{}, fcLookup: fontconfigCovering} - for i, style := range fontStyles { - path, err := fcMatch(family + ":" + style) - if err != nil { - return nil, err - } - if r.styles[i], err = r.load(path); err != nil { + refs, lookup, err := resolve(family) + if err != nil { + return nil, err + } + r := &Rasterizer{byFile: map[fontRef]*fontFace{}, lookup: lookup} + for i, ref := range refs { + if r.styles[i], err = r.load(ref); err != nil { return nil, err } } return r, nil } +// resolve names the fonts of family's four styles, in styleIndex order, and +// the lookup of a font covering a character the family lacks. +func resolve(family string) ([4]fontRef, func(rune) (fontRef, error), error) { + if usesFontconfig { + refs, err := fontconfigStyles(family) + if !errors.Is(err, ErrNoFontconfig) { + return refs, fontconfigLookup, err + } + } + installed := installedFonts(fontDirs()) + refs, err := installed.family(family) + return refs, installed.covering, err +} + +func fontconfigStyles(family string) ([4]fontRef, error) { + var refs [4]fontRef + for i, style := range fontStyles { + ref, err := fcMatch(family + ":" + style) + if err != nil { + return refs, err + } + refs[i] = ref + } + return refs, nil +} + +func fontconfigLookup(c rune) (fontRef, error) { + path, err := fontconfigCovering(c) + return fontRef{path: path}, err +} + // NewFromData loads the four styles from font files' contents, in // styleIndex order (regular, bold, italic, bold italic). It never looks a // fallback up: a character the fonts do not cover is drawn as tofu. func NewFromData(styles [4][]byte) (*Rasterizer, error) { - r := &Rasterizer{byFile: map[string]*fontFace{}} + r := &Rasterizer{byFile: map[fontRef]*fontFace{}} for i, data := range styles { - file, err := parse(data) + file, err := parse(data, 0) if err != nil { return nil, fmt.Errorf("%s font: %w", fontStyles[i], err) } f := &fontFace{file: file} r.styles[i] = f // byFile holds every face SetSize sizes; no path can be "data:N". - r.byFile[fmt.Sprint("data:", i)] = f + r.byFile[fontRef{path: fmt.Sprint("data:", i)}] = f } return r, nil } -func fcMatch(pattern string) (string, error) { - out, err := exec.Command("fc-match", "-f", "%{file}", pattern).Output() +// fcMatch names the font fontconfig picks for pattern, the font's index first +// since a path may hold a colon. +func fcMatch(pattern string) (fontRef, error) { + out, err := exec.Command("fc-match", "-f", "%{index}:%{file}", pattern).Output() if errors.Is(err, exec.ErrNotFound) { - return "", fmt.Errorf("%w (resolving %s)", ErrNoFontconfig, pattern) + return fontRef{}, fmt.Errorf("%w (resolving %s)", ErrNoFontconfig, pattern) } if err != nil { - return "", fmt.Errorf("fc-match %s: %w", pattern, err) + return fontRef{}, fmt.Errorf("fc-match %s: %w", pattern, err) } - return string(out), nil + index, path, ok := strings.Cut(string(out), ":") + n, err := strconv.Atoi(index) + if !ok || err != nil { + return fontRef{}, fmt.Errorf("fc-match %s: want index:file, got %q", pattern, out) + } + return fontRef{path: path, index: n}, nil } // fontconfigCovering names a font file that has a glyph for r. @@ -176,11 +218,11 @@ func parseFcListFiles(out string) []string { return files } -func (r *Rasterizer) load(path string) (*fontFace, error) { - if f, ok := r.byFile[path]; ok { +func (r *Rasterizer) load(ref fontRef) (*fontFace, error) { + if f, ok := r.byFile[ref]; ok { return f, nil } - file, err := openFontFile(path) + file, err := openFontFile(ref) if err != nil { return nil, err } @@ -190,7 +232,7 @@ func (r *Rasterizer) load(path string) (*fontFace, error) { return nil, err } } - r.byFile[path] = f + r.byFile[ref] = f return f, nil } @@ -243,7 +285,7 @@ func (r *Rasterizer) faceFor(k Key) *fontFace { // that fails is no fallback: a missing glyph is not worth failing a frame // over, and the character still shows, as tofu. func (r *Rasterizer) fallback(c rune) *fontFace { - if r.fcLookup == nil { + if r.lookup == nil { return nil } for _, f := range r.fallbacks { @@ -251,11 +293,11 @@ func (r *Rasterizer) fallback(c rune) *fontFace { return f } } - path, err := r.fcLookup(c) - if err != nil || path == "" { + ref, err := r.lookup(c) + if err != nil || ref.path == "" { return nil } - f, err := r.load(path) + f, err := r.load(ref) if err != nil || !r.covers(f, c) { return nil } diff --git a/internal/glyphs/raster_test.go b/internal/glyphs/raster_test.go index c223791..7722608 100644 --- a/internal/glyphs/raster_test.go +++ b/internal/glyphs/raster_test.go @@ -70,7 +70,7 @@ func TestALoadedFallbackServesTheCharactersItCovers(t *testing.T) { t.Fatal(err) } lookups := 0 - r.fcLookup = func(rune) (string, error) { lookups++; return fallback, nil } + r.lookup = func(rune) (fontRef, error) { lookups++; return fallback, nil } const first, distinct = 0x474, 20 // Cyrillic Go Mono lacks and DejaVu Sans draws for c := rune(first); c < first+distinct; c++ { if f := r.faceFor(Key{R: c}); f == nil { @@ -85,7 +85,7 @@ func TestALoadedFallbackServesTheCharactersItCovers(t *testing.T) { // Every view has a Rasterizer of its own, and a diff has a view per file; one // read of each font file per view was tens of megabytes per file shown. func TestRasterizersShareAFontFile(t *testing.T) { - path, err := fcMatch("DejaVu Sans") + ref, err := fcMatch("DejaVu Sans") if err != nil { t.Fatal(err) } @@ -98,12 +98,12 @@ func TestRasterizersShareAFontFile(t *testing.T) { if err := r.SetSize(24); err != nil { t.Fatal(err) } - if faces[i], err = r.load(path); err != nil { + if faces[i], err = r.load(ref); err != nil { t.Fatal(err) } } if faces[0].file != faces[1].file { - t.Errorf("two rasterizers each read %s", path) + t.Errorf("two rasterizers each read %s", ref.path) } if faces[0].face == faces[1].face { t.Error("two rasterizers draw through one sized face, which is not safe to share") diff --git a/view.go b/view.go index 3060a5a..db2619f 100644 --- a/view.go +++ b/view.go @@ -3,7 +3,7 @@ // image per visible row so a full screen of code costs a handful of quads. // // A View shows one document and a Split shows two side by side. Build them -// with New or NewSplit, which resolve a font family through fontconfig, or +// with New or NewSplit, which resolve a font family among the installed fonts, or // with NewFromFonts, which takes the font files themselves. Give them a // Content and call Layout every frame. // @@ -124,11 +124,14 @@ type rowImage struct { used uint64 } -// New builds a view drawing family at size. family is a fontconfig name -// (resolved with fc-match, as in "JetBrains Mono" or "monospace"), and a -// character it lacks is drawn from whichever installed font covers it. New -// fails with ErrNoFontconfig when fontconfig is not installed, and with -// another error when a font file it names cannot be read. +// New builds a view drawing family at size, as in "JetBrains Mono" or +// "monospace", and a character family lacks is drawn from whichever installed +// font covers it. Where fontconfig is installed, family is a fontconfig name +// resolved with fc-match. Elsewhere (Windows, macOS, a Linux without it) it is +// the family name a font file in the platform's font directories carries, and +// "monospace" is the platform's own: Cascadia Mono or Consolas on Windows, +// Menlo on macOS. New fails with ErrFontNotFound when no such font is +// installed, and with another error when a font file cannot be read. func New(family string, size unit.Sp) (*View, error) { r, err := glyphs.New(family) if err != nil {