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, SVG and soon, server-side.
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).
npm install @chenglou/pretextClone the repo, run bun install, then bun start, and open /demos/index 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
Pretext serves 2 use cases:
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, apply glue rules, 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 and configs; that'd defeat its precomputation. For example, on resize, only rerun layout().
If you want textarea-like text where ordinary spaces, \t tabs, and \n hard breaks stay visible, pass { whiteSpace: 'pre-wrap' } to prepare():
const prepared = prepare(textareaValue, '16px Inter', { whiteSpace: 'pre-wrap' })
const { height } = layout(prepared, textareaWidth, 20)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 guesstimates & caching
- fancy userland layouts: masonry, JS-driven flexbox-like implementations, nudging a few layout values without CSS hacks (imagine that), etc.
- development time verification (especially now with AI) that labels on e.g. buttons don't overflow to the next line, browser-free
- prevent layout shift when new text loads and you wanna re-anchor the scroll position
Switch out prepare with prepareWithSegments, then:
layoutWithLines()gives you all the lines at a fixed width:
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'
const prepared = prepareWithSegments('AGI 春天到了. بدأت الرحلة 🚀', '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 320, 26) // 320px max width, 26px line height
for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i].text, 0, i * 26)measureLineStats()andwalkLineRanges()give you line counts, widths and cursors without building the text strings:
import { measureLineStats, walkLineRanges } from '@chenglou/pretext'
const { lineCount, maxLineWidth } = measureLineStats(prepared, 320)
let maxW = 0
walkLineRanges(prepared, 320, line => { if (line.width > maxW) maxW = line.width })
// maxW is now the widest line — the tightest container width that still fits the text! This multiline "shrink wrap" has been missing from weblayoutNextLineRange()lets you route text one row at a time when width changes as you go:
import { layoutNextLineRange, materializeLineRange, prepareWithSegments, type LayoutCursor } from '@chenglou/pretext'
const prepared = prepareWithSegments(article, BODY_FONT)
let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0
// Flow text around a floated image: lines beside the image are narrower
while (true) {
const width = y < image.bottom ? columnWidth - image.width : columnWidth
const range = layoutNextLineRange(prepared, cursor, width)
if (range === null) break
const line = materializeLineRange(prepared, range)
ctx.fillText(line.text, 0, y)
cursor = range.end
y += 26
}See the /demos/dynamic-layout demo for a richer example.
For hyphenation, insert soft hyphens before calling prepare() or prepareWithSegments(). They stay invisible unless the line breaks there, in which case it ends with -. Pretext doesn't insert soft hyphens for you. For mixed-language or user-generated app text, prefer conservative, locale-aware insertion over aggressive pattern hyphenation.
To lay out text with mixed fonts, code spans, mentions, or chips, use @chenglou/pretext/rich-inline:
import { materializeRichInlineLineRange, prepareRichInline, walkRichInlineLineRanges } from '@chenglou/pretext/rich-inline'
const prepared = prepareRichInline([
{ text: 'Ship ', font: '500 17px Inter' },
{ text: '@maya', font: '700 12px Inter', break: 'never', extraWidth: 22 },
{ text: "'s rich-note", font: '500 17px Inter' },
])
walkRichInlineLineRanges(prepared, 320, range => {
const line = materializeRichInlineLineRange(prepared, range)
// each fragment keeps its source item index, text slice, gapBefore, and cursors
})Pass a flat list of text items. Only white-space: normal is supported. This is not a general CSS inline formatting engine.
Use-case 1 APIs:
prepare(text: string, font: string, options?: { whiteSpace?: 'normal' | 'pre-wrap', wordBreak?: 'normal' | 'keep-all', letterSpacing?: number }): PreparedText // one-time text analysis + measurement pass, returns an opaque value to pass to `layout()`. Make sure `font` and `letterSpacing` are synced with your CSS for the text you're measuring. `font` is the same format as what you'd use for `myCanvasContext.font = ...`, e.g. `16px Inter`; `letterSpacing` is a CSS pixel value.
layout(prepared: PreparedText, maxWidth: number, lineHeight: number): { height: number, lineCount: number } // calculates text height given a max width and lineHeight. Make sure `lineHeight` is synced with your css `line-height` declaration for the text you're measuring.Use-case 2 APIs:
prepareWithSegments(text: string, font: string, options?: { whiteSpace?: 'normal' | 'pre-wrap', wordBreak?: 'normal' | 'keep-all', letterSpacing?: number }): PreparedTextWithSegments // same as `prepare()`, but returns a richer structure for manual line layout needs
layoutWithLines(prepared: PreparedTextWithSegments, maxWidth: number, lineHeight: number): { height: number, lineCount: number, lines: LayoutLine[] } // high-level api for manual layout needs. Accepts a fixed max width for all lines. Similar to `layout()`'s return, but additionally returns the lines info
walkLineRanges(prepared: PreparedTextWithSegments, maxWidth: number, onLine: (line: LayoutLineRange) => void): number // low-level api for manual layout needs. Accepts a fixed max width for all lines. Calls `onLine` once per line with its actual calculated line width and start/end cursors, without building line text strings. Very useful for certain cases where you wanna speculatively test a few width and height boundaries (e.g. binary search a nice width value by repeatedly calling walkLineRanges and checking the line count, and therefore height, is "nice" too). You can have text messages shrinkwrap and balanced text layout this way. After walkLineRanges calls, you'd call layoutWithLines once, with your satisfying max width, to get the actual lines info.
measureLineStats(prepared: PreparedTextWithSegments, maxWidth: number): { lineCount: number, maxLineWidth: number } // returns only how many lines this width produces, and how wide the widest one is. Avoids line/string allocations.
measureNaturalWidth(prepared: PreparedTextWithSegments): number // Returns the width of the widest line when only explicit line breaks apply.
layoutNextLine(prepared: PreparedTextWithSegments, start: LayoutCursor, maxWidth: number): LayoutLine | null // iterator-like api for laying out each line with a different width! Returns the LayoutLine starting from `start`, or `null` when the paragraph's exhausted. Pass the previous line's `end` cursor as the next `start`.
layoutNextLineRange(prepared: PreparedTextWithSegments, start: LayoutCursor, maxWidth: number): LayoutLineRange | null // same as layoutNextLine(), but without allocating line text strings. Useful for variable-width manual layout, occlusion, and virtualization measurements.
materializeLineRange(prepared: PreparedTextWithSegments, line: LayoutLineRange): LayoutLine // turns a LayoutLineRange from layoutNextLineRange() or walkLineRanges() into a full line with text
type LineStats = {
lineCount: number // Number of wrapped lines, e.g. 3
maxLineWidth: number // Widest wrapped line, e.g. 192.5
}
type LayoutLine = {
text: string // Full text content of this line, e.g. 'hello world'
width: number // Measured width of this line, e.g. 87.5, leaving out spaces and tabs that hang past its end
start: LayoutCursor // Inclusive start cursor in prepared segments/graphemes
end: LayoutCursor // Exclusive end cursor in prepared segments/graphemes
}
type LayoutLineRange = {
width: number // Measured width of this line, e.g. 87.5, leaving out spaces and tabs that hang past its end
start: LayoutCursor // Inclusive start cursor in prepared segments/graphemes
end: LayoutCursor // Exclusive end cursor in prepared segments/graphemes
}
type LayoutCursor = {
segmentIndex: number // Segment index in prepareWithSegments' prepared rich segment stream
graphemeIndex: number // Grapheme index within that segment; `0` at segment boundaries
}Helper for rich-text inline flow:
prepareRichInline(items: RichInlineItem[]): PreparedRichInline // prepares the items for layout and collapses spaces between them
layoutNextRichInlineLineRange(prepared: PreparedRichInline, maxWidth: number, start?: RichInlineCursor): RichInlineLineRange | null // stream one line of rich-text inline flow at a time without building fragment text strings
walkRichInlineLineRanges(prepared: PreparedRichInline, maxWidth: number, onLine: (line: RichInlineLineRange) => void): number // non-materializing line walker for rich-text inline flow shrinkwrap/stats work
materializeRichInlineLineRange(prepared: PreparedRichInline, line: RichInlineLineRange): RichInlineLine // turns one previously computed rich-inline line range back into full fragment text
measureRichInlineStats(prepared: PreparedRichInline, maxWidth: number): { lineCount: number, maxLineWidth: number } // returns only how many lines this width produces, and how wide the widest one is. Avoids fragment-text allocations.
type RichInlineItem = {
text: string // raw text, including leading/trailing collapsible spaces
font: string // canvas font shorthand for this item
letterSpacing?: number // extra horizontal spacing between graphemes, in CSS px
break?: 'normal' | 'never' // `never` keeps the item atomic (aka on one line), like a chip
extraWidth?: number // extra width around the text, e.g. padding and borders
}
type RichInlineCursor = {
itemIndex: number // Which source RichInlineItem this cursor is currently in
segmentIndex: number // Segment index within that item's prepared text
graphemeIndex: number // Grapheme index within that segment; `0` at segment boundaries
}
type RichInlineFragment = {
itemIndex: number // index back into the original RichInlineItem array
text: string // Text slice for this fragment
gapBefore: number // collapsed space before this fragment, in pixels; can be zero or negative
occupiedWidth: number // text width plus extraWidth
start: LayoutCursor // Start cursor within the item's prepared text
end: LayoutCursor // End cursor within the item's prepared text
}
type RichInlineLine = {
fragments: RichInlineFragment[] // Materialized fragments on this line
width: number // Measured width of this line, including gapBefore/extraWidth
end: RichInlineCursor // Exclusive end cursor for continuing the next line
}
type RichInlineFragmentRange = {
itemIndex: number // index back into the original RichInlineItem array
gapBefore: number // collapsed space before this fragment, in pixels; can be zero or negative
occupiedWidth: number // text width plus extraWidth
start: LayoutCursor // Start cursor within the item's prepared text
end: LayoutCursor // End cursor within the item's prepared text
}
type RichInlineLineRange = {
fragments: RichInlineFragmentRange[] // Non-materialized fragment ownership/ranges on this line
width: number // Measured width of this line, including gapBefore/extraWidth
end: RichInlineCursor // Exclusive end cursor for continuing the next line
}
type RichInlineStats = {
lineCount: number // Number of wrapped lines, e.g. 3
maxLineWidth: number // Widest wrapped line, e.g. 192.5
}Other helpers:
clearCache(): void // clears Pretext's shared internal caches used by prepare() and prepareWithSegments(). Useful if your app cycles through many different fonts or text variants and you want to release the accumulated cache
setLocale(locale?: string): void // optional (by default we use the current locale). Sets locale for future prepare() and prepareWithSegments(). Internally, it also calls clearCache(). Setting a new locale doesn't affect existing prepare() and prepareWithSegments() states (no mutations to them)Notes:
LayoutCursoris a segment/grapheme cursor, not a raw string offset.- A line's
widthleaves out spaces and tabs that hang past its end, as browsers draw them: all of them where the line wraps, and inpre-wrapbefore a newline or at the end of the text, only the part that doesn't fit inmaxWidth. Firefox doesn't hang tabs, so there a tab counts.measureNaturalWidth()still counts spaces before a newline, like CSS max-content. layout()with an empty string returns{ lineCount: 0, height: 0 }. Browsers still size an empty block to oneline-height, so clamp withMath.max(1, lineCount) * lineHeightif you need that behavior.- Pretext doesn't compute bidi levels. If you're drawing mixed bidi text, like English and Arabic, render each paragraph as one DOM element with its direction set, and the browser orders every line. If you draw lines separately, such as with Canvas
fillText(), each line is ordered as its own paragraph, so numbers or punctuation next to a line break, or bidi controls that span lines, can come out in a different order. - Segment widths are browser-canvas widths for line breaking. They aren't enough to position individual characters correctly in Arabic or mixed bidi text.
Pretext doesn't try to be a full font rendering engine (yet?). It currently targets the common text setup:
white-space: normalandpre-wrapword-break: normalandkeep-alloverflow-wrap: break-word. Very narrow widths can still break inside words, independent symbol runs,keep-allgroups and kinsoku clusters such as漢。, but only at grapheme boundaries.line-break: autoletter-spacingas a numeric pixel value passed toprepare()/prepareWithSegments()- Tabs follow the default browser-style
tab-size: 8 - In
pre-wrap, Pretext treats a lone\ras a line break, but browsers don't. Normalize\rto\nin the text you render, not just the text you measure. system-uiand-apple-systemare unsafe forlayout()accuracy on macOS. Use a named font. See the platform bug ledger for the Chrome and Firefox issues.- Page language changes fonts and line breaks, and without
langChrome and Firefox use the browser's or system's language. A generic font likesans-serif, or a character missing from a named font, may use a different font from the one Pretext measures, and curly quotes can wrap differently per language. Setlangon<html>, use a named font that covers your text, and check the result in your browser. If you change<html lang>, prepare your text again; existing prepared handles keep the widths and line-break rules from before the change. - Runtime requires
Intl.Segmenter, Canvas 2D text measurement, and Unicode property escapes (\p{...}). Browsers without these features aren't supported. Without Unicode property escapes, Pretext can't load and throws aSyntaxError. - Pretext uses the canvas
fontstring. Separate CSS settings such asfont-optical-sizing,font-feature-settings, andfont-variation-settingsaren't supported. Variable-font settings only apply when expressed through that string, such as font weight. - Pass font sizes in px. If your CSS sizes text in
remorem, resolve them to px once, higher up in your app (for example, when the root font size changes), and pass that string to Pretext. Firefox measures canvas text at a rounded font size, so a fractional size like13.33pxcan wrap differently there; prefer whole-pixel sizes. - Pretext assumes default font kerning and word spacing. Text painted with a different
font-kerningorword-spacing, including word spacing inherited from the page, can wrap differently. - Chrome and Firefox let people set a minimum font size. Text below it paints at the minimum, while Pretext measures the size you pass. If your app uses small sizes, measure the height of an element with
font-size: 1px; line-height: 1once and pass Pretext the larger size.
See DEVELOPMENT.md for the dev setup and commands.
Sebastian Markbage first planted the seed with text-layout last decade. His design — canvas measureText for shaping, bidi from pdf.js, streaming line breaking — informed the architecture we kept pushing forward here.