---
title: "aadhar.sh/garage/pretext: DOM-free text measurement"
description: "chenglou/pretext lays out multiline text with canvas as ground truth, sidestepping DOM reflow. A live demo, where it'd help aadhar.sh, and the tradeoffs."
path: "/garage/pretext"
section: "garage"
kind: "content"
updated: "2026-06-07"
source: "https://aadhar.sh/garage/pretext"
---

> Site index: https://aadhar.sh/llms.txt
> Section index: https://aadhar.sh/garage/llms.txt
> This is the Markdown twin of a page on aadhar.sh. The HTML at the source
> URL below is the original, and is hand-written and unminified on purpose.

# pretext: text measurement off the DOM watching

[chenglou/pretext](https://github.com/chenglou/pretext) measures & lays out multiline text using the **[canvas][canvas-api]** as ground truth instead of the DOM, so you never trigger a [reflow][reflow] just 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][main-thread]. pretext splits the work in two:

- **`prepare(text, font)`**: the one-time cost: normalize whitespace, segment ([graphemes][grapheme-cluster], since it handles [CJK][cjk-characters], Arabic, emoji, [ZWJ][zwj]), then measure each segment with canvas `measureText`. Cache it. Never re-run for the same text+font.
- **`layout(prepared, width, lineHeight)`**: the cheap part: *pure arithmetic* over the cached widths. Re-run it free on every resize tick. No DOM, no reflow.

## 2 · The technique, live

This runs **the real [pretext](https://github.com/chenglou/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. The result is 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

- **Honestly: not much yet.** The site's text is tiny: a dozen track rows, a 3×4 photo grid, one tooltip at a time. A handful of one-off measurements is exactly the case where the DOM is fine. pretext earns its keep at *volume*, which this homepage doesn't have.
- **The real candidate is serendipity.** If the event / attendee lists ever get *virtualized* (only render what's on-screen), you need accurate row heights without mounting every row. That's pretext's home turf.
- **[CLS][cls] / [scroll anchoring][scroll-anchoring]:** measure incoming text before it paints to reserve the exact box, instead of `aspect-ratio` guesses. Niche here, but real.
- **The Fuji-LCD tooltip** could pre-size itself to its recipe text without a measure-then-reposition reflow on hover: a micro-win, probably below the noise floor.

## 7 · Tradeoffs (why it stays "watching")

- **Fonts must be loaded first.** Measure before the [webfont][web-font] swaps and every number is wrong. (aadhar.sh dodges this: it uses system Tahoma/Verdana, no webfont, which is also why the demo above agrees so closely.)
- **Canvas widths ≠ glyph-exact.** Good enough for line breaking; font rendering is out of scope. `system-ui` is explicitly unsafe for accuracy on macOS.
- **CSS subset.** Only `white-space: normal/pre-wrap`, a few break modes, pixel `letter-spacing`; no `font-feature-settings` / optical sizing.
- **It's a JS layout path** that must mirror the browser's CSS line-breaker. That cuts against this site's whole "let CSS do layout" ethos: you're taking on a model that can drift from the real engine.
- **Deps:** `Intl.Segmenter` + Canvas 2D (both broadly shipped now, incl. Safari).

**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](https://github.com/chenglou/pretext), bundled to a single 12 KB-brotli [ES module][es-module] and *vendored [same-origin][same-origin]* at `/garage/pretext.lib.js`. Because it's served from `'self'`, the existing [CSP][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.

← back to the [garage](https://aadhar.sh/garage) · [aadhar.sh](https://aadhar.sh/)

real @chenglou/pretext, vendored same-origin (this page only) · the naïve split() comparison is mine

[canvas-api]: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API "The browser's drawing surface for scripts. Its drawImage call can copy an image onto it at a new size."
[reflow]: https://developer.mozilla.org/en-US/docs/Glossary/Reflow "The browser recomputing the size and position of elements on a page, which makes content jump when something like an image finishes loading."
[main-thread]: https://developer.mozilla.org/en-US/docs/Glossary/Main_thread "The single thread where a browser runs a page's JavaScript, handles input and lays out the page, so long work there freezes everything."
[grapheme-cluster]: https://www.unicode.org/reports/tr29/ "What a reader sees as one character, even when it is stored as several code points, such as an accented letter or an emoji with a skin tone."
[cjk-characters]: https://en.wikipedia.org/wiki/CJK_characters "Chinese, Japanese and Korean, grouped in computing because their writing systems share a large set of Chinese-derived characters."
[zwj]: https://en.wikipedia.org/wiki/Zero-width_joiner "An invisible Unicode character that joins its neighbors, used to combine several emoji into one, such as a family."
[cls]: https://developer.mozilla.org/en-US/docs/Glossary/CLS "A score for how much visible content jumps around while a page loads; lower is better, and 0 means nothing moved."
[scroll-anchoring]: https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll_anchoring "A browser behavior that keeps what you are reading in place when content loads or grows above it, so the page doesn't jump."
[web-font]: https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Text_styling/Web_fonts "A font file a site sends along with its pages, so text shows in that typeface even when the reader's device lacks it."
[es-module]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules "JavaScript's built-in module format, where files share code through import and export statements."
[same-origin]: https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Same-origin_policy "The browser rule that treats a page's own scheme, host and port as one site, and limits what pages from other origins can read."
[csp]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP "A header that tells the browser which sources a page may load scripts, styles and other resources from, to block injected code."

Source: https://aadhar.sh/garage/pretext
