Pretext's public API consists of core text measurement and layout functions: prepare(), layout(), prepareWithSegments(), layoutWithLines(), and utility functions for advanced use cases. The simplicity of the core interface hides a thoughtful internal architecture designed to make text layout fast enough to run on every animation frame. This guide explains all functions, when to use each, and the design principles behind the separation.
The fundamental observation behind Pretext is that text processing has two distinct phases with very different cost profiles:
Measurement (slow, cacheable): Determining how wide each piece of text is. This involves font engines, Unicode segmentation, kerning tables — genuinely complex work that browsers handle through dedicated C++ code. Once measured, these widths don't change unless the font or font size changes.
Layout (fast, must be repeated): Given known widths, deciding where line breaks go. This is fundamentally a bin-packing problem over a sequence of known widths — pure arithmetic. It must be repeated whenever the container width changes.
DOM-based approaches conflate these two phases: every getBoundingClientRect() call re-runs both measurement and layout on a DOM element. Pretext separates them: prepare() runs measurement once, layout() runs the pure arithmetic as many times as needed.
prepare(text, font, options?) — The Measurement Phaseimport { prepare } from '@chenglou/pretext';
const prepared = prepare(
"The quick brown fox jumps over the lazy dog.",
"18px Georgia, serif"
);
The font parameter is a canvas-format string (the same format used by canvas.ctx.font), not an object.
prepare()Step 1: Unicode Segmentation
Pretext uses the Unicode Line Breaking Algorithm (UAX #14) to identify legal break points in the text. This is non-trivial for global scripts:
The output is a sequence of "segments" — chunks of text that can't be broken internally, plus "glue" — the break opportunities between them.
Step 2: Canvas Measurement
Each segment is measured using an off-screen <canvas> element's ctx.measureText() API. This calls the browser's actual font shaping engine (typically HarfBuzz), so the measurements account for:
Crucially, ctx.measureText() does not trigger a layout reflow — it uses the font engine directly. This is what makes it fast.
Step 3: Width Caching
Measured widths are stored in a compact typed array (Float32Array or similar) indexed by segment position. The prepared object returned by prepare() is an opaque data structure containing this cached measurement data.
The font parameter must be a CSS-compatible canvas font string:
// Good examples (canvas format)
prepare(text, "16px Georgia");
prepare(text, "18px 'Helvetica Neue'");
prepare(text, "bold 20px Arial");
prepare(text, "italic 14px Georgia, serif");
// NOT: prepare(text, { fontFamily: 'Georgia', fontSize: 18 })
prepare() performanceprepare() is intentionally not free — it does real work. On a modern device, preparing a 500-word batch takes roughly 19ms. This is acceptable as a one-time initialization cost, but you should never call prepare() inside a render loop.
Call prepare() when:
Do not call prepare() when:
layout() instead)prepare(text, font, {
whiteSpace?: 'normal' | 'pre-wrap' // how to handle whitespace
});
font — canvas-format string (e.g., "18px Georgia", "bold italic 16px Arial")whiteSpace — optional, controls whitespace handling (defaults to 'normal')layout(prepared, maxWidth, lineHeight) — The Arithmetic Phaseimport { layout } from '@chenglou/pretext';
const result = layout(prepared, 400, 1.5); // 400px width, 1.5x line height
console.log(result); // { height: 128, lineCount: 4 }
layout() returnsA simple object containing layout dimensions:
{
height: 128, // total height of all lines in pixels
lineCount: 4 // number of lines
}
Use layout() when you only need dimensions. For detailed line-by-line data (text, width, position), use layoutWithLines() instead (see below).
layout()Given the cached segment widths from prepare(), layout() runs a greedy line-breaking algorithm:
Initialize: current line = [], current width = 0
For each segment:
If current width + segment width ≤ maxWidth:
Append segment to current line
Add segment width to current width
Else:
Finalize current line, push to output
Start new line with this segment
This is O(n) in the number of segments — linear time with tiny constants (array reads, additions, comparisons). No DOM, no font engine calls, no allocation beyond the output arrays.
For a 500-word batch (roughly 100 segments), layout() runs in approximately 0.09ms. This is fast enough to call on every requestAnimationFrame callback, every mousemove event, every resize event — without any throttling or debouncing.
layout() parametersprepared — the result of prepare()maxWidth — the container width in pixels (number only, not a function)lineHeight — line height multiplier or explicit pixel value (e.g., 1.5, 24)layoutWithLines(prepared, maxWidth, lineHeight) — Line-by-Line LayoutFor rendering, you need detailed line information. Use layoutWithLines():
import { layoutWithLines } from '@chenglou/pretext';
const { height, lineCount, lines } = layoutWithLines(prepared, 400, 1.5);
// lines = [
// { text: "The quick brown fox", width: 398.5, start, end },
// { text: "jumps over the lazy dog.", width: 356.2, start, end },
// ...
// ]
Each line in the array contains:
text — the string content of this linewidth — the measured width in pixelsstart — LayoutCursor for the first characterend — LayoutCursor for the last characterA LayoutCursor is { segmentIndex: number, graphemeIndex: number }.
Pretext deliberately does not render anything. It returns layout data, and leaves rendering entirely to the caller. This makes Pretext work with any rendering target:
DOM rendering:
const { lines } = layoutWithLines(prepared, container.offsetWidth, 1.5);
container.innerHTML = lines.map((line, i) =>
`<div style="position:absolute; top:${i * 1.5 * 16}px">${line.text}</div>`
).join('');
Canvas rendering:
const { lines } = layoutWithLines(prepared, canvasWidth, 1.5);
let y = 0;
lines.forEach(line => {
ctx.fillText(line.text, 0, y);
y += 1.5 * 16; // lineHeight * fontSize
});
SVG rendering:
const { lines } = layoutWithLines(prepared, svgWidth, 1.5);
let y = 0;
lines.forEach(line => {
const tspan = document.createElementNS(SVG_NS, 'tspan');
tspan.setAttribute('x', 0);
tspan.setAttribute('y', y);
tspan.textContent = line.text;
textEl.appendChild(tspan);
y += 1.5 * 16;
});
Server-side (Node.js): Works with node-canvas or @napi-rs/canvas for server-side rendering.
prepareWithSegments(text, font, options?) — Rich Segment DataFor advanced layouts that need custom segment information:
import { prepareWithSegments } from '@chenglou/pretext';
const prepared = prepareWithSegments(text, "16px Georgia");
// Contains detailed segmentation data for custom layout algorithms
layoutNextLine(prepared, start, maxWidth) — Variable-Width LayoutsFor text that wraps around shapes, images, or sprites with varying widths per line:
import { layoutNextLine } from '@chenglou/pretext';
const prepared = prepare(text, "16px Georgia");
let cursor = { segmentIndex: 0, graphemeIndex: 0 };
let lineIndex = 0;
while (cursor) {
const widthForThisLine = lineIndex < 3 ? 200 : 400; // narrow first 3 lines
const line = layoutNextLine(prepared, cursor, widthForThisLine);
if (!line) break;
render(line);
cursor = line.end;
lineIndex++;
}
Returns null when all text has been laid out. Each call positions one line based on the variable width for that line.
walkLineRanges(prepared, maxWidth, onLine) — Streaming LayoutFor memory-constrained environments, process lines as they're generated:
import { walkLineRanges } from '@chenglou/pretext';
const prepared = prepare(text, "16px Georgia");
walkLineRanges(prepared, 400, (line) => {
render(line); // called for each line as it's laid out
});
Pretext has no framework dependencies. Integration patterns are simple:
React:
import { prepare, layoutWithLines } from '@chenglou/pretext';
function TextBlock({ text, containerWidth }) {
const prepared = useMemo(
() => prepare(text, "16px Georgia"),
[text]
);
const { lines } = layoutWithLines(prepared, containerWidth, 1.5);
return (
<div style={{ position: 'relative' }}>
{lines.map((line, i) => (
<span key={i} style={{ display: 'block' }}>
{line.text}
</span>
))}
</div>
);
}
Animation loop:
import { prepare, layoutWithLines } from '@chenglou/pretext';
const prepared = prepare(text, "16px Georgia");
let containerWidth = 800;
function animate() {
containerWidth = getNewWidth(); // whatever drives animation
const { lines } = layoutWithLines(prepared, containerWidth, 1.5);
render(lines);
requestAnimationFrame(animate);
}
clearCache() — Clear the internal font measurement cache. Useful when fonts are dynamically loaded or unloaded.
setLocale(locale?) — Set the locale for Unicode line breaking rules. Defaults to the user's browser locale. Affects how text breaks in scripts like Thai, Hindi, and others.
Pretext's API reflects a clear philosophy about where complexity should live:
Complexity at the boundaries (prepare and render): Font engines are complex; the browser handles that in prepare(). Visual output is complex; your renderer handles that. Pretext owns only the middle.
Simplicity at the core (layout): Given widths, find line breaks. This should be fast, pure, and testable — and it is. Core layout functions are deterministic with no side effects, making them easy to reason about and optimize.
Separation of frequency: Measurement (once per content change) is separated from layout (once per frame). This matches the natural frequency of each operation with the appropriate cost structure.
Flexible API: Start with layout() for dimensions-only use cases. Upgrade to layoutWithLines() for rendering, layoutNextLine() for variable widths, or walkLineRanges() for streaming. Each API level pays only for what it uses.
| Operation | Time per 500-word batch | When to use |
|---|---|---|
prepare() |
~19ms | Once per content change |
layout() |
~0.09ms | Every frame, dimensions only |
layoutWithLines() |
~0.12ms | Every frame, need line data |
layoutNextLine() |
~0.02ms per line | Variable-width containers |
walkLineRanges() |
~0.09ms | Streaming, memory-constrained |
All are safe to call in animation loops and event handlers without throttling.
system-ui font accuracy: On macOS, the system-ui font family may render differently than expected in canvas measurement. Use explicit font families (Georgia, Helvetica, etc.) for consistent measurements.
Narrow container widths: When container width is very narrow (< grapheme width), graphemes may break unexpectedly. Always ensure minimum container width matches your text constraints.
Common text setups: Pretext is optimized for typical web text (Latin, CJK, Arabic, Devanagari). Unusual scripts or bidirectional text mixing may have edge cases. Test thoroughly with your content.
The best way to internalize how the API works is to play with the Pretext Playground — an interactive sandbox where you can see the output of all functions in real time as you resize containers and change text.
For complete installation and a first working example, see the getting started guide.
Pretext library by chenglou. Community showcase at pretext.cool.