- Zig 96%
- Nix 2.9%
- Python 0.8%
- Shell 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01XxMfD5H5cp867D4Rdkx6Di |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
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
-
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/