chenglou /pretext

Fast, accurate & comprehensive text measurement & layout

★ 51,024⑂ 2,759👁 133⑃ 384● 141▤ 1◈ 0
TypeScriptNOASSERTIONCreated 2026-03-07chenglou.me/pretext/
51,024
stars
+7
2,759
forks
133
watchers
384
prs
141
issues
1
releases
0
discussions

README

Pretext

Pure JavaScript/TypeScript library for multiline text measurement & layout. Fast, accurate & supports all the languages you didn't even know about. Allows rendering to DOM, Canvas and SVG.

Pretext side-steps the need for DOM measurements (e.g. getBoundingClientRect, offsetHeight), which trigger layout reflow, one of the most expensive operations in the browser. It implements its own text measurement logic, using the browsers' own font engine as ground truth (very AI-friendly iteration method).

 Installation

sh
npm install @chenglou/pretext


 Demos

The demos are exemplary API usage patterns we encourage you to read. They don't ship in the npm package, so clone the repo, run bun install, then bun start, and open  in your browser. On Windows, use bun run start:windows.
Alternatively, see them live at chenglou.me/pretext. Some more at somnai-dreams.github.io/pretext-demos
Building a chat or another long list? pages/demos/markdown-chat.md walks through the Markdown chat demo's patterns and when you can skip each.

 API

Pretext serves 2 use cases:

 1. Measure a paragraph's height _without ever touching DOM_

ts
import { prepare, layout } from '@chenglou/pretext'

const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀‎', '16px Inter')
const { height, lineCount } = layout(prepared, 320, 20) // pure arithmetic. No DOM layout & reflow!


prepare() does the one-time work: normalize whitespace, segment the text at its break opportunities, measure the segments with canvas, and return an opaque handle. layout() is the cheap hot path after that: pure arithmetic over cached widths. Do not rerun prepare() for the same text, font and options; that'd defeat its precomputation. For example, on resize, only rerun layout().

To paint the text, give one element that font, the line-height and content width you passed to layout(), and overflow-wrap: break-word: the browser wraps it to the same lines. Text in several fonts or with padded pieces goes through the rich inline text API below, and is painted a line at a time.

If you want textarea-like text where ordinary spaces, \t tabs, and \n hard breaks stay visible, pass { whiteSpace: 'pre-wrap' } to prepare():

ts
const prepared = prepare(textareaValue, '16px Inter', { whiteSpace: 'pre-wrap' })
const { height } = layout(prepared, textareaWidth, 20)
// A  shows one more line than this when its value is empty or ends with \n

// Long text edited live: prepare each paragraph apart, keeping its \n, and re-prepare only the one an edit touches
const paragraphs = textareaValue.split(/(? prepare(p, '16px Inter', { whiteSpace: 'pre-wrap' }))
const lineCount = paragraphs.reduce((n, p) = n + layout(p, textareaWidth, 20).lineCount, 0)


Other prepare() options are { wordBreak: 'keep-all' } for CSS-like word-break: keep-all, and { letterSpacing: n } to match CSS letter-spacing (n is treated as a px value).

The returned height is the crucial last piece for unlocking web UIs:
- proper virtualization/occlusion without guesst

Top issues

Latest release

1.0.0 v1.0.0 2026-10-14
Lines break where the browser breaks them in many more cases: in Chrome, Safari and Firefox, Pretext now finds where lines can break with a port of that browser's own line breaker and its data, in place of its own rules, and rich inline lays its items out as one paragraph, as a browser lays out a paragraph's spans.

Against 0.0.9, layout() at a new width is three to six times as fast and prepare() of text it has measured before 1.1 to 13 times as fast, while loading the library takes about 0.4 ms more on each page in Chrome, 0.7 ms in Safari and 1.1 ms in Firefox.

Bundles are larger: the main

Snapshot: 2026-11-10T09:35:25Z · Data license Apache-2.0 · JSON