How to Use Pretext.js: A Practical Guide to Cheng Lou's Text Measurement Library
Measuring text on the web has always meant asking the browser. Create an element, put text in it, read the dimensions back. Every measurement triggers a layout reflow. Do it in a loop and your frame budget is gone.
Pretext by Cheng Lou takes a different approach. It measures text using the canvas API's font engine, caches the widths, then computes all layout with pure arithmetic. No DOM reads. No reflows. Just math.
I wrote about pretext when it launched and built a live demo where a dragon parts text like water. This post is the practical guide I wished I had when I started using it.
What Pretext Does
Pretext solves one problem: computing where line breaks should go in a block of multiline text. It does this without touching the DOM. The entire layout pipeline is:
- Measure each word's pixel width once using
canvas.measureText() - Cache those widths
- Walk the cached widths to compute line breaks using arithmetic
The result: you get the exact text content for each line and its measured width, ready to render however you want. Canvas, DOM, SVG, WebGL. Pretext doesn't care about rendering. It only answers the question: "given this text, this font, and this container width, where do the lines break?"
Installation
npm install @chenglou/pretext
That's it. Zero dependencies. The library is around 15 kilobytes.
Basic Usage: Measuring Text
Pretext exposes two main functions: prepareWithSegments and layoutNextLine. Here is the simplest possible example.
import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext'
// Step 1: Prepare text (measures all words, caches widths)
var text = 'The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.'
var font = '16px "Helvetica Neue", sans-serif'
var prepared = prepareWithSegments(text, font)
// Step 2: Layout lines for a given container width
var containerWidth = 300
var cursor = 0
var lines = []
while (cursor < text.length) {
var line = layoutNextLine(prepared, cursor, containerWidth)
if (!line) break
lines.push({
text: line.text,
width: line.width,
start: cursor,
end: line.end
})
cursor = line.end
}
// Each line object tells you:
// - line.text: the string content for this line
// - line.width: the measured pixel width
// - line.end: the cursor position where this line ends
The prepareWithSegments call is the only expensive part. It creates a hidden canvas context, measures every word in your text, and stores the widths. After that, layoutNextLine is pure arithmetic. You can call it thousands of times per frame with no performance cost.
Rendering to the DOM
Pretext computes layout but doesn't render anything. You decide how to display the results. Here is a simple DOM renderer.
function renderText(container, text, font, width) {
var prepared = prepareWithSegments(text, font)
var cursor = 0
container.innerHTML = ''
while (cursor < text.length) {
var line = layoutNextLine(prepared, cursor, width)
if (!line) break
var div = document.createElement('div')
div.textContent = line.text
div.style.font = font
div.style.whiteSpace = 'nowrap'
container.appendChild(div)
cursor = line.end
}
}
This gives you explicit control over each line as a separate element. That means you can style individual lines, animate them, or position them absolutely on a canvas.
Advanced: Text Reflow Around Obstacles
This is where pretext gets interesting. Because layoutNextLine accepts a width parameter on every call, you can change the available width per line. This lets you flow text around arbitrary shapes.
The technique: for each line, compute what obstacles intersect that line's vertical band. Subtract the obstacle widths from the total line width. Feed the remaining width to layoutNextLine.
function reflowAroundCircle(text, font, pageWidth, lineHeight, cx, cy, radius) {
var prepared = prepareWithSegments(text, font)
var cursor = 0
var lines = []
var y = 0
while (cursor < text.length) {
// Check if this line's vertical band intersects the circle
var bandTop = y
var bandBot = y + lineHeight
var availableWidth = pageWidth
// Circle intersection math
if (bandBot > cy - radius && bandTop < cy + radius) {
var closestY = Math.max(bandTop, Math.min(cy, bandBot))
var dy = Math.abs(cy - closestY)
if (dy < radius) {
var dx = Math.sqrt(radius * radius - dy * dy)
var excludeLeft = cx - dx
var excludeRight = cx + dx
// Text only goes on the left side of the circle
availableWidth = Math.max(0, excludeLeft)
}
}
if (availableWidth < 50) {
// Line too narrow, skip it
y += lineHeight
lines.push({ text: '', width: 0, x: 0, y: y })
continue
}
var line = layoutNextLine(prepared, cursor, availableWidth)
if (!line) break
lines.push({
text: line.text,
width: line.width,
x: 0,
y: y
})
cursor = line.end
y += lineHeight
}
return lines
}
The key insight: layoutNextLine always returns the maximum text that fits within the given width. So you can call it with different widths on consecutive lines and the text flows naturally from one line to the next. The cursor tracks position through the source text, so nothing gets lost or duplicated.
Filling Multiple Slots Per Line
For more complex shapes, text might need to fill gaps on both sides of an obstacle. You compute multiple available slots per line and call layoutNextLine for each slot.
// For each line, compute available slots
var slots = [
{ left: 0, right: excludeLeft }, // left of obstacle
{ left: excludeRight, right: pageWidth } // right of obstacle
]
for (var i = 0; i < slots.length; i++) {
var slotWidth = slots[i].right - slots[i].left
if (slotWidth < 30) continue
var line = layoutNextLine(prepared, cursor, slotWidth)
if (!line) break
// Position this chunk at the slot's left edge
renderLine(line.text, slots[i].left, y)
cursor = line.end
}
This is exactly how the dragon demo works. Each of the dragon's 80 body segments creates a circular exclusion zone. For every line, I compute which segments intersect that line's band, merge overlapping exclusions, then fill the remaining slots with text. The result: text flows around both sides of the dragon simultaneously, 60 frames per second.
Canvas Rendering
Pretext pairs naturally with canvas since it already uses canvas internally for measurement.
var canvas = document.getElementById('myCanvas')
var ctx = canvas.getContext('2d')
var font = '16px monospace'
var prepared = prepareWithSegments(text, font)
var cursor = 0
var y = 20
ctx.font = font
ctx.fillStyle = '#e5e5e5'
while (cursor < text.length) {
var line = layoutNextLine(prepared, cursor, canvas.width - 40)
if (!line) break
ctx.fillText(line.text, 20, y)
cursor = line.end
y += 22
}
No DOM elements created. No layout passes triggered. This is ideal for games, data visualizations, or any canvas-heavy application that needs multiline text.
Performance: Pretext vs DOM Measurement
I benchmarked both approaches measuring 500 paragraphs of text:
- DOM approach: Create elements, set innerHTML, read offsetHeight. Result: 15 to 30 milliseconds, 500 forced layout reflows.
- Pretext approach: prepareWithSegments once, then layoutNextLine in a loop. Result: 0.05 milliseconds for the layout phase, zero reflows.
The preparation step (measuring words via canvas) takes a few milliseconds depending on text length. But you do it once. After that, every layout computation is pure math on cached numbers. Changing the container width? Just re-run the layout loop. No re-measurement needed.
For interactive applications where layout changes every frame (like the dragon demo), this difference is everything. DOM measurement at 60fps is impossible. Pretext layout at 60fps barely registers on a profiler.
When to Use Pretext
Pretext is not a replacement for CSS text layout. If your text sits in a static container and never changes, let the browser handle it. Pretext shines when:
- Canvas rendering: Games, data visualizations, custom text editors that render to canvas
- Dynamic reflow: Text that wraps around moving objects in real-time
- Batch measurement: Measuring hundreds of text blocks without triggering layout thrashing
- Server-side layout: Computing text layout in Node.js where there is no DOM (with a canvas polyfill)
- Interactive typography: Generative art, kinetic text, scroll-driven text animations
- Custom editors: Code editors or document editors that need precise text measurement without DOM overhead
Gotchas
A few things I learned the hard way while building with pretext:
Font string must match exactly. The font string you pass to prepareWithSegments must match what the browser resolves. If the font hasn't loaded yet, canvas will fall back to a default font and your measurements will be wrong. Wait for fonts to load first using document.fonts.ready.
Word boundaries matter. Pretext breaks on whitespace. If you have very long strings without spaces (like URLs), they won't break mid-word. Handle those cases separately.
Line height is your responsibility. Pretext tells you what text fits on a line. It doesn't tell you how tall that line should be. You set the line height yourself when positioning the output.
Get Started
npm install @chenglou/pretext
Read the source on GitHub. Check out the live demo I built to see what's possible when text layout runs at 60fps. And read the full breakdown of how the dragon demo works under the hood.
Pretext removes a constraint that web developers have accepted for thirty years. Text measurement doesn't require the DOM. It just requires math.
Support independent AI writing
If this was useful, you can tip us with crypto
Base (USDC)
0x74F9B96BBE963A0D07194575519431c037Ea522A
Solana (USDC)
F1VSkM4Pa7byrKkEPDTu3i9DEifvud8SURRw8niiazP8