Something went wrong. Try again.
A Zig library that finds fonts as fontconfig does, without it: the same configuration, OpenType and bitmap fonts, and the same answers.
Something went wrong. Try again.
23 kB · 539 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-FileCopyrightText: © 2002 Keith Packard// SPDX-License-Identifier: MIT AND HPND-sell-variant
//! A loaded configuration, fontconfig's `FcConfig`: the font directories,//! the rules, the font selection globs and patterns, and, once scanned, the//! fonts.//!//! ```zig//! var config = try Config.load(gpa, io, .fromMap(init.environ_map), .{});//! defer config.deinit();//! try config.scan(io);//! ```//!//! A scan keeps what it reads in a cache of its own, so that the next one//! reads only the fonts that have changed; see `Options.cache`.//!//! Everything a configuration holds lives in its arena and is freed by//! `deinit`. A pattern returned by matching may borrow from it.
const std = @import("std");const Io = std.Io;const Allocator = std.mem.Allocator;const str = @import("str.zig");const object = @import("object.zig");const value = @import("value.zig");const LangSet = @import("LangSet.zig");const Pattern = @import("Pattern.zig");const rules_ = @import("rules.zig");const xml = @import("xml.zig");const load_ = @import("load.zig");const default = @import("default.zig");const match_ = @import("match.zig");const list_ = @import("list.zig");const walk = @import("scan/walk.zig");const cache = @import("cache.zig");const known_folders = @import("known_folders");const CharSet = @import("CharSet.zig");const Object = object.Object;
const Config = @This();
gpa: Allocator,arena_state: std.heap.ArenaAllocator,env: Environment,options: Options,/// `FONTCONFIG_SYSROOT`, resolved: prepended to every path read.sysroot: ?[]const u8 = null,/// The working directory, against which relative paths are made absolute.cwd: ?[]const u8 = null,
/// The directories to scan, in order.font_dirs: std.ArrayList(FontDir) = .empty,/// The `conf.d`-style directories read.config_dirs: std.ArrayList([]const u8) = .empty,/// `<cachedir>`s. Recorded, never used: fontconfig's caches are not read,/// and this library keeps its own (`Options.cache`).cache_dirs: std.ArrayList([]const u8) = .empty,/// Every configuration file read, as it was named.config_files: std.ArrayList([]const u8) = .empty,/// Every configuration file read, with a link followed, so that none is read/// twice.available_config_files: std.ArrayList([]const u8) = .empty,rules: rules_.RuleSets = .{},accept_globs: std.ArrayList([]const u8) = .empty,reject_globs: std.ArrayList([]const u8) = .empty,accept_patterns: std.ArrayList(*Pattern) = .empty,reject_patterns: std.ArrayList(*Pattern) = .empty,rescan_interval: i32 = 30,/// What fontconfig would have printed to standard error while loading.messages: std.ArrayList(Message) = .empty,/// Whether the configuration failed to load and the built-in fallback was/// used instead, as fontconfig falls back.fallback: bool = false,
/// The languages of the user's locale, from `FcConfigGetDefaultLangs`.default_langs: []const []const u8 = &.{},
/// The scanned fonts, in the order fontconfig would have them. Empty until/// `scan`.fonts: std.ArrayList(*Pattern) = .empty,/// What the last scan did with the cache.cache_report: CacheReport = .{},
pub const CacheReport = cache.Report;
/// The environment variables fontconfig reads. Strings are copied when a/// configuration is loaded.pub const Environment = struct { home: ?[]const u8 = null, xdg_config_home: ?[]const u8 = null, xdg_data_home: ?[]const u8 = null, xdg_cache_home: ?[]const u8 = null, /// Windows' `LOCALAPPDATA`, for the cache. local_app_data: ?[]const u8 = null, xdg_data_dirs: ?[]const u8 = null, xdg_current_desktop: ?[]const u8 = null, fontconfig_file: ?[]const u8 = null, fontconfig_path: ?[]const u8 = null, fontconfig_sysroot: ?[]const u8 = null, fc_lang: ?[]const u8 = null, lc_all: ?[]const u8 = null, lc_ctype: ?[]const u8 = null, lang: ?[]const u8 = null,
pub fn fromMap(map: *const std.process.Environ.Map) Environment { return .{ .home = map.get("HOME"), .xdg_config_home = map.get("XDG_CONFIG_HOME"), .xdg_data_home = map.get("XDG_DATA_HOME"), .xdg_cache_home = map.get("XDG_CACHE_HOME"), .local_app_data = map.get("LOCALAPPDATA"), .xdg_data_dirs = map.get("XDG_DATA_DIRS"), .xdg_current_desktop = map.get("XDG_CURRENT_DESKTOP"), .fontconfig_file = map.get("FONTCONFIG_FILE"), .fontconfig_path = map.get("FONTCONFIG_PATH"), .fontconfig_sysroot = map.get("FONTCONFIG_SYSROOT"), .fc_lang = map.get("FC_LANG"), .lc_all = map.get("LC_ALL"), .lc_ctype = map.get("LC_CTYPE"), .lang = map.get("LANG"), }; }
fn dupe(e: Environment, a: Allocator) Allocator.Error!Environment { var out: Environment = .{}; inline for (@typeInfo(Environment).@"struct".field_names) |f| { if (@field(e, f)) |s| @field(out, f) = try a.dupe(u8, s); } return out; }};
/// What fontconfig decides when it is compiled.pub const Options = struct { /// The configuration file read when `FONTCONFIG_FILE` is not set, /// looked for along `FONTCONFIG_PATH` and then `config_path`. config_file: []const u8 = "fonts.conf", /// The built-in end of `FONTCONFIG_PATH`. config_path: []const u8 = "/etc/fonts", /// For the fallback configuration: the font directories, the /// `conf.d` directory and the cache directory. default_font_dirs: []const []const u8 = &.{"/usr/share/fonts"}, config_dir: []const u8 = "/etc/fonts/conf.d", cache_dir: []const u8 = "/var/cache/fontconfig", /// The program name, for `prgname`. fontconfig takes it from the running /// program; a library cannot, so it is given or left out. prgname: ?[]const u8 = null, /// Where `scan` keeps what it reads. This is not fontconfig's: it reads /// none of fontconfig's caches and writes none. cache: Cache = .user,};
/// Where a scan's cache lives.pub const Cache = union(enum) { /// `zig-font-config` in the user's cache folder, as known-folders /// finds it: `$XDG_CACHE_HOME`, or `~/.cache` if that is unset or not /// absolute; `~/Library/Caches` on macOS; `%LOCALAPPDATA%\Temp` on /// Windows. No cache if there is no such folder. user, /// This directory. path: []const u8, /// No cache: every scan reads every font. none,};
pub const FontDir = struct { path: []const u8, /// `<remap-dir as-path=...>`: the path the fonts are reported under. map: ?[]const u8 = null, salt: ?[]const u8 = null,};
pub const Message = struct { severity: xml.Severity, file: ?[]const u8, line: ?usize, text: []const u8,
/// In fontconfig's words: `Fontconfig warning: "file", line 3: text`. pub fn format(m: Message, w: *std.Io.Writer) std.Io.Writer.Error!void { try w.print("Fontconfig {t}: ", .{m.severity}); if (m.file) |f| { try w.print("\"{s}\", ", .{f}); } if (m.line) |l| try w.print("line {d}: ", .{l}); try w.writeAll(m.text); }};
pub fn arena(c: *Config) Allocator { return c.arena_state.allocator();}
fn create(gpa: Allocator, environment: Environment, options: Options) Allocator.Error!*Config { const c = try gpa.create(Config); c.* = .{ .gpa = gpa, .arena_state = .init(gpa), .env = undefined, .options = options, }; errdefer c.deinit(); c.env = try environment.dupe(c.arena()); c.default_langs = try defaultLangs(c.arena(), c.env); return c;}
pub fn deinit(c: *Config) void { for (c.fonts.items) |f| { f.deinit(); c.gpa.destroy(f); } c.fonts.deinit(c.gpa); c.arena_state.deinit(); c.gpa.destroy(c);}
/// `FcInitLoadConfig`: reads the configuration fontconfig would read, from/// `FONTCONFIG_FILE` or `fonts.conf`, following its includes. If that fails,/// the built-in fallback is used, as fontconfig uses it; `fallback` says so/// and `messages` says why.pub fn load(gpa: Allocator, io: Io, environment: Environment, options: Options) Allocator.Error!*Config { var c = try create(gpa, environment, options); errdefer c.deinit(); try c.setup(io); var loader: load_.Loader = .{ .config = c, .io = io, .arena = c.arena() }; if (try loader.parseFile(null, true)) return c;
// Keep why, for the fallback to report. const failed = c; defer failed.deinit(); c = try create(gpa, environment, options); try c.setup(io); for (failed.messages.items) |m| try c.messages.append(c.arena(), .{ .severity = m.severity, .file = if (m.file) |f| try c.arena().dupe(u8, f) else null, .line = m.line, .text = try c.arena().dupe(u8, m.text), }); c.fallback = true; loader = .{ .config = c, .io = io, .arena = c.arena() }; _ = try loader.parseMemory("fallback", try c.fallbackConfig(), false); return c;}
/// Loads a configuration from a string, as `FcConfigParseAndLoadFromMemory`/// does. With an `io`, `<include>`s are followed; without one every include/// is missing, and the configuration is exactly what the string says.pub fn fromMemory(gpa: Allocator, io: ?Io, data: []const u8, environment: Environment, options: Options) Allocator.Error!*Config { var c = try create(gpa, environment, options); errdefer c.deinit(); if (io) |i| try c.setup(i); var loader: load_.Loader = .{ .config = c, .io = io, .arena = c.arena() }; _ = try loader.parseMemory("memory", data, true); return c;}
fn setup(c: *Config, io: Io) Allocator.Error!void { c.cwd = std.process.currentPathAlloc(io, c.arena()) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => null, }; if (c.env.fontconfig_sysroot) |sr| { const real = Io.Dir.cwd().realPathFileAlloc(io, sr, c.arena()) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => null, }; if (real) |r| c.sysroot = try load_.canonFilename(c.arena(), r, c.cwd); }}
fn fallbackConfig(c: *Config) Allocator.Error![]const u8 { var out: std.Io.Writer.Allocating = .init(c.arena()); const w = &out.writer; w.writeAll("<fontconfig>") catch return error.OutOfMemory; for (c.options.default_font_dirs) |d| w.print("<dir>{s}</dir>", .{d}) catch return error.OutOfMemory; w.print( \\ <dir prefix="xdg">fonts</dir> \\ <cachedir>{s}</cachedir> \\ <cachedir prefix="xdg">fontconfig</cachedir> \\ <include ignore_missing="yes">{s}</include> \\ <include ignore_missing="yes" prefix="xdg">fontconfig/conf.d</include> \\ <include ignore_missing="yes" prefix="xdg">fontconfig/fonts.conf</include> \\</fontconfig> , .{ c.options.cache_dir, c.options.config_dir }) catch return error.OutOfMemory; return out.written();}
/// `FcConfigGetDefaultLangs`: the first of `FC_LANG`, `LC_ALL`, `LC_CTYPE`/// (unless it is just `UTF-8`) and `LANG` that is set, as a colon-separated/// list of locales, each normalized; `en` if none gives anything.fn defaultLangs(a: Allocator, env: Environment) Allocator.Error![]const []const u8 { var langs: ?[]const u8 = env.fc_lang; if (langs == null or langs.?.len == 0) langs = env.lc_all; if (langs == null or langs.?.len == 0) { langs = env.lc_ctype; if (langs) |l| if (str.eqlIgnoreCase(l, "UTF-8")) { langs = null; }; } if (langs == null or langs.?.len == 0) langs = env.lang;
var out: std.ArrayList([]const u8) = .empty; if (langs) |l| if (l.len > 0) { var it = std.mem.splitScalar(u8, l, ':'); while (it.next()) |item| { if (item.len == 0) continue; const normalized = try LangSet.normalize(a, item[0..@min(item.len, 127)]) orelse continue; for (out.items) |x| { if (std.mem.eql(u8, x, normalized)) break; } else try out.append(a, normalized); } }; if (out.items.len == 0) try out.append(a, "en"); return out.items;}
/// The environment the substitution engine sees.pub fn substituteEnvironment(c: *const Config) rules_.Environment { return .{ .default_langs = c.default_langs, .prgname = c.options.prgname };}
/// `FcConfigSubstitute`: applies the configuration's rules of `kind` to `p`.pub fn substitute(c: *const Config, p: *Pattern, kind: rules_.MatchKind) Allocator.Error!void { try rules_.substitute(&c.rules, c.substituteEnvironment(), p, null, kind);}
/// `FcConfigSubstituteWithPat`: the font rules, applied to a font being/// prepared for the query `p_pat`.pub fn substituteWithPattern(c: *const Config, p: *Pattern, p_pat: *const Pattern, kind: rules_.MatchKind) Allocator.Error!void { try rules_.substitute(&c.rules, c.substituteEnvironment(), p, p_pat, kind);}
/// `FcConfigSetDefaultSubstitute`: fills in what a query left unsaid —/// weight, slant, width, size, and the rest.pub fn defaultSubstitute(c: *const Config, p: *Pattern) Allocator.Error!void { try default.substitute(p, .{ .default_lang = c.default_langs[0], .prgname = c.options.prgname, .desktop = if (c.env.xdg_current_desktop) |d| (if (d.len > 0) d else null) else null, });}
/// `FcConfigAcceptFilename`: whether `<selectfont>`'s globs let a file or/// directory in. An accept glob overrides a reject glob.pub fn acceptFilename(c: *const Config, filename: []const u8) bool { for (c.accept_globs.items) |g| if (str.globMatch(g, filename)) return true; for (c.reject_globs.items) |g| if (str.globMatch(g, filename)) return false; return true;}
/// `FcConfigBuildFonts`: scans every font directory, applying the/// `<selectfont>` rules and the scan rules, and replaces `fonts` with what/// it finds.////// A font the cache has a current entry for is not read; every other one/// is, and the cache is brought up to date with it. The cache directory is/// made if it does not exist. If it cannot be made or written, the scan/// goes ahead without it, reading every font — which can take a while — and/// `cache_report` says so.pub fn scan(c: *Config, io: Io) !void { return c.scanCached(io, false);}
/// Like `scan`, but reads every font whatever the cache holds, and replaces/// the cache with what it reads: every cache file in the cache directory is/// deleted first, including those of directories this configuration does/// not scan. `fc-cache --really-force`, more or less.pub fn rebuildCache(c: *Config, io: Io) !void { return c.scanCached(io, true);}
fn scanCached(c: *Config, io: Io, rebuild: bool) !void { for (c.fonts.items) |f| { f.deinit(); c.gpa.destroy(f); } c.fonts.clearRetainingCapacity(); c.cache_report = .{};
var session: ?cache.Session = null; defer if (session) |*s| s.deinit(io); if (try c.cachePath(io)) |path| { if (cache.openDir(io, path)) |dir| { c.cache_report.path = path; session = .{ .gpa = c.gpa, .dir = dir, .refresh = rebuild, .report = &c.cache_report }; if (rebuild) try cache.clear(io, dir); } else |_| {} } try walk.scan(c, io, if (session) |*s| s else null); if (session) |*s| try s.prune(io);}
/// The cache directory `Options.cache` names, or null for none.pub fn cachePath(c: *Config, io: Io) known_folders.Error!?[]const u8 { switch (c.options.cache) { .none => return null, .path => |p| return p, .user => { // known-folders reads the environment from a map; it is given // the configuration's rather than the process's. The base // directory specification has a relative XDG_CACHE_HOME // ignored. var env: std.process.Environ.Map = .init(c.gpa); defer env.deinit(); if (c.env.home) |h| try env.put("HOME", h); if (c.env.xdg_cache_home) |x| if (std.fs.path.isAbsolute(x)) try env.put("XDG_CACHE_HOME", x); if (c.env.local_app_data) |l| try env.put("LOCALAPPDATA", l); const base = try known_folders.getPath(io, c.arena(), &env, .cache) orelse return null; if (!std.fs.path.isAbsolute(base)) return null; return try std.fs.path.join(c.arena(), &.{ base, "zig-font-config" }); }, }}
pub const MatchError = match_.MatchError;
/// `FcFontMatch`: the font that best matches `p`, prepared for use: the/// query's other wishes merged in and the font rules applied. `p` should/// already have been through `substitute(.pattern)` and/// `defaultSubstitute`, as `fc-match` puts it through them; `prepare` does/// all three. Null if there are no fonts.////// The result may borrow from the configuration and from `p`.pub fn match(c: *const Config, gpa: Allocator, p: *const Pattern) MatchError!?Pattern { var best = try match_.bestFont(gpa, c.fonts.items, p) orelse return null; defer best.deinit(); return try match_.renderPrepare(gpa, &c.rules, c.substituteEnvironment(), p, &best);}
/// `FcFontSort`: every font, best match first. With `trim`, fonts that add/// no character to those before them are left out. The patterns are the/// configuration's own; prepare each with `renderPrepare` before use, as/// `fc-match -s` does. If `coverage` is given, it receives the union of the/// returned fonts' charsets.pub fn sort(c: *const Config, gpa: Allocator, p: *const Pattern, trim: bool, coverage: ?*CharSet) MatchError![]*Pattern { return match_.sort(gpa, c.fonts.items, p, trim, coverage);}
/// `FcFontRenderPrepare`.pub fn renderPrepare(c: *const Config, gpa: Allocator, p: *const Pattern, font: *const Pattern) MatchError!Pattern { return match_.renderPrepare(gpa, &c.rules, c.substituteEnvironment(), p, font);}
/// What `fc-match` does to a query before matching: the configuration's/// pattern rules, then the defaults.pub fn prepare(c: *const Config, p: *Pattern) Allocator.Error!void { try c.substitute(p, .pattern); try c.defaultSubstitute(p);}
/// `FcFontList`: a pattern for each distinct combination of `objects` among/// the fonts `p` describes, or of every object if `objects` is null.pub fn list(c: *const Config, gpa: Allocator, p: *const Pattern, objects: ?[]const Object) Allocator.Error![]*Pattern { return list_.list(gpa, c.fonts.items, p, objects, c.default_langs[0]);}
const testing = std.testing;
test "load from memory" { const c = try fromMemory(testing.allocator, null, \\<?xml version="1.0"?> \\<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd"> \\<fontconfig> \\ <dir>/usr/share/fonts</dir> \\ <dir>~/.fonts</dir> \\ <dir prefix="xdg">fonts</dir> \\ <dir>/usr/share/fonts</dir> \\ <match target="pattern"> \\ <test qual="any" name="family"><string>mono</string></test> \\ <edit name="family" mode="assign" binding="same"><string>monospace</string></edit> \\ </match> \\ <alias> \\ <family>monospace</family> \\ <prefer><family>DejaVu Sans Mono</family><family>Noto Sans Mono</family></prefer> \\ </alias> \\ <selectfont><rejectfont><glob>*.pcf.gz</glob></rejectfont></selectfont> \\ <int>12px</int> \\</fontconfig> , .{ .home = "/home/u", .xdg_data_dirs = "/usr/local/share:/usr/share/" }, .{}); defer c.deinit();
const dirs = c.font_dirs.items; try testing.expectEqual(@as(usize, 4), dirs.len); try testing.expectEqualStrings("/home/u/.fonts", dirs[0].path); try testing.expectEqualStrings("/home/u/.local/share/fonts", dirs[1].path); try testing.expectEqualStrings("/usr/local/share/fonts", dirs[2].path); // Added again, so moved to the end. try testing.expectEqualStrings("/usr/share/fonts", dirs[3].path);
try testing.expectEqual(@as(usize, 2), c.rules.pattern.items.len); // Two delete rules and an append rule for each preferred family. try testing.expectEqual(@as(usize, 6), c.rules.scan.items.len); try testing.expect(!c.acceptFilename("/usr/share/fonts/misc/6x13.pcf.gz")); try testing.expect(c.acceptFilename("/usr/share/fonts/dejavu.ttf"));
var p = try @import("name.zig").parse(testing.allocator, "mono:bold"); defer p.deinit(); try c.substitute(&p, .pattern); try testing.expectEqualStrings("DejaVu Sans Mono", p.getString(.family, 0).?); try testing.expectEqualStrings("Noto Sans Mono", p.getString(.family, 1).?); try testing.expectEqualStrings("monospace", p.getString(.family, 2).?);
var saw_int_error = false; for (c.messages.items) |m| { if (std.mem.find(u8, m.text, "not a valid integer") != null) saw_int_error = true; } try testing.expect(saw_int_error);}
test "default languages from the locale" { const a = testing.allocator; var arena_state: std.heap.ArenaAllocator = .init(a); defer arena_state.deinit(); const langs = try defaultLangs(arena_state.allocator(), .{ .lc_ctype = "UTF-8", .lang = "de_DE.UTF-8:fr_FR" }); try testing.expectEqual(@as(usize, 2), langs.len); try testing.expectEqualStrings("de", langs[0]); try testing.expectEqualStrings("fr", langs[1]); const none = try defaultLangs(arena_state.allocator(), .{}); try testing.expectEqualStrings("en", none[0]);}
test "where the cache is" { if (@import("builtin").os.tag == .windows or @import("builtin").os.tag == .macos) return error.SkipZigTest; const io = testing.io; const xdg = try fromMemory(testing.allocator, null, "<fontconfig/>", .{ .home = "/home/u", .xdg_cache_home = "/var/c" }, .{}); defer xdg.deinit(); try testing.expectEqualStrings("/var/c/zig-font-config", (try xdg.cachePath(io)).?); const relative = try fromMemory(testing.allocator, null, "<fontconfig/>", .{ .home = "/home/u", .xdg_cache_home = "c" }, .{}); defer relative.deinit(); try testing.expectEqualStrings("/home/u/.cache/zig-font-config", (try relative.cachePath(io)).?); const homeless = try fromMemory(testing.allocator, null, "<fontconfig/>", .{}, .{}); defer homeless.deinit(); try testing.expectEqual(null, try homeless.cachePath(io)); const off = try fromMemory(testing.allocator, null, "<fontconfig/>", .{ .home = "/home/u" }, .{ .cache = .none }); defer off.deinit(); try testing.expectEqual(null, try off.cachePath(io));}