- Zig 92.6%
- Nix 5.9%
- Python 1.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyyMjYZLRMjLCoyxQJwfTC |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
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
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.
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
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
- 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. 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