// @ts-check /** * Lays out the text of a text block into lines, measured in page units. * * The writer's client does this and sends the lines along with the text, * so that everyone shows the same line breaks even where fonts differ a * little. A block with a width wraps at it; a block without one breaks * lines only where the writer pressed Enter. */ /** Line height as a multiple of the font size. */ export const LINE_HEIGHT = 1.25; /** * Room between a block's top edge and its first line, as a multiple of * the font size (Andy, 2026-10-03: the text should not sit on the edge). */ export const TOP_GAP = 0.3; /** * Room between a block's left edge (its corner square) and the text, as a * multiple of the font size: about half a character's width (Andy, * 2026-10-03). */ export const LEFT_GAP = 0.25; /** * @param {number} size Font size in page units. * @param {string} family * @returns {string} The CSS font for a canvas whose units are page units. */ export function fontOf(size, family) { return `${size}px ${family}`; } /** @type {CanvasRenderingContext2D | null} */ let measurer = null; /** * @param {string} text * @param {number} size * @param {string} family * @returns {number} The text's width in page units. */ export function textWidth(text, size, family) { if (!measurer) { measurer = /** @type {CanvasRenderingContext2D} */ (document.createElement('canvas').getContext('2d')); } measurer.font = fontOf(size, family); return measurer.measureText(text).width; } /** * Break one paragraph into lines no wider than `width`. Breaks come at * spaces; a word wider than the width is broken where it must be. * @param {string} paragraph * @param {number} width * @param {number} size * @param {string} family * @returns {string[]} */ function wrap(paragraph, width, size, family) { /** @type {string[]} */ const lines = []; let line = ''; for (const word of paragraph.split(' ')) { const tried = line === '' ? word : `${line} ${word}`; if (textWidth(tried, size, family) <= width || line === '' && textWidth(word, size, family) <= width) { line = tried; continue; } if (line !== '') lines.push(line); // The word alone: fits, or is broken by characters. line = ''; for (const character of word) { if (line !== '' && textWidth(line + character, size, family) > width) { lines.push(line); line = ''; } line += character; } } lines.push(line); return lines; } /** * @param {string} text What the writer typed, with its own line breaks. * @param {number | null} width Wrap at this width, or null for no wrapping. * @param {number} size * @param {string} family * @returns {{lines: string[], boxWidth: number, boxHeight: number}} * The lines as shown, and the rectangle they take up. */ export function layoutText(text, width, size, family) { /** @type {string[]} */ const lines = []; const gap = size * LEFT_GAP; for (const paragraph of text.split('\n')) { if (width === null) lines.push(paragraph); else lines.push(...wrap(paragraph, Math.max(size, width - gap), size, family)); } const widest = Math.max(0, ...lines.map((line) => textWidth(line, size, family))); return { lines, boxWidth: width ?? Math.ceil(widest + gap), boxHeight: Math.ceil(size * TOP_GAP + lines.length * size * LINE_HEIGHT), }; }