aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/textOutlines.js
blob: 334a92d47e734d32563f136d4221a48d7bfc14f3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
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;
}