Skip to content

SixLabors.Fonts (C# / .NET) ​

A complete managed text stack — parser, shaper, hinter, outline emitter, fallback — with no native rasterizer and no native dependency except three optional platform font matchers.

FieldValue
LanguageC# (net10.0, nullable enforced as errors, IsTrimmable) (SixLabors.Fonts.csproj)
LicenseSix Labors Split License 1.0 — Apache 2.0 for OSS / small-revenue consumers, commercial otherwise (LICENSE)
RepositorySixLabors/Fonts
Documentationsixlabors.github.io/docs; XML doc comments on every public member
Categorylayout engine (managed, pure C#)
Layer(s) coveredparse · discover · match/fallback · shape · outline · layout (no raster)
Pinned revision4eb468d9b7f4b209859ff98c6c2ab41d9c7df6c5 (2026-09-17)

Overview ​

What it solves ​

SixLabors.Fonts is the text engine under ImageSharp.Drawing. It reads TrueType, CFF1/CFF2, WOFF and WOFF2, runs its own GSUB/GPOS engine with script-specific shapers, interprets TrueType hinting bytecode, resolves variation axes down to the outline, and emits outlines plus decorations into a caller-supplied sink. It deliberately stops before pixels: the IGlyphRenderer sink is the last thing it owns. The Tables/AdvancedTypographic/ tree alone is 129 files and 29,989 lines of C# — a shaper the size of a small HarfBuzz, written in a GC language.

Design philosophy ​

SixLabors.Fonts is a cross-platform library for loading, measuring, and laying out fonts and text. It supports TrueType and OpenType fonts (including CFF1 and CFF2 outlines), WOFF/WOFF2 web fonts, variable fonts, color fonts (COLR v0/v1 and SVG), and TrueType hinting. The library provides a full OpenType layout engine with GSUB/GPOS support, advanced text shaping for complex scripts, and bidirectional text rendering.

— README.md

"Loading, measuring, and laying out" is the whole scope; "rendering" means driving a sink. Everything is managed memory, parsed eagerly into typed table objects, and shared read-only between threads via immutable Font instances and ConcurrentDictionary caches.

How it works ​

Three public nouns, one abstract spine:

  • FontCollection (Add(path|Stream), AddCollection for .ttc, Get/TryGet, *ByCulture variants) is a locked List<FontCollectionEntry> of FontMetrics (FontCollection.cs). SystemFonts is a static facade over a lazy SystemFontCollection (SystemFonts.cs).
  • FontFamily is a struct holding a name, a culture and a back-reference to its collection; CreateFont(size, style, params FontVariation[]) yields an immutable Font whose FontMetrics is a Lazy<FontMetrics?> resolved on first use (FontFamily.cs, Font.cs).
  • FontMetrics is the abstract face: UnitsPerEm, HorizontalMetrics/VerticalMetrics, OS/2 sub/superscript and strikeout, post underline and italic angle, TryGetTableData(Tag, out ReadOnlyMemory<byte>), OpenStream(), TryGetVariationAxes, TryGetGlyphMetrics(codePoint|glyphId, attributes, decorations, layoutMode, colorSupport, palette, out FontGlyphMetrics) and GetAvailableCodePoints() (FontMetrics.cs). FileFontMetrics wraps a Lazy<StreamFontMetrics> so a collection can hold thousands of faces having read only each one's name/OS/2/head (FileFontMetrics.cs).

Text enters through TextOptions(Font) — fallback families, resolver, DPI, hinting, wrapping, direction, script, features, TextRuns — and leaves through TextMeasurer (advance, bounds, renderable bounds, per-glyph/grapheme/word/line metrics, intersections) or TextRenderer.RenderTo(IGlyphRenderer, text, options) (TextOptions.cs, TextMeasurer.cs, TextRenderer.cs). A lower door, TextShaper.Shape(Font, TextShapingBuffer), exposes the shaper without layout (TextShaper.cs).

Analysis spine ​

1. Layering and ownership ​

Five layers, all managed: FontSource (bytes) → FontReader/TableLoader (directory + typed tables) → StreamFontMetrics (face + caches) → Font (face × size × variations × style) → TextShaper/TextLayout/TextRenderer (text). There is no scaled-font object: Font.Size is a float carried into every call and applied at the edge (glyphOrigin *= dpi in FontGlyphMetrics.RenderTo, FontGlyphMetrics.cs).

Ownership is the GC's. FontSource is a string path, long offset or a byte[]; every table read reopens a FileStream/MemoryStream (FontSource.cs). No IDisposable appears on the public surface; FontReader is disposable but internal and short-lived. Thread-safety is by immutability plus three ConcurrentDictionary caches keyed on (codePoint, glyphId, attributes, colorSupport, isVertical, palette) in StreamFontMetrics (StreamFontMetrics.cs); Font.WithWeight copies its variation array because "Font instances are immutable and can be shared by concurrent layouts" (Font.cs). The TrueType interpreter is pooled (ObjectPool<TrueTypeInterpreter>) since it is stateful. Errors are exceptions: InvalidFontFileException, MissingFontTableException, GlyphMissingException, FontFamilyNotFoundException; lookups the caller expects to fail are Try* with out.

2. Face loading and table access ​

FontReader reads the directory (sfnt, OTTO, TTC via TtcHeader, WOFF, WOFF2 with inflate in IO/ZlibInflateStream.cs) and dispatches by tag through a registry of 32 loaders (TableLoader.cs). Loading is eager and ordered: LoadTrueTypeFont reads head, hhea, maxp, OS/2, hmtx, cmap, fpgm, prep, cvt, hdmx, loca, glyf, kern, name, post, vhea/vmtx, BASE, GDEF, GSUB, GPOS, fvar, avar, gvar, HVAR, VVAR, MVAR, cvar, COLR, CPAL, SVG in one pass "using recommended order for best performance" (StreamFontMetrics.TrueType.cs). Everything is parsed into managed objects; the file is closed. Raw bytes remain reachable through TryGetTableData(Tag), which re-reads the directory — the inspector's escape hatch for tables the library does not model. The lightweight path is FontDescription.LoadDescription(path|Stream): "Only read the name tables" — head, OS/2, name — and LoadFontCollectionDescriptions walks a .ttc the same way (FontDescription.cs). That is exactly what a font browser wants for a first listing pass.

3. Shaping ​

Own engine, HarfBuzz-shaped. ShaperFactory picks among DefaultShaper, ArabicShaper, HebrewShaper, IndicShaper, KhmerShaper, MyanmarShaper (+ Zawgyi), ThaiShaper, HangulShaper and UniversalShaper (ShaperFactory.cs); GSUB lookup types 1–8 and GPOS 1–9 each have a subtable class, with NotImplementedSubTable as the explicit hole. Features are IReadOnlyList<Tag> FeatureTags on options or per TextRun; script is ScriptClass?, language CultureInfo, direction TextDirection.Auto|LeftToRight|RightToLeft with TextBidiMode. The low-level contract is TextShapingBuffer: Add(text), set TextDirection/Language/Script/LayoutMode/KerningMode, call TextShaper.Shape or ShapeRun, read ReadOnlySpan<ShapedGlyph> Glyphs and LineEnds (TextShapingBuffer.cs). Cluster mapping is ShapedGlyphInfo{CodePointIndex, CodePoint, CodePointCount, GlyphId, RunIndex, Flags} with Substituted/Decomposed/Placeholder flags; positions are ShapedGlyphPosition{AdvanceWidth, AdvanceHeight, Offset, Bearing} in ushort design units (ShapedGlyphInfo.cs, ShapedGlyphPosition.cs). The shaper doc states the unit rule: "Advances and offsets are scaled to the supplied font's size."

4. Variation and instances ​

Full fvar/avar/gvar/cvar/HVAR/VVAR/MVAR plus CFF2 blend. FontVariation(tag, value) is a user-coordinate pair; KnownVariationAxes names ital/opsz/slnt/wdth/wght (FontVariation.cs, KnownVariationAxes.cs). StreamFontMetrics.CreateVariationInstance fills a float[] from fvar defaults, overwrites matching tags, and builds a GlyphVariationProcessor that shares every table and the glyph-id caches with the parent while owning new glyph and metric state (StreamFontMetrics.cs). Normalization and avar mapping live in the processor (NormalizedCoordinates); TransformPoints applies gvar deltas to the outline, AdvanceAdjustment applies HVAR, ApplyCvtDeltas feeds cvar into the hinter's CVT, and ApplyMVarDeltas rewrites ascender, descender, line gap, sub/superscript, strikeout and underline (GlyphVariationProcessor.cs). fvar named instances are parsed (FVarTable.Instances) but there is no public API to select one — a caller must read TryGetVariationAxes and set coordinates (FVarTable.cs). Font.WithWeight prefers the wght axis of a variable face over hunting for a static sibling (Font.cs).

5. Rasterization and outlines ​

No rasterizer. Output is a callback sink:

csharp
public interface IGlyphRenderer
{
    public void BeginText(in FontRectangle bounds);
    public void EndText();
    public bool BeginGlyph(in FontRectangle bounds, in GlyphRendererParameters parameters);
    public void EndGlyph();
    public void BeginLayer(Paint? paint, FillRule fillRule);
    public void EndLayer();
    public void BeginGroup(CompositeMode mode);
    public void EndGroup();
    public void BeginFigure();
    public void MoveTo(Vector2 point);
    public void LineTo(Vector2 point);
    public void QuadraticBezierTo(Vector2 secondControlPoint, Vector2 point);
    public void CubicBezierTo(Vector2 secondControlPoint, Vector2 thirdControlPoint, Vector2 point);
    public void ArcTo(float radiusX, float radiusY, float rotation, bool largeArc, bool sweep, Vector2 point);
    public void EndFigure();
    public TextDecorations EnabledDecorations();
    public void SetDecoration(TextDecorations textDecorations, Vector2 start, Vector2 end, float thickness, ReadOnlyMemory<float> intersections);
}

— Rendering/IGlyphRenderer.cs

Points arrive in device pixels (already scaled by pointSize × dpi / 72 and translated to the glyph origin). BeginGlyph returns bool so a sink may serve the glyph from its own cache keyed on GlyphRendererParameters{Font, GlyphId, PointSize, Dpi, FontStyle, HintingMode, FontPalette, …} and decline the outline (GlyphRendererParameters.cs). Colour glyphs become BeginLayer(Paint, FillRule)/BeginGroup(CompositeMode) nests — COLRv0, COLRv1 (gradients, transforms, composites) and SVG are all modelled, selected by ColorFontSupport flags (ColorFontSupport.cs). Hinting is real: HintingMode.None|Standard|Full, where Standard "matches the behavior of FreeType's v40 subpixel hinting interpreter, with horizontal hinting disabled" and Full lifts the backward-compatibility restrictions — a 4,549-line managed port of the TrueType bytecode interpreter plus 479 lines of opcodes (TrueTypeInterpreter.cs). Scaled, hinted outlines are cached per (scaledPPEM, HintingMode) on each TrueTypeGlyphMetrics (TrueTypeGlyphMetrics.cs). CFF hinting data is parsed (CffHintMask, HintMap) for bounds, not applied as grid fitting. AA, LCD, gamma and atlases are the sink's problem.

6. Metrics and measurement ​

Font-level metrics are short design units on HorizontalMetrics{Ascender, Descender, LineGap, LineHeight, AdvanceWidthMax, AdvanceHeightMax} (HorizontalMetrics.cs); ScaleFactor = unitsPerEm × 72 so one point equals one pixel at 72 dpi. The ascender rule is FreeType's, stated in a comment: honour OS/2 USE_TYPO_METRICS (fsSelection bit 7) → sTypo*; else hhea; if those are zero, sTypo* if non-zero, else usWin* (StreamFontMetrics.cs). XHeight/CapHeight come from OS/2; underline from post; strikeout and sub/superscript from OS/2; MVAR deltas apply to all of them. Per-glyph FontGlyphMetrics carries AdvanceWidth/Height, four side bearings, Width/Height, GlyphType, and TryGetHintedAdvanceWidth for Full hinting (FontGlyphMetrics.cs). TextMeasurer distinguishes MeasureAdvance (pen), MeasureBounds (ink), and MeasureRenderableBounds (ink plus decorations) and returns ReadOnlyMemory<GlyphMetrics> per glyph with GraphemeIndex and StringIndex (TextMeasurer.cs).

7. Discovery, matching and fallback ​

Enumeration is a directory scan with per-OS roots — Windows %SYSTEMROOT%\Fonts and the two per-user folders; Linux ~/.fonts, ~/.local/share/fonts, /usr/local/share/fonts, /usr/share/fonts; macOS the four Library/Fonts; Android /system/fonts/ — layered under native family lists (SystemFontCollection.cs). SystemFontMatcher dispatches to DirectWriteSystemFontMatcher (COM vtables hand-declared), CoreTextSystemFontMatcher (LibraryImport of CTFontManagerCopyAvailableFontFamilyNames, CTFontCreateForStringWithLanguage) and FontConfigSystemFontMatcher (libfontconfig.so.1: FcPatternAddCharSet + FcFontMatch, with a lock when FcGetVersion() predates thread safety) (SystemFontMatcher.cs, FontConfigSystemFontMatcher.cs, CoreText.cs, DirectWriteSystemFontMatcher.cs). Each native face is reduced to NativeSystemFontFace{FamilyName, FaceName, Path, Style, StyleScore, FaceIndex} (NativeSystemFontFace.cs). Matching inside a collection is by family name (culture-aware comparer) and FontStyle flags (Regular|Bold|Italic), with FontWeight only honoured for system faces or variable wght. Fallback is three-tier and explicit: shape with Font; re-run the still-unmapped code points through each FallbackFontFamilies entry in order; then ask IFontFallbackResolver.TryResolve(codePoint, requestedFamily, style, culture) "at most once per distinct unresolved code point per shaping operation" (TextShaper.Pipeline.cs, IFontFallbackResolver.cs). SystemFontFallbackResolver is the platform-backed implementation with a ConcurrentDictionary cache (SystemFontFallbackResolver.cs). No PANOSE or OS/2 classification is used for matching.

What it teaches sparkles:font ​

  • A face handle plus a size value, not a scaled-font object. Font is (FontMetrics, float size, FontVariation[], style); scaling happens once in RenderTo. For a terminal with one size this is the right economy; cache hinted outlines by (ppem, hintingMode) as TrueTypeGlyphMetrics does.
  • Two-level loading. LoadDescription reads three tables; FileFontMetrics defers the rest behind Lazy. The explorer's listing pass should be the first; TryGetTableData(Tag) keeps raw bytes reachable for the inspector.
  • BeginGlyph returns bool. A sink that caches rasters by GlyphRendererParameters can refuse the outline. That is the atlas seam, named at the right place.
  • Fallback as an ordered list, then a resolver, each consulted once per unmapped code point. The FallbackFontFamilies list is user configuration; IFontFallbackResolver is the platform; the pipeline documents the order.
  • Variation instances share tables and caches with their parent. CreateVariationInstance is cheap because only GlyphVariationProcessor and glyph caches are new. Offsets over one borrowed buffer make this free in D.
  • State the ascender rule in code. The USE_TYPO_METRICS → hhea → sTypo → usWin ladder is a comment-with-code; copy it verbatim and test it.

Strengths ​

  • One language, zero native code on the hot path; trimmable; the entire GSUB/GPOS/hinting stack is debuggable in a managed debugger.
  • Modern coverage: CFF2, COLRv1, WOFF2, MVAR/cvar, caret and hit testing, text decorations with skip-ink.
  • The sink interface models layers, groups and decorations, not just paths.
  • Honest Try* APIs and documented fallback order.

Weaknesses ​

  • No rasterizer, no atlas, no LCD/gamma — every consumer re-solves rendering.
  • Eager parsing into managed objects: a 20 MB CJK face is fully materialized on first Font use, and TryGetTableData re-reads the file each call.
  • No named-instance selection and no STAT table.
  • Static-face weight matching in user collections is by FontStyle flags only.
  • Split license: Apache 2.0 only below 1M USD revenue or for OSS consumers.

Key design decisions and trade-offs ​

DecisionRationaleTrade-off
Pure managed parser + shaper + hinterOne deployable assembly, trimmable, no P/Invoke on hot pathsEvery table is a heap object; GC owns 30k lines of shaping
Outline sink instead of rasterizerImageSharp.Drawing owns pixels; reusable by any 2-D backendAtlas, AA, LCD, gamma all pushed to consumers
Eager table load in spec orderSequential I/O, simple invariants, immutable faceStartup cost per face; memory proportional to font size
Lazy<StreamFontMetrics> behind FontDescriptionEnumerate thousands of system faces cheaplyTwo metric classes with forwarding boilerplate
Variation instance shares parent tables and cachesCheap per-coordinate instances for animation / UI slidersGlyph caches per instance; no named-instance API
Ordered fallback list + single-shot resolverDeterministic, user-controllable, platform as last resortPer-codepoint, so a run can fragment across fonts
Native matchers only for discovery, never parsingPlatform knows installed families; library keeps one parserThree bespoke bindings (COM vtables, CoreText, fontconfig)

Sources ​

All paths verified at the pinned revision with git cat-file -e.