Finds fonts the way fontconfig does, from fontconfig's own configuration, in Zig.
  • Zig 96%
  • Nix 2.9%
  • Python 0.8%
  • Shell 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 76b49281f8
All checks were successful
test / test (push) Successful in 13m58s
test / docs (push) Successful in 8m41s
Update zig-font, now named font
zig-font renamed its package from zig_font to font, so the dependency is
fetched and looked up under that name. The new revision also decodes BDF
and PCF bitmap fonts, which nothing here uses yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxMfD5H5cp867D4Rdkx6Di
2026-10-10 16:21:31 -05:00
.forgejo/workflows Find fonts the way fontconfig does 2026-10-03 22:37:39 -05:00
LICENSES Find fonts the way fontconfig does 2026-10-03 22:37:39 -05:00
src Walk the charmap, build charsets and pick subtables more cheaply 2026-10-10 13:55:58 -05:00
tests Cache scanned fonts between runs 2026-10-05 15:56:29 -05:00
tools Cache scanned fonts between runs 2026-10-05 15:56:29 -05:00
.gitignore Find fonts the way fontconfig does 2026-10-03 22:37:39 -05:00
build.zig Update zig-font, now named font 2026-10-10 16:21:31 -05:00
build.zig.zon Update zig-font, now named font 2026-10-10 16:21:31 -05:00
build.zig.zon.nix Update zig-font, now named font 2026-10-10 16:21:31 -05:00
flake.lock Find fonts the way fontconfig does 2026-10-03 22:37:39 -05:00
flake.nix Find fonts the way fontconfig does 2026-10-03 22:37:39 -05:00
package.nix Find fonts the way fontconfig does 2026-10-03 22:37:39 -05:00
README.md Rename the package to font_config 2026-10-10 12:32:27 -05:00
REUSE.toml Find fonts the way fontconfig does 2026-10-03 22:37:39 -05:00

zig-font-config

A Zig 0.17 library that finds fonts the way fontconfig does, without fontconfig. It reads the configuration a fontconfig system already has — /etc/fonts/fonts.conf, its conf.d, the user's ~/.config/fontconfig — scans the font directories that configuration names, and answers the same questions fc-match, fc-match -s and fc-list answer, with the same answers.

It is a port of fontconfig 2.18.3 rather than a reimplementation of the idea: the substitution rules, the default substitutions, the scoring and the sort are fontconfig's own, quirks included, and where fontconfig reads a font through FreeType, this reads it with zig-font and makes each decision FreeType would have made. Across the 12,543 faces of one desktop's fonts it gives every face the same pattern fc-query gives it, and for every query in tests/differential/queries.txt the same fc-match, the same fc-match -s order and the same fc-list.

The API reference is generated from the doc comments and is published from the default branch to https://jeff.jcollie.page/zig-font-config/. To read it locally, run zig build docs-serve, which serves it at http://127.0.0.1:8000/; use -Ddocs-port=N for a different port. The pages have to be served rather than opened from disk, because the viewer fetches its data at runtime and a browser refuses to do that from a file:// page.

Quick start

const std = @import("std");
const fc = @import("font_config");

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    const io = init.io;

    // The configuration fontconfig would read, then every font it names.
    const config = try fc.Config.load(gpa, io, .fromMap(init.environ_map), .{});
    defer config.deinit();
    try config.scan(io);

    // `fc-match monospace:bold`.
    var query = try fc.name.parse(gpa, "monospace:bold");
    defer query.deinit();
    try config.prepare(&query);
    var font = try config.match(gpa, &query) orelse return;
    defer font.deinit();

    std.debug.print("{s} face {d}\n", .{
        font.getString(.file, 0).?,
        font.getInteger(.index, 0).?,
    });
}

prepare applies the configuration's pattern rules and fills in the defaults, which is what fc-match does to a name before matching; match is FcFontMatch, and its result is the font with the query's other wishes merged in and the font rules applied. sort and list are FcFontSort and FcFontList.

To use it from another project, run zig fetch --save git+https://git.jcollie.dev/jeff/zig-font-config.git and import the font_config module in build.zig:

const font_config = b.dependency("font_config", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("font_config", font_config.module("font_config"));

What it does

Part fontconfig Here
Configuration files: FONTCONFIG_FILE, FONTCONFIG_PATH, FONTCONFIG_SYSROOT, <include> of files and conf.d directories, the xdg, relative and default prefixes fccfg.c, fcxml.c Config.zig, load.zig
Every element of fonts.conf: <match>, <test>, <edit>, <alias>, <selectfont>, <remap-dir>, <reset-dirs>, every expression fcxml.c xml.zig
Applying the rules to a query, a font being scanned and a matched font FcConfigSubstitute rules.zig, Expr.zig
Default substitution: weight, slant, size, pixel size, languages from the locale fcdefault.c default.zig
Reading a font: names in every language, weight, width, slant, spacing, charset, languages, capabilities, variable fonts and their named instances fcfreetype.c scan/query.zig, scan/Face.zig
Scanning directories, with <selectfont> and the scan rules fcdir.c scan/walk.zig
Matching, sorting, and preparing a match fcmatch.c match.zig
Listing fclist.c list.zig
Font names, Family-12:bold fcname.c name.zig
Charsets, language sets, case-insensitive comparison fccharset.c, fclang.c, fcstr.c CharSet.zig, LangSet.zig, str.zig

It does not read:

  • Bitmap and Type 1 fonts. PCF, BDF and Type 1 files, which fontconfig reads through FreeType, are skipped; so are WOFF 2.0 files, which zig-font cannot decompress. Everything sfnt-based — TrueType, OpenType with CFF or CFF2 outlines, collections, WOFF 1.0 — is read.
  • fontconfig's caches. It keeps a cache of its own instead, and writes none of fontconfig's. On a system whose fontconfig caches were built under a different configuration — NixOS ships ones built at package build time — fontconfig answers from those, and the scan rules of the running configuration have not been applied to them, so the two can differ there and only there.
  • Names in Wansung. fontconfig decodes a name record in a legacy CJK encoding with iconv; this decodes Shift JIS, GB 18030, Big5 and Johab with zig-charset, adjusted wherever glibc's iconv reads a byte sequence differently, so that each name comes out as fontconfig would have it — checked against glibc for every one- and two-byte sequence and the four-byte GB 18030 ones. Names in Wansung are skipped, as fontconfig skips them, because glibc's iconv does not know the name fontconfig asks for.

Two things fontconfig takes from the running process are given instead: the program name for prgname (Config.Options.prgname), and the environment, which Config.Environment.fromMap reads from a std.process.Environ.Map.

How it works

The configuration is parsed the way fcxml.c parses it, with a stack of open elements and a stack of the values the closed ones left behind, so that a file with an element in an odd place has the effect it has under fontconfig rather than one a grammar would have chosen. Messages fontconfig would have printed are kept in Config.messages; a configuration that fails to load is replaced with fontconfig's built-in fallback, as fontconfig replaces it, and Config.fallback says so.

Fonts are read through scan/Face.zig, which stands in for FreeType: it decides which cmap is the Unicode one, whether a face is scalable or colored, its bold and italic flags, its PostScript name, and how many named instances a variable font has, each the way FreeType decides it. Some of FreeType's answers depend on the order fontconfig asks its questions in, and those are reproduced too: the advance widths that decide a named instance's spacing come from its gvar phantom points rather than HVAR, because FreeType has not loaded HVAR by then; the whole-font pattern of a variable font is measured at the last named instance rather than the default; and in a collection, a named instance's GSUB and GPOS scripts are looked for at the wrong offset, because fontconfig's own table reader uses a face index that still carries the instance.

Matching scores every font against the query, one number for each of fontconfig's priorities, and compares the scores in priority order, the earlier font winning a tie; a sort orders all of them that way, relaxes the language requirement once each of the query's languages has been satisfied, and with trimming leaves out the fonts that add no character. The result of a match is merged with the query and has the font rules applied, as FcFontRenderPrepare does.

Memory. A Config keeps everything it reads in an arena, freed by deinit, and owns the scanned font patterns. A Pattern keeps its values in an arena of its own, and may borrow from the configuration: a match result is valid as long as the Config it came from.

The data tables — Unicode case folding, the orthographies of the 339 languages fontconfig knows, and the families it knows the generic family of — are built at build time from fontconfig's own source, fetched as a dependency, by tools/gen_tables.zig. The tables fontconfig keys by FreeType's macros, the name-table languages, are in src/sfnt_tables.zig, generated once by tools/gen_sfnt_tables.py.

The cache

Reading every font on a desktop takes seconds — about ten for the 5,883 files of the desktop mentioned above — so Config.scan keeps what it reads in a cache, and the next scan reads only the fonts that have changed: under a second for the same desktop. It is this library's own, in a format of its own, and has nothing to do with fontconfig's.

It lives in zig-font-config in the user's cache folder, which known-folders finds: $XDG_CACHE_HOME, or ~/.cache, on Linux and the BSDs; ~/Library/Caches on macOS; %LOCALAPPDATA%\Temp on Windows. The first scan makes the directory and fills it. If it cannot be made or written — a read-only home, or none at all — the scan goes ahead without it and reads every font, every time. Config.Options.cache names another directory instead, or turns the cache off, and Config.cache_report says what the last scan did with it.

There is a cache file for each font directory. For each file in the directory it holds the file's size, modification time and inode, and the patterns reading it gave. A file is read again when any of the three has changed; a directory's cache file is rewritten when a file in it was read again, appeared or went away. The patterns are kept as they come out of the font, before the configuration's scan rules, which are applied afresh on every scan, so one cache serves every configuration and a change to the configuration needs no rebuild. Each cache file carries a fingerprint of the code that reads fonts and of the tables its values are numbered by, and a checksum; one written by another version, or damaged, is ignored and replaced.

Nothing needs rebuilding by hand, but zfc cache does it: it deletes every cache file and reads every font again, like fc-cache -r. Config.rebuildCache is the same from code.

zfc

zfc is the library behind a command line shaped like fontconfig's, so that the two can be compared:

$ zig build -Doptimize=ReleaseSafe
$ ./zig-out/bin/zfc match monospace:bold        # like fc-match -f '%{file}:%{index}\n'
$ ./zig-out/bin/zfc match -u monospace:bold     # like fc-match -f '%{=unparse}\n'
$ ./zig-out/bin/zfc sort sans-serif:lang=ja     # like fc-match -s
$ ./zig-out/bin/zfc list -u ':lang=ja'          # like fc-list -f '%{=unparse}\n'
$ ./zig-out/bin/zfc query /path/to/font.ttf     # like fc-query -f '%{=unparse}\n'
$ ./zig-out/bin/zfc batch -s < queries.txt      # one query per line, scanning once
$ ./zig-out/bin/zfc config                      # what the configuration says
$ ./zig-out/bin/zfc cache                       # rebuild the cache, like fc-cache -r

Building and testing

The development shell has Zig 0.17.0, fontconfig's own tools to compare against, and the rest:

$ nix develop
$ zig build test                     # unit tests, and the fixture tests below
$ zig build test --fuzz=200K         # a bounded fuzzing run
$ zig build docs                     # the API reference, into zig-out/docs
$ zig build coverage                 # a kcov report, into zig-out/coverage
$ nix flake check                    # the package, and the differential test

The fixture tests (tests/fixtures.zig) read the fonts in tests/fonts under the configuration in tests/config, and compare every answer with what fontconfig said about the same fonts under the same configuration, recorded in tests/golden by tools/make_golden.sh. Run that again after changing a fixture or a query.

The differential test (tests/differential.nix, a flake check) runs fontconfig's fc-match, fc-match -s and fc-list and zfc side by side over fontconfig's default configuration and a dozen font packages from nixpkgs, and fails on any difference.

The fuzzers (tests/fuzz.zig) feed mutated fixture fonts, configuration files, font names and cache entries through everything that reads them.

The test fonts are copied from zig-font, where they are cut down from OFL-licensed fonts; see its tools/make_fixtures.py.

Where this lives

git clone https://git.jcollie.dev/jeff/zig-font-config.git

License

The project follows the REUSE specification; reuse lint checks it. Code written for it is MIT-licensed. The files that port fontconfig's code or carry its data are also under fontconfig's own license, HPND-sell-variant, and say so in their SPDX headers. The test fonts are under the SIL Open Font License, as REUSE.toml records font by font.

References cited