A Zig library that finds fonts as fontconfig does, without it: the same configuration, OpenType and bitmap fonts, and the same answers.

Files
README.md

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 compress or bzip2 (.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

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 #