Pretext code snippets

Copy-ready examples for @chenglou/pretext. Use ▶ Run for a sandboxed live preview (Pretext from esm.sh).

Basic

📏 Simple height measurement

prepare() once, then layout() with maxWidth and lineHeight. Returns height (px) and lineCount — no DOM.

Basic
import { prepare, layout } from '@chenglou/pretext';

const LINE_HEIGHT = 24;
const prepared = prepare('Hello, world! This is a test.', '16px Inter');
const result = layout(prepared, 300, LINE_HEIGHT);

console.log(result.height); // total height in px
console.log(result.lineCount); // number of lines

⚡ Batch height calculation

Map many strings to heights in the main thread. Compare with a hidden DOM node to see reflow cost.

Basic
import { prepare, layout } from '@chenglou/pretext';

const FONT = '14px Inter';
const WIDTH = 320;
const LINE_HEIGHT = 21;

const items = Array.from({ length: 1000 }, (_, i) => `Item ${i}: some text content here`);

const t0 = performance.now();
const heights = items.map((text) => {
	const p = prepare(text, FONT);
	return layout(p, WIDTH, LINE_HEIGHT).height;
});
console.log(`Pretext: ${(performance.now() - t0).toFixed(2)}ms`);

// DOM comparison (forces reflow each iteration)
const t1 = performance.now();
const div = document.createElement('div');
div.style.cssText = `width:${WIDTH}px;font:${FONT};position:absolute;visibility:hidden;white-space:normal`;
document.body.appendChild(div);
const domHeights = items.map((text) => {
	div.textContent = text;
	return div.getBoundingClientRect().height;
});
document.body.removeChild(div);
console.log(`DOM: ${(performance.now() - t1).toFixed(2)}ms`);

♻️ Handle reuse

PreparedText is immutable measurement data. Reuse the same handle for many container widths.

Basic
import { prepare, layout } from '@chenglou/pretext';

const text = 'Reuse the same handle across multiple container widths.';
const prepared = prepare(text, '16px Inter');
const LINE_HEIGHT = 22;

const widths = [200, 320, 480, 640, 960];
widths.forEach((w) => {
	const { height, lineCount } = layout(prepared, w, LINE_HEIGHT);
	console.log(`width=${w}: height=${height}, lines=${lineCount}`);
});

🔤 Font string format

Pass a CSS font shorthand (size + family). Multi-word families should be quoted like in CSS.

Basic
import { prepare } from '@chenglou/pretext';

// Typical: size then family (quoted if the name has spaces)
prepare('text', '16px Inter');
prepare('text', 'bold 14px "Geist Mono"');
prepare('text', '500 18px "Satoshi", sans-serif');

// Common mistakes (avoid):
// prepare('text', 'Inter');           // missing size
// prepare('text', 'Inter 16px');      // size should come before family in shorthand
// prepare('text', '16px');            // missing family

Layout

📝 layoutWithLines + Canvas

layoutWithLines needs prepareWithSegments. Draw each line.text with the same lineHeight you passed in.

Layout
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

const LINE_HEIGHT = 22;
const prepared = prepareWithSegments(
	'The quick brown fox jumps over the lazy dog.',
	'16px Inter',
);
const { lines, height } = layoutWithLines(prepared, 300, LINE_HEIGHT);

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
ctx.font = '16px Inter';
ctx.fillStyle = '#000';

lines.forEach((line, i) => {
	ctx.fillText(line.text, 0, (i + 1) * LINE_HEIGHT);
});

console.log('Total height:', height);

🫧 walkLineRanges (shrink-wrapped width)

Walk each line range at a max width and take the largest range.width for a tight bubble.

Layout
import { prepareWithSegments, walkLineRanges } from '@chenglou/pretext';

function maxContentWidth(text: string, font: string, maxWidth: number): number {
	const prepared = prepareWithSegments(text, font);
	let maxLine = 0;
	walkLineRanges(prepared, maxWidth, (range) => {
		if (range.width > maxLine) maxLine = range.width;
	});
	return maxLine;
}

const w = maxContentWidth('Hey! How are you?', '15px Inter', 280);
console.log('Content width:', w);

🌊 layoutNextLine: flow around an obstacle

Per-line maxWidth models text beside an image. Advance the cursor with line.end after each line.

Layout
import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext';

const text =
	'Long paragraph that flows around an image in the top-right. Text wraps with a narrower measure beside the image.';
const prepared = prepareWithSegments(text, '16px Inter');

const imageWidth = 120;
const imageHeight = 80;
const containerWidth = 400;
const LINE_HEIGHT = 22;

let cursor = { segmentIndex: 0, graphemeIndex: 0 };
let yOffset = 0;
const lines: Array<{ text: string; x: number; maxWidth: number }> = [];

for (;;) {
	const nearImage = yOffset < imageHeight;
	const lineMaxWidth = nearImage ? containerWidth - imageWidth - 8 : containerWidth;
	const line = layoutNextLine(prepared, cursor, lineMaxWidth);
	if (!line) break;

	lines.push({ text: line.text, x: 0, maxWidth: lineMaxWidth });
	cursor = line.end;
	yOffset += LINE_HEIGHT;
}

console.log(lines);

📰 Multi-column flow

Fill columns line by line with layoutNextLine until a column hits maxLines, then move to the next.

Layout
import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext';

function flowToColumns(
	text: string,
	font: string,
	colWidth: number,
	colCount: number,
	maxLinesPerCol: number,
) {
	const prepared = prepareWithSegments(text, font);
	const columns: string[][] = Array.from({ length: colCount }, () => []);
	let cursor = { segmentIndex: 0, graphemeIndex: 0 };
	let col = 0;

	for (;;) {
		if (col >= colCount) break;
		const line = layoutNextLine(prepared, cursor, colWidth);
		if (!line) break;
		columns[col].push(line.text);
		cursor = line.end;
		if (columns[col].length >= maxLinesPerCol) col++;
	}

	return columns;
}

const cols = flowToColumns(
	'Very long article text that keeps going so you can see multiple columns fill up… '.repeat(8),
	'16px Inter',
	280,
	2,
	10,
);
console.log('Column 1:', cols[0]);
console.log('Column 2:', cols[1]);

❝ Pull-quote inset

Narrow the measure for a vertical band (inset) by reducing maxWidth and shifting x for those lines.

Layout
import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext';

function layoutWithInset(
	text: string,
	font: string,
	fullWidth: number,
	insetTopPx: number,
	insetBottomPx: number,
	insetNarrowByPx: number,
	lineHeight: number,
) {
	const prepared = prepareWithSegments(text, font);
	let cursor = { segmentIndex: 0, graphemeIndex: 0 };
	let y = 0;
	const lines: Array<{ text: string; x: number; y: number }> = [];

	for (;;) {
		const inInset = y >= insetTopPx && y < insetBottomPx;
		const maxW = inInset ? fullWidth - insetNarrowByPx : fullWidth;
		const xOffset = inInset ? insetNarrowByPx / 2 : 0;

		const line = layoutNextLine(prepared, cursor, maxW);
		if (!line) break;

		lines.push({ text: line.text, x: xOffset, y });
		cursor = line.end;
		y += lineHeight;
	}

	return lines;
}

const lines = layoutWithInset(
	'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(6),
	'16px Inter',
	400,
	40,
	200,
	80,
	22,
);
console.log(lines.length, 'lines');

Animation

📐 ResizeObserver + Pretext

On width changes, set height from layout() so the hot path avoids measuring with the DOM.

Animation
import { prepare, layout } from '@chenglou/pretext';

const container = document.getElementById('container');
const text = container?.textContent ?? '';
const LINE_HEIGHT = 24;
const prepared = prepare(text, '16px Inter');

function reflow(width: number) {
	if (!container) return;
	const { height } = layout(prepared, width, LINE_HEIGHT);
	container.style.height = `${height}px`;
}

const ro = new ResizeObserver((entries) => {
	const width = entries[0].contentRect.width;
	reflow(width);
});

if (container) ro.observe(container);

📜 Virtual list: precomputed offsets

Precompute each row height with layout(), build prefix sums, then binary-search visible indices in scroll handlers.

Animation
import { prepare, layout } from '@chenglou/pretext';

const items = Array.from({ length: 50_000 }, (_, i) => `Row ${i}: content text here`);
const FONT = '14px Inter';
const WIDTH = 400;
const LINE_HEIGHT = 21;

const heights = items.map((text) => layout(prepare(text, FONT), WIDTH, LINE_HEIGHT).height);

const offsets: number[] = [];
let acc = 0;
for (let i = 0; i < heights.length; i++) {
	offsets.push(acc);
	acc += heights[i];
}

function lowerBound(arr: number[], x: number) {
	let lo = 0;
	let hi = arr.length;
	while (lo < hi) {
		const mid = (lo + hi) >> 1;
		if (arr[mid] < x) lo = mid + 1;
		else hi = mid;
	}
	return lo;
}

function getVisibleRange(scrollTop: number, viewportHeight: number) {
	const start = Math.max(0, lowerBound(offsets, scrollTop) - 1);
	const end = lowerBound(offsets, scrollTop + viewportHeight) + 1;
	return { start, end: Math.min(items.length, end) };
}

// In your scroll handler: const { start, end } = getVisibleRange(...); render rows [start, end).

🖱️ Text dodging the cursor (Canvas)

Each frame, choose maxWidth/x from mouse position and refill lines with layoutNextLine from the start cursor.

Animation
import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext';

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const text =
	'This text shifts measure on the row near your cursor. Move the mouse over the canvas.';
const prepared = prepareWithSegments(text, '16px Inter');
const LINE_HEIGHT = 22;

let mouseX = -999;
let mouseY = -999;

function render() {
	if (!canvas || !ctx) return;
	ctx.clearRect(0, 0, canvas.width, canvas.height);
	ctx.font = '16px Inter';
	ctx.fillStyle = '#111';

	let cursor = { segmentIndex: 0, graphemeIndex: 0 };
	let y = LINE_HEIGHT;

	for (;;) {
		const cursorRow = Math.abs(y - mouseY) < LINE_HEIGHT;
		const avoidLeft = cursorRow && mouseX < canvas.width / 2;
		const maxW = cursorRow ? canvas.width - 60 : canvas.width;
		const x = avoidLeft ? 60 : 0;

		const line = layoutNextLine(prepared, cursor, maxW);
		if (!line) break;
		ctx.fillText(line.text, x, y);
		cursor = line.end;
		y += LINE_HEIGHT;
	}

	requestAnimationFrame(render);
}

canvas.addEventListener('mousemove', (e) => {
	mouseX = e.offsetX;
	mouseY = e.offsetY;
});
render();

🪗 Accordion height (WAAPI)

Measure the opened state with layout(), then animate height from the current pixel height to the target.

Animation
import { prepare, layout } from '@chenglou/pretext';

function animateAccordion(el: HTMLElement, text: string, font: string, lineHeight: number) {
	const width = el.offsetWidth;
	const prepared = prepare(text, font);
	const { height: target } = layout(prepared, width, lineHeight);

	const from = el.offsetHeight;
	const to = target;

	el.animate([{ height: `${from}px` }, { height: `${to}px` }], {
		duration: 280,
		easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
		fill: 'forwards',
	});
}

✨ Height transition on content swap

Compute the next height before updating textContent, then transition height and swap in rAF.

Animation
import { prepare, layout } from '@chenglou/pretext';

function updateWithTransition(
	el: HTMLElement,
	newText: string,
	font: string,
	lineHeight: number,
) {
	const width = el.offsetWidth;
	const currentHeight = el.offsetHeight;

	const prepared = prepare(newText, font);
	const { height: nextHeight } = layout(prepared, width, lineHeight);

	el.style.height = `${currentHeight}px`;
	el.style.overflow = 'hidden';
	el.style.transition = 'height 250ms cubic-bezier(0.16, 1, 0.3, 1)';

	requestAnimationFrame(() => {
		el.textContent = newText;
		el.style.height = `${nextHeight}px`;
	});
}

Components

💬 Chat bubble width

Shrink-wrap to the longest line using walkLineRanges, cap at maxWidth, add horizontal padding.

Components
import { prepareWithSegments, walkLineRanges } from '@chenglou/pretext';

function chatBubbleWidth(message: string, font = '15px Inter', maxWidth = 280, paddingX = 24) {
	const prepared = prepareWithSegments(message, font);
	let contentW = 0;
	walkLineRanges(prepared, maxWidth, (range) => {
		if (range.width > contentW) contentW = range.width;
	});
	return Math.min(contentW + paddingX, maxWidth);
}

const width = chatBubbleWidth('Hey! How are you doing today?');
console.log('Bubble width:', width);

🧱 Masonry card columns

Greedy column packing: each card height = layout height + padding; assign to the shortest column.

Components
import { prepare, layout } from '@chenglou/pretext';

function buildMasonryColumns(
	cards: string[],
	font: string,
	colWidth: number,
	colCount: number,
	lineHeight: number,
) {
	const columns: number[][] = Array.from({ length: colCount }, () => []);
	const colHeights = new Array(colCount).fill(0);

	cards.forEach((text, i) => {
		const prepared = prepare(text, font);
		const { height } = layout(prepared, colWidth, lineHeight);
		const cardHeight = height + 32;

		const shortestCol = colHeights.indexOf(Math.min(...colHeights));
		columns[shortestCol].push(i);
		colHeights[shortestCol] += cardHeight + 16;
	});

	return columns;
}

const cards = Array.from({ length: 12 }, (_, i) => `Card ${i}: ${'Lorem ipsum '.repeat((i % 5) + 1)}`);
const cols = buildMasonryColumns(cards, '14px Inter', 280, 3, 21);
console.log(cols);

💡 Tooltip size & position

layoutWithLines yields height and per-line widths; clamp box to viewport and center on the anchor.

Components
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

function positionTooltip(tooltip: HTMLElement, text: string, anchorRect: DOMRect) {
	const font = '13px Inter';
	const lineHeight = 18;
	const maxW = Math.min(240, window.innerWidth - 32);
	const prepared = prepareWithSegments(text, font);
	const { lines, height } = layoutWithLines(prepared, maxW, lineHeight);

	const contentW = lines.length ? Math.max(...lines.map((l) => l.width)) : 0;
	const width = Math.min(contentW + 16, maxW);
	const boxH = height + 12;

	let left = anchorRect.left + anchorRect.width / 2 - width / 2;
	left = Math.max(8, Math.min(left, window.innerWidth - width - 8));
	const top = anchorRect.top - boxH - 8;

	tooltip.style.cssText = `position:fixed;width:${width}px;min-height:${boxH}px;left:${left}px;top:${top}px`;
	tooltip.textContent = text;
}

🧩 Rich line: mixed fonts

Sum segment widths with prepareWithSegments + layoutWithLines per segment (add chip padding for code).

Components
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

function measureRichLine(
	segments: Array<{ text: string; type: 'text' | 'code' }>,
	containerWidth: number,
	lineHeight: number,
) {
	let total = 0;

	for (const seg of segments) {
		const font = seg.type === 'code' ? '13px "Geist Mono"' : '15px Inter';
		const prep = prepareWithSegments(seg.text, font);
		const { lines } = layoutWithLines(prep, containerWidth, lineHeight);
		const lineW = lines[0]?.width ?? 0;
		total += lineW + (seg.type === 'code' ? 12 : 0);
	}

	return total;
}

const w = measureRichLine(
	[
		{ type: 'text', text: 'Use ' },
		{ type: 'code', text: 'layout()' },
		{ type: 'text', text: ' with a line height.' },
	],
	400,
	22,
);
console.log('Estimated inline width:', w);

📖 Read more: line cap

layoutWithLines to count lines; if over maxLines, join visible lines and trim space for an ellipsis control.

Components
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

function truncateAtLine(
	text: string,
	font: string,
	containerWidth: number,
	maxLines: number,
	lineHeight: number,
) {
	const prepared = prepareWithSegments(text, font);
	const { lines } = layoutWithLines(prepared, containerWidth, lineHeight);

	if (lines.length <= maxLines) return { truncated: text, isCut: false };

	const joined = lines
		.slice(0, maxLines)
		.map((l) => l.text)
		.join('');
	const trimmed = joined.slice(0, Math.max(0, joined.length - 4));

	return { truncated: trimmed + '…', isCut: true };
}

const r = truncateAtLine('Long body '.repeat(40), '16px Inter', 320, 3, 22);
console.log(r);

Canvas & WebGL

🖼️ Canvas text render

Resize the canvas to padding + layout height, then draw each line at a fixed baseline step.

Canvas & WebGL
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

function renderToCanvas(canvas: HTMLCanvasElement, text: string) {
	const ctx = canvas.getContext('2d');
	if (!ctx) return;

	const font = '16px Inter';
	const lineHeight = 24;
	const padding = 16;

	const prepared = prepareWithSegments(text, font);
	const { lines, height } = layoutWithLines(prepared, canvas.width - padding * 2, lineHeight);

	canvas.height = height + padding * 2;
	ctx.font = font;
	ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--color-text') || '#111';

	lines.forEach((line, i) => {
		ctx.fillText(line.text, padding, padding + (i + 1) * lineHeight);
	});
}

📐 SVG tspans from lines

Emit one tspan per line; escape XML entities in text before interpolating into SVG markup.

Canvas & WebGL
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

function escapeXml(s: string) {
	return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function textToSVG(text: string, font: string, width: number, lineHeight: number): string {
	const prepared = prepareWithSegments(text, font);
	const { lines, height } = layoutWithLines(prepared, width, lineHeight);

	const tspans = lines
		.map(
			(line, i) =>
				`<tspan x="0" dy="${i === 0 ? 0 : lineHeight}">${escapeXml(line.text)}</tspan>`,
		)
		.join('');

	return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<text font-family="Inter" font-size="16" fill="currentColor" y="16">${tspans}</text>
</svg>`;
}

🎮 WebGL texture (Three.js)

Rasterize lines to an offscreen canvas, then THREE.CanvasTexture. Install: npm i three

Canvas & WebGL
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';
import * as THREE from 'three';

function createTextTexture(text: string, width = 512): THREE.CanvasTexture {
	const canvas = document.createElement('canvas');
	canvas.width = width;

	const font = '16px Inter';
	const lineHeight = 22;
	const pad = 8;
	const prepared = prepareWithSegments(text, font);
	const { lines, height } = layoutWithLines(prepared, width - pad * 2, lineHeight);
	canvas.height = height + pad * 2;

	const ctx = canvas.getContext('2d');
	if (!ctx) throw new Error('2d context');
	ctx.fillStyle = '#ffffff';
	ctx.fillRect(0, 0, canvas.width, canvas.height);
	ctx.fillStyle = '#000000';
	ctx.font = font;
	lines.forEach((line, i) => ctx.fillText(line.text, pad, pad + (i + 1) * lineHeight));

	return new THREE.CanvasTexture(canvas);
}

🛰️ Server-side layout (Node / Astro)

Pretext has no DOM dependency — safe in getStaticPaths or any Node script for SSR props.

Canvas & WebGL
// In Astro frontmatter or a Node script
import { prepare, layout } from '@chenglou/pretext';

const LINE_HEIGHT = 24;

export async function getStaticPaths() {
	const posts = [
		{ slug: 'a', excerpt: 'Short excerpt.' },
		{ slug: 'b', excerpt: 'A longer excerpt that will wrap when given a finite column width in the card UI.' },
	];

	return posts.map((post) => {
		const prepared = prepare(post.excerpt, '16px Inter');
		const { height } = layout(prepared, 640, LINE_HEIGHT);

		return {
			params: { slug: post.slug },
			props: { ...post, excerptHeight: height },
		};
	});
}

Patterns

🗄️ Prepare cache (LRU)

Reuse PreparedText for repeated (text, font) pairs. layout() stays cheap; prepare() is the heavier step.

Patterns
import { prepare, layout, type PreparedText } from '@chenglou/pretext';

class PrepareCache {
	private cache = new Map<string, PreparedText>();
	constructor(private maxSize = 200) {}

	get(text: string, font: string): PreparedText {
		const key = font + '::' + text;
		const hit = this.cache.get(key);
		if (hit) {
			this.cache.delete(key);
			this.cache.set(key, hit);
			return hit;
		}
		if (this.cache.size >= this.maxSize) {
			const first = this.cache.keys().next().value as string;
			this.cache.delete(first);
		}
		const prepared = prepare(text, font);
		this.cache.set(key, prepared);
		return prepared;
	}
}

const cache = new PrepareCache(500);
const prepared = cache.get('Hello world', '16px Inter');
const { height } = layout(prepared, 300, 24);
console.log(height);

👷 Measure in a Web Worker

Offload prepare + layout to a module worker so the main thread stays responsive (bundle pretext into the worker).

Patterns
// worker.ts
import { prepare, layout } from '@chenglou/pretext';

self.onmessage = (e: MessageEvent<{ text: string; font: string; width: number; lineHeight: number }>) => {
	const { text, font, width, lineHeight } = e.data;
	const prepared = prepare(text, font);
	const result = layout(prepared, width, lineHeight);
	self.postMessage(result);
};

// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });

function measureAsync(text: string, font: string, width: number, lineHeight: number) {
	return new Promise<{ height: number; lineCount: number }>((resolve) => {
		worker.onmessage = (e) => resolve(e.data);
		worker.postMessage({ text, font, width, lineHeight });
	});
}

const result = await measureAsync('Hello from the worker!', '16px Inter', 320, 22);
console.log(result.height, result.lineCount);

⚛️ React useMemo layout

Derive height and lineCount from props; dependencies are text, font, and container width.

Patterns
import { prepare, layout } from '@chenglou/pretext';
import { useMemo } from 'react';

interface PretextMetrics {
	height: number;
	lineCount: number;
}

function usePretextLayout(text: string, font: string, containerWidth: number, lineHeight: number): PretextMetrics {
	return useMemo(() => {
		if (!text || !font || !containerWidth) return { height: 0, lineCount: 0 };
		const prepared = prepare(text, font);
		const { height, lineCount } = layout(prepared, containerWidth, lineHeight);
		return { height, lineCount };
	}, [text, font, containerWidth, lineHeight]);
}

function Card({ text }: { text: string }) {
	const { height, lineCount } = usePretextLayout(text, '16px Inter', 320, 22);
	return (
		<div style={{ minHeight: height }}>
			{text} ({lineCount} lines)
		</div>
	);
}

⏱️ Pretext vs DOM benchmark

Same texts and width: time prepare+layout vs getBoundingClientRect in a hidden div.

Patterns
import { prepare, layout } from '@chenglou/pretext';

async function benchmark(samples = 1000) {
	const texts = Array.from({ length: samples }, (_, i) =>
		`Sample text item ${i}, with some variable content length added here.`,
	);
	const font = '16px Inter';
	const width = 320;
	const lineHeight = 22;

	const t0 = performance.now();
	for (const text of texts) {
		layout(prepare(text, font), width, lineHeight);
	}
	const pretextMs = performance.now() - t0;

	const el = document.createElement('div');
	el.style.cssText = `width:${width}px;font:${font};position:absolute;visibility:hidden;white-space:normal`;
	document.body.appendChild(el);

	const t1 = performance.now();
	for (const text of texts) {
		el.textContent = text;
		el.getBoundingClientRect();
	}
	const domMs = performance.now() - t1;
	document.body.removeChild(el);

	console.table({
		Pretext: { total: pretextMs.toFixed(1) + 'ms', perItem: (pretextMs / samples).toFixed(3) + 'ms' },
		DOM: { total: domMs.toFixed(1) + 'ms', perItem: (domMs / samples).toFixed(3) + 'ms' },
		speedup: { ratio: (domMs / pretextMs).toFixed(0) + '×' },
	});
}

benchmark();