Pretext Lab
14Mixed fonts + sizes
Arc 5 · Rich Inline

Mixed fonts and sizes

One paragraph can carry more than one voice. A running body serif can make room for a pull-quote set larger and italic, then drop a token of inline code in mono, then return. The browser handles this for static HTML; the challenge comes the moment you need to know the width of each run — for wrapping, for virtualization, for animation.

Pretext prepares each run in its own font. When you compose them, widths stay honest.

— lines · 4 type specs
520

Mechanism

The public API offers prepareWithSegments(text, font) — note the per-call font argument. Rich inline with multiple fonts is just this call, once per run, with each run's own font string. The handle returned carries that run's widths, measured against the canvas font engine at the correct type specs.

Composition is a userland loop. We walk the items; for each text run we ask layoutNextLine(handle, cursor, remainingWidth); the returned line width is correct for that run's font, even though the previous fragment on the same line was measured in a different one. When a run can't fit on the current line, we break; the next line starts fresh with the full width.

The arithmetic is still pure. No DOM reads, no mid-handler reflow. Every run's width, at every line, comes back in microseconds.

Application

Typography stops being locked into one spec per paragraph:

The common unlock: the type specs you want on the page are the type specs Pretext measures with. No fallback to the body font for the hard cases.

"I went to the woods because I wished to live deliberately. Not as the crowd lives, but as Whitman urged, standing at ease in nature, among the voices that speak before speech."

Henry David Thoreau, Walden (1854), with Walt Whitman, Song of Myself (1855)

Direct Claude

"pull-quote inside a paragraph" a run with a larger italic font descriptor, prepared alongside the body runs "inline code in flowing text" a <code> run prepared with the mono font string "small-caps label without breaking wrap" prepare the label run in its tracked-out font; width is measured with that tracking "no drift when sizes coexist" one prepareWithSegments() per type spec; share the cursor math across all of them
Next: the same two-phase dance, but on a moving target — tokens arriving from a stream.
one handle per type spec
import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext';

const BODY_FONT = "400 19px 'EB Garamond', serif";
const PULL_FONT = "italic 600 28px 'EB Garamond', serif";
const CODE_FONT = "500 15px 'JetBrains Mono', ui-monospace, monospace";

// Each run is prepared in its own font. The handles are independent.
const items = [
  { kind: 'text', className: 'frag--body',
    prepared: prepareWithSegments('I went to the woods ', BODY_FONT) },
  { kind: 'text', className: 'frag--pull',
    prepared: prepareWithSegments('to live deliberately', PULL_FONT) },
  { kind: 'text', className: 'frag--body',
    prepared: prepareWithSegments(', and to call the ', BODY_FONT) },
  { kind: 'text', className: 'frag--code',
    prepared: prepareWithSegments('layout()', CODE_FONT) },
  { kind: 'text', className: 'frag--body',
    prepared: prepareWithSegments(' of my days what it is.', BODY_FONT) },
];

// Composition — walk items, emitting lines that respect every run's font.
function layoutMixed(items, maxWidth) {
  const lines = [];
  let i = 0, textCursor = null;
  while (i < items.length) {
    const fragments = [];
    let remaining = maxWidth;
    while (i < items.length) {
      const item = items[i];
      const start = textCursor ?? { segmentIndex: 0, graphemeIndex: 0 };
      const line = layoutNextLine(item.prepared, start, Math.max(1, remaining));
      if (line === null) { i++; textCursor = null; continue; }
      fragments.push({ text: line.text, className: item.className, width: line.width });
      remaining -= line.width;
      if (cursorsEqual(line.end, item.endCursor)) { i++; textCursor = null; }
      else { textCursor = line.end; break; }
    }
    if (!fragments.length) break;
    lines.push({ fragments });
  }
  return lines;
}