Pretext Architecture Grand Tour
How Pretext measures and lays out text without touching the DOM
What you will learn
- Why DOM-based text measurement causes synchronous layout reflow and how canvas sidesteps it
- How the two-phase prepare / layout split keeps resize handling at sub-millisecond speed
- The nested Map<font, Map<segment, metrics>> cache that makes re-measurement nearly free
- How browser engine detection drives engine-specific epsilon values and layout quirks
- Where the Unicode Bidirectional Algorithm lives in the codebase and what it skips intentionally
Prerequisites
- Comfortable reading TypeScript
- Basic familiarity with how browsers render text (reflow, the DOM, canvas)
- No prior knowledge of Pretext required
src/layout.ts:1
// Text measurement for browser environments using canvas measureText.
//
// Problem: DOM-based text measurement (getBoundingClientRect, offsetHeight)
// forces synchronous layout reflow. When components independently measure text,
// each measurement triggers a reflow of the entire document. This creates
// read/write interleaving that can cost 30ms+ per frame for 500 text blocks.
//
// Solution: two-phase measurement centered around canvas measureText.
// prepare(text, font) — segments text via Intl.Segmenter, measures each word
// via canvas, caches widths, and does one cached DOM calibration read per
// font when emoji correction is needed. Call once when text first appears.
// layout(prepared, maxWidth, lineHeight) — walks cached word widths with pure
// arithmetic to count lines and compute height. Call on every resize.
// ~0.0002ms per text.
//
// i18n: Intl.Segmenter handles CJK (per-character breaking), Thai, Arabic, etc.
// Bidi: simplified rich-path metadata for mixed LTR/RTL custom rendering.
// Punctuation merging: "better." measured as one unit (matches CSS behavior).
// Trailing whitespace: hangs past line edge without triggering breaks (CSS behavior).
// overflow-wrap: pre-measured grapheme widths enable character-level word breaking.
//
// Emoji correction: Chrome/Firefox canvas measures emoji wider than DOM at font
// sizes <24px on macOS (Apple Color Emoji). The inflation is constant per emoji
// grapheme at a given size, font-independent. Auto-detected by comparing canvas
// vs actual DOM emoji width (one cached DOM read per font). Safari canvas and
// DOM agree (both wider than fontSize), so correction = 0 there.
//
// Limitations:
// - system-ui font: canvas resolves to different optical variants than DOM on macOS.
// Use named fonts (Helvetica, Inter, etc.) for guaranteed accuracy.
// See RESEARCH.md "Discovery: system-ui font resolution mismatch".
//
// Based on Sebastian Markbage's text-layout research (github.com/chenglou/text-layout).
The Problem Statement
This block is more than a doc comment -- it is the entire design rationale in 34 lines. The core insight is that DOM measurement and DOM mutation cannot coexist without triggering reflow, so Pretext exits the DOM entirely for the measurement phase. The comment also inventories every non-obvious edge case the library had to absorb: CJK per-character breaking, Arabic bidirectionality, trailing whitespace hanging, overflow-wrap, and the Chrome/Firefox emoji inflation bug. Notice that the solution is stated as a constraint: layout() must stay arithmetic-only. Everything else in the codebase is in service of that single invariant.
The two-phase design is not an optimization -- it is the only architecture that lets layout() run without touching the DOM.
src/measurement.ts:28
let
export
if
if
measureContext
return
}
if
measureContext
return
}
throw
}
Canvas Context Selection
getMeasureContext() is called on every cache miss, so it is written to be near-zero-cost after the first call: the module-level measureContext variable short-circuits everything. The OffscreenCanvas branch matters because it runs off the main thread -- callers in a Web Worker never need a DOM reference at all. The DOM canvas fallback is sized 1x1 because nothing is ever drawn; the canvas exists solely as a font engine proxy. The explicit error on the third branch is intentional: silent fallback to a broken state would produce wrong line counts instead of a visible failure.
A 1x1 OffscreenCanvas is all you need to access the browser's font engine -- no rendering pipeline, no compositor, no main thread.
src/measurement.ts:47
const
export
let
if
cache
segmentMetricCaches.
}
return
}
export
let
if
const
metrics
width: ctx.
containsCJK:
}
cache.
}
return
}
The Two-Level Segment Cache
The outer map keys on the full CSS font string (e.g. "16px Inter"), so "hello" at 16px Inter and "hello" at 14px Inter are stored independently. The inner map keys on the segment string itself. The SegmentMetrics object is intentionally sparse on construction -- emojiCount, graphemeWidths, and graphemePrefixWidths are left undefined and computed lazily only if the segment actually needs per-grapheme breaking. This avoids paying grapheme segmentation cost for the vast majority of segments that never need it. The pattern is a textbook example of structured lazy evaluation: eagerly cache the cheap fields, defer the expensive fields until they are actually needed.
The Map<font, Map<segment, metrics>> structure ensures font changes invalidate only what they should, while keeping common-case lookup at O(1).
src/measurement.ts:74
export
if
if
cachedEngineProfile
lineFitEpsilon:
carryCJKAfterClosingQuote:
preferPrefixWidthsForBreakableRuns:
preferEarlySoftHyphenBreak:
}
return
}
const
const
const
vendor
ua.
!
!
!
!
!
const
ua.
ua.
ua.
ua.
cachedEngineProfile
lineFitEpsilon: isSafari
carryCJKAfterClosingQuote: isChromium,
preferPrefixWidthsForBreakableRuns: isSafari,
preferEarlySoftHyphenBreak: isSafari,
}
return
}
Engine Profile and Browser Detection
Each field in EngineProfile maps to a measured browser-specific behavior, not a guess. lineFitEpsilon encodes Safari's sub-pixel rounding -- Safari uses 1/64px precision internally, so the fit tolerance is set to exactly that to avoid false line breaks at boundaries that Safari itself considers clean. carryCJKAfterClosingQuote is Chromium-only because only Chromium carries a CJK character past a closing quote onto the previous line. The SSR fallback (no navigator) returns conservative defaults that work correctly across all engines. Notice there is no isFirefox branch: Firefox's behavior was close enough to Chromium's that no additional shims were justified.
Each engine profile field represents a browser behavior that was empirically measured and documented in RESEARCH.md before a shim was written for it.
src/line-break.ts:177
function
prepared
maxWidth
onLine
)
const
if
const
const
let
let
let
let
let
let
function
pendingBreakSegmentIndex
pendingBreakPaintWidth
}
function
if
pendingBreakSegmentIndex
pendingBreakPaintWidth
}
// ...
// When lineW > maxWidth + lineFitEpsilon and no room for the next segment:
// if pendingBreakSegmentIndex >= 0: break at last viable space
// else: break within the breakable segment grapheme-by-grapheme
Greedy Line Breaking
The algorithm is a greedy scan, not a Knuth-Plass optimal paragraph solver. That is intentional: CSS white-space: normal is itself greedy, so producing different breaks would create mismatches against the browser. The pendingBreakSegmentIndex variable is the key mechanism -- it records the last index where a break was legal (after a space, soft hyphen, or zero-width break opportunity). When the accumulator lineW exceeds maxWidth + lineFitEpsilon, the algorithm jumps back to that pending break rather than hunting forward. The lineFitEpsilon from the engine profile appears here: it prevents Safari's 1/64px rounding from producing one extra line on text that Safari itself renders on a single line.
The pending-break cursor is what makes greedy wrapping O(n) in segment count: no backtracking, no look-ahead, just a single forward pass with a remembered last-safe-break position.
src/bidi.ts:1
// Simplified bidi metadata helper for the rich prepareWithSegments() path,
// forked from pdf.js via Sebastian's text-layout. It classifies characters
// into bidi types, computes embedding levels, and maps them onto prepared
// segments for custom rendering. The line-breaking engine does not consume
// these levels.
function
const
if
const
let
for
const
if
types[i]
}
if
const
// W1-W7: resolve weak types based on adjacent strong types
// N1-N2: resolve neutral characters between opposing strong types
// I1-I2: assign final embedding levels (even = LTR, odd = RTL)
// ...
export
const
if
const
for
segLevels[i]
}
return
}
Bidirectional Text
The first optimization is the early-exit: if numBidi === 0 (no right-to-left characters detected), the entire algorithm is skipped and null is returned. This means purely Latin text pays zero bidi cost. The startLevel heuristic is subtle: if RTL characters are fewer than 30% of the string (ratio len / numBidi > ~3.3), the paragraph base direction is assumed LTR and starts at level 0. Above 30%, it starts at level 1 (RTL). The W, N, and I rule passes implement the UBA's character-type resolution in a single forward scan each. computeSegmentLevels() then projects character-level levels onto segment boundaries, which is all the line-breaking engine needs -- the renderer handles actual RTL reordering using these levels.
The bidi engine produces levels, not reordered strings -- reordering is deliberately delegated to the caller's renderer so Pretext stays layout-only.
RESEARCH.md:55
##
Canvas and
In the recorded scan, mismatches clustered at
`13px`
macOS uses
Canvas and
Practical
-
-
-
prepare
##
Chrome and Firefox on macOS can measure emoji wider
at small sizes. Safari does not share the same discrepancy.
What held
-
DOM
-
-
The Browser Inconsistency Field Notes
RESEARCH.md functions as the evidence base for why Pretext's known limitations exist as limitations rather than bugs. The system-ui finding is particularly instructive: macOS switches from SF Pro Text to SF Pro Display at different size thresholds in canvas versus the DOM. Because the switch points differ, no static correction table can fix it reliably -- which is why Pretext documents system-ui as unsafe rather than shipping a fragile shim. The emoji correction took a different path: the inflation between canvas and DOM at small sizes on Chrome and Firefox is consistent per font, so it can be detected with one real DOM measurement and cached. Both decisions were driven by measurement, not assumption.
RESEARCH.md is the reason each shim exists and each known limitation is documented as a limitation rather than silently producing wrong output.
Try this tour interactively
Walk through this tour step-by-step in VS Code with Intraview. The AI agent will guide you through the actual codebase.
Start Interactive Tour in VS CodeFree for up to 25 tours/month. Works with VS Code, Cursor, and Roo Code.