Draws glyphs from zig-font onto z2d surfaces from positioned glyph runs, with fontconfig-based font loading and fallback.

Files
README.md

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, light autohinting, a TrueType bytecode interpreter, 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.

Hinting #

A face can fit its outlines to the pixel grid before they are filled, so that baselines, x-heights, cap heights and the tops and bottoms of stems fall on whole pixel rows instead of being smeared across two. Face.Options.hinting chooses how:

Face.Hinting What it does
none Outlines are drawn exactly as designed. This is the default for a face made directly.
light The autohinter fits vertical positions only. Stems keep their weight, and every x coordinate and advance is left as designed, so glyphs still go exactly where the shaper put them.
full A TrueType font with instructions is hinted by them, vertically, with x left as designed as under light. Any other font, CFF ones included, gets light.

The loader takes the mode from the matched pattern, mapping it as cairo does, where only hintslight asks for FreeType's light mode:

Pattern Hinting
hinting=false or hintstyle=hintnone none
hintstyle=hintslight light
hintstyle=hintmedium, or hintfull, fontconfig's default full
autohint=true, with any hint style but hintnone light

The autohinter #

The autohinter follows the method of FreeType's light autohinter, as Turner and Lemberg describe it, but is written from scratch here. Once per face it measures blue zones from the Latin letters that reach them:

  • capital tops and bottoms;
  • ascenders;
  • the x-height;
  • small bottoms;
  • descenders.

Each zone has a reference height from the flat letters and an overshoot from the round ones. It also measures the usual thickness of a horizontal stroke, from o.

For each glyph:

  1. The vertical scale is nudged so the x-height lands on a whole pixel.
  2. The outline is cut into horizontal segments, and facing segments are linked into stems.
  3. Segments at nearly the same height are gathered into edges.
  4. Edges in a blue zone snap to the zone's rounded height, and an overshoot shows only once it reaches half a pixel.
  5. Other stems have one edge rounded to the grid and keep their width.
  6. Every other point is placed between the fitted edges, or interpolated along its contour.

A hinted glyph's origin is rounded to a whole pixel row, so that the grid it is fitted to is the real one. Horizontal quarter-pixel placement is kept.

The TrueType interpreter #

Under full, a font whose maxp says it has instructions is hinted by them, as FreeType decides. hint/tt/Interpreter.zig runs the whole TrueType instruction set, written from the OpenType specification and Apple's TrueType Reference Manual, in their own 26.6 fixed-point arithmetic. hint/tt.zig drives it:

  • Per size, the control value table is scaled to the size, varied by cvar at the face's coordinates, and fpgm and then prep run. What they leave is kept for the last four sizes drawn.
  • Per glyph, the glyph's points are scaled together with its four phantom points, varied by gvar, and its instructions run, always from the state prep left, so a glyph hints the same whichever was drawn before it.
  • Per composite, each component is hinted on its own and placed, with its offset rounded to whole pixels where the composite asks for that, and then the composite's own instructions run over the assembled points.

The interpreter behaves as Microsoft's ClearType rasterizer does in its backward compatibility mode, and as FreeType's default interpreter (version 40) does:

  • Horizontal moves are ignored, so the glyph keeps its designed x coordinates and advance and goes where the shaper put it.
  • DELTAP and SHPIX move only points already placed vertically, apart from in composites.
  • Once both IUP[y] and IUP[x] have run, later moves are ignored. Those are mostly the corrections a font made for black-and-white rendering.
  • A font can ask for ClearType's native mode with INSTCTRL, which turns all of this off inside the interpreter. Only the vertical result is drawn either way.
  • GETINFO reports version 40, with ClearType and its symmetric, sub-pixel positioned and gray modes, as a font hinted for today's screens expects.

Fonts get their bytecode wrong often enough that the interpreter forgives what other interpreters forgive. A point number out of range makes its instruction do nothing, and a stack with too few operands gives zeros. A program that fails stops where it failed, and keeps what it did before. Every program runs under limits on stack, storage, call depth and instruction count, from maxp where it says, so broken bytecode costs neither a crash nor a hang. A glyph that will not load at all is left to the autohinter.

The tests check the interpreter against FreeType 2.14.3: tools/make_hinting_refs.py records the hinted height of every point of every glyph of the two instructed fixture fonts, at twelve sizes from 9 to 32 pixels per em, and every one matches exactly, apart from the sizes Fixture Mono's gasp table asks not to be hinted. Static system fonts checked the same way (DejaVu Sans, Liberation Sans and Serif, Gentium Plus, Hack, JetBrains Mono) match exactly. A few glyphs of FreeSerif differ by up to 9/64 of a pixel, and variable fonts by up to 2/64, where FreeType's fixed-point gvar arithmetic rounds differently from zig-font's.

What is not done #

  • Zones are measured for Latin only. A glyph from another script is fitted by its stems and by whichever Latin zones its edges happen to fall in.
  • Color glyphs, bitmaps and outline.appendRun are never hinted.
  • An outline the autohinter cannot take is drawn unhinted rather than not at all, for example one with more than 16,384 points.
  • The interpreter's horizontal results are never drawn, so a font hinted for black-and-white rendering does not get its sharp vertical stems.
  • At a size where a font's gasp table asks for no grid-fitting, the font is drawn unhinted. FreeType ignores gasp, and Windows honors it.
  • A font whose head flags ask for whole pixels per em is hinted at its size rounded to a whole pixel, and drawn at that size.

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
$ zig build run -- --font DejaVuSans.ttf --size 13 --hinting light -o h.png Hamburgefonstiv
$ zig build run -- --font DejaVuSans.ttf --size 13 --hinting full -o f.png Hamburgefonstiv

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, variation coordinates and hinting modes that the input chooses. A second fuzz target runs bytecode from the input through the TrueType interpreter.

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. fixture-sans.hinted and fixture-mono.hinted record how FreeType hints the two fixtures that have instructions; tools/make_hinting_refs.py makes them. 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 #