How Pretext Works: prepare() and layout() Explained

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 Core Insight: Separate Measurement From Layout

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 Phase

import { 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.

What happens inside 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.

Font format

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() performance

prepare() 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:

Options reference

prepare(text, font, {
  whiteSpace?: 'normal' | 'pre-wrap'  // how to handle whitespace
});

layout(prepared, maxWidth, lineHeight) — The Arithmetic Phase

import { layout } from '@chenglou/pretext';

const result = layout(prepared, 400, 1.5); // 400px width, 1.5x line height
console.log(result); // { height: 128, lineCount: 4 }

What layout() returns

A 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).

What happens inside 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() parameters

layoutWithLines(prepared, maxWidth, lineHeight) — Line-by-Line Layout

For 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:

A LayoutCursor is { segmentIndex: number, graphemeIndex: number }.

Rendering: What Pretext Doesn't Do

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 Data

For 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 Layouts

For 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 Layout

For 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
});

Framework Integration

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);
}

Utility Functions

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.

The Design Philosophy

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.

Performance Summary

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.

Known Limitations

See It In Action

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.

More Guides

Try These Demos

← Browse all 18 demos