July 10, 2026

Measuring how much text fits before the browser renders it.

To split text into Cards slides, the splitter has to know how much text fits on a slide before anything is drawn. It cannot render each candidate layout to the page and measure it. That would be slow, and it would fight React for control of the DOM on every keystroke. So the measurement happens off to the side, against a canvas, and the numbers feed the splitter.

Measuring against a canvas

The browser will measure a string without laying it out. A 2D canvas context has measureText, which returns the width a string would take in a given font. One hidden canvas, reused for the life of the page, answers every query:

let ctx: CanvasRenderingContext2D | null = null;
const cache = new Map<string, Map<string, number>>();   // font -> (text -> width)

export function measureSubstringWidth(s, start, len, spec): number {
  const fragment = s.slice(start, start + len);
  const bucket = cache.get(cacheKey(spec)) ?? new Map();
  const hit = bucket.get(fragment);
  if (hit !== undefined) return hit;

  ctx ??= document.createElement("canvas").getContext("2d");
  ctx.font = fontCss(spec);                 // "normal 600 48px 'Inter', sans-serif"
  const w = ctx.measureText(fragment).width;
  bucket.set(fragment, w);
  return w;
}

Wrapping a paragraph measures the same words over and over, so results are cached two levels deep: by font, then by string fragment. The same word at the same size is measured once.

Wrapping words the way the renderer will

With a width function in hand, line wrapping is a greedy walk: whole words go onto the current line until the next word would overflow, then a new line starts. The interesting case is a single token wider than the column, a long URL or a compound in a language that does not break on spaces. That token is split into pieces that fit.

Splitting a token cannot step character by character through the string’s UTF-16 units, because an emoji or many non-Latin glyphs occupy two units. Cut between them and the result is a broken character. So the split steps by Unicode code point instead:

// step by Unicode scalar values so surrogate pairs stay intact
while (end < word.length) {
  const cp = word.codePointAt(end)!;
  const step = cp >= 0x10000 ? 2 : 1;       // astral character spans two UTF-16 units
  const fits = measureSubstringWidth(word, start, end + step - start, spec) <= maxWidthPx;
  if (fits) end += step;
  else break;
}

Before that fallback runs, a token with ASCII hyphens is split at the hyphens, so “mother-in-law” breaks at its joints rather than mid-syllable. Each wrapped line also records whether it ends a sentence, ends a paragraph, or begins a continuation, which is what the slide splitter reads to choose its breaks.

Correcting for the gap between canvas and DOM

Canvas measureText and the browser’s own text layout do not agree exactly. The canvas tends to under-report width for Cyrillic and for heavier weights. Trust it directly and text that measured as fitting will overflow once the DOM renders it for real.

The fix is to measure honestly and then keep a margin. The usable column is shrunk before wrapping, so the engine plans for narrower lines than the canvas claims:

// canvas measureText under-reports Cyrillic and bold widths vs the DOM,
// so shrink the wrap column rather than risk overflow at render time
const SPLIT_WRAP_HORIZONTAL_HEADROOM = 0.88;

const wrapWidthPx = Math.floor(usableWidthPx * SPLIT_WRAP_HORIZONTAL_HEADROOM);
const maxSlotsPerCard = Math.floor(bodyHeightPxBudget / lineSlotPx);

The vertical budget gets the same treatment. The interior height starts from the canvas size, then subtracts the header and footer if they are on, the padding, and a safety slack of about half a font size for the last line’s ascent and descent. What remains, divided by the height of one line, is how many lines a slide can hold. These constants are tuning we found by watching real text render and pulling the budget in until overflow stopped happening.

The shape of it

Measuring text outside the render tree is what makes the live split fast: every keystroke remeasures only uncached fragments, the wrap runs on plain numbers, and the slide packer works from line counts rather than DOM nodes. The cost is that the measurement is an approximation of what the browser will draw, so it is paired with deliberate margins. That trade, measure cheaply off-screen and leave room for the gap, is the part we would carry into any live text layout.

Cards is free and runs in the browser at cards.tinygods.dev.