2026-10-06 15:58:02 +02:00
|
|
|
// @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
|
2026-10-10 19:01:20 +02:00
|
|
|
* the font size (Andy, 2026-10-03: the text should not sit on the edge;
|
|
|
|
|
* halved from 0.3 on 2026-10-09).
|
2026-10-06 15:58:02 +02:00
|
|
|
*/
|
2026-10-10 19:01:20 +02:00
|
|
|
export const TOP_GAP = 0.15;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
/**
|
|
|
|
|
* Where the baseline of line `i` of a block lies below the block's top, in
|
|
|
|
|
* page units, placed as a browser places text in a line box: the gap, then
|
|
|
|
|
* half the leading (the line height less the font's own ascent and
|
|
|
|
|
* descent), then the ascent. The text field and the canvas then agree
|
|
|
|
|
* to the pixel (Andy, 2026-10-09: the text moved when editing ended).
|
|
|
|
|
* @param {number} size
|
|
|
|
|
* @param {string} family
|
|
|
|
|
* @param {number} i
|
|
|
|
|
* @returns {number}
|
|
|
|
|
*/
|
|
|
|
|
export function baselineOf(size, family, i) {
|
|
|
|
|
const { ascent, descent } = fontMetrics(size, family);
|
|
|
|
|
const halfLeading = (size * LINE_HEIGHT - ascent - descent) / 2;
|
|
|
|
|
return size * (TOP_GAP + i * LINE_HEIGHT) + halfLeading + ascent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The font's ascent and descent, in page units, as the browser lays
|
|
|
|
|
* lines out with them.
|
|
|
|
|
* @param {number} size
|
|
|
|
|
* @param {string} family
|
|
|
|
|
* @returns {{ascent: number, descent: number}}
|
|
|
|
|
*/
|
|
|
|
|
export function fontMetrics(size, family) {
|
|
|
|
|
if (!measurer) {
|
|
|
|
|
measurer = /** @type {CanvasRenderingContext2D} */ (document.createElement('canvas').getContext('2d'));
|
|
|
|
|
}
|
|
|
|
|
measurer.font = fontOf(size, family);
|
|
|
|
|
const metrics = measurer.measureText('Mg');
|
|
|
|
|
return {
|
|
|
|
|
ascent: metrics.fontBoundingBoxAscent ?? size * 0.8,
|
|
|
|
|
descent: metrics.fontBoundingBoxDescent ?? size * 0.2,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
/**
|
|
|
|
|
* 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),
|
|
|
|
|
};
|
|
|
|
|
}
|