← Back
Home › Systems › Frontend and UI

text-rendering

text shaping and font rendering

HarfBuzz, FreeType

Open the text-rendering pagetext-rendering Bézier curvesgraphics: Smooth curves defined by 3-4 control points, evaluated and split with repeated linear interpolation (de Casteljau) and flattened to lines with a closed-form segment count.›Color space conversiongraphics: Convert pixels between transfer curves (sRGB, linear light), primaries (sRGB, Display P3, XYZ) and Y'CbCr, using tables for the curve and 3x3 matrices for the rest.›Font fallback itemizationgraphics: Split text into runs by script, direction and font coverage; pick fallback fonts per runFont hintinggraphics: Grid fitting of glyph outlines: TrueType bytecode VM, autohinting, stem darkeningGlyph atlas cachinggraphics: Rasterize glyphs on demand into GPU texture atlases keyed by glyph, size and subpixel offsetKnuth plass line breakinggraphics: Optimal paragraph line breaking minimizing total badness with boxes, glue and penaltiesOpentype font parsinggraphics: Sfnt tables (cmap, glyf, CFF, hmtx, GSUB, GPOS), variable font axes, safe parsingPath rasterization coveragegraphics: Scanline or sparse-strip coverage accumulation with nonzero and even-odd fill and analytic antialiasingSigned distance field renderinggraphics: Render glyphs and shapes from SDF or MSDF textures for crisp scalingText shapinggraphics: Map Unicode to positioned glyphs via OpenType GSUB/GPOS lookups, clusters and complex scriptsUnicode bidi algorithmgraphics: UAX 9: resolve embedding levels and reorder mixed left-to-right and right-to-left textUnicode line breakinggraphics: UAX 14 line break opportunities plus hyphenation hooksRectangle bin packingoptimization: Skyline, guillotine and MaxRects packing for texture and glyph atlasesUnicode text tokenization forsearchsearch-sort: Turn multilingual text into index terms: segment words (UAX 29 style, CJK bigrams), normalize, case-fold and accent-fold, identically at index and query time.›LRU and CLOCKsystems: LRU evicts the least recently used entry via hash map plus linked list; CLOCK approximates it with a reference bit, so hits take no lock.›Chroma subsamplingmedia: 4:2:0/4:2:2 colour plane downsampling and sitingDijkstra's algorithmgraphs: Single-source shortest paths with non-negative edge weights by settling vertices in order of distance with a priority queue.›Parallel prefix sumsearch-sort: Work-efficient scan (Blelloch, decoupled look-back) on CPU SIMD and GPUs; stream compactionText position encodingcompilers: Store byte offsets, convert to (line, column) on demand with a line-start table and binary search, and count columns in the unit the peer negotiated.›Text normalizationmachine-learning: Rewrite messy text into one canonical form for matchers and models, or into spoken words for speech output (and back), keeping a map to the original offsets.›d-ary heapdata-structures: Array heap with d children per node (d = 4 typical): half the binary heap's depth, siblings in one cache line, faster priority queues.›

How it is built

Architecture overview

A text renderer turns a string of Unicode code points into pixels, and almost every step in between is a separate problem with its own standard. The string is first cut into runs that share one direction, one script and one font; each run is shaped (code points become positioned glyph ids by applying the font's OpenType substitution and positioning rules); the shaped runs are broken into lines; each line is put into visual order; and finally every glyph is rasterized from its outline and drawn, usually from a cache. The one idea that makes it work is the strict split of responsibilities: Unicode algorithms decide structure (levels, runs, break opportunities), the font decides shapes and adjustments, and a cache keyed by glyph and size makes the expensive rasterization happen once. The usual open-source stack mirrors this split: HarfBuzz shapes, FreeType (or a Rust equivalent) scales and rasterizes, ICU or FriBidi handle bidi and break rules, and a GPU glyph atlas draws.

Parts:

  • Itemizer — splits a paragraph into runs by bidi level, script and covering font — unicode-bidi-algorithm (no page yet), font-fallback-itemization (no page yet), Unicode text tokenization
  • Font reader — safe access to the font's tables and variation axes — opentype-font-parsing (no page yet)
  • Shaper — code points to positioned glyphs — text-shaping (no page yet)
  • Line breaker — where a line may end, and which of those places to use — unicode-line-breaking (no page yet), knuth-plass-line-breaking (no page yet), Dijkstra's algorithm, d-ary heap
  • Outline scaler — scaled, optionally grid-fitted glyph outlines — font-hinting (no page yet), Bézier curves
  • Rasterizer — outline to coverage values, or to a distance field — path-rasterization-coverage (no page yet), parallel-prefix-sum (no page yet), signed-distance-field-rendering (no page yet)
  • Glyph cache — rasterize once, reuse every frame — glyph-atlas-caching (no page yet), rectangle-bin-packing (no page yet), LRU and CLOCK
  • Text model and compositor — source offsets for caret and selection, blending in the right colour space — Text position encoding, Text normalization, Color space conversion
 UTF-8 text + style spans
        │
        ▼
 ┌────────────┐  runs (one level, script, font)   ┌──────────┐ glyph ids,
 │ itemizer   │ ───────────────────────────────►  │  shaper  │ advances, offsets,
 │ bidi,script│        fonts ◄─ font reader       │(GSUB/GPOS)│ clusters
 │ fallback   │                                   └────┬─────┘
 └────────────┘                                        ▼
                                              ┌────────────────┐   lines of runs
                                              │ line breaker   │ ──► visual reorder
                                              └────────────────┘      (per line)
                                                                          │ glyph id + size + subpixel
                                                                          ▼
   ┌──────────────┐  miss   ┌────────────┐   ┌───────────────┐   hit  ┌─────────────┐
   │ outline +    │ ◄────── │ glyph cache│ ◄─│ draw list     │ ─────► │ GPU atlas   │ ─► pixels
   │ hinting      │ bitmap/ │ (atlas map)│   │ (quads)       │        │ quads       │
   │ rasterizer   │  SDF    └────────────┘   └───────────────┘        └─────────────┘
   └──────────────┘

Two lifetimes matter. Layout (itemize, shape, break) runs when text or width changes and its output is cached per paragraph or per run. Drawing runs every frame and should touch only the glyph cache and the atlas.

How the parts fit together

Itemizer

Shaping is defined for a run of characters with the same properties: HarfBuzz states that it expects such a run and leaves bidirectional processing, mixed fonts or scripts, and line breaking to the caller (HarfBuzz manual, "What HarfBuzz doesn't do"). The itemizer produces those runs. It first applies unicode-bidi-algorithm (no page yet), which gives every character an embedding level, even for left to right and odd for right to left (UAX #9). It then splits further at script changes and at points where the chosen font stops covering the text, which is font-fallback-itemization (no page yet): the font for each run is picked from a fallback list, per character or per cluster, so an Arabic phrase or an emoji inside Latin text gets a font that has the glyphs. Both steps need grapheme-cluster and word boundaries, which come from the UAX #29 style segmentation described in Unicode text tokenization; the fallback step must not split a base letter from its combining mark.

Contract to the next part: runs in logical order, each with a level, a script, a language tag, a font and a byte range of the source text.

Why bidi before shaping and reordering after line breaking: levels are resolved for the whole paragraph, but the visual reordering (rules L1 and L2 of UAX #9) is applied per line, after the line breaker has chosen the lines.

Font reader and shaper

opentype-font-parsing (no page yet) reads the sfnt tables: cmap for code point to glyph, glyf or CFF for outlines, hmtx for advances, GSUB and GPOS for layout, and the variation tables. Fonts are untrusted input, so every offset must be bounds-checked; the Rust ecosystem has a dedicated low-level parsing library for it (read-fonts, under Skrifa, in the fontations project; Parley README).

text-shaping (no page yet) is the heart of the system. For one run it selects the script's shaping model, applies GSUB lookups (ligatures, positional forms, multiple and single substitution) and GPOS lookups (kerning, mark attachment), and returns glyph ids with advances and offsets plus a cluster value per glyph that maps back to the source text (HarfBuzz manual, "Shaping and shape plans"). GSUB turns the Arabic letter into its initial, medial or final form and an f followed by i into a ligature glyph (OpenType specification, GSUB). Because one glyph can stand for several characters and one character for several glyphs, the cluster index is what the text model needs for caret placement and selection.

Contract: input is one run plus font, size-independent font units; output is glyph id, advance and offset in font units (scale later), and a cluster index. Shaping is the most expensive layout step, so results are cached; cosmic-text's roadmap lists "cache results" for both shaping and rendering.

Line breaker

unicode-line-breaking (no page yet) implements UAX #14: it assigns each character a line-break class and returns the positions where a break is allowed or required. It does not choose lines; the annex leaves selecting breaks by width to the layout code. With break opportunities and the shaped advances (shaping must come first, because widths depend on ligatures and kerning), a greedy first-fit loop gives the lines a browser or editor needs. For print-quality paragraphs, knuth-plass-line-breaking (no page yet) minimises the total badness of all lines over boxes, glue and penalties (Knuth and Plass, 1981). It is a shortest-path problem over the feasible break points, which is why Dijkstra's algorithm and a priority queue such as a d-ary heap can drive it; a simple dynamic program over the break DAG does the same for typical paragraph sizes.

Why both and not one: greedy is O(n) and stable under edits, so it fits interactive text fields; optimal fit rewrites earlier lines when a later word changes, so it fits typesetting and cached static layout.

Outline scaler and rasterizer

The scaler loads a glyph outline at a size and optionally grid-fits it: font-hinting (no page yet) covers the TrueType bytecode interpreter, automatic hinting and stem darkening. FreeType's own documentation describes the trade-off: slight hinting snaps glyphs to the pixel grid only vertically so inter-glyph spacing survives, and stem darkening should only be enabled together with gamma correction (FreeType, "Text rendering general"). Curves are Bézier curves (quadratic in TrueType, cubic in CFF) and are flattened into line segments with a tolerance chosen from the size.

path-rasterization-coverage (no page yet) converts the flattened outline into 8-bit coverage per pixel with the non-zero rule and analytic antialiasing; FreeType's FT_Render_Glyph offers normal, mono, LCD and SDF modes. GPU renderers move this into compute shaders (Vello's original compute renderer "performs most rendering work in GPU compute shaders", while its newer Sparse Strips renderers preprocess paths on the CPU; Vello README), and parallel-prefix-sum (no page yet) is the usual tool for turning per-tile counts into output offsets. The alternative is signed-distance-field-rendering (no page yet): store a distance field per glyph and let the fragment shader threshold it, so a single small texture serves many sizes. The single-channel version is best known from Valve's 2007 paper; multi-channel fields (msdfgen) reproduce sharp corners that a single channel cannot (msdfgen README). Pick coverage bitmaps for body text at known sizes with hinting, and SDF or MSDF for text that scales, rotates or lives in 3D, where re-rasterizing per size would thrash the cache.

Glyph cache and atlas

The cache key is glyph id, font, size, variation settings and, if the renderer uses subpixel positioning, a quantised horizontal offset. Misses rasterize; hits only emit a textured quad. glyph-atlas-caching (no page yet) stores the bitmaps in one GPU texture so a whole page of text is a single draw call. Packing them is rectangle-bin-packing (no page yet); shelf packing suits glyphs because they have similar heights, and etagere is such a dynamic shelf allocator (README). When the atlas is full, LRU and CLOCK decide which glyphs to evict; the contract is that the draw list of the current frame may reference only entries pinned for that frame.

Text model and compositor

The renderer's coordinates are not the editor's coordinates. Text position encoding stores byte offsets and converts to line and column on demand, using the unit the peer expects; clusters from the shaper connect those offsets to glyph positions for hit testing. Text normalization is useful when the same text must be searched or compared, with a map back to the original offsets. Finally, blending antialiased glyph coverage into the frame is a colour-space question: Color space conversion matters because blending coverage in gamma-encoded space changes the apparent weight of light-on-dark versus dark-on-light text, and FreeType's documentation says stem darkening should only be enabled when doing gamma correction.

Data and control flow

1. First display of a paragraph

  1. Normalise and store the text; style spans give font family, size, weight and language per range.
  2. Resolve bidi levels for the paragraph; split into runs by level, script and style.
  3. For each run, pick a font from the fallback list that covers the text; split where coverage changes.
  4. Shape every run in logical order; store glyphs, advances, offsets and clusters.
  5. Compute UAX #14 break opportunities, then choose lines against the available width.
  6. Reorder runs inside each line into visual order and assign x/y to every glyph.
  7. Emit one draw item per glyph: (font, glyph id, size, subpixel bin, position). Most of the time goes to font loading on first use and to shaping; both are cached afterwards.

2. Drawing a frame from cached layout

  1. For each draw item, look up the glyph cache.
  2. On a miss, load the outline, hint, rasterize (CPU bitmap or GPU compute), allocate a rectangle in the atlas, upload.
  3. Build one vertex buffer of quads (atlas rectangle plus screen position) and issue a single draw per atlas page.
  4. The fragment shader samples coverage (or reconstructs it from a distance field) and blends with the text colour. The steady state is a cache lookup and a quad per glyph; no font code runs.

3. Resize or zoom Width changes redo only steps 5 and 6 of flow 1; the shaped runs are reused. A size change invalidates the shaped advances if hinting changes them, and invalidates bitmap cache entries; SDF text instead scales the quads and keeps its atlas entries.

Scaling

In the order you would pull the levers:

  1. Shaping time on long documents: shape lazily for visible lines only, cache shaped runs by (text, font, features), and split paragraphs so an edit reshapes one paragraph.
  2. Cache misses at start or after a size change: prerasterize the likely glyph set (ASCII, common punctuation), quantise sizes and subpixel offsets to few bins, rasterize on a worker thread.
  3. Atlas pressure with many scripts or sizes (CJK is the worst case, since a font holds thousands of glyphs): multiple atlas pages, eviction by LRU, or switch large or animated text to SDF.
  4. Font loading: memory-map fonts and share them between processes; Parley's Fontique crate states it uses memory-mapped IO for this.
  5. Fill and bandwidth at high resolution: coverage on the GPU, batched draws, and for big text a distance-field path so the per-pixel work is one texture fetch.

Failure modes

FailureSymptomCauseFix
Missing glyph boxes ("tofu")rectangles instead of charactersno font in the fallback chain covers the code pointlonger fallback list, per-run coverage check, a last-resort font
Wrong Arabic or Indic shapesletters disconnected or in wrong ordershaping across a run with mixed script or direction, or wrong script/language tagitemize by script and level; pass script, language and direction to the shaper
Mixed-direction text out of ordernumbers or punctuation jump to the wrong sidelevels resolved per line instead of per paragraph, or reordering done before line breakingresolve per paragraph, reorder per line (UAX #9 L1 and L2)
Caret lands inside a ligature or clustercursor between the parts of one letter, deleting half a charactercaret moved by code point, not by grapheme clusterstep by cluster boundaries; use shaper clusters for hit testing
Text looks thin or unevenheavy light-on-dark, blurry stemsblending coverage in gamma-encoded space; hinting disabled at small sizesblend in linear light or darken stems; use slight hinting
Glyph shimmering when scrollingletters change width or position by a pixelsubpixel offset not part of the cache key, or rounding differs between layout and drawinclude the quantised offset in the key; snap consistently
Atlas thrashframe time spikes, repeated uploadsatlas smaller than the working set, no eviction order, or many sizes during an animated zoombigger or paged atlas, SDF for scaling text, quantise sizes
Out-of-bounds read on a hostile fontcrash or exploit through a font fileunchecked table offsetsparse with bounds-checked readers, fuzz the loader
Wrong line breaks in CJK or URLsbreaks inside a word, or never breaksUAX #14 classes ignored, or a space-only breakeruse the UAX #14 algorithm with locale tailoring

Real implementations

namewhat it isrepolicencenotes
HarfBuzzOpenType text shaping engineharfbuzz/harfbuzz"Old MIT"the shaper in most desktop and browser stacks; leaves bidi, itemization and line breaking to the caller
FreeTypefont loading, hinting, outline scaling and rasterization (incl. SDF mode)gitlab.freedesktop.org/freetype/freetypeFTL or GPL-2.0 (your choice)mirror at github.com/freetype/freetype
ICUUnicode library with bidi, break iterators, normalizationunicode-org/icuUnicode License V3HarfBuzz docs point to it for break iteration and bidi
FriBidiUAX #9 implementation in Cfribidi/fribidiLGPL-2.1named in the HarfBuzz docs as a bidi option
HarfRustHarfBuzz port to Rust, successor of the archived rustybuzzharfbuzz/harfrustMITshaping in cosmic-text and Parley
swashpure Rust font introspection, shaping and glyph renderingdfrg/swashApache-2.0 OR MITrasterizer used by cosmic-text
cosmic-textpure Rust multi-line text: shaping, layout, bidi, fallback, renderingpop-os/cosmic-textMIT OR Apache-2.0custom fallback reusing browser static lists
Parley (+ Fontique, Skrifa, ICU4X)rich text layout stack in Rustlinebender/parleyApache-2.0 OR MITclean split: enumeration and fallback, shaping, outlines, Unicode data, layout
glyphontext drawing on wgpu: cosmic-text + etagere atlasgrovesNL/glyphonMIT OR Apache-2.0 OR Zlibthe whole layout-to-atlas pipeline in a small crate
etageredynamic shelf-packing atlas allocatornical/etagereMIT OR Apache-2.0for glyph atlases of similar-height items
msdfgenmulti-channel signed distance field generatorChlumsky/msdfgenMITMSDF-Atlas-Gen builds whole font atlases
VelloGPU/CPU 2D vector renderer with glyph-run supportlinebender/velloApache-2.0 OR MITsparse-strips architecture

Sources

Similar systems

Share the most building blocks with this one.