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,595 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 |
| Reading a BDF or PCF bitmap font through its XLFD properties | fcfreetype.c |
scan/bitmap.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:
- Type 1 fonts, and some compressed ones. Type 1 files, which
fontconfig reads through FreeType, are skipped; so are WOFF 2.0 files, and
PCF files compressed with
compressorbzip2(.pcf.Z,.pcf.bz2), which zig-font cannot decompress. Everything sfnt-based — TrueType, OpenType with CFF or CFF2 outlines, collections, WOFF 1.0 — is read, and so are BDF and PCF bitmap fonts, PCF gzip-compressed or not. - 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.
Bitmap fonts in BDF and PCF are read with zig-font's BitmapFont and
described as FreeType's bdf and pcf drivers describe them, since those
fonts have no tables of their own and fontconfig takes nearly everything
from what the drivers make of their XLFD properties: the family from
FAMILY_NAME, the style from WEIGHT_NAME, SLANT, SETWIDTH_NAME and
ADD_STYLE_NAME, the pixel size from PIXEL_SIZE or else the point size and
resolution, and characters only for a font encoded in ISO 10646, ISO 8859-1
or ASCII. The drivers' quirks come along: a BDF font's last value for a
property counts and a PCF font's first, a quoted number in a BDF numeric
property reads as 0, a gzip-compressed BDF font is not opened at all, and a
font with no Unicode characters and a PIXEL_SIZE is skipped, as fontconfig
skips it.
Bitmap-only sfnt fonts, the .otb files most bitmap fonts are now also
shipped as, are read as FreeType reads them. The empty glyf table that
fontforge writes into them counts as no table at all, so they are not
scalable, have a pixel size for each strike, and take bold and italic from
head rather than OS/2. fontforge also writes a BDF table of each strike's
XLFD properties, which FreeType answers BDF property lookups from, so a
SPACING or PIXEL_SIZE there counts as it does in a BDF font. It counts
only in the strike matching the size FreeType has selected: the one nearest
16 pixels tall, chosen while measuring the spacing.
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 a while — about 1.3 seconds for the
5,883 files of the desktop mentioned above, built with ReleaseSafe, and
several times that in Debug — so Config.scan keeps what it reads in a
cache, and the next scan reads only the fonts that have changed: a quarter of
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 damaged is ignored and replaced. The fingerprint is part of the file's name too, so two versions of the library sharing the cache — an installed program and a development build of it, say — each keep their own files instead of overwriting each other's on every start.
A scan touches each cache file it reads, at most once a day, and deletes any cache file that nothing has touched in 30 days. That clears out the files of versions no longer run and of font directories no longer scanned, such as Nix store paths a configuration has moved on from.
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 eighteen font packages from
nixpkgs, six of them X bitmap fonts in BDF, PCF and .otb, and fails on any
difference. The default configuration rejects bitmap fonts, so the test
adds fontconfig's 70-yes-bitmaps.conf to let them through to be matched
and listed.
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, or from the BSD-licensed Spleen for the bitmap ones, or
built from nothing; see its tools/make_fixtures.py. The .otb fonts are
made here from Spleen by tools/make_otb_fixtures.py.
Where this lives #
git clone https://git.jcollie.dev/jeff/zig-font-config.git
-
Radicle, as
rad:z6Zas8bmZhd8yy24HuEHusc5rNrw:rad clone rad:z6Zas8bmZhd8yy24HuEHusc5rNrw
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 #
- Fontconfig developers. Fontconfig, version 2.18.3. Source code. https://gitlab.freedesktop.org/fontconfig/fontconfig
- Fontconfig developers. fonts-conf: Font configuration files. Fontconfig User's Guide. https://www.freedesktop.org/software/fontconfig/fontconfig-user.html
- Fontconfig developers. Fontconfig Developers Reference. https://www.freedesktop.org/software/fontconfig/fontconfig-devel/
- FreeType project. FreeType, version 2.14.3. Source code. https://gitlab.freedesktop.org/freetype/freetype
- Microsoft Corporation. OpenType Specification, version 1.9.1, May 2024. https://learn.microsoft.com/en-us/typography/opentype/spec/
- Unicode Consortium. CaseFolding.txt. Unicode Character Database. https://www.unicode.org/Public/UCD/latest/ucd/CaseFolding.txt
- freedesktop.org. XDG Base Directory Specification, version 0.8. https://specifications.freedesktop.org/basedir-spec/latest/
- van Kesteren, Anne. Encoding Standard. WHATWG Living Standard, 21 May 2026. https://encoding.spec.whatwg.org/
- GNU C Library developers. The GNU C Library, version 2.44. Free Software
Foundation. Source code, the iconv converters in
iconvdata/. https://sourceware.org/glibc/
- Zig 95%
- Nix 3%
- Python 2%
- Shell <1%