From bc2821adb11a30244fc4663f4fafb756877d9508 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:09:40 +0900 Subject: bluebey-studio: public/ の外へ移動し非公開化 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bluebey-studio/src/textOutlines.js | 432 +++++++++++++++++++++++++++++++++++++ 1 file changed, 432 insertions(+) create mode 100644 bluebey-studio/src/textOutlines.js (limited to 'bluebey-studio/src/textOutlines.js') diff --git a/bluebey-studio/src/textOutlines.js b/bluebey-studio/src/textOutlines.js new file mode 100644 index 0000000..334a92d --- /dev/null +++ b/bluebey-studio/src/textOutlines.js @@ -0,0 +1,432 @@ +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; +} -- cgit v1.3.1