diff --git a/CHANGELOG.md b/CHANGELOG.md index ced56d7..e037aa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/glyphs/fontconfig_test.go b/internal/glyphs/fontconfig_test.go index 33baaee..b7818c2 100644 --- a/internal/glyphs/fontconfig_test.go +++ b/internal/glyphs/fontconfig_test.go @@ -4,6 +4,7 @@ import ( "encoding/binary" "os" "path/filepath" + "sync" "testing" "golang.org/x/image/font/gofont/gomono" @@ -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() diff --git a/internal/glyphs/raster.go b/internal/glyphs/raster.go index 82870af..da1afee 100644 --- a/internal/glyphs/raster.go +++ b/internal/glyphs/raster.go @@ -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()) @@ -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. @@ -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 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) { diff --git a/internal/glyphs/raster_test.go b/internal/glyphs/raster_test.go index 7722608..8825ae0 100644 --- a/internal/glyphs/raster_test.go +++ b/internal/glyphs/raster_test.go @@ -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") } } @@ -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)