A sans-I/O Zig library that decodes TrueType and OpenType fonts into plain in-memory structures.
  • Zig 96.1%
  • Python 2.7%
  • Nix 1.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 7dbcd398e3
All checks were successful
test / test (push) Successful in 1h32m14s
test / docs (push) Successful in 5m45s
Name the package font
The package is now `font` rather than `zig_font`, matching the module it
exports, so a dependent writes `b.dependency("font", ...)`.

The fingerprint's high half is a checksum of the name and changes with
it; the low half, the package's random identity, is kept, since this is
the same package renamed and not a fork.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGQDQ1mkbqUhBH5poiYMVH
2026-10-10 15:10:52 -05:00
.forgejo/workflows Decode WOFF 2.0 fonts and collections 2026-10-03 23:40:00 -05:00
LICENSES Decode BDF and PCF bitmap fonts 2026-10-10 15:05:42 -05:00
src Decode BDF and PCF bitmap fonts 2026-10-10 15:05:42 -05:00
tests Decode BDF and PCF bitmap fonts 2026-10-10 15:05:42 -05:00
tools Decode BDF and PCF bitmap fonts 2026-10-10 15:05:42 -05:00
.gitignore Decode WOFF 2.0 fonts and collections 2026-10-03 23:40:00 -05:00
build.zig Decode WOFF 2.0 fonts and collections 2026-10-03 23:40:00 -05:00
build.zig.zon Name the package font 2026-10-10 15:10:52 -05:00
build.zig.zon.nix Decode WOFF 2.0 fonts and collections 2026-10-03 23:40:00 -05:00
flake.lock Decode WOFF 2.0 fonts and collections 2026-10-03 23:40:00 -05:00
flake.nix Decode WOFF 2.0 fonts and collections 2026-10-03 23:40:00 -05:00
package.nix Decode WOFF 2.0 fonts and collections 2026-10-03 23:40:00 -05:00
README.md Name the package font 2026-10-10 15:10:52 -05:00
REUSE.toml Decode BDF and PCF bitmap fonts 2026-10-10 15:05:42 -05:00

zig-font

A Zig 0.17 library that decodes TrueType and OpenType fonts into plain Zig values: metrics, character maps, names, outlines, layout lookups, variation data, color glyphs and embedded bitmaps. It also decodes the X Window System's bitmap fonts, BDF and PCF.

It does no I/O. You hand it the bytes of a font file and it hands back structures. Shaping, rasterizing, subsetting and anything else you might do with a font are left to the program that imports it.

The API reference is generated from the doc comments, which carry most of the explanation of what each structure holds and why. It is published from the default branch to https://jeff.jcollie.page/zig-font/. 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 font = @import("font");

pub fn example(gpa: std.mem.Allocator, bytes: []const u8) !void {
    var f = try font.Font.parse(gpa, bytes, .{});
    defer f.deinit();

    const name = f.name.?.find(font.tables.name.id.full_name) orelse "(unnamed)";
    const units_per_em = f.head.units_per_em;

    // From a character to a glyph, and from a glyph to its outline.
    const glyph = f.cmap.?.lookup('A') orelse 0;
    const path = try f.glyphPath(gpa, @intCast(glyph), &.{});
    defer gpa.free(path.commands);

    std.debug.print("{s}: {d} units/em, 'A' is glyph {d} with {d} contours\n", .{
        name, units_per_em, glyph, path.contourCount(),
    });
}

To use it from another project, run zig fetch --save <url> and import the font module in build.zig:

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

What it decodes

Font.parse decodes everything at once, and each table also has a decoder of its own under font.tables, for a program that wants one table and not the rest.

Area Tables
Containers TrueType and OpenType sfnt, TrueType Collections (.ttc/.otc), WOFF 1.0, WOFF 2.0 (collections included)
Required head, maxp
Metrics hhea, hmtx, vhea, vmtx, VORG, hdmx, LTSH, VDMX, PCLT
Naming and mapping name (decoded to UTF-8), OS/2 (versions 0–5), post (all versions, with glyph names), cmap (formats 0, 2, 4, 6, 8, 10, 12, 13 and 14)
TrueType outlines loca, glyf (simple and composite glyphs), cvt , fpgm, prep, gasp
PostScript outlines CFF (including CID-keyed fonts) and CFF2, with the charstrings run into paths
Layout GDEF, GSUB and GPOS (every lookup type and subtable format), BASE, JSTF, MATH, legacy kern (OpenType and Apple versions)
Variations fvar, avar (versions 1 and 2), gvar, cvar, HVAR, VVAR, MVAR, STAT
Color and bitmaps COLR (version 0 and the version 1 paint graph), CPAL, CBLC/CBDT, EBLC/EBDT, EBSC, sbix, SVG
Other meta, DSIG (decoded, not verified)
Bitmap fonts BDF 2.1, 2.2 and the grayscale 2.3; PCF in every padding, bit order and byte order; either one gzip-compressed

PNG images in bitmap tables and SVG documents are handed back as bytes rather than decoded; for SVG documents, tables.svg.decompress inflates the gzip-compressed ones on request.

WOFF and WOFF 2.0 files are unwrapped into the sfnt they were made from, which is then decoded like any other. WOFF 2.0 compresses with Brotli, which the standard library does not have, so it comes from zig-brotli, the library's one dependency. Its glyf, loca and hmtx transforms are undone, so the rebuilt font has every glyph, point and instruction of the original, though its glyf may pack them into different bytes. A WOFF2 collection is rebuilt as a TrueType Collection, whose fonts Collection and Font.parseIndex decode as they would from a .ttc.

Bitmap fonts

BDF and PCF fonts are not sfnt fonts, and Font does not take them. BitmapFont.parse does, and decodes either format, gzip-compressed or not, to the same glyphs:

pub fn drawA(gpa: std.mem.Allocator, bytes: []const u8) !void {
    var f = try font.BitmapFont.parse(gpa, bytes, .{});
    defer f.deinit();

    const glyph = f.glyph('A') orelse f.defaultGlyph() orelse return;
    for (0..glyph.bitmap.height) |y| {
        for (0..glyph.bitmap.width) |x| {
            const inked = glyph.bitmap.pixel(@intCast(x), @intCast(y)) != 0;
            std.debug.print("{s}", .{if (inked) "#" else "."});
        }
        std.debug.print("\n", .{});
    }
}

A glyph is its box relative to the origin, its advance, and its pixels, which are handed back the same way whatever the file stored them as: most significant bit first, each row starting on a byte. A PCF font may have been compiled with any of four row paddings and any order of bits and bytes, and that is undone here. Codes are looked up in the font's own encoding, which its CHARSET_REGISTRY and CHARSET_ENCODING properties name, and are Unicode code points only when those say ISO10646 and 1.

The font's properties are kept as the file has them, as integers and strings, and so are the parts of each format the other has no place for: BitmapFont.bdf holds a BDF font's version, comments, size and resolution, and BitmapFont.pcf every table of a PCF font, ink metrics and accelerators included.

How it works

Memory. A Font keeps everything in a single arena, and Font.deinit frees it. By default parse copies the font's bytes into that arena first, so you can free your own buffer as soon as parse returns. Fields that hold bytes, such as hinting instructions, bitmap images, SVG documents and tables the library does not decode, are slices of that copy rather than separate allocations. With .copy = false the Font borrows your buffer instead, and the buffer has to outlive it.

Broken fonts. head and maxp are required, and without them parse fails. Every other table is optional: if one is present but cannot be decoded, it is left null and recorded in Font.issues, and the rest of the font still loads. Within a table, outlines work the same way: one malformed glyph is recorded against that glyph and does not take the other 60,000 down with it. Pass .strict = true to make any table failure fail the whole parse.

Hostile input. Every read goes through a bounds-checked cursor, so data that runs past its end is error.Truncated rather than a panic. Counts are checked against the bytes available before anything is allocated. Recursion such as composite glyphs, charstring subroutines and the COLR paint graph is bounded and checked for cycles. Subtables that a font may reference thousands of times, such as coverage tables and paints, are decoded once and shared, so memory stays proportional to the size of the file.

Outlines. Font.glyphPath returns any glyph's outline as a Path of move, line, quadratic, cubic and close commands. It works for glyf, CFF and CFF2 fonts alike, in font units with y pointing up. TrueType outlines stay quadratic and CFF outlines stay cubic; neither is converted to the other.

Variations. Font.normalizeCoordinates converts user-space axis values, such as wght=700, to the normalized coordinates the variation tables use. It applies avar, and its integer arithmetic matches HarfBuzz exactly. Passing those coordinates to glyphPath gives the outline at that point in the design space: gvar deltas are applied for TrueType outlines, including interpolation of untouched points, and CFF2 blends are applied for PostScript outlines. The metric variation tables offer delta helpers of their own, for example HVAR.advanceDelta and MVAR.delta.

Building and testing

The toolchain comes from Nix. nixpkgs does not carry Zig 0.17 yet, so the 0.17.0 release is taken from zig-overlay.

$ nix develop
$ zig build test --summary all        # unit tests, then tests against real fonts
$ zig build test --fuzz               # mutation fuzzing, until interrupted
$ zig build test --fuzz=1M            # a bounded fuzzing run
$ zig build coverage                  # kcov report in zig-out/coverage
$ zig build docs                      # API reference in zig-out/docs
$ nix build .#zig-font                # the package, tests included, in the sandbox

Every table has unit tests built from hand-made bytes. The fixture tests in tests/ check decoded values against what fontTools reads from the same files. Outlines are compared glyph by glyph with fontTools' pens, at the default location and at other locations in variable fonts. A BDF font is text, and is its own reference. Every PCF fixture is compiled from one of the BDF fixtures by X.Org's bdftopcf, each in a different bit order, byte order and padding, and has to decode to exactly the glyphs of the BDF it came from.

The fuzzer starts from the fixture fonts and edits them: it overwrites bytes and sets fields to the values most likely to break a decoder. This keeps enough of each file intact that the edits reach the decoders deep inside it. It then decodes every table, draws every glyph and runs the lookups. Without --fuzz, it runs each fixture once, unedited.

The test binaries are compiled by LLVM even in Debug builds. The self-hosted backend emits neither the coverage table the fuzzer steers by nor debug information that kcov can read.

Test fonts

tests/fonts/ contains small subsets of fonts released under the SIL Open Font License, made by tools/make_fixtures.py from the fonts in nixpkgs. The OFL does not allow a modified version to use a reserved font name, so subsets of Source Sans and Anonymous Pro are renamed Fixture Sans and Fixture Mono. Two fixtures are built from scratch: colr-v1-paints.ttf uses every COLRv1 paint format, and sbix.ttf covers sbix, which no small OFL font in nixpkgs has. The WOFF 2.0 fixtures are compressed from these, by fontTools and by Google's reference encoder, so that the output of both is tested; only the reference encoder can make a WOFF2 collection. The bitmap fonts are a subset of Spleen, which is BSD-licensed and ships as BDF, and odd-metrics.bdf, built from scratch with the awkward glyph boxes Spleen's uniform cells never have; bdftopcf compiles both to PCF. REUSE.toml records the copyright and license of each.

To regenerate them:

$ nix develop -c python3 tools/make_fixtures.py

Where this lives

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

It is on Radicle as rad:z2z9bVWrjxkhQNdw3Uz6zgeTP92vd. A Radicle repository can only be found by its identifier, so that is all a peer needs to fetch it:

rad clone rad:z2z9bVWrjxkhQNdw3Uz6zgeTP92vd

License

The code is MIT-licensed, and the project follows the REUSE specification; reuse lint checks it. The test fonts keep their own licenses, as recorded in REUSE.toml.

References cited