chenglou-pretext

IndexedCommit: 1dda66b0 pullsUpdated Mar 28, 2026

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

Install this reference
Reference

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

FilePurpose
src/layout.tsMain entry point — all public API exports
README.mdOfficial documentation with API glossary and examples
CHANGELOG.mdVersion history and release notes
pages/demos/Working demo applications showing real usage patterns
STATUS.mdCurrent 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 maxWidth per 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

  1. Use named fonts, not system-ui: On macOS, canvas and DOM can resolve system-ui to different font variants, causing measurement inaccuracy. Use named fonts like '16px Inter' or '16px "Helvetica Neue"'.

  2. Keep font strings in sync with CSS: The font parameter uses the same format as the canvas ctx.font property (e.g., '16px Inter', 'bold 18px "Helvetica Neue"'). It must match your CSS font declaration for the text being measured.

  3. 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.

  4. Use prepare() for height-only work, prepareWithSegments() for line rendering: The opaque prepare() handle is the fast path when you only need { height, lineCount }. Switch to prepareWithSegments() only when you need to render individual lines or inspect line geometry.

  5. Wait for fonts to load: Call prepare() after document.fonts.ready resolves, or re-prepare when fonts finish loading, to ensure accurate measurements.

  6. Use clearCache() when cycling through many fonts: Pretext caches segment measurements per font. If your app uses many different font configurations over time, call clearCache() 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

ExportSignatureDescription
prepare(text, font, options?) → PreparedTextOne-time text analysis + measurement pass. Returns an opaque handle for layout().
prepareWithSegments(text, font, options?) → PreparedTextWithSegmentsSame as prepare() but returns a richer structure exposing segments for manual line layout.
layout(prepared, maxWidth, lineHeight) → LayoutResultFast hot-path: computes { height, lineCount } from cached widths. Pure arithmetic, no DOM/canvas.
layoutWithLines(prepared, maxWidth, lineHeight) → LayoutLinesResultReturns all lines with text, width, and start/end cursors. Use with PreparedTextWithSegments.
walkLineRanges(prepared, maxWidth, onLine) → numberNon-materializing line geometry pass. Calls onLine per line with width and cursors, no text strings. Returns line count.
layoutNextLine(prepared, start, maxWidth) → LayoutLine | nullIterator-style API: returns one line at a time with a potentially different maxWidth per call.
clearCache() → voidClears all shared internal caches. Useful when cycling through many fonts.
setLocale(locale?) → voidSets the Intl.Segmenter locale for future prepare() calls. Also calls clearCache(). Does not affect already-prepared handles.

Types

TypeFieldsDescription
PreparedText(opaque)Handle returned by prepare(). Pass to layout().
PreparedTextWithSegmentssegments: string[]Richer handle from prepareWithSegments(). Extends PreparedText with visible segment data.
LayoutResultlineCount: number, height: numberResult of layout(). Height = lineCount × lineHeight.
LayoutLinetext: string, width: number, start: LayoutCursor, end: LayoutCursorA materialized line with its text content and measured width.
LayoutLineRangewidth: number, start: LayoutCursor, end: LayoutCursorA line's geometry without materialized text (from walkLineRanges).
LayoutLinesResultlineCount, height, lines: LayoutLine[]Full layout result with per-line details.
LayoutCursorsegmentIndex: number, graphemeIndex: numberPosition within the prepared segment stream. Used for line boundaries and layoutNextLine iteration.
PrepareOptionswhiteSpace?: '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

ValueBehavior
'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 (or pre-wrap when opted in)
  • word-break: normal
  • overflow-wrap: break-word
  • line-break: auto

At narrow widths, words may break at grapheme boundaries (matching the overflow-wrap: break-word behavior).

Caveats

  • Browser environment required: Needs OffscreenCanvas or a DOM <canvas> element for text measurement via measureText().
  • system-ui is unsafe: On macOS, canvas and DOM can resolve system-ui to 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, or anywhere line-break modes.
  • lineHeight is explicit: You must pass lineHeight to layout() yourself — it corresponds to the CSS line-height value of the text being measured.

“Explore distant worlds.”

© 2026 Oscar Gabriel