Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cmd/godemirror-demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 4 additions & 4 deletions diffview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
24 changes: 17 additions & 7 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 13 additions & 6 deletions fonts.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,28 @@ 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.
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)
Expand Down
60 changes: 56 additions & 4 deletions fonts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package godemirror
import (
"errors"
"image"
"os"
"path/filepath"
"runtime"
"testing"
"time"

Expand All @@ -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)
}
}

Expand Down
31 changes: 19 additions & 12 deletions internal/glyphs/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
66 changes: 66 additions & 0 deletions internal/glyphs/fontconfig_test.go
Original file line number Diff line number Diff line change
@@ -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 := `<?xml version="1.0"?><fontconfig><dir>` + dir + `</dir><cachedir>` + t.TempDir() + `</cachedir></fontconfig>`
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
}
Loading
Loading