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 }