chenglou-pretext
Pure JavaScript/TypeScript library for multiline text measurement and layout without DOM reflow. Pretext uses canvas `measureText` under the hood to segment, measure, and cache text widths, then compu
Pretext
Pure JavaScript/TypeScript library for multiline text measurement and layout without DOM reflow. Pretext uses canvas measureText under the hood to segment, measure, and cache text widths, then computes line breaks and paragraph height with pure arithmetic — eliminating the expensive getBoundingClientRect / offsetHeight calls that trigger synchronous layout reflow. It supports all languages (CJK, Thai, Arabic, mixed bidi, emoji) and targets accurate browser-matching line breaks.
Quick References
| File | Purpose |
|---|---|
src/layout.ts | Main entry point — all public API exports |
README.md | Official documentation with API glossary and examples |
CHANGELOG.md | Version history and release notes |
pages/demos/ | Working demo applications showing real usage patterns |
STATUS.md | Current accuracy and benchmark dashboard |
When to Use
- Virtualized lists / infinite scroll: Predict text block heights without rendering to DOM, enabling proper virtualization without guesstimates or per-item DOM measurement
- Custom text rendering: Lay out multiline text to Canvas, SVG, or WebGL by iterating computed line breaks with exact widths and cursors
- Shrinkwrap / balanced text: Binary search for the tightest container width that preserves a target line count — the "multiline shrinkwrap" that CSS can't do
- Layout-shift prevention: Know the exact height of incoming text before it renders, so you can pre-allocate space and anchor scroll positions
- AI/dev-time verification: Confirm that label text won't overflow button boundaries without running a browser
- Variable-width reflow: Route text around floating images or obstacles by feeding a different
maxWidthper line
Installation
npm install @chenglou/pretext
Requires a browser environment (or OffscreenCanvas support) for canvas text measurement. TypeScript 5+ as a peer dependency.
Best Practices
-
Use named fonts, not
system-ui: On macOS, canvas and DOM can resolvesystem-uito different font variants, causing measurement inaccuracy. Use named fonts like'16px Inter'or'16px "Helvetica Neue"'. -
Keep
fontstrings in sync with CSS: Thefontparameter uses the same format as the canvasctx.fontproperty (e.g.,'16px Inter','bold 18px "Helvetica Neue"'). It must match your CSSfontdeclaration for the text being measured. -
Call
prepare()once,layout()on every resize:prepare()does expensive segmentation and canvas measurement (~19ms for 500 texts).layout()is pure arithmetic over cached widths (~0.09ms for the same batch). Structure your code to prepare once when text appears, then relayout cheaply on width changes. -
Use
prepare()for height-only work,prepareWithSegments()for line rendering: The opaqueprepare()handle is the fast path when you only need{ height, lineCount }. Switch toprepareWithSegments()only when you need to render individual lines or inspect line geometry. -
Wait for fonts to load: Call
prepare()afterdocument.fonts.readyresolves, or re-prepare when fonts finish loading, to ensure accurate measurements. -
Use
clearCache()when cycling through many fonts: Pretext caches segment measurements per font. If your app uses many different font configurations over time, callclearCache()to release accumulated memory.
Common Patterns
Measure paragraph height without DOM:
import { prepare, layout } from '@chenglou/pretext'
const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')
const { height, lineCount } = layout(prepared, containerWidth, 20)
// height and lineCount are computed with pure arithmetic — no DOM reflow
Textarea-like pre-wrap mode (preserved spaces, tabs, hard breaks):
import { prepare, layout } from '@chenglou/pretext'
const prepared = prepare(textareaValue, '16px Inter', { whiteSpace: 'pre-wrap' })
const { height } = layout(prepared, textareaWidth, 20)
Render lines to Canvas:
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'
const prepared = prepareWithSegments('Hello world, this is multiline text.', '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)
}
Shrinkwrap — find the tightest container width:
import { prepareWithSegments, walkLineRanges } from '@chenglou/pretext'
const prepared = prepareWithSegments(text, '15px "Helvetica Neue"')
let maxLineWidth = 0
walkLineRanges(prepared, maxWidth, line => {
if (line.width > maxLineWidth) maxLineWidth = line.width
})
// maxLineWidth is the tightest width that still fits the text at this line count
Binary search for balanced text layout:
import { prepareWithSegments, layout, walkLineRanges } from '@chenglou/pretext'
const prepared = prepareWithSegments(text, font)
const targetLineCount = layout(prepared, maxWidth, lineHeight).lineCount
let lo = 1, hi = Math.ceil(maxWidth)
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2)
if (layout(prepared, mid, lineHeight).lineCount <= targetLineCount) {
hi = mid
} else {
lo = mid + 1
}
}
// lo is the minimum width that preserves the original line count
Variable-width reflow around obstacles:
import { prepareWithSegments, layoutNextLine, type LayoutCursor } from '@chenglou/pretext'
const prepared = prepareWithSegments(bodyText, '20px Palatino')
let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0
while (true) {
// Lines beside the image are narrower
const width = y < image.bottom ? columnWidth - image.width : columnWidth
const line = layoutNextLine(prepared, cursor, width)
if (line === null) break
ctx.fillText(line.text, 0, y)
cursor = line.end
y += lineHeight
}
Re-prepare on resize with cached font:
import { prepare, layout, clearCache, type PreparedText } from '@chenglou/pretext'
let cached: { font: string; prepared: PreparedText } | null = null
function getHeight(text: string, font: string, width: number, lineHeight: number): number {
if (!cached || cached.font !== font) {
cached = { font, prepared: prepare(text, font) }
}
return layout(cached.prepared, width, lineHeight).height
}
Set locale for language-specific segmentation:
import { setLocale, prepare, layout } from '@chenglou/pretext'
setLocale('th') // Use Thai word segmentation rules
const prepared = prepare('ภาษาไทยภาษาไทย', '16px sans-serif')
const result = layout(prepared, 200, 20)
setLocale(undefined) // Reset to runtime default
API Quick Reference
Functions
| Export | Signature | Description |
|---|---|---|
prepare | (text, font, options?) → PreparedText | One-time text analysis + measurement pass. Returns an opaque handle for layout(). |
prepareWithSegments | (text, font, options?) → PreparedTextWithSegments | Same as prepare() but returns a richer structure exposing segments for manual line layout. |
layout | (prepared, maxWidth, lineHeight) → LayoutResult | Fast hot-path: computes { height, lineCount } from cached widths. Pure arithmetic, no DOM/canvas. |
layoutWithLines | (prepared, maxWidth, lineHeight) → LayoutLinesResult | Returns all lines with text, width, and start/end cursors. Use with PreparedTextWithSegments. |
walkLineRanges | (prepared, maxWidth, onLine) → number | Non-materializing line geometry pass. Calls onLine per line with width and cursors, no text strings. Returns line count. |
layoutNextLine | (prepared, start, maxWidth) → LayoutLine | null | Iterator-style API: returns one line at a time with a potentially different maxWidth per call. |
clearCache | () → void | Clears all shared internal caches. Useful when cycling through many fonts. |
setLocale | (locale?) → void | Sets the Intl.Segmenter locale for future prepare() calls. Also calls clearCache(). Does not affect already-prepared handles. |
Types
| Type | Fields | Description |
|---|---|---|
PreparedText | (opaque) | Handle returned by prepare(). Pass to layout(). |
PreparedTextWithSegments | segments: string[] | Richer handle from prepareWithSegments(). Extends PreparedText with visible segment data. |
LayoutResult | lineCount: number, height: number | Result of layout(). Height = lineCount × lineHeight. |
LayoutLine | text: string, width: number, start: LayoutCursor, end: LayoutCursor | A materialized line with its text content and measured width. |
LayoutLineRange | width: number, start: LayoutCursor, end: LayoutCursor | A line's geometry without materialized text (from walkLineRanges). |
LayoutLinesResult | lineCount, height, lines: LayoutLine[] | Full layout result with per-line details. |
LayoutCursor | segmentIndex: number, graphemeIndex: number | Position within the prepared segment stream. Used for line boundaries and layoutNextLine iteration. |
PrepareOptions | whiteSpace?: 'normal' | 'pre-wrap' | Options for prepare() / prepareWithSegments(). |
Configuration
font Parameter
The font string follows the same format as the canvas CanvasRenderingContext2D.font property, which mirrors the CSS font shorthand:
// Size + family (most common)
prepare(text, '16px Inter')
prepare(text, '18px "Helvetica Neue"')
// With weight and style
prepare(text, 'bold 14px Arial')
prepare(text, 'italic 600 20px "Iowan Old Style"')
// Multiple fallback families
prepare(text, '15px "Helvetica Neue", Helvetica, Arial, sans-serif')
whiteSpace Option
| Value | Behavior |
|---|---|
'normal' (default) | Collapses whitespace runs, trims edges, breaks at word boundaries. Matches CSS white-space: normal + overflow-wrap: break-word. |
'pre-wrap' | Preserves ordinary spaces, \t tabs (browser-style tab-size: 8), and \n hard breaks. Tabs follow default tab-stop alignment. CRLF (\r\n) is normalized to a single \n. |
CSS Target
Pretext targets this common CSS text configuration:
white-space: normal(orpre-wrapwhen opted in)word-break: normaloverflow-wrap: break-wordline-break: auto
At narrow widths, words may break at grapheme boundaries (matching the overflow-wrap: break-word behavior).
Caveats
- Browser environment required: Needs
OffscreenCanvasor a DOM<canvas>element for text measurement viameasureText(). system-uiis unsafe: On macOS, canvas and DOM can resolvesystem-uito different fonts. Always use named fonts.- Emoji correction: Chrome/Firefox canvas measures emoji wider than DOM at small font sizes on macOS. Pretext auto-detects and corrects this per font size.
- Not a full CSS engine: Does not support
break-all,keep-all,strict,loose, oranywhereline-break modes. lineHeightis explicit: You must passlineHeighttolayout()yourself — it corresponds to the CSSline-heightvalue of the text being measured.