Font libraries — comparison and verdicts
The synthesis of the font-libraries survey: 34 subjects read for the six questions the survey exists to answer, each question closed with a verdict that the sparkles:font specification can cite. Vocabulary is defined in concepts.md.
Last reviewed: October 3, 2026
IMPORTANT
The verdicts here are recommendations backed by the cited deep-dives and by two runnable programs in the survey's examples/ directory, not settled design. The library specification under docs/specs/font/ is where they become requirements, and it may overrule any of them with a stated reason.
At a glance
| Question | Verdict | Strongest evidence |
|---|---|---|
| RQ1 Layering | Five objects: borrowed bytes, immutable face, value-type font instance, shaper with caller scratch, font database. Caches live beside, never inside. | HarfBuzz, fontations, Ghostty, parley |
| RQ2 Raster | Go for a D grayscale CPU rasterizer. No-go for D shaping. Defer GPU compute and hinting. | fontdue, ab_glyph, rustybuzz, Vello, example 2 |
| RQ3 Variation | One normalized coordinate vector per instance, avar applied once, fed to shaper, metrics, outlines and the cache key alike. | fontations, DirectWrite, ttf-parser's regret |
| RQ4 Fallback | A scanned, cached face index; an ordered, coverage-pruned fallback list as the primary API; per-codepoint memo; lazy face opening. | fontconfig, Ghostty, go-text, crossfont |
| RQ5 Inspector | Tables as reflectable structs, plus enumeration of features per script and language, axes, instances, coverage and all three metric sets. | opentype.js, lib-font, fontTools |
| RQ6 Outlines | Both forms over one decoder: a DbI sink and a flat segment array, in font units, with explicit contour boundaries. | FreeType, fontTools pens, Go sfnt, msdfgen |
RQ1: Layering and ownership
The field converges on five objects, and the deep-dives disagree only on where the edges go (concepts.md § object layers):
- Bytes — owned, memory-mapped or borrowed. HarfBuzz models the face as a
tag → bytesfunction (hb_face_create_for_tables), and allsorts as atag → Cow<bytes>provider; both make WOFF decoding, subsetting and synthetic faces another provider rather than another face type. - Face — immutable, shareable across threads, cheap to construct. ttf-parser, fontations and stb_truetype all reduce it to a slice plus a dozen table offsets located eagerly, with glyph data decoded lazily.
- Font instance — face × size × variation coordinates × synthesis × hinting choice. This is the layer that differs most and matters most:
FT_Size,hb_font_t,SkFont,PxScaleFont, Go'sFacebeside itsFont. SixLabors.Fonts and stb_truetype skip it and pass size per call; font-kit's deep-dive records the consequence —FT_Set_Char_Sizeper glyph and no place for a cache. - Shaper — a function of (instance, text, script, language, direction, features) with a reusable buffer the caller owns. rustybuzz makes "codepoints in, glyphs out" a typestate (
UnicodeBuffer→GlyphBuffer), an improvement over HarfBuzz's runtime content-type flag. - Database — enumerates and matches; never holds open faces it was not asked for.
Caches are beside the objects, not inside them. FreeType's shared FT_GlyphSlot is why an FT_Face is single-threaded; allsorts puts lazy caches behind &mut self and cannot share a face; cosmic-text's single &mut FontSystem is correct and confines everything to one thread. The designs that scale split a long-lived, shareable database from per-thread scratch: parley's FontContext plus LayoutContext, Go's caller-owned *sfnt.Buffer per call, Ghostty's Collection (storage), CodepointResolver (policy) and SharedGrid (cache) as three owners.
Errors distinguish malformed from absent. ttf-parser's Option everywhere makes diagnosis impossible; fontations returns structural errors and clamps field reads to defaults; allsorts returns the partial result with the first error. An inspector needs the third.
Verdict.
Faceis a@safe pure nothrow @nogcvalue overscope const(ubyte)[]with eager table offsets.FontInstanceis a value holding the face, ppem, a normalized-coordinate slice and aSynthesisvalue. Shaping, outline and raster calls take a caller-owned scratch byref. Glyph and shape caches are separate objects with their own owner. Errors areExpected, and a decoder that can continue returns what it read.
RQ2: Rasterization and GPU
A CPU rasterizer in D: go
Every from-scratch rasterizer in the survey uses one algorithm, signed-area accumulation, and its core is small:
| Implementation | Rasterizer core | Notes |
|---|---|---|
| This survey's example | 71 lines of D | exact coverage, uniform curve flattening, no gamma |
| fontdue | ~200 lines | plus cached per-glyph geometry, which is where its speed comes from |
| ab_glyph_rasterizer | ~200 lines | abs() overlap defect noted |
| stb_truetype v2 | ~700 lines | overlapping-contour overestimate |
Go x/image/vector | ~1,500 lines | plus 1,019 lines of generated SSE4.1 accumulation assembly |
The example renders Noto Sans g and Maple Mono a at 28 ppem with correct coverage from HarfBuzz's outline callbacks alone. What is not small sits around the core, and each item is a decision rather than a cost to absorb:
- Overlap and winding. Accumulation computes nonzero coverage only when contours do not overlap; variable fonts routinely overlap contours. Two deep-dives name this defect (stb_truetype, ab_glyph). FreeType's
smoothcell list has it too: it accumulates the same way and escapes only for glyphs whoseglyfdata setsOVERLAP_SIMPLEorOVERLAP_COMPOUND, which it renders at 4×4 and averages. The raster oracle example measures both the defect and that rule, whichsparkles:fontadopts (FTX9). - Hinting. A TrueType bytecode interpreter is the largest piece of every rasterizer that has one. Typography's C# port is ~2,100 lines and incomplete; fontations wrote a whole autohinter in Rust and proves it differentially against FreeType. Grayscale unhinted text at terminal sizes on high-DPI panels is the default of fontdue, Vello and cosmic-text.
- Gamma and stem darkening. Coverage is linear; every serious stack adjusts it. The adjustment is a lookup table, but choosing it needs the A/B oracle.
- Colour.
COLRv0 is layered outlines through the same rasterizer.CBDTandsbixare embedded PNGs, so a D path needs a PNG decoder (inflate plus filters).COLRv1 is a paint graph with gradients and compositing; it is a small vector renderer of its own (fontations'ColorPainter, HarfBuzz'shb_paint_funcs_t). - Proof. fontations'
fauntletdiffs its outlines against FreeType across the Google Fonts corpus. A D rasterizer gets the same oracle for free: FreeType stays in the dev shell as the reference, and the per-face raster hashes the explorer decision already calls for become the regression suite.
Verdict: go, for grayscale, unhinted,
glyf+CFF+CFF2outlines andCOLRv0, with per-contour accumulation and a gamma table, differentially tested against FreeType. Bitmap colour strikes (CBDT,sbix) wait on a PNG decoder and keep FreeType as the path until then. Hinting is out of scope;COLRv1 is a later milestone.
Shaping in D: no-go
Four independent shapers measured the cost of owning OpenType layout:
| Shaper | Size | Fate |
|---|---|---|
| rustybuzz (HarfBuzz port) | ~17 KLOC hand-written, ~10 KLOC without complex scripts | archived; its maintainers moved to HarfRust, a fresh port, to keep up |
| go-text (HarfBuzz port) | ~16 KLOC hand-written | maintained, by porting HarfBuzz releases |
| SixLabors.Fonts (own) | ~30 KLOC of C# | maintained |
| allsorts (own) | ~17 KLOC core | maintained; ignores HVAR in shaping |
| fontkit, opentype.js | partial engines | opentype.js's per-script feature wiring is the deep-dive's "dead end" |
The code is feasible; the upstream tracking is not. Every port that survived does it as standing work, and the one that stopped was archived. The survey found no evidence that a D shaper would be faster or safer than HarfBuzz, which is already in every consumer's closure.
Verdict: no-go. HarfBuzz stays, reached through hand-declared
extern(C)prototypes over opaque handles (both examples do exactly this, with no ImportC and no C shim) or through ImportC of the real headers.
GPU compute: defer
- Slug evaluates quadratics per pixel from per-glyph band lists: scale-independent and exact, but around 40× the cost of a textured quad in the best case, and its own optimisations stop paying at 10–16 px — terminal sizes.
- Vello runs a full vector pipeline in about 5,400 lines of shaders. A glyph-only subset (flatten, bin, fine) is the plausible
sparkles:shadertarget. - glyphon shows the opposite trade: CPU coverage into an R8 atlas plus an RGBA colour atlas, one instanced draw, 28 bytes per glyph.
- msdfgen is the middle path for scalable text from one bitmap.
Verdict: defer. The explorer's specimens are block bitmaps through the image primitive, so its raster path is the CPU one. A GPU path earns its place only for zoomable or transformed text, and the glyph-only Vello subset on
sparkles:shaderis the candidate when it does.
RQ3: Variation and shaping
Where coordinates live is the most-regretted decision in the survey.ttf-parser stores them on the face behind a mutable set_variation, caps axes at 64, and its own comment calls this an API simplification; ab_glyph inherits it and must clone per instance. The designs that work make the coordinates immutable per instance:
- DirectWrite — a variation instance is a new face (
IDWriteFontResource::CreateFontFace(axisValues)), so nothing downstream takes a variation parameter. - fontations — user coordinates become normalized
F2Dot14once, throughavar, and a borrowedLocationRefis passed to metrics, outlines, hinting and paint. - HarfBuzz, CoreText, Pango — on the sized font, so one object feeds shaping, metrics and outlines.
Variation must reach advances, not only outlines. allsorts ignores HVAR in shaping; opentype.js drops FeatureVariations and GPOS deltas; cosmic-text derives two coordinate sets independently. The outline example shows the single-object case working: one hb_font_set_variations(wght=900) moves the advance from 16 to 18 px and the ink from 47 to 250 px², while the segment count stays at 2 moves, 8 lines, 31 quadratics. Variation moves points and never changes contour topology, so an outline cache may key on glyph plus coordinates without re-decoding structure.
Programming ligatures survive a cell grid. The shaping example shapes a -> b != c ffi in Maple Mono with calt and liga on and off. Both runs have 15 glyphs at 600 units each. -> becomes hyphen.sta.seq plus greater_hyphen.end.seq; != becomes a zero-ink spacer plus one glyph that overhangs it. The font, not the renderer, keeps one glyph per cell, so a terminal can shape a whole run and still place glyphs by cell. Fonts that use liga with true glyph-count changes remain the case Ghostty handles by shaping per run and assigning clusters to cells.
Shape at upem, scale outside. HarfBuzz with scale = upem, rustybuzz (no scale at all) and cosmic-text (em-fraction advances) make shaping results size-independent and cacheable across zoom; only the raster key carries pixels.
Verdict.
FontInstanceowns one normalized coordinate vector computed once (user →fvarnormalize →avar), keeps user values for display, and passes the normalized slice tohb_font_set_var_coords_normalized, to the metrics reader (HVAR,MVAR), to the outline decoder (gvar,CFF2blends) and into the glyph cache key as a first-level partition, as Vello does. Shaping runs at upem; results are cached per (instance, run, features).
RQ4: Discovery, matching and fallback
Three fallback strategies appear, and the systems that work combine them:
| Strategy | Subjects | Weakness alone |
|---|---|---|
| ordered fallback list | fontconfig FcFontSort, SixLabors, Pango fontsets | the list is long; opening every face is slow |
| per-codepoint OS query | CoreText CTFontCreateForString, DirectWrite MapCharacters, Skia matchFamilyStyleCharacter | platform-only; nothing to inspect or test |
| per-script preference tables | cosmic-text, go-text, parley/fontique | stale tables; misses fonts the user installed |
What the strong designs add:
- The list is the API; the best match is its head.fontconfig's
FcFontSortwithtrimprunes faces that add no coverage; crossfont builds a terminal on exactly that. - Faces open lazily. Ghostty's deferred faces answer coverage and presentation from discovery metadata, so a long fallback list costs nothing until a miss.
- A cached face index. go-text's
Footprint(family, aspect, rune set, script set, languages, location, mtime-validated) and cosmic-text's mtime-keyed index remove the start-up scan; font-kit shows the cost of skipping it, opening every face to readOS/2. - Matching as a transcription of CSS Fonts §5.2. font-kit's ~130-line
find_best_matchand fontdb'squeryare reference ports; Ghostty encodes precedence as a packed integer so the comparison is one compare. - The answer says what it did. parley returns
Synthesis(embolden, skew, or "setwghtto 600"), and the CoreText deep-dive argues for returning the score so a UI can say "fell back from X to Y". - Discovery separated from loading by a serializable handle,
{path | bytes, index}(font-kit), so a fontconfig answer, a CoreText answer and a bundle directory scan all open through one parser.
Verdict. A
FontDatabasebuilt from per-platform sources (fontconfig on Linux, CoreText on macOS, DirectWrite on Windows, directory scans for the bundle and Android) into one cachedFaceRecordindex keyed by (path, index, mtime), with coverage from the owncmapparser so no platform needs fontconfig. Matching is CSS §5.2 with a packed score. Fallback is aFallbackChain: an ordered, coverage-pruned list of deferred faces with a per-codepoint memo. Results carry the score and aSynthesisvalue.
RQ5: What an inspector must expose
The browser inspectors are thin shells over their parsers, so the parsers set the list. Taking the union of opentype.js (FontDrop's engine), lib-font (Wakamai Fondue's), fontkit, fontTools ttx and the product survey behind the explorer decisions:
| Group | Items |
|---|---|
| identity | every name record by platform and language; version; vendor (OS/2.achVendID); license and embedding (OS/2.fsType, name IDs 13–14) |
| classification | usWeightClass, usWidthClass, fsSelection, post.isFixedPitch, measured monospace, PANOSE, sFamilyClass |
| metrics | upem; all three ascender/descender/gap sets with USE_TYPO_METRICS; x-height, cap height; underline and strikeout; per-glyph advance and bounds |
| layout | scripts → language systems → features from GSUB and GPOS, deduplicated; per feature, its substitutions as records ({ sub, by }) |
| variation | fvar axes with user ranges, named instances, avar segments, STAT axis values |
| coverage | cmap codepoints grouped by Unicode block and script; Nerd Font and Powerline ranges; missing characters for a chosen language sample |
| glyphs | names from post/CFF; outline kind; composite structure; colour layers |
| colour | which of COLR v0, COLR v1, CPAL palettes, CBDT, sbix, SVG are present |
| raw | table directory with tags, offsets, lengths, checksums; any table as bytes |
Two design lessons ride along. opentype.js's explorer loops over table properties reflectively, which sparkles:reflection gives a D parser for free if tables are plain structs. lib-font and fontkit expose GSUB as an enumerable structure, not only an engine that applies it, which is what a ligature list needs.
Verdict. Every parsed table is a plain struct (or a view with named accessors) so the inspector is a reflection walk. Layout tables are enumerable per script and language. The table directory and raw bytes are always reachable. This list is the acceptance surface of
font-explorer inspect.
RQ6: Outline access
| Form | Subjects | Good for |
|---|---|---|
| callback sink | FreeType FT_Outline_Funcs, HarfBuzz hb_draw_funcs_t, fontTools pens, fontations OutlinePen, SixLabors IGlyphRenderer, CoreText CGPath | rasterizers, path builders, @nogc |
| segment array | stb_truetype stbtt_vertex, ab_glyph OutlineCurve, Go sfnt Segments, opentype.js Path | caching, inspection, editing, tests |
| path object | msdfgen Shape, Skia SkPath | random access: neighbours, edge colouring |
The deep-dives agree on four details. Keep font units at the parser boundary: Go sfnt bakes ppem and the y-flip into LoadGlyph and its own comment concedes that blocks hinting. Keep explicit contour boundaries: ab_glyph's self-contained segments lose them, which an inspector needs. Let a sink declare what it accepts: HarfBuzz synthesizes cubics from quadratics on request. And a contour-count prologue (Typography's BeginRead(contourCount)) lets a builder reserve once.
Verdict. One decoder, two faces of it: a DbI
isOutlineSink!Sconcept (moveTo,lineTo,quadTo, optionalcubicTowith quad-to-cubic elevation when absent,close, optionalbegin(contours)) and adecodeOutlinethat fills a caller-ownedSegment[]with contour marks. Both in font units.
Consensus shape for sparkles:font
// Bytes and faces: borrowed, immutable, @nogc.
struct FontBytes { const(ubyte)[] data; } // owned elsewhere
struct Face { /* slice + table offsets */ } // Face.open(bytes, index)
struct FontInstance // face × ppem × coords × synthesis
{
Face face; float ppem; const(F2Dot14)[] coords; Synthesis synthesis;
}
// Reading: tables as plain structs; enumeration for the inspector.
auto os2 = face.table!OS2; // Expected!(OS2, FontError)
auto feats = face.layoutFeatures(script, lang);
auto axes = face.axes; auto inst = face.namedInstances;
// Outlines: sink or array, font units.
face.drawOutline(gid, coords, sink); // isOutlineSink!S
face.decodeOutline(gid, coords, segments[]);
// Shaping: HarfBuzz behind a typed buffer pair.
auto glyphs = shaper.shape(instance, text, ShapeOptions(features: [...]), scratch);
// Rasterizing: coverage into a caller region.
rasterize(instance, gid, subpixelX, target[]);
// Discovery: cached index, CSS matching, fallback chains.
auto db = FontDatabase.build(sources); // fontconfig | CoreText | DirectWrite | dirs
auto chain = db.fallbackChain(query); // ordered, coverage-pruned, deferred
auto face = chain.faceFor(codepoint); // memoizedArchitectural trade-offs the specification must decide
| Decision | Option A | Option B | Survey leaning |
|---|---|---|---|
| Table readers | hand-written views | generated at compile time from declared layouts | B (fontations, go-text, fontTools otData) |
| Variation instance | coordinates on the instance value | instance as a new face (DirectWrite) | A, with the face shared |
| Raster target | per-glyph allocation | caller-owned region or atlas slot | B (msdfgen, fontdue's regret) |
| Glyph cache | inside the font object | separate, keyed by (face, gid, ppem bits, coords, subpixel, flags) | B (Skia, glyphon, cosmic-text) |
| Procedural glyphs (box drawing) | renderer special case | a synthetic face in the fallback chain | B (Ghostty sprite face) |
| HarfBuzz access | hand-declared extern(C) over opaque handles | ImportC of the real headers | either; no C shim in both |
Delta: the raylib-text spike against the verdicts
What exists today in libs/raylib-text (PR #555's shaping spike over the original FontSet), set against where the verdicts point.
| Capability | Today | Verdict target |
|---|---|---|
| Face loading | raylib LoadFontEx (stb) for the primary; FreeType FT_New_Face per path in shaping_c.c | one Face over borrowed bytes, any collection index |
| Raster paths | two: stb atlas for single codepoints, FreeType bitmaps for clusters and fallbacks | one: D accumulation rasterizer, FreeType as test oracle |
| Shaping | HarfBuzz per cluster inside a C shim, no feature control | HarfBuzz per run, features and coordinates as typed options |
| Variation | none | normalized coordinates on the instance, end to end |
| Metrics | cell width/height, ascent only | all three metric sets, underline/strikeout, a named terminal set |
| Outlines | none exposed | sink and segment array |
| Colour | FT_LOAD_COLOR bitmaps in the shim | COLR v0 in D; CBDT/sbix via FreeType until PNG decode exists |
| Discovery | fc-match subprocess, CoreText by dlopen, shallow directory scan; no Linux enumeration | cached FaceRecord index over per-platform sources |
| Fallback | fixed named fields (bold, italic, regular and Nerd fallbacks) plus up to 8 codepoint maps | FallbackChain, coverage-pruned, deferred, memoized |
| Procedural glyphs | drawBox special case in the renderer | a synthetic face in the chain |
| Inspection | none | reflectable tables, enumerations, raw access |
| Threading | one FontSet per host, GL-bound | shareable faces and database; per-thread scratch and caches |
Sources
All claims above are cited in the linked deep-dives, which carry the pinned revisions. The two programs in the survey's examples/ directory reproduce the measured numbers:
harfbuzz-shape-features.d— feature inventory, axes, andcalt/ligaon and off.outline-sink-raster.d— outline sink, variation reaching outlines, and the 71-line accumulation rasterizer.