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
- Normalise and store the text; style spans give font family, size, weight and language per range.
- Resolve bidi levels for the paragraph; split into runs by level, script and style.
- For each run, pick a font from the fallback list that covers the text; split where coverage changes.
- Shape every run in logical order; store glyphs, advances, offsets and clusters.
- Compute UAX #14 break opportunities, then choose lines against the available width.
- Reorder runs inside each line into visual order and assign x/y to every glyph.
- 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
- For each draw item, look up the glyph cache.
- On a miss, load the outline, hint, rasterize (CPU bitmap or GPU compute), allocate a rectangle in the atlas, upload.
- Build one vertex buffer of quads (atlas rectangle plus screen position) and issue a single draw per atlas page.
- 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:
- 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.
- 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.
- 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.
- Font loading: memory-map fonts and share them between processes; Parley's Fontique crate states it uses memory-mapped IO for this.
- 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
| Failure | Symptom | Cause | Fix |
|---|---|---|---|
| Missing glyph boxes ("tofu") | rectangles instead of characters | no font in the fallback chain covers the code point | longer fallback list, per-run coverage check, a last-resort font |
| Wrong Arabic or Indic shapes | letters disconnected or in wrong order | shaping across a run with mixed script or direction, or wrong script/language tag | itemize by script and level; pass script, language and direction to the shaper |
| Mixed-direction text out of order | numbers or punctuation jump to the wrong side | levels resolved per line instead of per paragraph, or reordering done before line breaking | resolve per paragraph, reorder per line (UAX #9 L1 and L2) |
| Caret lands inside a ligature or cluster | cursor between the parts of one letter, deleting half a character | caret moved by code point, not by grapheme cluster | step by cluster boundaries; use shaper clusters for hit testing |
| Text looks thin or uneven | heavy light-on-dark, blurry stems | blending coverage in gamma-encoded space; hinting disabled at small sizes | blend in linear light or darken stems; use slight hinting |
| Glyph shimmering when scrolling | letters change width or position by a pixel | subpixel offset not part of the cache key, or rounding differs between layout and draw | include the quantised offset in the key; snap consistently |
| Atlas thrash | frame time spikes, repeated uploads | atlas smaller than the working set, no eviction order, or many sizes during an animated zoom | bigger or paged atlas, SDF for scaling text, quantise sizes |
| Out-of-bounds read on a hostile font | crash or exploit through a font file | unchecked table offsets | parse with bounds-checked readers, fuzz the loader |
| Wrong line breaks in CJK or URLs | breaks inside a word, or never breaks | UAX #14 classes ignored, or a space-only breaker | use the UAX #14 algorithm with locale tailoring |
Real implementations
| name | what it is | repo | licence | notes |
|---|---|---|---|---|
| HarfBuzz | OpenType text shaping engine | harfbuzz/harfbuzz | "Old MIT" | the shaper in most desktop and browser stacks; leaves bidi, itemization and line breaking to the caller |
| FreeType | font loading, hinting, outline scaling and rasterization (incl. SDF mode) | gitlab.freedesktop.org/freetype/freetype | FTL or GPL-2.0 (your choice) | mirror at github.com/freetype/freetype |
| ICU | Unicode library with bidi, break iterators, normalization | unicode-org/icu | Unicode License V3 | HarfBuzz docs point to it for break iteration and bidi |
| FriBidi | UAX #9 implementation in C | fribidi/fribidi | LGPL-2.1 | named in the HarfBuzz docs as a bidi option |
| HarfRust | HarfBuzz port to Rust, successor of the archived rustybuzz | harfbuzz/harfrust | MIT | shaping in cosmic-text and Parley |
| swash | pure Rust font introspection, shaping and glyph rendering | dfrg/swash | Apache-2.0 OR MIT | rasterizer used by cosmic-text |
| cosmic-text | pure Rust multi-line text: shaping, layout, bidi, fallback, rendering | pop-os/cosmic-text | MIT OR Apache-2.0 | custom fallback reusing browser static lists |
| Parley (+ Fontique, Skrifa, ICU4X) | rich text layout stack in Rust | linebender/parley | Apache-2.0 OR MIT | clean split: enumeration and fallback, shaping, outlines, Unicode data, layout |
| glyphon | text drawing on wgpu: cosmic-text + etagere atlas | grovesNL/glyphon | MIT OR Apache-2.0 OR Zlib | the whole layout-to-atlas pipeline in a small crate |
| etagere | dynamic shelf-packing atlas allocator | nical/etagere | MIT OR Apache-2.0 | for glyph atlases of similar-height items |
| msdfgen | multi-channel signed distance field generator | Chlumsky/msdfgen | MIT | MSDF-Atlas-Gen builds whole font atlases |
| Vello | GPU/CPU 2D vector renderer with glyph-run support | linebender/vello | Apache-2.0 OR MIT | sparse-strips architecture |
Sources
- HarfBuzz manual: What is HarfBuzz, What HarfBuzz doesn't do.
- Unicode Standard Annexes: UAX #9 Bidirectional Algorithm, UAX #14 Line Breaking, UAX #29 Text Segmentation.
- Microsoft, OpenType specification: GSUB.
- FreeType documentation: glyph retrieval and render modes, text rendering and hinting.
- D. E. Knuth and M. F. Plass, Breaking paragraphs into lines, Software: Practice and Experience, 1981. DOI record
- Chris Green (Valve), Improved Alpha-Tested Magnification for Vector Textures and Special Effects, SIGGRAPH 2007 course. PDF
- V. Chlumský, master's thesis on multi-channel distance fields, linked from the msdfgen README.
- READMEs of cosmic-text, Parley, glyphon, etagere and Vello.