Draws glyphs from zig-font onto z2d surfaces, from positioned glyph runs a shaper produces, with fontconfig-based loading and fallback.
  • Zig 92.6%
  • Nix 5.9%
  • Python 1.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie ed194713bf
Some checks are pending
test / docs (push) Blocked by required conditions
test / test (push) Has started running
Update zig-font and zig-font-config
Both packages are now named font and font_config, and the dependencies
here take those names too. zig-font-config now builds on the same
zig-font as this, so the two share one copy instead of two.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NyyMjYZLRMjLCoyxQJwfTC
2026-10-10 16:32:49 -05:00
.forgejo/workflows Draw glyphs from zig-font onto z2d surfaces 2026-10-06 02:25:08 -05:00
LICENSES Draw glyphs from zig-font onto z2d surfaces 2026-10-06 02:25:08 -05:00
src Draw CBDT and sbix emoji 2026-10-10 16:25:08 -05:00
tests Draw CBDT and sbix emoji 2026-10-10 16:25:08 -05:00
tools Draw CBDT and sbix emoji 2026-10-10 16:25:08 -05:00
.gitignore Draw glyphs from zig-font onto z2d surfaces 2026-10-06 02:25:08 -05:00
build.zig Update zig-font and zig-font-config 2026-10-10 16:32:49 -05:00
build.zig.zon Update zig-font and zig-font-config 2026-10-10 16:32:49 -05:00
build.zig.zon.nix Update zig-font and zig-font-config 2026-10-10 16:32:49 -05:00
flake.lock Draw glyphs from zig-font onto z2d surfaces 2026-10-06 02:25:08 -05:00
flake.nix Draw glyphs from zig-font onto z2d surfaces 2026-10-06 02:25:08 -05:00
package.nix Draw glyphs from zig-font onto z2d surfaces 2026-10-06 02:25:08 -05:00
README.md Draw CBDT and sbix emoji 2026-10-10 16:25:08 -05:00
REUSE.toml Draw CBDT and sbix emoji 2026-10-10 16:25:08 -05:00

zig-font-renderer

Draws glyphs from fonts decoded by zig-font onto z2d surfaces, with zig-svg for SVG color glyphs and z2dimg for emoji pictures, and finds those fonts the way fontconfig does with zig-font-config. It is written for Zig 0.17.

The API documentation is generated from the doc comments and published at https://jeff.jcollie.page/zig-font-renderer/.

The renderer is the layer below a shaper. What goes in is positioned glyphs: one face at one size, plus a list of glyph IDs and where each one goes. That is exactly what a shaper produces. The renderer never looks at text. A small unshaped layout stands in for a shaper until there is one.

Modules

The package exports two modules:

Module Depends on What it does
font_renderer zig-font, z2d, zig-svg, z2dimg Face, Run, drawRun, GlyphCache, COLR, SVG, CBDT and sbix color glyphs, EBDT bitmaps, and the stand-in simple_layout. Does no I/O.
font_loader font_renderer, zig-font-config Resolves a fontconfig pattern to a fallback list, loads font files into faces, and picks a face per codepoint. Reads files through the Io it is given.

A program that already has its font bytes needs only font_renderer, and does not pull in a fontconfig implementation.

Quick start

const font = @import("font");
const renderer = @import("font_renderer");
const z2d = @import("z2d");

var f = try font.Font.parse(gpa, bytes, .{});
defer f.deinit();
var face = try renderer.Face.init(gpa, &f, .{
    .variations = &.{.{ .tag = .init("wght"), .value = 700 }},
});
defer face.deinit(gpa);

var surface = try z2d.Surface.init(.image_surface_rgba, gpa, 400, 80);
defer surface.deinit(gpa);

var cache: renderer.GlyphCache = .init(gpa, renderer.GlyphCache.default_budget);
defer cache.deinit();

// A shaper would produce these glyphs; the stand-in layout does it here.
var placed = try renderer.simple_layout.layout(gpa, &face, 32, "Hello");
defer placed.deinit(gpa);
try renderer.drawRun(gpa, &surface, .{ .face = &face, .size = 32, .glyphs = placed.glyphs }, 10, 50, .{
    .color = .{ .rgba = .{ .r = 0, .g = 0, .b = 0, .a = 255 } },
    .cache = &cache,
});

With fontconfig:

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

const config = try fc.Config.load(gpa, io, .fromMap(init.environ_map), .{});
defer config.deinit();
try config.scan(io);

var loader: font_loader.Loader = .init(gpa, io, config);
defer loader.deinit();
var list = try loader.resolveName("sans-serif:bold");
defer list.deinit();

const face = (try list.faceFor('A')).?;               // what an itemizer asks
var laid = try font_loader.fallback_layout.layout(gpa, &list, 32, "Hello, 世界");
defer laid.deinit(gpa);
for (laid.runs) |r| try renderer.drawRun(gpa, &surface, r, 10, 50, .{});

The interface a shaper uses

  • Face is a borrowed font.Font together with normalized variation coordinates, set by a named instance and/or axis values. It also provides glyphIndex, advance (with HVAR), kerning (the legacy kern table), metrics (OS/2 or hhea, with MVAR) and glyphOutline. Face.Options.embedded_bitmaps decides whether the face's bitmaps are used.
  • Run is { face, size, glyphs }. Each Glyph is { id, x, y } in pixels, y down, relative to the run's origin.
  • run.place turns HarfBuzz-style advances and offsets, in font units, into a Run's glyphs.
  • FontList.faceFor(codepoint) returns the first face in the fallback list whose fontconfig charset has that codepoint and that the renderer can draw.
  • outline.appendRun appends a run's outlines to a z2d.Path. Use it to draw text under any transformation, such as rotation or skew, or to stroke text.

How it works

Outlines

zig-font keeps whatever curves the font had: quadratics from glyf, cubics from CFF and CFF2. z2d draws only cubics, so each quadratic is raised to the cubic that traces the same curve, with control points two thirds of the way toward the quadratic's. Variable fonts are drawn at the face's coordinates through gvar or CFF2 blends.

Masks and the cache

Each glyph is rendered once into an image of its own:

  • an alpha8 mask for an outline or bitmap glyph, painted in the text color when it is composited;
  • a pre-multiplied RGBA image for a color glyph.

Glyphs are placed to the nearest quarter pixel in each direction. A cache entry is keyed by:

  • face, glyph and size;
  • that quarter-pixel offset;
  • anti-aliasing mode;
  • for color glyphs, the palette and foreground color.

GlyphCache holds entries up to a byte budget and evicts the least recently used. A glyph with nothing to draw, such as a space, is cached too. Drawing without a cache gives the same pixels, only slower. The tests check this byte for byte.

Embedded bitmaps

EBLC/EBDT hold monochrome or grayscale images drawn for particular sizes. They are in bitmap-only fonts, such as Terminus's .otb files, and in outline fonts that carry hand-tuned images for their smallest sizes. A glyph is drawn from the first of these that it has:

  1. a bitmap at exactly the size asked for, within 1/64 pixel;
  2. its outline;
  3. the nearest bitmap, scaled.

At its own size a bitmap is copied pixel for pixel, its origin rounded to a whole pixel. Scaled, it is resampled with a box filter. That keeps an enlargement by a whole number sharp and softens other scales as little as it can. Image formats 1, 2 and 5 to 9 are read, at 1, 2, 4 or 8 bits a pixel, composites included. EBSC is not used.

Bitmaps are on by default, as in FreeType. Face.Options.embedded_bitmaps turns them off, and the loader sets it from the matched pattern's embeddedbitmap, which fontconfig configurations sometimes turn off for particular fonts. Bitmaps are not paths, so outline.appendRun always draws outlines.

Color glyphs

A glyph with more than one color form is drawn from the first of these it has:

  1. a COLR version 1 paint graph;
  2. an SVG document;
  3. COLR version 0 layers. A font with SVG glyphs often carries these only as a fallback for renderers that cannot draw SVG.
  4. a CBDT picture;
  5. an sbix picture.

Pictures come last because they are drawn for a few sizes and scaled to any other. If the chosen form can't be drawn, the next one is tried, and the plain outline comes last. drawRun's color_glyphs option turns all of them off, and then a glyph with nothing but a picture draws nothing.

COLR

COLR version 0 glyphs are layers of outlines, each filled with a CPAL color. Version 1 glyphs are paint graphs, evaluated recursively:

Paint Drawn as
Solid An opaque z2d pattern
Linear and radial gradients z2d gradients. The color line is stretched so its stops fit z2d's 0 to 1 range, and its colors are blended as the palette's sRGB bytes, as other renderers blend them.
Sweep gradients A z2d conic gradient, with a stop wherever the sweep's color changes slope, including the copies that repeat and reflect produce
PaintGlyph A glyph filled with its child paint. When the child is a solid or a gradient under transforms, this is a single fill. Otherwise the child is drawn into a layer and masked.
Transforms Composed into the current transformation
PaintComposite Two layers combined with the matching z2d operator: all Porter-Duff and blend modes
PaintColrGlyph Recursion, with a cycle check

Variable paints take their deltas from COLR's item variation store. The image is the clip box when the glyph has one, and the union of its outlines otherwise. Hostile fonts are limited in three ways:

  • nesting depth is capped at 64;
  • a glyph may evaluate at most 10,000 paints;
  • an image may be at most 4096 pixels on a side.

SVG

Each document in the SVG table covers a range of glyphs, and glyph N is its element with id="glyphN". zig-svg draws that element as the specification asks, as though it were the target of a <use>:

  • one SVG unit is one font unit;
  • the origin is the glyph origin, and y points down;
  • a root viewBox, width or height scales the drawing to the em square.

Palette entries reach the document as the custom properties --color0, --color1 and so on, read through var(). currentColor, context-fill and context-stroke are the text color. The image is the box zig-svg reports for the glyph, which includes the reach of its strokes.

A document is inflated if it is gzipped (up to 16 MiB), parsed on first use and kept by the face for the glyphs it shares. A face keeps at most 32 parsed documents and drops the oldest to make room. Because of this, one face must not be drawn from on two threads at once. A document that won't parse is remembered as such, so it isn't tried again for every glyph in it. zig-svg bounds the walk of each glyph on its own, and any error it reports sends the glyph on to its next form.

Bitmap color glyphs

CBLC/CBDT (Google's emoji fonts) and sbix (Apple's) hold pictures, almost always PNGs, for a few sizes. z2dimg decodes them, PNG and JPEG alike; an sbix TIFF or PDF is not drawn. The strike drawn from is the smallest at least as large as the size asked for, or else the largest, because shrinking a picture loses less than enlarging one:

  • at a strike's own size, the picture is copied as it is, its origin rounded to a whole pixel;
  • shrunk, every source pixel is averaged into the result (a box filter);
  • enlarged, it is interpolated linearly between pixel centers.

A CBDT picture is placed by its glyph metrics and an sbix one by its origin offset, which is where its lower left corner goes. CBDT composites and uncompressed 32-bit images are drawn too, and an sbix dupe draws the glyph it names. No picture larger than 2048 pixels on a side is decoded.

The loader skips fonts that have nothing at all to draw when it chooses a fallback, so that it does not pick a face that would draw nothing.

The stand-in layout

simple_layout and fallback_layout map each codepoint through cmap and then place glyphs using hmtx advances (with HVAR) and legacy kern pairs. They do no GSUB and no GPOS, so there are no ligatures, no mark positioning and no right-to-left text. They exist so that the renderer can be tested and used before a shaping library replaces them.

fontrender

A command-line tool that renders a line of text to a PNG:

$ zig build run -- -f "Noto Sans:bold" --size 48 -o hello.png "Hello, world"
$ zig build run -- --font tests/fonts/nabla.ttf --size 120 -o nabla.png AB
$ zig build run -- --font Some-Variable.ttf --var wght=300,wdth=80 -o v.png Text

Run fontrender with no arguments to see all the options.

Building and testing

The development shell provides Zig 0.17 from nixpkgs, kcov, reuse, git-pages-cli and zon2nix:

$ nix develop
$ zig build test --summary all
$ zig build test --fuzz=100K        # a bounded fuzzing run
$ zig build coverage                # kcov report in zig-out/coverage
$ zig build docs                    # API docs in zig-out/docs
$ zig build docs-serve              # and served on http://127.0.0.1:8000/
$ nix build .#zig-font-renderer     # the package, tests included

The fuzzer edits the fixture fonts' bytes and draws whatever still decodes, at sizes, offsets, palettes and variation coordinates that the input chooses.

After changing a dependency in build.zig.zon, regenerate the Nix expression for it:

$ nix develop -c zon2nix --17 --nix=build.zig.zon.nix build.zig.zon

Test fonts

tests/fonts holds copies of zig-font's fixture fonts. Most are subsets of OFL fonts, and colr-v1-paints.ttf is built from scratch to exercise every COLRv1 paint format. sbix-color.ttf is this project's own, built by tools/make_fixtures.py (which says how to run it) with real pictures in two strikes. REUSE.toml gives each one's license. tests/config is zig-font-config's fixture fontconfig configuration, which scans tests/fonts.

Where this lives

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

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

rad clone rad:z33kpyaaAiumLmTUDm4WBBo5WAQLx

License

MIT, apart from the fixture fonts, which are under their own licenses (see REUSE.toml). The project follows the REUSE specification.

References cited