aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/textOutlines.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/textOutlines.js')
-rw-r--r--public/bluebey-studio/src/textOutlines.js432
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;
-}