Pretext Architecture Grand Tour

How Pretext measures and lays out text without touching the DOM

7 stops ~20 min Verified 2026-04-01
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
1 / 7

The Problem Statement

src/layout.ts:1

The header comment is the design doc -- it names the root cause, the solution, and every non-obvious constraint the library had to handle.

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.

Key takeaway

The two-phase design is not an optimization -- it is the only architecture that lets layout() run without touching the DOM.

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).
2 / 7

Canvas Context Selection

src/measurement.ts:28

getMeasureContext() picks the fastest available canvas backend: OffscreenCanvas in workers and modern browsers, a regular DOM canvas as fallback, and a hard error if neither exists.

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.

Key takeaway

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:28
let measureContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null

export function getMeasureContext(): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D {
  if (measureContext !== null) return measureContext

  if (typeof OffscreenCanvas !== 'undefined') {
    measureContext = new OffscreenCanvas(1, 1).getContext('2d')!
    return measureContext
  }

  if (typeof document !== 'undefined') {
    measureContext = document.createElement('canvas').getContext('2d')!
    return measureContext
  }

  throw new Error('Text measurement requires OffscreenCanvas or a DOM canvas context.')
}
3 / 7

The Two-Level Segment Cache

src/measurement.ts:47

Segment metrics are stored in a Map<font, Map<segment, SegmentMetrics>> so that the same word in different fonts never collides, and the same word in the same font is measured at most once.

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.

Key takeaway

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:47
const segmentMetricCaches = new Map<string, Map<string, SegmentMetrics>>()

export function getSegmentMetricCache(font: string): Map<string, SegmentMetrics> {
  let cache = segmentMetricCaches.get(font)
  if (!cache) {
    cache = new Map()
    segmentMetricCaches.set(font, cache)
  }
  return cache
}

export function getSegmentMetrics(seg: string, cache: Map<string, SegmentMetrics>): SegmentMetrics {
  let metrics = cache.get(seg)
  if (metrics === undefined) {
    const ctx = getMeasureContext()
    metrics = {
      width: ctx.measureText(seg).width,
      containsCJK: isCJK(seg),
    }
    cache.set(seg, metrics)
  }
  return metrics
}
4 / 7

Engine Profile and Browser Detection

src/measurement.ts:74

getEngineProfile() detects Safari versus Chromium via navigator.userAgent and navigator.vendor, then returns a profile of rendering quirks that drive layout decisions throughout the codebase.

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.

Key takeaway

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/measurement.ts:74
export function getEngineProfile(): EngineProfile {
  if (cachedEngineProfile !== null) return cachedEngineProfile

  if (typeof navigator === 'undefined') {
    cachedEngineProfile = {
      lineFitEpsilon: 0.005,
      carryCJKAfterClosingQuote: false,
      preferPrefixWidthsForBreakableRuns: false,
      preferEarlySoftHyphenBreak: false,
    }
    return cachedEngineProfile
  }

  const ua = navigator.userAgent
  const vendor = navigator.vendor
  const isSafari =
    vendor === 'Apple Computer, Inc.' &&
    ua.includes('Safari/') &&
    !ua.includes('Chrome/') &&
    !ua.includes('Chromium/') &&
    !ua.includes('CriOS/') &&
    !ua.includes('FxiOS/') &&
    !ua.includes('EdgiOS/')
  const isChromium =
    ua.includes('Chrome/') ||
    ua.includes('Chromium/') ||
    ua.includes('CriOS/') ||
    ua.includes('Edg/')

  cachedEngineProfile = {
    lineFitEpsilon: isSafari ? 1 / 64 : 0.005,
    carryCJKAfterClosingQuote: isChromium,
    preferPrefixWidthsForBreakableRuns: isSafari,
    preferEarlySoftHyphenBreak: isSafari,
  }
  return cachedEngineProfile
}
5 / 7

Greedy Line Breaking

src/line-break.ts:177

walkPreparedLinesSimple() implements CSS white-space: normal wrapping in pure arithmetic -- no DOM, no string reconstruction, just segment widths and a pending-break cursor.

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.

Key takeaway

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/line-break.ts:177
function walkPreparedLinesSimple(
  prepared: PreparedLineBreakData,
  maxWidth: number,
  onLine?: (line: InternalLayoutLine) => void,
): number {
  const { widths, kinds, breakableWidths, breakablePrefixWidths } = prepared
  if (widths.length === 0) return 0

  const engineProfile = getEngineProfile()
  const lineFitEpsilon = engineProfile.lineFitEpsilon

  let lineCount = 0
  let lineW = 0
  let hasContent = false
  let lineStartSegmentIndex = 0
  let pendingBreakSegmentIndex = -1
  let pendingBreakPaintWidth = 0

  function clearPendingBreak(): void {
    pendingBreakSegmentIndex = -1
    pendingBreakPaintWidth = 0
  }

  function updatePendingBreak(segmentIndex: number, segmentWidth: number): void {
    if (!canBreakAfter(kinds[segmentIndex]!)) return
    pendingBreakSegmentIndex = segmentIndex + 1
    pendingBreakPaintWidth = lineW - segmentWidth
  }
  // ...
  // 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
6 / 7

Bidirectional Text

src/bidi.ts:1

A stripped-down implementation of the Unicode Bidirectional Algorithm (UBA) that classifies characters, computes embedding levels, and maps those levels onto prepared segments for RTL rendering.

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.

Key takeaway

The bidi engine produces levels, not reordered strings -- reordering is deliberately delegated to the caller's renderer so Pretext stays layout-only.

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 computeBidiLevels(str: string): Int8Array | null {
  const len = str.length
  if (len === 0) return null

  const types: BidiType[] = new Array(len)
  let numBidi = 0

  for (let i = 0; i < len; i++) {
    const t = classifyChar(str.charCodeAt(i))
    if (t === 'R' || t === 'AL' || t === 'AN') numBidi++
    types[i] = t
  }

  if (numBidi === 0) return null

  const startLevel = (len / numBidi) < 0.3 ? 0 : 1

  // 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 function computeSegmentLevels(normalized: string, segStarts: number[]): Int8Array | null {
  const bidiLevels = computeBidiLevels(normalized)
  if (bidiLevels === null) return null

  const segLevels = new Int8Array(segStarts.length)
  for (let i = 0; i < segStarts.length; i++) {
    segLevels[i] = bidiLevels[segStarts[i]!]!
  }
  return segLevels
}
7 / 7

The Browser Inconsistency Field Notes

RESEARCH.md:55

RESEARCH.md is the empirical record behind every shim in the codebase. Two discoveries show how each browser quirk was measured before any code was written to handle it.

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.

Key takeaway

RESEARCH.md is the reason each shim exists and each known limitation is documented as a limitation rather than silently producing wrong output.

RESEARCH.md:55
## Discovery: system-ui font resolution mismatch

Canvas and DOM resolve `system-ui` to different font variants on macOS at certain sizes.

In the recorded scan, mismatches clustered at `10-12px`, `14px`, and `26px`.
`13px`, `15-25px`, and `27-28px` were exact.

macOS uses SF Pro Text at smaller sizes and SF Pro Display at larger sizes.
Canvas and DOM switch between them at different thresholds.

Practical conclusion:
- use a named font if accuracy matters
- keep `system-ui` documented as unsafe
- if we ever support it properly, the believable path is a narrow
  prepare-time DOM fallback for detected bad tuples

## Discovery: emoji canvas/DOM width discrepancy

Chrome and Firefox on macOS can measure emoji wider in canvas than in DOM
at small sizes. Safari does not share the same discrepancy.

What held up:
- detect the discrepancy by comparing canvas emoji width against actual
  DOM emoji width per font
- cache that correction
- keep it outside the hot layout path
Tour complete

You've walked through 7 key areas of the Pretext codebase.

Read the Pretext origin story
Your codebase next

Create code tours for your project

Intraview lets AI create interactive walkthroughs of any codebase. Install the free VS Code extension and generate your first tour in minutes.

Install Intraview Free