The Problem
Text measurement in web UIs typically triggers DOM layout reflow — one of the most expensive browser operations. When rendering complex multiline text, virtualized lists, or dynamic layouts, each measurement can force the browser to recalculate element geometry, causing jank and preventing server-side rendering. Pretext was built to side-step DOM measurements entirely.
What This Does
Pretext is a collection of 4 self-contained projects (pages, scripts, src, shared — 117 files total) that implements its own text measurement logic using the browser's font engine as ground truth. The core flow: prepare() normalizes whitespace, segments text, applies glue rules, measures segments via canvas, and returns an opaque handle. layout() is the hot path — pure arithmetic over cached widths. Key files include pages/corpus.ts (defines parseWidthList, withRequestId, toNavigationReport), src/layout.ts (defines getSharedGraphemeSegmenter, createEmptyPrepared, measureAnalysis), and scripts/browser-automation.ts (defines runAppleScript, sleep, waitForPort). The library supports whiteSpace: 'pre-wrap' for textarea-like behavior, emoji and mixed-bidi layout, and three export modes: layout() for height only, layoutWithLines() for full line strings, and walkLineRanges() for width/cursor data without building text strings.
How It Is Wired
Execution starts at init in pages/corpus.ts:1119 (reaches 142 functions, called from 1 place) or run in pages/benchmark.ts:400 (reaches 139 functions, called from 5 places). The internal call graph has 676 resolved call edges; prepareWithSegments is called from 16 places, layout from 11. Hub module src/layout.ts has 14 importers and 4 callees — high blast radius means changes ripple widely. The dependency graph has 48 internal modules and 60 import edges with 0 circular dependencies. Three entry points stand out: render in pages/demos/masonry/index.ts:126 (reaches 210 functions), computeLayout in pages/demos/masonry/index.ts:55 (reaches 25 functions), and run in pages/benchmark.ts:400. One function makes an outbound network call (scripts/browser-automation.ts). The module graph shows pages/accuracy as a sink (Ca 0, Ce 4, instability 1) and pages/benchmark as another (Ca 0, Ce 3, instability 1).
How To Use It
Setup: Clone the repo, run bun install, then bun start, and open /demos in your browser (no trailing slash). Alternatively, npm install @chenglou/pretext.
Basic API (from README):
import { prepare, layout } from '@chenglou/pretext'
const prepared = prepare('AGI 春天到了. Started journey 🚀', '16px Inter')
const { height, lineCount } = layout(prepared, textWidth, 20)
For textarea-like behavior:
const prepared = prepare(textareaValue, '16px Inter', { whiteSpace: 'pre-wrap' })
const { height } = layout(prepared, textareaWidth, 20)
Segment-aware layout:
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'
const prepared = prepareWithSegments('AGI 春天到了. Started journey 🚀', '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 320, 26)
for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i].text, 0, i * 26)
Shrink-wrap width:
let maxW = 0
walkLineRanges(prepared, 320, line => { if (line.width > maxW) maxW = line.width })
Configuration lives in package.json (npm/pnpm implied) and tsconfig.json. No environment variables or keys are required.
Real-World Use
In a virtualized list component, run prepare() once when text content changes (e.g., after an API fetch), then call layout() on every render to compute line counts and heights without triggering reflow. This lets you size list items precisely, anchor scroll position when new items load, and implement masonry or asymmetric layouts in JavaScript without CSS hacks. The layoutNextLine() function enables row-by-row text routing when width varies per row — useful for text wrapping around floated images or dynamic column layouts.
Code Health & Issues
- HIGH - Pin third-party GitHub Actions to a commit SHA:
.github/workflowsdeclaresoven-sh/setup-bun@v2. A tag can move, so the action running with your token and secrets is whatever its owner last pushed. Fix: replace@vNwith the 40-character commit SHA and let Dependabot bump SHAs. - HIGH - Make CI invoke the test suite it has: 3 test files exist but no test command appears in any workflow. A green check that never executed an assertion is worse than no check. Fix: add a test step to the existing workflow.
- MEDIUM - Enable Dependabot or Renovate: 1 manifest exists but no update bot is configured. Without a bot, a published advisory sits unpatched until hand-audited. Fix: commit
.github/dependabot.ymlcovering repo ecosystems and github-actions. - MEDIUM - Gate pull requests on a dependency vulnerability scan: no dependency scan is in CI. This is the one gate that would catch a known-vulnerable package before it reaches a build. Fix: add
dependency-review-actionon pull_request orosv-scanneron push and schedule. - LOW - Set
timeout-minuteson workflow jobs: 1 workflow declares no job timeout. A wedged step runs to the six-hour platform default, meaning three runs can overlap behind a two-hourly schedule. Fix: add realistictimeout-minutesto each job.
The Bottom Line
This is a specialized, well-executed library for DOM-free text measurement and multiline layout. The core algorithm is sound and the performance numbers (prepare ~19ms for 500 texts, layout ~0.09ms) are genuine. The codebase shows measurable technical debt — duplicated logic across 23 files, oversized module files, and GitHub Actions that don't actually run tests — but the central capability works as advertised. Use it when you need to measure or lay out multiline text without paying the DOM reflow cost; avoid it if you need a fully-featured text processing engine with rich editing capabilities.