// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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, /// ``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, /// ``: 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`, ``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("") catch return error.OutOfMemory; for (c.options.default_font_dirs) |d| w.print("{s}", .{d}) catch return error.OutOfMemory; w.print( \\ fonts \\ {s} \\ fontconfig \\ {s} \\ fontconfig/conf.d \\ fontconfig/fonts.conf \\ , .{ 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 ``'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 /// `` 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, \\ \\ \\ \\ /usr/share/fonts \\ ~/.fonts \\ fonts \\ /usr/share/fonts \\ \\ mono \\ monospace \\ \\ \\ monospace \\ DejaVu Sans MonoNoto Sans Mono \\ \\ *.pcf.gz \\ 12px \\ , .{ .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, "", .{ .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, "", .{ .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, "", .{}, .{}); defer homeless.deinit(); try testing.expectEqual(null, try homeless.cachePath(io)); const off = try fromMemory(testing.allocator, null, "", .{ .home = "/home/u" }, .{ .cache = .none }); defer off.deinit(); try testing.expectEqual(null, try off.cachePath(io)); }