pretext: text measurement off the DOM watching

chenglou/pretext measures & lays out multiline text using the canvas as ground truth instead of the DOM, so you never trigger a reflow to ask "how tall is this paragraph at 320px?"


1 · Why the DOM is the slow path

To measure text the browser-native way, you put it in an element and read offsetHeight / getBoundingClientRect(). But those reads force a synchronous reflow: the browser must lay out the page right then to answer. Do it once and it's fine; do it in a loop (virtualized lists, measuring hundreds of items, re-measuring on every resize frame) and a reflow becomes one of the costliest things on the main thread. pretext splits the work in two:

2 · The technique, live

This runs the real pretext (v0.0.7, vendored locally; see the closing note on keeping its bytes off every other page). Left: the browser laying the text out and reporting offsetHeight (a reflow). Right: pretext's layout() over a one-time prepare(), same line count, zero DOM. Drag the width:

DOM: offsetHeight (reflow)

pretext: layout()

3 · Dynamic wrapping: flow around a float

The thing a width-only measure can't do: text whose available width changes per line. pretext's layoutNextLine(prepared, cursor, width) lays out one line at a time, so you feed it a narrow width while the line sits beside the floated box and the full width once the text clears it: real shaped wrapping, drawn to a canvas, no DOM. Drag the box bigger and watch the text reflow around it:

floated
object

4 · International text: why a toy isn't enough

Here's the gap a 30-line re-creation falls into. A naïve split(/\s+/) assumes spaces separate words, so CJK (which has none) becomes one giant token that overflows, and scripts it can't segment get mangled. pretext segments by grapheme via Intl.Segmenter and breaks the same string correctly:

naïve split(/\\s+/)

pretext

5 · The payoff: re-layout cost

Same paragraph, re-laid-out at many widths (the resize / virtualization pattern). The DOM path forces a reflow each time; the canvas path just crunches arithmetic over the prepared widths (prepared once, outside the loop). Run it:

press the button

6 · Where it'd actually help aadhar.sh

7 · Tradeoffs (why it's "watching," not "shipped")

Verdict: a genuinely clever inversion. Treat the canvas as the measurement oracle and keep re-layout in pure arithmetic. But its win scales with how often you measure, and aadhar.sh measures almost nothing. Filed under watching: the moment a virtualized list shows up (most likely in serendipity), this is the tool to reach for.

On the dependency: this page runs the real github.com/chenglou/pretext, bundled to a single 12 KB-brotli ES module and vendored same-origin at /garage/pretext.lib.js. Because it's served from 'self', the existing CSP needs no change, so pretext's bytes load only when you open this page and never touch the homepage or any other page. The only other demo here that's mine is the naïve split(/\\s+/) wrapper in section 4, kept deliberately bad to show the gap.