import opentype from 'opentype.js'; /** * Caption text as outlines. * * A caption drawn with changes shape in every viewer, because the glyphs * come from whatever font that viewer happens to have. The studio ships one * subset font (M PLUS Rounded 1c) and this module turns the caption into plain * SVG paths instead, so an exported SVG looks the same everywhere and stays * editable as vector art. * * The module runs unchanged in the browser and in Node: it touches no DOM and no * Node built-in at module scope, so the tests can parse the font straight from * disk. `loadFont` is the only asynchronous export; everything else is a pure * function of the font, the text and the options. * * Widths are summed one character at a time, without kerning between them. That * costs a fraction of a pixel on Latin pairs but keeps measuring and wrapping in * exact agreement, which matters more for a short caption. */ /** Families tried in order when a caption falls back to live . */ const FALLBACK_FAMILIES = [ 'M PLUS Rounded 1c', 'Hiragino Maru Gothic ProN', 'Yu Gothic', 'Meiryo', 'sans-serif', ]; /** CSS keywords that must stay unquoted inside a font stack. */ const GENERIC_FAMILIES = new Set([ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'math', 'emoji', 'fangsong', ]); /** * Closing marks and brackets that should not start a line. * * A full kinsoku table needs per-font metrics; this short list covers the marks * a caption actually uses, and dragging the preceding character down is enough * to fix the rest. */ const CLOSING_PUNCTUATION = new Set([ '、', '。', ',', '.', ':', ';', '!', '?', ')', ']', '}', '〕', '〉', '》', '」', '』', '】', '〗', '〙', '〟', '”', '’', '⦆', '»', ]); const SPACE = /\s/; const LINE_BREAK = /\r\n|\r|\n/; const DEFAULT_FONT_SIZE = 16; const DEFAULT_LINE_HEIGHT = 1.4; /** * Parsed fonts, keyed by URL. The stored value is the in-flight promise, so two * callers asking for the same URL share one request instead of loading twice. */ const fontCache = new Map(); /** Family name of the most recent font from `loadFont`, read by `captionFontStack`. */ let loadedFamily = null; /** * Load a font and remember it under `url`. * * Resolves to an `opentype.Font`; rejects if the font cannot be fetched or * parsed, and does not cache that failure, so a later call can retry. * * @param {string} url URL (browser) or file path (Node) of the font * @returns {Promise} */ export async function loadFont(url) { if (!url) throw new Error('loadFont: a font URL is required'); if (fontCache.has(url)) return fontCache.get(url); const pending = opentype.load(url).then((font) => { loadedFamily = familyNameOf(font) ?? loadedFamily; return font; }); // A rejected promise left in the cache would make every retry fail forever. pending.catch(() => fontCache.delete(url)); fontCache.set(url, pending); return pending; } /** * Synchronous twin of `loadFont` for tests and offline use. * * @param {ArrayBuffer} arrayBuffer font bytes; a typed-array view is accepted too * @returns {import('opentype.js').Font} */ export function parseFont(arrayBuffer) { // `opentype.parse` needs a real ArrayBuffer, so unwrap a view (for example a // Node Buffer) before handing it over. const view = ArrayBuffer.isView(arrayBuffer) ? arrayBuffer : null; const buffer = view ? view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) : arrayBuffer; return opentype.parse(buffer); } /** * Does the font really cover every character of `text`? * * Callers use this to decide between outlines and live text: a single missing * glyph means the caption would come out with a hole in it, so the answer is * then `false`. Newlines carry no glyph and are ignored. * * @param {import('opentype.js').Font} font * @param {string} text * @returns {boolean} */ export function hasGlyphs(font, text) { if (!font || typeof text !== 'string') return false; for (const ch of text) { if (ch === '\n' || ch === '\r') continue; if (font.charToGlyphIndex(ch) === 0) return false; // 0 is .notdef } return true; } /** * Size one string in pixels. * * The width is the widest line, so the same call works for a single line and for * text that already contains breaks. `ascent` is above the baseline and * `descent` below it, both in the font's own sign convention. * * @param {import('opentype.js').Font} font * @param {string} text * @param {number} [fontSize] * @returns {{width: number, ascent: number, descent: number}} */ export function measureText(font, text, fontSize = DEFAULT_FONT_SIZE) { const size = Number.isFinite(fontSize) ? fontSize : DEFAULT_FONT_SIZE; const scale = size / (font.unitsPerEm || 1000); let width = 0; for (const line of String(text ?? '').split(LINE_BREAK)) { width = Math.max(width, measureLine(font, line, size)); } return { width, ascent: font.ascender * scale, descent: font.descender * scale }; } /** * Wrap `text` into lines and report the block's size. * * Rules, in the order they apply: * - an explicit `\n` always ends a line; * - a Latin word breaks only at a space, never in the middle; * - CJK characters break anywhere, one break opportunity per character; * - a piece that is too wide on its own still gets a line (nothing is dropped); * - a line is not allowed to start with closing punctuation when dragging the * previous character down would avoid it. * * @param {import('opentype.js').Font} font * @param {string} text * @param {object} [options] * @param {number} [options.fontSize=16] * @param {number|null} [options.maxWidth=0] 0/null/undefined means "no wrapping" * @param {number} [options.lineHeight=1.4] multiplier of `fontSize` * @param {'left'|'center'|'right'} [options.align='left'] * @returns {{ * lines: Array<{text: string, width: number}>, * width: number, height: number, lineHeight: number, * fontSize: number, align: string, * }} */ export function layoutText(font, text, options = {}) { const opts = options ?? {}; const fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : DEFAULT_FONT_SIZE; const lineHeight = Number.isFinite(opts.lineHeight) ? opts.lineHeight : DEFAULT_LINE_HEIGHT; const align = opts.align === 'center' || opts.align === 'right' ? opts.align : 'left'; const maxWidth = opts.maxWidth; const wrapWidth = Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : Infinity; const lines = []; for (const paragraph of String(text ?? '').split(LINE_BREAK)) { const atoms = tokenise(font, paragraph, fontSize); for (const wrapped of wrapAtoms(atoms, wrapWidth)) { lines.push({ text: wrapped.map((atom) => atom.text).join(''), width: wrapped.reduce((sum, atom) => sum + atom.width, 0), }); } } let width = 0; for (const line of lines) width = Math.max(width, line.width); const height = lines.length * fontSize * lineHeight; return { lines, width, height, lineHeight, fontSize, align }; } /** * Convert a layout to one SVG path `d` string. * * `x`/`y` is the top-left corner of the text block. Each line sits on its own * baseline, placed with half-leading so a line box of `fontSize * lineHeight` * surrounds the glyphs evenly, and shifted sideways by `layout.align`. * * Characters the font does not cover are skipped rather than drawn as .notdef, * and a line whose outline cannot be built cleanly is dropped, so the result * never carries `NaN` into the document. * * @param {import('opentype.js').Font} font * @param {object} layout value returned by `layoutText` * @param {object} [options] * @param {number} [options.x=0] * @param {number} [options.y=0] * @param {number} [options.round=2] decimal places in the output * @returns {string} */ export function textToPathData(font, layout, options = {}) { const opts = options ?? {}; const x = Number.isFinite(opts.x) ? opts.x : 0; const y = Number.isFinite(opts.y) ? opts.y : 0; const round = Number.isFinite(opts.round) ? Math.max(0, Math.floor(opts.round)) : 2; if (!font || !layout || !Array.isArray(layout.lines) || layout.lines.length === 0) return ''; const fontSize = Number.isFinite(layout.fontSize) ? layout.fontSize : DEFAULT_FONT_SIZE; const lineHeight = Number.isFinite(layout.lineHeight) ? layout.lineHeight : DEFAULT_LINE_HEIGHT; const blockWidth = Number.isFinite(layout.width) ? layout.width : 0; const step = fontSize * lineHeight; const scale = fontSize / (font.unitsPerEm || 1000); const ascent = font.ascender * scale; const descent = -font.descender * scale; // depth below the baseline, positive const halfLeading = (step - (ascent + descent)) / 2; const parts = []; for (let i = 0; i < layout.lines.length; i++) { const line = layout.lines[i]; const drawable = drawableText(font, line.text); if (!drawable) continue; const baseline = y + i * step + halfLeading + ascent; const lineX = x + alignOffset(layout.align, blockWidth, line.width); const d = font.getPath(drawable, lineX, baseline, fontSize).toPathData(round); if (!d || d.includes('NaN') || d.includes('Infinity')) continue; parts.push(d); } return parts.join(' '); } /** * The CSS `font-family` the app should use for live `` or canvas captions. * * Starts with the family of the most recently loaded font, so the fallback text * looks as close as possible to the outlines, and ends with Japanese-safe * families that exist on the machines the studio runs on. * * @returns {string} */ export function captionFontStack() { const families = [loadedFamily ?? FALLBACK_FAMILIES[0]]; const seen = new Set(families.map((name) => name.toLowerCase())); for (const name of FALLBACK_FAMILIES) { if (seen.has(name.toLowerCase())) continue; seen.add(name.toLowerCase()); families.push(name); } return families.map(cssFamily).join(', '); } // --- internals --------------------------------------------------------------- /** Width of one line of text, in pixels. */ function measureLine(font, line, fontSize) { let width = 0; for (const ch of line) width += font.getAdvanceWidth(ch, fontSize); return width; } /** Family name of a font, or `null` when it does not carry one. */ function familyNameOf(font) { const names = font?.names?.fontFamily; if (!names) return null; // A parsed font stores one entry per language tag; a font built from scratch // stores a plain string. Accept both. const value = typeof names === 'string' ? names : names.en ?? Object.values(names)[0]; return typeof value === 'string' && value.trim() !== '' ? value.trim() : null; } /** Only the characters the font can actually draw, newlines removed. */ function drawableText(font, text) { let out = ''; for (const ch of String(text ?? '')) { if (ch === '\n' || ch === '\r') continue; if (font.charToGlyphIndex(ch) === 0) continue; // no outline to draw out += ch; } return out; } /** Sideways shift of a line inside the block, for the block's alignment. */ function alignOffset(align, blockWidth, lineWidth) { const slack = blockWidth - lineWidth; if (align === 'center') return slack / 2; if (align === 'right') return slack; return 0; } /** Quote a family for CSS unless it is a generic keyword. */ function cssFamily(name) { if (GENERIC_FAMILIES.has(name.toLowerCase())) return name; return `'${name.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; } /** `true` for scripts that may break between any two characters. */ function isCjk(ch) { const c = ch.codePointAt(0); return ( (c >= 0x1100 && c <= 0x11ff) || // Hangul Jamo (c >= 0x2e80 && c <= 0x303f) || // radicals, CJK punctuation (c >= 0x3040 && c <= 0x30ff) || // hiragana, katakana (c >= 0x3130 && c <= 0x318f) || // Hangul compatibility Jamo (c >= 0x3400 && c <= 0x4dbf) || // CJK extension A (c >= 0x4e00 && c <= 0x9fff) || // CJK unified ideographs (c >= 0xa960 && c <= 0xa97f) || // Hangul Jamo extended A (c >= 0xac00 && c <= 0xd7ff) || // Hangul syllables (c >= 0xf900 && c <= 0xfaff) || // CJK compatibility ideographs (c >= 0xfe10 && c <= 0xfe4f) || // vertical and compatibility forms (c >= 0xff00 && c <= 0xffef) || // fullwidth and halfwidth forms (c >= 0x1f200 && c <= 0x1f2ff) || // enclosed ideographic supplement (c >= 0x20000 && c <= 0x2fa1f) // CJK extensions B onwards ); } /** One breakable piece of text together with its advance width. */ function makeAtom(font, text, fontSize, space, closing) { return { text, width: font.getAdvanceWidth(text, fontSize), space, closing }; } /** * Split a paragraph into the smallest pieces a line may break between. * * A Latin run stays one atom so it can never be split; every CJK character is * its own atom so a break may fall on either side of it. * * @returns {Array<{text: string, width: number, space: boolean, closing: boolean}>} */ function tokenise(font, paragraph, fontSize) { const atoms = []; let word = null; const endWord = () => { if (word !== null) { atoms.push(word); word = null; } }; for (const ch of paragraph) { if (SPACE.test(ch)) { endWord(); atoms.push(makeAtom(font, ch, fontSize, true, false)); } else if (isCjk(ch)) { endWord(); atoms.push(makeAtom(font, ch, fontSize, false, CLOSING_PUNCTUATION.has(ch))); } else { if (word === null) word = makeAtom(font, '', fontSize, false, false); word.text += ch; word.width += font.getAdvanceWidth(ch, fontSize); } } endWord(); return atoms; } /** * Greedy line breaker over atoms. Always returns at least one line, so an empty * paragraph comes back as one empty line and explicit breaks are preserved. * * @param {Array} atoms * @param {number} maxWidth `Infinity` disables wrapping * @returns {Array>} */ function wrapAtoms(atoms, maxWidth) { const lines = []; let current = []; const currentWidth = () => current.reduce((sum, atom) => sum + atom.width, 0); const finish = () => { // A break swallows the spaces next to it, so no line ends or starts blank. while (current.length > 0 && current[current.length - 1].space) current.pop(); while (current.length > 0 && current[0].space) current.shift(); lines.push(current); current = []; }; for (const atom of atoms) { if (current.length === 0) { if (atom.space) continue; // never start a line with a space current.push(atom); continue; } if (currentWidth() + atom.width <= maxWidth) { current.push(atom); continue; } // The atom does not fit. Keep closing punctuation off the start of the next // line by dragging the previous character down, but only when that leaves // something behind and the pair still respects the maximum width. const carried = current[current.length - 1]; const visible = current.filter((a) => !a.space); if ( atom.closing && visible.length > 1 && !carried.space && carried.width + atom.width <= maxWidth ) { current.pop(); finish(); current.push(carried); } else { finish(); } // The break already stands in for a space, so it does not start the new line. if (atom.space) continue; current.push(atom); } finish(); return lines; }