From dd67694e091bacf5a9eb286c6ea73c0c4d03c06e Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 19 Oct 2026 11:19:00 -0300 Subject: [PATCH 1/2] feat: resolve font families without fontconfig On Windows, macOS and a Linux without fc-match, New, NewSplit and NewDiffView read the fonts in the platform's font directories, match the family by the name each font carries and fall back to them for characters the family lacks. Add ErrFontNotFound and deprecate ErrNoFontconfig, which New no longer returns. --- CHANGELOG.md | 23 +++ README.md | 10 +- SECURITY.md | 5 +- cmd/godemirror-demo/main.go | 2 +- diffview.go | 8 +- docs/ceilings.md | 24 ++- example_test.go | 6 +- fonts.go | 19 ++- fonts_test.go | 60 ++++++- internal/glyphs/files.go | 31 ++-- internal/glyphs/fontdirs_darwin.go | 21 +++ internal/glyphs/fontdirs_other.go | 35 ++++ internal/glyphs/fontdirs_windows.go | 26 +++ internal/glyphs/fontscan.go | 237 ++++++++++++++++++++++++++++ internal/glyphs/fontscan_test.go | 62 ++++++++ internal/glyphs/raster.go | 90 +++++++---- internal/glyphs/raster_test.go | 4 +- view.go | 15 +- 18 files changed, 599 insertions(+), 79 deletions(-) create mode 100644 internal/glyphs/fontdirs_darwin.go create mode 100644 internal/glyphs/fontdirs_other.go create mode 100644 internal/glyphs/fontdirs_windows.go create mode 100644 internal/glyphs/fontscan.go create mode 100644 internal/glyphs/fontscan_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d3b83d..307e79b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ 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. + ## [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/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..4c60acd 100644 --- a/internal/glyphs/raster.go +++ b/internal/glyphs/raster.go @@ -20,7 +20,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 +61,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,45 +75,79 @@ 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} + 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 { path, err := fcMatch(family + ":" + style) if err != nil { - return nil, err - } - if r.styles[i], err = r.load(path); err != nil { - return nil, err + return refs, err } + refs[i] = fontRef{path: path} } - return r, nil + 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 } @@ -176,11 +210,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 +224,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 +277,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 +285,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..c09e80c 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 fontRef{path: 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 { @@ -98,7 +98,7 @@ 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(fontRef{path: path}); err != nil { t.Fatal(err) } } 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 { From de64934f7ca3689e4c55fd340e97c38df3666913 Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 19 Oct 2026 11:33:25 -0300 Subject: [PATCH 2/2] fix: keep the collection index fontconfig names fc-match names a font in a collection by file and index, and the index was dropped, so every style of a family in a .ttc came from the file's first font. --- CHANGELOG.md | 8 ++++ internal/glyphs/fontconfig_test.go | 66 ++++++++++++++++++++++++++++++ internal/glyphs/raster.go | 22 ++++++---- internal/glyphs/raster_test.go | 8 ++-- 4 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 internal/glyphs/fontconfig_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 307e79b..ced56d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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/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/raster.go b/internal/glyphs/raster.go index 4c60acd..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" @@ -120,11 +121,11 @@ func resolve(family string) ([4]fontRef, func(rune) (fontRef, error), error) { func fontconfigStyles(family string) ([4]fontRef, error) { var refs [4]fontRef for i, style := range fontStyles { - path, err := fcMatch(family + ":" + style) + ref, err := fcMatch(family + ":" + style) if err != nil { return refs, err } - refs[i] = fontRef{path: path} + refs[i] = ref } return refs, nil } @@ -152,15 +153,22 @@ func NewFromData(styles [4][]byte) (*Rasterizer, error) { 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. diff --git a/internal/glyphs/raster_test.go b/internal/glyphs/raster_test.go index c09e80c..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.lookup = func(rune) (fontRef, error) { lookups++; return fontRef{path: 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(fontRef{path: 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")