What it does
Pretext is a TypeScript library that measures and lays out multiline text without touching the DOM. You call prepare() once to segment and measure text via canvas, then layout() on every resize — pure arithmetic, no reflow.
Why I starred it
40k stars for a text measurement library is unusual. The reason: DOM-based measurement (getBoundingClientRect, offsetHeight) triggers synchronous layout reflow. If you have 500 text blocks each measuring independently, you get read/write interleaving that costs 30ms+ per frame. Pretext eliminates that entirely.
This matters for virtualized lists, canvas-rendered UIs, chat apps with rich inline content, and anything where you need to know text height before committing to layout. The API is two functions. That's it.
How it works
The architecture splits into four files that form a clean pipeline:
src/analysis.ts — Text segmentation. Uses Intl.Segmenter with word granularity to break input into segments, then classifies each as a SegmentBreakKind: 'text', 'space', 'glue', 'soft-hyphen', 'hard-break', etc. The interesting bit is how it merges punctuation with adjacent words — "better." stays one segment, matching actual CSS behavior. CJK detection uses a Unicode regex (\p{Script=Arabic}, \p{Nd}) and a dedicated isCJK() check so characters get per-grapheme break opportunities.
src/measurement.ts — Canvas-based width measurement with a per-font cache (segmentMetricCaches). The standout detail is emoji correction. Chrome and Firefox measure emoji wider on canvas than the DOM renders them at font sizes below 24px on macOS (Apple Color Emoji inflation). Pretext detects this by inserting a hidden span, comparing canvas.measureText against getBoundingClientRect, and caching the per-emoji-grapheme correction:
// src/measurement.ts:141
const canvasW = ctx.measureText('\u{1F600}').width
// ...
const domW = span.getBoundingClientRect().width
if (canvasW - domW > 0.5) {
correction = canvasW - domW
}
This single DOM read happens once per font and gets cached. Safari doesn't need it — canvas and DOM agree there. The engine profile in getEngineProfile() sniffs the browser to set lineFitEpsilon (Safari uses 1/64, Chromium uses 0.005) and toggles like carryCJKAfterClosingQuote and preferEarlySoftHyphenBreak.
src/line-break.ts (1,101 lines) — The actual line-breaking engine. It precompiles text into "chunks" bounded by hard breaks, then walks segments accumulating widths against maxWidth. The simple fast path (walkPreparedLinesSimple) handles normal text — no bidi, no tabs, no keep-all. It tracks a pending break point and backtracks when overflow occurs:
// src/line-break.ts — simple walker core
let lineW = 0
let hasContent = false
let pendingBreakSegmentIndex = -1
// For each segment: accumulate width, record break opportunity at spaces,
// backtrack to last break when line overflows fitLimit
When a word is too wide for any line, it falls through to grapheme-level breaking using precomputed breakableWidths — widths per grapheme within a segment. Soft hyphens get special treatment: fitSoftHyphenBreak() walks graphemes and adds discretionaryHyphenWidth only when the hyphen would actually be placed mid-word.
src/layout.ts — The public API layer. prepare() orchestrates analysis, measurement, and chunk compilation into an opaque PreparedText handle. layout() calls countPreparedLines() and multiplies by lineHeight. The richer prepareWithSegments() exposes segment data for manual rendering — canvas, SVG, WebGL.
The PreparedCore type shows the parallel-array design:
type PreparedCore = {
widths: number[] // per-segment measured widths
kinds: SegmentBreakKind[] // break behavior per segment
breakableWidths: (number[] | null)[] // grapheme widths for overflow
chunks: PreparedLineChunk[] // hard-break boundaries
// ...
}
Parallel arrays over objects — a deliberate choice for cache locality when iterating thousands of segments.
Using it
import { prepare, layout } from '@chenglou/pretext'
const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')
const { height, lineCount } = layout(prepared, 320, 20)
// height: 20, lineCount: 1 — pure arithmetic, zero DOM reads
For variable-width layouts (text flowing around a floated image):
import { prepareWithSegments, layoutNextLineRange, materializeLineRange } from '@chenglou/pretext'
const prepared = prepareWithSegments(article, '16px Inter')
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0
while (true) {
const width = y < imageBottom ? narrowWidth : fullWidth
const range = layoutNextLineRange(prepared, cursor, width)
if (!range) break
const line = materializeLineRange(prepared, range)
ctx.fillText(line.text, 0, y)
cursor = range.end
y += 26
}
The walkLineRanges API is interesting for shrink-wrap: binary search a width, check line count, find the tightest container that fits. This is the "multiline shrink-wrap" that CSS still doesn't have.
Rough edges
Zero runtime dependencies, which is good. But the docs stop at the README — no standalone docs site, no guides for the rich-inline API. The rich-inline subpath export for inline chips and mentions is explicitly "intentionally narrow" and white-space: normal only.
Browser-sniffing for engine profiles (getEngineProfile() reads navigator.userAgent) is a pragmatic choice but will need updates as browsers change their text rendering. The system-ui font is explicitly unsupported for accuracy — macOS resolves it differently on canvas vs DOM.
Tests exist (src/layout.test.ts) but aren't runnable with a standard test runner — the accuracy checks use Playwright across Chrome, Firefox, and Safari with corpus texts in Arabic, Japanese, Korean, Thai, and Burmese. Solid coverage for a layout library, but it's a custom harness.
The library is at 0.0.4. The API is still moving — the CHANGELOG.md and TODO.md suggest active development, and commits are daily as of early April 2026.
Bottom line
If you're building virtualized lists, canvas-rendered text, or any UI where DOM reflow is the bottleneck, Pretext solves the measurement problem cleanly. The i18n coverage — CJK, bidi, Thai, emoji — puts it ahead of anything else in the pure-JS text layout space.
