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 #
Faceis a borrowedfont.Fonttogether with normalized variation coordinates, set by a named instance and/or axis values. It also providesglyphIndex,advance(withHVAR),kerning(the legacykerntable),metrics(OS/2orhhea, withMVAR) andglyphOutline.Face.Options.embedded_bitmapsdecides whether the face's bitmaps are used.Runis{ face, size, glyphs }. EachGlyphis{ id, x, y }in pixels, y down, relative to the run's origin.run.placeturns HarfBuzz-style advances and offsets, in font units, into aRun'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.appendRunappends a run's outlines to az2d.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:
- The vertical scale is nudged so the x-height lands on a whole pixel.
- The outline is cut into horizontal segments, and facing segments are linked into stems.
- Segments at nearly the same height are gathered into edges.
- Edges in a blue zone snap to the zone's rounded height, and an overshoot shows only once it reaches half a pixel.
- Other stems have one edge rounded to the grid and keep their width.
- 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
cvarat the face's coordinates, andfpgmand thenpreprun. 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 stateprepleft, 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.
DELTAPandSHPIXmove only points already placed vertically, apart from in composites.- Once both
IUP[y]andIUP[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. GETINFOreports 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.appendRunare 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
gasptable asks for no grid-fitting, the font is drawn unhinted. FreeType ignoresgasp, and Windows honors it. - A font whose
headflags 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:
- a bitmap at exactly the size asked for, within 1/64 pixel;
- its outline;
- 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:
- a
COLRversion 1 paint graph; - an
SVGdocument; COLRversion 0 layers. A font with SVG glyphs often carries these only as a fallback for renderers that cannot draw SVG.- a
CBDTpicture; - an
sbixpicture.
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,widthorheightscales 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 #
- Apple Inc. TrueType Reference Manual. https://developer.apple.com/fonts/TrueType-Reference-Manual/
- Fontconfig developers. Fontconfig Developers Reference. freedesktop.org. https://www.freedesktop.org/software/fontconfig/fontconfig-devel/
- Fontconfig developers. fonts-conf: Font configuration files. Fontconfig User's Guide. https://www.freedesktop.org/software/fontconfig/fontconfig-user.html
- fontTools contributors. fontTools. https://github.com/fonttools/fonttools
- FreeType project. FreeType (version 2.14.3). https://gitlab.freedesktop.org/freetype/freetype
- HarfBuzz contributors. HarfBuzz. https://github.com/harfbuzz/harfbuzz
- Marchesi, Chris. z2d. https://github.com/vancluever/z2d
- Microsoft Corporation. "Backwards Compatibility of TrueType Instructions with Microsoft ClearType." White paper. Microsoft Typography, February 5, 2018. https://learn.microsoft.com/en-us/typography/cleartype/truetypecleartype
- Microsoft Corporation. OpenType Specification (version 1.9.1), May 2024. https://learn.microsoft.com/en-us/typography/opentype/spec/
- Reizner, Yevhenii. resvg (version 0.48.1). Linebender. https://github.com/linebender/resvg
- Rougier, Nicolas P. freetype-py (version 2.5.1). https://github.com/rougier/freetype-py
- Turner, David, and Werner Lemberg. "Real-Time Grid Fitting of Typographic Outlines." TUGboat 24, no. 3 (2003): 430–434. Proceedings of EuroTeX 2003. https://tug.org/TUGboat/tb24-3/lemberg.pdf
- Waxweiler, Nikolaus. "The New v40 TrueType Interpreter Mode." FreeType, September 8, 2016. https://freetype.org/freetype2/docs/hinting/subpixel-hinting.html
- Zig 95%
- Nix 4%
- Python 1%