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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
- **A character the family lacks is drawn from the font fontconfig ranks
first, also when that font is in a collection.** The fallback took the
collection's first font instead: drawn bold or in another family the file
holds, or as a box when that font lacked the character.

## [0.7.1] - 2026-10-17

Expand Down
22 changes: 22 additions & 0 deletions internal/glyphs/fontconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/binary"
"os"
"path/filepath"
"sync"
"testing"

"golang.org/x/image/font/gofont/gomono"
Expand All @@ -28,6 +29,27 @@ func TestFontconfigResolvesAStyleToItsFontInACollection(t *testing.T) {
}
}

// A fallback in a collection is the font fontconfig ranks first there, not
// the file's first: a regular fallback after its bold was drawn bold.
func TestAFontconfigFallbackIsItsFontInACollection(t *testing.T) {
dir := t.TempDir()
ttc := filepath.Join(dir, "GoMono.ttc")
if err := os.WriteFile(ttc, collection(gomonobold.TTF, gomono.TTF), 0o644); err != nil {
t.Fatal(err)
}
onlyFontconfigDir(t, dir)
ranked := rankedAgainstMono
rankedAgainstMono = sync.OnceValues(rankAgainstMono)
t.Cleanup(func() { rankedAgainstMono = ranked })
ref, err := fontconfigCovering('é')
if err != nil {
t.Fatal(err)
}
if want := (fontRef{path: ttc, index: 1}); ref != want {
t.Fatalf("é covered by %+v, want %+v", ref, want)
}
}

// onlyFontconfigDir points fontconfig at dir alone for the rest of the test.
func onlyFontconfigDir(t *testing.T, dir string) {
t.Helper()
Expand Down
87 changes: 52 additions & 35 deletions internal/glyphs/raster.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ 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
return refs, fontconfigCovering, err
}
}
installed := installedFonts(fontDirs())
Expand All @@ -130,11 +130,6 @@ func fontconfigStyles(family string) ([4]fontRef, error) {
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.
Expand All @@ -153,69 +148,91 @@ func NewFromData(styles [4][]byte) (*Rasterizer, error) {
return r, nil
}

// fcMatch names the font fontconfig picks for pattern, the font's index first
// since a path may hold a colon.
// fontRefFormat is how fc-match and fc-list are asked to name a font: its
// index in its file first, since a path may hold a colon.
const fontRefFormat = "%{index}:%{file}\n"

// fcMatch names the font fontconfig picks for pattern.
func fcMatch(pattern string) (fontRef, error) {
out, err := exec.Command("fc-match", "-f", "%{index}:%{file}", pattern).Output()
out, err := exec.Command("fc-match", "-f", fontRefFormat, pattern).Output()
if errors.Is(err, exec.ErrNotFound) {
return fontRef{}, fmt.Errorf("%w (resolving %s)", ErrNoFontconfig, pattern)
}
if err != nil {
return fontRef{}, fmt.Errorf("fc-match %s: %w", pattern, err)
}
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)
refs, err := parseFontRefs(string(out))
if err != nil {
return fontRef{}, fmt.Errorf("fc-match %s: %w", pattern, err)
}
if len(refs) != 1 {
return fontRef{}, fmt.Errorf("fc-match %s: want one font, got %q", pattern, out)
}
return fontRef{path: path, index: n}, nil
return refs[0], nil
}

// fontconfigCovering names a font file that has a glyph for r.
func fontconfigCovering(r rune) (string, error) {
// fontconfigCovering names a font that has a glyph for r, or none with an
// empty path.
func fontconfigCovering(r rune) (fontRef, error) {
charset := fmt.Sprintf(":charset=%x", r)
out, err := exec.Command("fc-list", charset, "file").Output()
out, err := exec.Command("fc-list", "-f", fontRefFormat, charset).Output()
if err != nil {
return fontRef{}, fmt.Errorf("fc-list %s: %w", charset, err)
}
covering, err := parseFontRefs(string(out))
if err != nil {
return "", fmt.Errorf("fc-list %s: %w", charset, err)
return fontRef{}, fmt.Errorf("fc-list %s: %w", charset, err)
}
covering := parseFcListFiles(string(out))
if len(covering) == 0 {
return "", nil
return fontRef{}, nil
}
ranked, err := rankedAgainstMono()
if err != nil {
return "", err
return fontRef{}, err
}
for f := range strings.Lines(ranked) {
if f = strings.TrimSpace(f); slices.Contains(covering, f) {
for _, f := range ranked {
if slices.Contains(covering, f) {
return f, nil
}
}
return covering[0], nil
}

// rankedAgainstMono is every installed font file ranked against a plain
// rankedAgainstMono is every installed font ranked against a plain
// monospace, asked once per process: fc-list knows who covers a character but
// in no useful order, so the first covering font in this ranking is the
// closest regular face. A font installed since is missing from it and is
// picked only when it is the first fc-list names.
var rankedAgainstMono = sync.OnceValues(func() (string, error) {
out, err := exec.Command("fc-match", "-s", "-f", "%{file}\n", "monospace").Output()
var rankedAgainstMono = sync.OnceValues(rankAgainstMono)

func rankAgainstMono() ([]fontRef, error) {
out, err := exec.Command("fc-match", "-s", "-f", fontRefFormat, "monospace").Output()
if err != nil {
return nil, fmt.Errorf("fc-match -s monospace: %w", err)
}
refs, err := parseFontRefs(string(out))
if err != nil {
return "", fmt.Errorf("fc-match -s monospace: %w", err)
return nil, fmt.Errorf("fc-match -s monospace: %w", err)
}
return string(out), nil
})
return refs, nil
}

// parseFcListFiles reads `fc-list <pattern> file`, one "path: " per line.
func parseFcListFiles(out string) []string {
var files []string
// parseFontRefs reads fontRefFormat, one font per line.
func parseFontRefs(out string) ([]fontRef, error) {
var refs []fontRef
for line := range strings.Lines(out) {
if f := strings.TrimSuffix(strings.TrimSpace(line), ":"); f != "" {
files = append(files, f)
line = strings.TrimSuffix(line, "\n")
if line == "" {
continue
}
index, path, ok := strings.Cut(line, ":")
n, err := strconv.Atoi(index)
if !ok || err != nil || path == "" {
return nil, fmt.Errorf("want index:file, got %q", line)
}
refs = append(refs, fontRef{path: path, index: n})
}
return files
return refs, nil
}

func (r *Rasterizer) load(ref fontRef) (*fontFace, error) {
Expand Down
24 changes: 14 additions & 10 deletions internal/glyphs/raster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,18 @@ import (
"golang.org/x/image/math/fixed"
)

func TestParseFcListFiles(t *testing.T) {
out := "/usr/share/fonts/TTF/A.ttf: \n/usr/share/fonts/TTF/B Mono.ttf: \n"
want := []string{"/usr/share/fonts/TTF/A.ttf", "/usr/share/fonts/TTF/B Mono.ttf"}
if got := parseFcListFiles(out); !slices.Equal(got, want) {
t.Fatalf("got %q, want %q", got, want)
func TestParseFontRefs(t *testing.T) {
out := "0:/usr/share/fonts/TTF/A.ttf\n2:/usr/share/fonts/B Mono:CJK.ttc\n"
want := []fontRef{{path: "/usr/share/fonts/TTF/A.ttf"}, {path: "/usr/share/fonts/B Mono:CJK.ttc", index: 2}}
got, err := parseFontRefs(out)
if err != nil || !slices.Equal(got, want) {
t.Fatalf("got %+v, %v, want %+v", got, err, want)
}
if got := parseFcListFiles(""); len(got) != 0 {
t.Fatalf("empty output parsed as %q", got)
if got, err := parseFontRefs(""); err != nil || len(got) != 0 {
t.Fatalf("empty output parsed as %+v, %v", got, err)
}
if _, err := parseFontRefs("/usr/share/fonts/TTF/A.ttf\n"); err == nil {
t.Fatal("a line with no index parsed")
}
}

Expand Down Expand Up @@ -117,9 +121,9 @@ func TestFallbackLookupsRankAgainstMonospaceOnce(t *testing.T) {
t.Fatal(err)
}
calls := countCalls(t, "fc-match")
path, err := fontconfigCovering('B')
if err != nil || path == "" {
t.Fatalf("fontconfigCovering('B') = %q, %v", path, err)
ref, err := fontconfigCovering('B')
if err != nil || ref.path == "" {
t.Fatalf("fontconfigCovering('B') = %+v, %v", ref, err)
}
if got := calls(); got != 0 {
t.Fatalf("a second lookup ran fc-match %d times, want 0", got)
Expand Down
Loading