Files
mesh-catalog/modules/fonts/cmd/fonts-tools/fonts.go
T
jochen 86cf6d438d fonts: the five decided faces as packages, and what the generic families mean (hq to-be 42 phase 2.1)
JetBrains Mono Nerd Font for monospace, Inter for the interface, the Nerd
Fonts symbols and Noto Color Emoji as fallbacks, Noto for serif. One owned
fontconfig file in the account's conf.d maps monospace, sans-serif,
system-ui, serif and emoji, bound `same`: measured with fontconfig 2.18, a
weakly bound preference loses to Noto Sans Mono. Families today's configs
name but the laptop lacks (Iosevka, the old JetBrains name) stop falling
back to sans-serif.

Six Go tools: families, match, glyph, sources (which hand-copied files can
go and why), config, cache-rebuild. The README lists the hand-copied files
to remove and the modules that must name the new family.
2026-10-04 13:02:23 +02:00

523 lines
16 KiB
Go

package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
)
// ConfigFile is where the module's fontconfig file is placed, under the account's home. fontconfig's
// stock 50-user.conf reads this directory, before the distribution's 60-latin.conf, so what it says
// is what the generic families mean.
const ConfigFile = ".config/fontconfig/conf.d/50-mesh-fonts.conf"
// Decided is the face each generic family means (novox/hq research 026/04).
var Decided = map[string]string{
"monospace": "JetBrainsMono Nerd Font",
"sans-serif": "Inter",
"system-ui": "Inter",
"serif": "Noto Serif",
"emoji": "Noto Color Emoji",
}
// generics is the order fonts_match answers them in.
var generics = []string{"monospace", "sans-serif", "system-ui", "serif", "emoji"}
// Retired are the monospace families the desktop's files named before the module; a hand-copied
// file of one of them is replaced by the decided face.
var Retired = []string{"Hack Nerd Font", "MesloLGS NF", "Iosevka Nerd Font", "JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font"}
// fontDirs are the account's own font directories, relative to its home.
var fontDirs = []string{".local/share/fonts", ".fonts"}
const packagedRoot = "/usr/share/fonts/"
// Family is one installed family.
type Family struct {
Family string `json:"family"`
Styles []string `json:"styles"`
Files int `json:"files"`
Monospace bool `json:"monospace"`
Source string `json:"source"`
}
// FamiliesAnswer is what fonts_families answers.
type FamiliesAnswer struct {
Count int `json:"count"`
Families []Family `json:"families"`
Truncated bool `json:"truncated,omitempty"`
}
func sourceOf(file string) string {
home := accountHome()
switch {
case home != "" && strings.HasPrefix(file, strings.TrimRight(home, "/")+"/"):
return "account"
case strings.HasPrefix(file, packagedRoot):
return "package"
}
return "other"
}
func accountHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
// Families lists what fc-list knows, by family.
func Families(contains string, limit int) (FamiliesAnswer, error) {
r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{spacing}\t%{file}\n"}})
if err != nil {
return FamiliesAnswer{}, err
}
by := map[string]*Family{}
styles := map[string]map[string]bool{}
sources := map[string]map[string]bool{}
for _, l := range lines(r.Stdout) {
f := strings.Split(l, "\t")
if len(f) < 4 || f[0] == "" {
continue
}
name := f[0]
if contains != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(contains)) {
continue
}
if by[name] == nil {
by[name] = &Family{Family: name, Monospace: true}
styles[name], sources[name] = map[string]bool{}, map[string]bool{}
}
fam := by[name]
fam.Files++
if f[1] != "" {
styles[name][f[1]] = true
}
// fontconfig's spacing: 100 mono, 110 charcell, 90 dual; absent is proportional.
if f[2] != "100" && f[2] != "110" && f[2] != "90" {
fam.Monospace = false
}
sources[name][sourceOf(f[3])] = true
}
out := FamiliesAnswer{Families: []Family{}}
for name, fam := range by {
fam.Styles = keys(styles[name])
fam.Source = strings.Join(keys(sources[name]), "+")
out.Families = append(out.Families, *fam)
}
sort.Slice(out.Families, func(i, k int) bool { return out.Families[i].Family < out.Families[k].Family })
out.Count = len(out.Families)
if len(out.Families) > limit {
out.Families, out.Truncated = out.Families[:limit], true
}
return out, nil
}
func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// Resolved is what one pattern resolves to.
type Resolved struct {
Asked string `json:"asked"`
Family string `json:"family"`
Style string `json:"style"`
File string `json:"file"`
Source string `json:"source"`
Decided string `json:"decided,omitempty"`
AsDecided *bool `json:"as_decided,omitempty"`
}
func checkPattern(p string) error {
if strings.HasPrefix(p, "-") || strings.ContainsAny(p, "\n\t") {
return fmt.Errorf("%q is not a fontconfig pattern", p)
}
return nil
}
func resolve(pattern string) (Resolved, error) {
if err := checkPattern(pattern); err != nil {
return Resolved{}, err
}
r, err := call(Cmd{Name: "fc-match", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{file}", pattern}})
if err != nil {
return Resolved{}, err
}
f := strings.Split(strings.TrimSpace(r.Stdout), "\t")
if len(f) < 3 || f[0] == "" {
return Resolved{}, fmt.Errorf("fc-match answered nothing usable for %q: %q", pattern, strings.TrimSpace(r.Stdout))
}
return Resolved{Asked: pattern, Family: f[0], Style: f[1], File: f[2], Source: sourceOf(f[2])}, nil
}
// MatchAnswer is what fonts_match answers.
type MatchAnswer struct {
Resolved []Resolved `json:"resolved"`
// AllAsDecided is set when only the generics were asked, and says whether each is the decided face.
AllAsDecided *bool `json:"all_as_decided,omitempty"`
}
// Match resolves the generic families, or the patterns given.
func Match(patterns []string) (MatchAnswer, error) {
generic := len(patterns) == 0
if generic {
patterns = generics
}
out := MatchAnswer{Resolved: []Resolved{}}
all := true
for _, p := range patterns {
r, err := resolve(p)
if err != nil {
return MatchAnswer{}, err
}
if want, ok := Decided[p]; ok {
agree := r.Family == want
r.Decided, r.AsDecided = want, &agree
all = all && agree
}
out.Resolved = append(out.Resolved, r)
}
if generic {
out.AllAsDecided = &all
}
return out, nil
}
// codePoint reads one character, or a code point written U+XXXX or 0xXXXX.
func codePoint(s string) (rune, error) {
s = strings.TrimSpace(s)
if utf8.RuneCountInString(s) == 1 {
r, _ := utf8.DecodeRuneInString(s)
return r, nil
}
up := strings.ToUpper(s)
for _, prefix := range []string{"U+", "0X"} {
if strings.HasPrefix(up, prefix) {
n, err := strconv.ParseUint(up[len(prefix):], 16, 32)
if err != nil || n == 0 || n > utf8.MaxRune {
return 0, fmt.Errorf("%q is not a code point", s)
}
return rune(n), nil
}
}
return 0, fmt.Errorf("give one character, or its code point as U+XXXX or 0xXXXX, not %q", s)
}
// GlyphAnswer is what fonts_glyph answers.
type GlyphAnswer struct {
CodePoint string `json:"code_point"`
Character string `json:"character"`
Count int `json:"count"`
Families []Family `json:"families"`
DrawnBy map[string]string `json:"drawn_by"`
Truncated bool `json:"truncated,omitempty"`
}
// Glyph answers which fonts have a character and which face the generics would draw it with.
func Glyph(s string) (GlyphAnswer, error) {
cp, err := codePoint(s)
if err != nil {
return GlyphAnswer{}, err
}
hx := strconv.FormatInt(int64(cp), 16)
r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{spacing}\t%{file}\n", ":charset=" + hx}})
if err != nil {
return GlyphAnswer{}, err
}
by := map[string]*Family{}
for _, l := range lines(r.Stdout) {
f := strings.Split(l, "\t")
if len(f) < 4 || f[0] == "" {
continue
}
if by[f[0]] == nil {
by[f[0]] = &Family{Family: f[0], Monospace: f[2] == "100" || f[2] == "110" || f[2] == "90", Source: sourceOf(f[3]), Styles: []string{}}
}
by[f[0]].Files++
}
out := GlyphAnswer{CodePoint: fmt.Sprintf("U+%04X", cp), Character: string(cp), Families: []Family{}, DrawnBy: map[string]string{}}
for _, f := range by {
out.Families = append(out.Families, *f)
}
sort.Slice(out.Families, func(i, k int) bool { return out.Families[i].Family < out.Families[k].Family })
out.Count = len(out.Families)
if len(out.Families) > 200 {
out.Families, out.Truncated = out.Families[:200], true
}
for _, g := range []string{"monospace", "sans-serif"} {
res, err := resolve(g + ":charset=" + hx)
if err != nil {
return GlyphAnswer{}, err
}
out.DrawnBy[g] = res.Family
}
return out, nil
}
// HandFile is one font file copied into the account's home.
type HandFile struct {
File string `json:"file"`
Family string `json:"family"`
Style string `json:"style"`
Bytes int64 `json:"bytes"`
Remove bool `json:"remove"`
Why []string `json:"why,omitempty"`
sum string
}
// Packaged is a family a package installs.
type Packaged struct {
Family string `json:"family"`
Package string `json:"package"`
}
// SourcesAnswer is what fonts_sources answers.
type SourcesAnswer struct {
Hand []HandFile `json:"hand_copied"`
HandBytes int64 `json:"hand_copied_bytes"`
RemoveCount int `json:"removable"`
Packaged []Packaged `json:"packaged"`
Note string `json:"note"`
}
var fontExt = map[string]bool{".ttf": true, ".otf": true, ".ttc": true, ".otc": true, ".pfb": true, ".pcf": true, ".woff": true, ".woff2": true, ".bdf": true}
// handFiles walks the account's font directories.
func handFiles(home string) ([]HandFile, error) {
out := []HandFile{}
for _, d := range fontDirs {
root := filepath.Join(home, d)
err := filepath.WalkDir(root, func(p string, e fs.DirEntry, err error) error {
if err != nil {
if p == root && os.IsNotExist(err) {
return filepath.SkipDir
}
return err
}
if e.IsDir() || !fontExt[strings.ToLower(filepath.Ext(p))] {
return nil
}
info, err := e.Info()
if err != nil {
return err
}
f, err := os.Open(p)
if err != nil {
return err
}
h := sha256.New()
_, err = io.Copy(h, f)
f.Close()
if err != nil {
return err
}
out = append(out, HandFile{File: p, Bytes: info.Size(), sum: hex.EncodeToString(h.Sum(nil))})
return nil
})
if err != nil && err != filepath.SkipDir {
return nil, fmt.Errorf("reading %s: %w", root, err)
}
}
sort.Slice(out, func(i, k int) bool { return out[i].File < out[k].File })
return out, nil
}
// Sources says which font files were copied by hand, which of them can go, and which families
// packages install.
func Sources() (SourcesAnswer, error) {
home := accountHome()
hand, err := handFiles(home)
if err != nil {
return SourcesAnswer{}, err
}
out := SourcesAnswer{Hand: hand, Packaged: []Packaged{},
Note: "Nothing is removed by this tool. The mesh removes nothing it did not place (ADR 0182): a file marked remove is for the operator to delete, once; then run fonts_cache_rebuild."}
if len(hand) > 0 {
args := []string{"--format", "%{file}\t%{family[0]}\t%{style[0]}\n"}
for _, h := range hand {
args = append(args, h.File)
}
r, err := call(Cmd{Name: "fc-scan", Args: args})
if err != nil {
return SourcesAnswer{}, err
}
named := map[string][2]string{}
for _, l := range lines(r.Stdout) {
f := strings.Split(l, "\t")
if len(f) >= 3 {
if _, seen := named[f[0]]; !seen {
named[f[0]] = [2]string{f[1], f[2]}
}
}
}
for i := range out.Hand {
n := named[out.Hand[i].File]
out.Hand[i].Family, out.Hand[i].Style = n[0], n[1]
}
}
// Families the packages install, each with one file to ask pacman about.
r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{file}\n"}})
if err != nil {
return SourcesAnswer{}, err
}
fileOf := map[string]string{}
for _, l := range lines(r.Stdout) {
f := strings.Split(l, "\t")
if len(f) >= 2 && f[0] != "" && strings.HasPrefix(f[1], packagedRoot) {
if _, seen := fileOf[f[0]]; !seen {
fileOf[f[0]] = f[1]
}
}
}
owner := map[string]string{}
if len(fileOf) > 0 {
args := []string{"-Qo"}
for _, fam := range keys(boolSet(fileOf)) {
args = append(args, fileOf[fam])
}
// pacman exits 1 when one file is unowned and still answers the rest: read what it said.
r := run(Cmd{Name: "pacman", Args: args})
if r.Error != "" {
return SourcesAnswer{}, failure(Cmd{Name: "pacman", Args: []string{"-Qo"}}, r)
}
for _, l := range lines(r.Stdout) {
// "/usr/share/fonts/x.ttf is owned by noto-fonts 1:2026.08.01-1"
if i := strings.Index(l, " is owned by "); i > 0 {
pkg := strings.Fields(l[i+len(" is owned by "):])
if len(pkg) > 0 {
owner[l[:i]] = pkg[0]
}
}
}
}
for _, fam := range keys(boolSet(fileOf)) {
pkg := owner[fileOf[fam]]
if pkg == "" {
pkg = "(no package)"
}
out.Packaged = append(out.Packaged, Packaged{Family: fam, Package: pkg})
}
// Which hand-copied files can go, and why.
firstOf := map[string]string{}
for i := range out.Hand {
h := &out.Hand[i]
if prev, dup := firstOf[h.sum]; dup {
h.Why = append(h.Why, "the same bytes as "+filepath.Base(prev))
} else {
firstOf[h.sum] = h.File
}
if strings.Contains(filepath.Base(h.File), "%20") {
h.Why = append(h.Why, "a URL-encoded copy of a name")
}
if pkg := owner[fileOf[h.Family]]; h.Family != "" && pkg != "" {
h.Why = append(h.Why, "its family is installed by the package "+pkg)
}
for _, r := range Retired {
if h.Family == r {
h.Why = append(h.Why, "a monospace face the desktop named before the fonts module; "+Decided["monospace"]+" replaces it once the terminal, window manager, bar and launcher name that family")
break
}
}
h.Remove = len(h.Why) > 0
out.HandBytes += h.Bytes
if h.Remove {
out.RemoveCount++
}
}
return out, nil
}
func boolSet(m map[string]string) map[string]bool {
out := map[string]bool{}
for k := range m {
out[k] = true
}
return out
}
// ConfigAnswer is what fonts_config answers.
type ConfigAnswer struct {
Path string `json:"path"`
Present bool `json:"present"`
Loaded bool `json:"loaded"`
Bytes int `json:"bytes,omitempty"`
Others []string `json:"account_files_beside_it"`
Note string `json:"note,omitempty"`
}
// Config says whether the module's file is in place and loaded.
func Config() (ConfigAnswer, error) {
path := filepath.Join(accountHome(), ConfigFile)
out := ConfigAnswer{Path: path, Others: []string{}}
if b, err := os.ReadFile(path); err == nil {
out.Present, out.Bytes = true, len(b)
} else if !os.IsNotExist(err) {
return ConfigAnswer{}, fmt.Errorf("reading %s: %w", path, err)
}
entries, _ := os.ReadDir(filepath.Dir(path))
for _, e := range entries {
if p := filepath.Join(filepath.Dir(path), e.Name()); p != path {
out.Others = append(out.Others, p)
}
}
if b, err := os.ReadFile(filepath.Join(accountHome(), ".config/fontconfig/fonts.conf")); err == nil && len(b) > 0 {
out.Others = append(out.Others, filepath.Join(accountHome(), ".config/fontconfig/fonts.conf"))
}
r, err := call(Cmd{Name: "fc-conflist"})
if err != nil {
return ConfigAnswer{}, err
}
for _, l := range lines(r.Stdout) {
// "+ /path: description" for a file in use, "- /path" for one skipped
if strings.HasPrefix(l, "+ "+path+":") || strings.TrimSpace(l) == "+ "+path {
out.Loaded = true
}
}
if !out.Present {
out.Note = "the file is not in place: the fonts module is not assigned to this machine, or its push has not reached it"
} else if !out.Loaded {
out.Note = "the file is in place and fontconfig does not load it: check that /etc/fonts/conf.d/50-user.conf is enabled"
}
if len(out.Others) > 0 {
out.Note = strings.TrimSpace(out.Note + " The other files are the account's own: they are read too, and one sorting after 50-mesh-fonts.conf can override it.")
}
return out, nil
}
// RebuildAnswer is what fonts_cache_rebuild answers.
type RebuildAnswer struct {
Scope string `json:"scope"`
ElapsedMS int64 `json:"elapsed_ms"`
Output string `json:"output,omitempty"`
}
// CacheRebuild rebuilds the account's font cache, or the system's.
func CacheRebuild(system bool) (RebuildAnswer, error) {
c := Cmd{Name: "fc-cache", Args: []string{"-f"}}
scope := "account"
if system {
c, scope = Cmd{Name: "fc-cache", Args: []string{"-s", "-f"}, Root: true}, "system"
}
start := time.Now()
r, err := call(c)
if err != nil {
return RebuildAnswer{}, err
}
return RebuildAnswer{Scope: scope, ElapsedMS: time.Since(start).Milliseconds(), Output: tail(strings.TrimSpace(r.Stdout+r.Stderr), 4000)}, nil
}