diff options
Diffstat (limited to 'public/bluebey-studio/src/textOutlines.js')
| -rw-r--r-- | public/bluebey-studio/src/textOutlines.js | 432 |
1 files changed, 0 insertions, 432 deletions
diff --git a/public/bluebey-studio/src/textOutlines.js b/public/bluebey-studio/src/textOutlines.js deleted file mode 100644 index 334a92d..0000000 --- a/public/bluebey-studio/src/textOutlines.js +++ /dev/null @@ -1,432 +0,0 @@ -import opentype from 'opentype.js'; - -/** - * Caption text as outlines. - * - * A caption drawn with <text> 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 <text>. */ -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<import('opentype.js').Font>} - */ -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 `<text>` 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<object>} atoms - * @param {number} maxWidth `Infinity` disables wrapping - * @returns {Array<Array<object>>} - */ -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; -} |
