Skip to content

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 ​

QuestionVerdictStrongest evidence
RQ1 LayeringFive 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 RasterGo for a D grayscale CPU rasterizer. No-go for D shaping. Defer GPU compute and hinting.fontdue, ab_glyph, rustybuzz, Vello, example 2
RQ3 VariationOne 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 FallbackA 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 InspectorTables 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 OutlinesBoth 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):

  1. Bytes — owned, memory-mapped or borrowed. HarfBuzz models the face as a tag → bytes function (hb_face_create_for_tables), and allsorts as a tag → Cow<bytes> provider; both make WOFF decoding, subsetting and synthetic faces another provider rather than another face type.
  2. 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.
  3. 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's Face beside its Font. SixLabors.Fonts and stb_truetype skip it and pass size per call; font-kit's deep-dive records the consequence — FT_Set_Char_Size per glyph and no place for a cache.
  4. 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.
  5. 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. Face is a @safe pure nothrow @nogc value over scope const(ubyte)[] with eager table offsets. FontInstance is a value holding the face, ppem, a normalized-coordinate slice and a Synthesis value. Shaping, outline and raster calls take a caller-owned scratch by ref. Glyph and shape caches are separate objects with their own owner. Errors are Expected, 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:

ImplementationRasterizer coreNotes
This survey's example71 lines of Dexact coverage, uniform curve flattening, no gamma
fontdue~200 linesplus cached per-glyph geometry, which is where its speed comes from
ab_glyph_rasterizer~200 linesabs() overlap defect noted
stb_truetype v2~700 linesoverlapping-contour overestimate
Go x/image/vector~1,500 linesplus 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 smooth cell list has it too: it accumulates the same way and escapes only for glyphs whose glyf data sets OVERLAP_SIMPLE or OVERLAP_COMPOUND, which it renders at 4×4 and averages. The raster oracle example measures both the defect and that rule, which sparkles:font adopts (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. COLR v0 is layered outlines through the same rasterizer. CBDT and sbix are embedded PNGs, so a D path needs a PNG decoder (inflate plus filters). COLR v1 is a paint graph with gradients and compositing; it is a small vector renderer of its own (fontations' ColorPainter, HarfBuzz's hb_paint_funcs_t).
  • Proof. fontations' fauntlet diffs 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 + CFF2 outlines and COLR v0, 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; COLR v1 is a later milestone.

Shaping in D: no-go ​

Four independent shapers measured the cost of owning OpenType layout:

ShaperSizeFate
rustybuzz (HarfBuzz port)~17 KLOC hand-written, ~10 KLOC without complex scriptsarchived; its maintainers moved to HarfRust, a fresh port, to keep up
go-text (HarfBuzz port)~16 KLOC hand-writtenmaintained, by porting HarfBuzz releases
SixLabors.Fonts (own)~30 KLOC of C#maintained
allsorts (own)~17 KLOC coremaintained; ignores HVAR in shaping
fontkit, opentype.jspartial enginesopentype.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:shader target.
  • 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:shader is 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 F2Dot14 once, through avar, and a borrowed LocationRef is 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. FontInstance owns one normalized coordinate vector computed once (user → fvar normalize → avar), keeps user values for display, and passes the normalized slice to hb_font_set_var_coords_normalized, to the metrics reader (HVAR, MVAR), to the outline decoder (gvar, CFF2 blends) 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:

StrategySubjectsWeakness alone
ordered fallback listfontconfig FcFontSort, SixLabors, Pango fontsetsthe list is long; opening every face is slow
per-codepoint OS queryCoreText CTFontCreateForString, DirectWrite MapCharacters, Skia matchFamilyStyleCharacterplatform-only; nothing to inspect or test
per-script preference tablescosmic-text, go-text, parley/fontiquestale tables; misses fonts the user installed

What the strong designs add:

  • The list is the API; the best match is its head.fontconfig's FcFontSort with trim prunes 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 read OS/2.
  • Matching as a transcription of CSS Fonts §5.2. font-kit's ~130-line find_best_match and fontdb's query are 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 "set wght to 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 FontDatabase built from per-platform sources (fontconfig on Linux, CoreText on macOS, DirectWrite on Windows, directory scans for the bundle and Android) into one cached FaceRecord index keyed by (path, index, mtime), with coverage from the own cmap parser so no platform needs fontconfig. Matching is CSS §5.2 with a packed score. Fallback is a FallbackChain: an ordered, coverage-pruned list of deferred faces with a per-codepoint memo. Results carry the score and a Synthesis value.


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:

GroupItems
identityevery name record by platform and language; version; vendor (OS/2.achVendID); license and embedding (OS/2.fsType, name IDs 13–14)
classificationusWeightClass, usWidthClass, fsSelection, post.isFixedPitch, measured monospace, PANOSE, sFamilyClass
metricsupem; all three ascender/descender/gap sets with USE_TYPO_METRICS; x-height, cap height; underline and strikeout; per-glyph advance and bounds
layoutscripts → language systems → features from GSUB and GPOS, deduplicated; per feature, its substitutions as records ({ sub, by })
variationfvar axes with user ranges, named instances, avar segments, STAT axis values
coveragecmap codepoints grouped by Unicode block and script; Nerd Font and Powerline ranges; missing characters for a chosen language sample
glyphsnames from post/CFF; outline kind; composite structure; colour layers
colourwhich of COLR v0, COLR v1, CPAL palettes, CBDT, sbix, SVG are present
rawtable 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 ​

FormSubjectsGood for
callback sinkFreeType FT_Outline_Funcs, HarfBuzz hb_draw_funcs_t, fontTools pens, fontations OutlinePen, SixLabors IGlyphRenderer, CoreText CGPathrasterizers, path builders, @nogc
segment arraystb_truetype stbtt_vertex, ab_glyph OutlineCurve, Go sfnt Segments, opentype.js Pathcaching, inspection, editing, tests
path objectmsdfgen Shape, Skia SkPathrandom 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!S concept (moveTo, lineTo, quadTo, optional cubicTo with quad-to-cubic elevation when absent, close, optional begin(contours)) and a decodeOutline that fills a caller-owned Segment[] with contour marks. Both in font units.


Consensus shape for sparkles:font ​

d
// 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);   // memoized

Architectural trade-offs the specification must decide ​

DecisionOption AOption BSurvey leaning
Table readershand-written viewsgenerated at compile time from declared layoutsB (fontations, go-text, fontTools otData)
Variation instancecoordinates on the instance valueinstance as a new face (DirectWrite)A, with the face shared
Raster targetper-glyph allocationcaller-owned region or atlas slotB (msdfgen, fontdue's regret)
Glyph cacheinside the font objectseparate, keyed by (face, gid, ppem bits, coords, subpixel, flags)B (Skia, glyphon, cosmic-text)
Procedural glyphs (box drawing)renderer special casea synthetic face in the fallback chainB (Ghostty sprite face)
HarfBuzz accesshand-declared extern(C) over opaque handlesImportC of the real headerseither; 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.

CapabilityTodayVerdict target
Face loadingraylib LoadFontEx (stb) for the primary; FreeType FT_New_Face per path in shaping_c.cone Face over borrowed bytes, any collection index
Raster pathstwo: stb atlas for single codepoints, FreeType bitmaps for clusters and fallbacksone: D accumulation rasterizer, FreeType as test oracle
ShapingHarfBuzz per cluster inside a C shim, no feature controlHarfBuzz per run, features and coordinates as typed options
Variationnonenormalized coordinates on the instance, end to end
Metricscell width/height, ascent onlyall three metric sets, underline/strikeout, a named terminal set
Outlinesnone exposedsink and segment array
ColourFT_LOAD_COLOR bitmaps in the shimCOLR v0 in D; CBDT/sbix via FreeType until PNG decode exists
Discoveryfc-match subprocess, CoreText by dlopen, shallow directory scan; no Linux enumerationcached FaceRecord index over per-platform sources
Fallbackfixed named fields (bold, italic, regular and Nerd fallbacks) plus up to 8 codepoint mapsFallbackChain, coverage-pruned, deferred, memoized
Procedural glyphsdrawBox special case in the renderera synthetic face in the chain
Inspectionnonereflectable tables, enumerations, raw access
Threadingone FontSet per host, GL-boundshareable 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: