aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/caption.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
commitbc2821adb11a30244fc4663f4fafb756877d9508 (patch)
treefea8d30d2eb1e67f895097dbf7ec4d152361243e /bluebey-studio/src/caption.js
parent0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff)
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'bluebey-studio/src/caption.js')
-rw-r--r--bluebey-studio/src/caption.js1321
1 files changed, 1321 insertions, 0 deletions
diff --git a/bluebey-studio/src/caption.js b/bluebey-studio/src/caption.js
new file mode 100644
index 0000000..71759ea
--- /dev/null
+++ b/bluebey-studio/src/caption.js
@@ -0,0 +1,1321 @@
+import {
+ captionFontStack,
+ hasGlyphs,
+ layoutText,
+ textToPathData,
+} from './textOutlines.js';
+
+/**
+ * The speech bubble that sits next to the character.
+ *
+ * The same caption is drawn three times: into the 2D canvas that is overlaid on
+ * the 3D view, into the canvas that gets composited into an exported PNG, and
+ * into the vector SVG export. Drawing a shape three times by hand is how a
+ * preview starts to disagree with the file it produced, so the outline is built
+ * exactly once, here, as a short list of path commands. `drawCaption` replays
+ * that list through a 2D context and `captionToSvg` prints the same list as SVG
+ * path data, which is what keeps the two renderers together.
+ *
+ * The module never measures or wraps text itself: `textOutlines.js` owns line
+ * breaking and glyph outlines. This file only decides how large the bubble has
+ * to be for the block it is handed, padding included, and guarantees that block
+ * fits inside.
+ *
+ * It is pure in the same sense as `trace.js`: no DOM, no globals and no
+ * asynchronous work, so the tests can drive it in Node with a stub context.
+ */
+
+/** Defaults mirroring the `caption` slice of `presets.js`. */
+const DEFAULT_FONT_SIZE = 34;
+const DEFAULT_LINE_HEIGHT = 1.42;
+const DEFAULT_PADDING = 18;
+const DEFAULT_RADIUS = 24;
+const DEFAULT_BORDER_WIDTH = 4;
+const DEFAULT_MAX_WIDTH = 0.36;
+const DEFAULT_TEXT_COLOR = '#3f2b52';
+const DEFAULT_BUBBLE_COLOR = '#ffffff';
+const DEFAULT_BORDER_COLOR = '#55386e';
+
+/** Advance widths used before the vendored font has loaded, as a fraction of em. */
+const FALLBACK_WIDE_EM = 1;
+const FALLBACK_NARROW_EM = 0.55;
+
+/**
+ * Vertical metrics of the fallback stack, as a fraction of the font size.
+ *
+ * A typical Japanese family sits close to these numbers, so the baseline of the
+ * pre-load preview barely moves when the real font arrives.
+ */
+const FALLBACK_ASCENT = 0.88;
+const FALLBACK_DESCENT = 0.12;
+
+/** Control-point distance that turns a corner into a quarter circle. */
+const KAPPA = 0.5522847498307936;
+
+/** Code-point ranges that deserve a full em in the fallback measurement. */
+const WIDE_RANGES = [
+ [0x1100, 0x11ff], [0x2e80, 0x30ff], [0x3130, 0x318f], [0x3400, 0x4dbf],
+ [0x4e00, 0x9fff], [0xac00, 0xd7ff], [0xf900, 0xfaff], [0xff00, 0xffef],
+];
+
+/**
+ * Punctuation that may hang past the bottom of a vertical column.
+ *
+ * Japanese line breaking (禁則処理) forbids starting a line with a closing
+ * mark, so when one of these would land at the top of a new column it is kept
+ * on the previous column and drawn past that column's last cell instead.
+ */
+const HANGING_PUNCTUATION = new Set(['、', '。', ',', '.', ',', '.']);
+
+/** Small kana, which sit toward the upper-right of their cell when stacked. */
+const SMALL_KANA = 'ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮ';
+
+/**
+ * Characters that may not open a column (行頭禁則).
+ *
+ * A closing mark such as `、` simply hangs off the previous column; `ー`, small
+ * kana and closing brackets are kept company by handing the preceding
+ * character over to the next column as well, so a column never starts with
+ * something that has nothing to attach to.
+ */
+const COLUMN_START_FORBIDDEN = new Set([
+ '」', '』', ')', '〕', '】', '〉', '》', '”', '’', '・', 'ー',
+ ...SMALL_KANA,
+ ...HANGING_PUNCTUATION,
+]);
+
+/** Characters that may not close a column (行末禁則). */
+const COLUMN_END_FORBIDDEN = new Set(['「', '『', '(', '〔', '【', '〈', '《', '“', '‘']);
+
+/**
+ * Per-character offset for vertical (縦書き) text, in em.
+ *
+ * A column draws one glyph per cell, so a few characters need a nudge to read
+ * the way a Japanese typesetter would place them: the prolonged sound mark
+ * becomes an upright tick, small kana tuck into the upper-right, and the
+ * brackets lean toward the ends of the span they enclose. Everything else
+ * stays dead-centre. Sharing this table is what keeps the canvas and the SVG
+ * export in step.
+ */
+const VERTICAL_OFFSETS = new Map();
+for (const ch of SMALL_KANA) VERTICAL_OFFSETS.set(ch, { dx: 0.12, dy: -0.12 });
+// 縦書きの句読点は、横書きの左下ではなく、字枠の右上に置く。
+for (const ch of HANGING_PUNCTUATION) VERTICAL_OFFSETS.set(ch, { dx: 0.68, dy: -0.55 });
+
+/**
+ * Characters drawn turned a quarter turn when stacked, so they read the way
+ * 縦書き sets them: the prolonged sound mark `ー` becomes an upright bar, and the
+ * brackets `「」『』[]()` turn so they open down the column.
+ */
+const VERTICAL_ROTATED = new Set(['ー', '~', '〜', '―']);
+const VERTICAL_BRACKETS = new Set([
+ '「', '」', '『', '』', '(', ')', '[', ']', '【', '】', '〔', '〕', '〈', '〉', '《', '》',
+]);
+
+/**
+ * How far a rotated glyph is nudged down inside its cell, in em. The rotated `ー`
+ * is centred otherwise, which leaves it hugging the character above it; the
+ * brackets stay centred.
+ */
+const VERTICAL_ROTATED_DROP = 0.14;
+
+/**
+ * The downward nudge (em) for a glyph that is turned a quarter turn in 縦書き, or
+ * `null` when the glyph is drawn the ordinary way.
+ */
+function verticalRotation(ch) {
+ if (VERTICAL_ROTATED.has(ch)) return VERTICAL_ROTATED_DROP;
+ if (VERTICAL_BRACKETS.has(ch)) return 0;
+ return null;
+}
+
+/** Offset used for the characters that need no special placement. */
+const NO_OFFSET = Object.freeze({ dx: 0, dy: 0 });
+
+/**
+ * The `ctx.font` string for a caption.
+ *
+ * Canvas wants size, weight and family in one string, and the size has to carry
+ * the export scale because the same caption is drawn at 1x for the preview and
+ * at 2x or more into a PNG. `loaded` is the app's answer to "is the vendored
+ * subset ready?": until it is, the vendored family is dropped from the stack so
+ * the preview does not ask for a font that is still downloading.
+ *
+ * @param {object} caption the `caption` slice of the state
+ * @param {number} [scale=1]
+ * @param {boolean} [loaded=false]
+ * @returns {string}
+ */
+export function fontSpec(caption, scale = 1, loaded = false) {
+ const state = caption ?? {};
+ const size = resolveFontSize(state, positiveScale(scale));
+ const weight = state.bold ? 'bold ' : '';
+ return `${weight}${formatNumber(size, 3)}px ${fontFamilyStack(state, loaded)}`;
+}
+
+/**
+ * Resolve a caption to pixel geometry at the output size.
+ *
+ * `x`/`y` are fractions of the image and land on the bubble's top-left corner;
+ * `maxWidth` is a fraction of the image width and caps the text column. The box
+ * is always the bubble itself, padding included and the tail excluded, and it
+ * always fits inside the image: padding gives way first, then the position is
+ * clamped (leaving room for the tail), and only a block larger than the image
+ * is clipped. Every returned number is finite, even for an empty caption or a
+ * zero-sized image.
+ *
+ * `font` is an opentype font from `textOutlines.js`. Without it the text is
+ * wrapped by a rough character count, so the preview still shows a bubble while
+ * the font is loading.
+ *
+ * @param {object} caption
+ * @param {object} [options]
+ * @param {number} [options.width=0]
+ * @param {number} [options.height=0]
+ * @param {number} [options.scale=1]
+ * @param {import('opentype.js').Font|null} [options.font=null]
+ * @returns {{
+ * box: {x: number, y: number, w: number, h: number},
+ * lines: Array<{text: string, width: number}>,
+ * fontSize: number, lineHeight: number, padding: number, scale: number,
+ * usedOutlines: boolean,
+ * }}
+ */
+export function layoutCaption(caption, { width, height, scale = 1, font = null } = {}) {
+ const state = caption ?? {};
+ const resolvedScale = positiveScale(scale);
+ const outWidth = toNonNegative(width, 0);
+ const outHeight = toNonNegative(height, 0);
+
+ const fontSize = resolveFontSize(state, resolvedScale);
+ const lineHeight = toPositive(state.lineHeight, DEFAULT_LINE_HEIGHT);
+ const requested = toNonNegative(state.padding, DEFAULT_PADDING) * resolvedScale;
+ const text = typeof state.text === 'string' ? state.text : '';
+
+ const vertical = state.vertical === true;
+ const block = vertical
+ ? layoutVertical(text, {
+ fontSize,
+ lineHeight,
+ maxHeight: resolveColumn(state, outHeight, requested),
+ })
+ : layoutBlock(font, text, {
+ fontSize,
+ maxWidth: resolveColumn(state, outWidth, requested),
+ lineHeight,
+ align: alignOf(state),
+ });
+
+ // Padding gives way before the bubble does, so a caption that cannot fit with
+ // its usual breathing room still fits inside the image.
+ const padding = Math.max(
+ 0,
+ Math.min(requested, (outWidth - block.width) / 2, (outHeight - block.height) / 2),
+ );
+ const w = Math.min(block.width + 2 * padding, outWidth);
+ const h = Math.min(block.height + 2 * padding, outHeight);
+
+ // The tail sticks out of the box, so the box has to keep that much clear of
+ // the edge it points at.
+ const tail = tailOf(state);
+ const tailLength = tail === 'none' ? 0 : Math.max(0, Math.min(fontSize * 0.9, h * 0.5));
+ const roomX = Math.max(0, outWidth - w);
+ const roomY = Math.max(0, outHeight - h);
+
+ // Which way the tail leaves the box, as a direction where each axis is -1,
+ // 0 or 1. A diagonal tail needs room on *both* axes, so the clearance is
+ // driven by the direction rather than by which name the tail has.
+ const dir = tailDirection(tail);
+ let lowX = 0;
+ let highX = roomX;
+ if (dir.x < 0) lowX = Math.min(tailLength, roomX);
+ else if (dir.x > 0) highX = Math.max(0, roomX - tailLength);
+
+ let lowY = 0;
+ let highY = roomY;
+ if (dir.y < 0) lowY = Math.min(tailLength, roomY);
+ else if (dir.y > 0) highY = Math.max(0, roomY - tailLength);
+
+ const box = {
+ x: clamp(toFinite(state.x, 0) * outWidth, lowX, highX),
+ y: clamp(toFinite(state.y, 0) * outHeight, lowY, highY),
+ w,
+ h,
+ };
+
+ return {
+ box,
+ lines: block.lines,
+ fontSize,
+ lineHeight,
+ padding,
+ scale: resolvedScale,
+ vertical,
+ usedOutlines: Boolean(font),
+ };
+}
+
+/**
+ * Lay the bubble down in a 2D context, ready to be filled and stroked.
+ *
+ * The tail is part of the same path as the bubble, so the border runs along it
+ * and joins the outline cleanly instead of meeting it at a seam. The caller
+ * owns the colours; this only traces the shape.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {{x: number, y: number, w: number, h: number}} box bubble box, tail excluded
+ * @param {object} caption
+ * @param {number} [scale=1]
+ * @returns {boolean} `true` when there was a shape to trace
+ */
+export function bubblePath(ctx, box, caption, scale = 1) {
+ const commands = bubbleCommands(box, caption ?? {}, positiveScale(scale));
+ applyCommands(ctx, commands);
+ return commands.length > 0;
+}
+
+/**
+ * Draw a caption: bubble first, then the text line by line.
+ *
+ * The 2D canvas is overlaid exactly on the 3D view for the preview and is
+ * composited into the PNG on export, which is why the very same function runs
+ * in both places: what the user sees is what the file contains.
+ *
+ * The bubble box comes back even when the caption is switched off, so the app
+ * can show a placeholder and hit-test a drag without a second layout.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {object} caption
+ * @param {object} [options]
+ * @param {number} [options.width=0]
+ * @param {number} [options.height=0]
+ * @param {number} [options.scale=1]
+ * @param {import('opentype.js').Font|null} [options.font=null]
+ * @returns {{x: number, y: number, w: number, h: number}}
+ */
+export function drawCaption(ctx, caption, { width, height, scale = 1, font = null } = {}) {
+ const state = caption ?? {};
+ const layout = layoutCaption(state, { width, height, scale, font });
+ const { box } = layout;
+ if (!state.enabled || !ctx) return box;
+
+ ctx.save();
+
+ const commands = bubbleCommands(box, state, layout.scale);
+ if (commands.length > 0) {
+ applyCommands(ctx, commands);
+ ctx.fillStyle = colorOf(state.bubbleColor, DEFAULT_BUBBLE_COLOR);
+ ctx.fill();
+
+ const borderWidth = toNonNegative(state.borderWidth, DEFAULT_BORDER_WIDTH) * layout.scale;
+ if (borderWidth > 0) {
+ ctx.strokeStyle = colorOf(state.borderColor, DEFAULT_BORDER_COLOR);
+ ctx.lineWidth = borderWidth;
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ // A whisper is the rounded bubble with a broken outline.
+ if (bubbleOf(state) === 'whisper' && typeof ctx.setLineDash === 'function') {
+ ctx.setLineDash([borderWidth * 3, borderWidth * 2]);
+ }
+ ctx.stroke();
+ }
+ }
+
+ if (layout.lines.length > 0) drawText(ctx, layout, state, font);
+
+ ctx.restore();
+ return box;
+}
+
+/**
+ * The caption as an SVG fragment, plus whether the text is outlined.
+ *
+ * Text becomes glyph outlines whenever the font covers it, because a `<text>`
+ * element is redrawn with whatever font the viewer happens to have and a
+ * caption that reflows is worse than one that cannot be selected. When a glyph
+ * is missing the whole caption falls back to `<text>` and `usedOutlines` is
+ * `false`, which is the caller's signal to warn that the file now depends on
+ * the viewer's fonts.
+ *
+ * The bubble and its tail are one `<path>`, traced from the same command list
+ * the canvas uses. `enabled` is not consulted: the export path only runs for an
+ * enabled caption, and drawing whatever is handed over keeps this usable for a
+ * thumbnail.
+ *
+ * @param {object} caption
+ * @param {object} [options]
+ * @param {number} [options.width=0]
+ * @param {number} [options.height=0]
+ * @param {number} [options.scale=1]
+ * @param {import('opentype.js').Font|null} [options.font=null]
+ * @param {number} [options.round=2] decimal places in the output
+ * @returns {{svg: string, usedOutlines: boolean}}
+ */
+export function captionToSvg(caption, { width, height, scale = 1, font = null, round = 2 } = {}) {
+ const state = caption ?? {};
+ const places = Math.max(0, Math.min(8, Math.floor(Number.isFinite(round) ? round : 2)));
+ const layout = layoutCaption(state, { width, height, scale, font });
+ const { box, lines, fontSize, lineHeight, padding } = layout;
+ const outWidth = toNonNegative(width, 0);
+ const outHeight = toNonNegative(height, 0);
+ const text = typeof state.text === 'string' ? state.text : '';
+
+ let usedOutlines = Boolean(font) && text.trim() !== '' && hasGlyphs(font, text);
+ // Vertical text falls back to `<text>`: the outline path lays glyphs out
+ // horizontally, and a column of glyphs reads fine as live text.
+ if (layout.vertical) usedOutlines = false;
+
+ const parts = [
+ '<svg xmlns="http://www.w3.org/2000/svg"'
+ + ` width="${formatNumber(outWidth, places)}"`
+ + ` height="${formatNumber(outHeight, places)}"`
+ + ` viewBox="0 0 ${formatNumber(outWidth, places)} ${formatNumber(outHeight, places)}">`,
+ ];
+
+ const bubble = commandsToPathData(bubbleCommands(box, state, layout.scale), places);
+ if (bubble) {
+ const fill = escapeAttribute(colorOf(state.bubbleColor, DEFAULT_BUBBLE_COLOR));
+ const borderWidth = toNonNegative(state.borderWidth, DEFAULT_BORDER_WIDTH) * layout.scale;
+ const stroke = borderWidth > 0
+ ? ` stroke="${escapeAttribute(colorOf(state.borderColor, DEFAULT_BORDER_COLOR))}"`
+ + ` stroke-width="${formatNumber(borderWidth, places)}" stroke-linejoin="round"`
+ + (bubbleOf(state) === 'whisper'
+ ? ` stroke-dasharray="${formatNumber(borderWidth * 3, places)} ${formatNumber(borderWidth * 2, places)}"`
+ : '')
+ : '';
+ parts.push(`<path d="${bubble}" fill="${fill}"${stroke}/>`);
+ }
+
+ if (lines.length > 0) {
+ const textColor = colorOf(state.textColor, DEFAULT_TEXT_COLOR);
+ let outlined = '';
+ if (usedOutlines) {
+ // `textToPathData` re-applies the alignment against the widest line, so
+ // the block it needs is the one `layoutText` measured.
+ outlined = textToPathData(font, {
+ lines,
+ width: blockWidthOf(lines),
+ height: lines.length * fontSize * lineHeight,
+ lineHeight,
+ fontSize,
+ align: alignOf(state),
+ }, { x: box.x + padding, y: box.y + padding, round: places });
+ usedOutlines = outlined !== '';
+ }
+ if (usedOutlines) {
+ parts.push(`<path d="${outlined}" fill="${escapeAttribute(textColor)}"/>`);
+ } else {
+ parts.push(liveText(state, layout, font, textColor, places));
+ }
+ }
+
+ parts.push('</svg>');
+ return { svg: parts.join('\n') + '\n', usedOutlines };
+}
+
+// --- text --------------------------------------------------------------------
+
+/** Draw every line at its own baseline, shifted sideways by the alignment. */
+function drawText(ctx, layout, state, font) {
+ const { box, lines, fontSize, lineHeight, padding, scale } = layout;
+ const metrics = baselineMetrics(font, fontSize);
+ const step = fontSize * lineHeight;
+ const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2;
+ const blockWidth = blockWidthOf(lines);
+ const left = box.x + padding;
+ const top = box.y + padding;
+ const align = alignOf(state);
+
+ ctx.font = fontSpec(state, scale, Boolean(font));
+ ctx.fillStyle = colorOf(state.textColor, DEFAULT_TEXT_COLOR);
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'alphabetic';
+
+ if (layout.vertical) {
+ // Columns run right to left; each glyph sits in its own cell. `ー` and its
+ // like are turned a quarter turn so they read as vertical strokes.
+ for (let i = 0; i < lines.length; i++) {
+ const chars = [...lines[i].text];
+ const x = left + (lines.length - 1 - i) * step;
+ for (let j = 0; j < chars.length; j++) {
+ const ch = chars[j];
+ const drop = verticalRotation(ch);
+ if (drop !== null) {
+ ctx.save();
+ ctx.translate(x + fontSize / 2, top + j * fontSize + fontSize / 2 + drop * fontSize);
+ ctx.rotate(Math.PI / 2);
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(ch, 0, 0);
+ ctx.restore();
+ continue;
+ }
+ const offset = verticalOffsetOf(ch);
+ const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize;
+ ctx.fillText(ch, x + offset.dx * fontSize, y);
+ }
+ }
+ return;
+ }
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const x = left + alignOffset(align, blockWidth, line.width);
+ const y = top + i * step + halfLeading + metrics.ascent;
+ ctx.fillText(line.text, x, y);
+ }
+}
+
+/**
+ * The `<text>` fallback.
+ *
+ * The family comes from the font stack alone, not from `fontSpec`: size and
+ * weight have attributes of their own, and a `font-family` that carried them
+ * would be ignored by every viewer.
+ */
+function liveText(state, layout, font, fill, places) {
+ const { box, lines, fontSize, lineHeight, padding } = layout;
+ const blockWidth = blockWidthOf(lines);
+ const align = alignOf(state);
+ const left = box.x + padding;
+ const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start';
+ const x = align === 'center'
+ ? left + blockWidth / 2
+ : align === 'right'
+ ? left + blockWidth
+ : left;
+
+ const metrics = baselineMetrics(font, fontSize);
+ const step = fontSize * lineHeight;
+ const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2;
+ const top = box.y + padding;
+
+ if (layout.vertical) {
+ // The stacked glyphs share one left-anchored <text>. The rotated ones (`ー`)
+ // get a <text> of their own, centred on the cell and turned a quarter turn.
+ const base = [
+ `font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`,
+ `font-size="${formatNumber(fontSize, places)}"`,
+ state.bold ? 'font-weight="bold"' : '',
+ `fill="${escapeAttribute(fill)}"`,
+ ].filter(Boolean).join(' ');
+ const tspans = [];
+ const rotated = [];
+ for (let i = 0; i < lines.length; i++) {
+ const chars = [...lines[i].text];
+ const x = left + (lines.length - 1 - i) * step;
+ for (let j = 0; j < chars.length; j++) {
+ const ch = chars[j];
+ const drop = verticalRotation(ch);
+ if (drop !== null) {
+ const cx = x + fontSize / 2;
+ const cy = top + j * fontSize + fontSize / 2 + drop * fontSize;
+ rotated.push(`<text ${base} text-anchor="middle" dy="0.36em"`
+ + ` transform="rotate(90 ${formatNumber(cx, places)} ${formatNumber(cy, places)})"`
+ + ` x="${formatNumber(cx, places)}" y="${formatNumber(cy, places)}">${escapeText(ch)}</text>`);
+ continue;
+ }
+ const offset = verticalOffsetOf(ch);
+ const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize;
+ tspans.push(`<tspan x="${formatNumber(x + offset.dx * fontSize, places)}" y="${formatNumber(y, places)}">${escapeText(ch)}</tspan>`);
+ }
+ }
+ const main = tspans.length ? `<text ${base} text-anchor="start">${tspans.join('')}</text>` : '';
+ return `${main}${rotated.join('')}`;
+ }
+
+ const attributes = [
+ `font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`,
+ `font-size="${formatNumber(fontSize, places)}"`,
+ state.bold ? 'font-weight="bold"' : '',
+ `fill="${escapeAttribute(fill)}"`,
+ `text-anchor="${anchor}"`,
+ ].filter(Boolean).join(' ');
+
+ const tspans = lines.map((line, i) => {
+ const y = top + i * step + halfLeading + metrics.ascent;
+ return `<tspan x="${formatNumber(x, places)}" y="${formatNumber(y, places)}">`
+ + `${escapeText(line.text)}</tspan>`;
+ }).join('');
+
+ return `<text ${attributes}>${tspans}</text>`;
+}
+
+/**
+ * Wrap text without a font, from a character count.
+ *
+ * The preview has to show something while the vendored font downloads, and this
+ * is a deliberately crude stand-in: wide (CJK) characters count as one em and
+ * everything else as half an em. Once `layoutText` can run its metrics replace
+ * these numbers, so the rough version only ever decides a preview, never a file.
+ */
+function wrapByCount(text, { fontSize, maxWidth, lineHeight }) {
+ const limit = Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : Infinity;
+ const lines = [];
+
+ for (const paragraph of String(text).split(/\r\n|\r|\n/)) {
+ let current = '';
+ for (const ch of paragraph) {
+ const candidate = current + ch;
+ if (current.trimEnd() !== '' && measureFallback(candidate, fontSize) > limit) {
+ lines.push(current.trimEnd());
+ current = /\s/.test(ch) ? '' : ch; // the break swallows the space
+ } else {
+ current = candidate;
+ }
+ }
+ lines.push(current.trimEnd());
+ }
+
+ const measured = lines.map((line) => ({ text: line, width: measureFallback(line, fontSize) }));
+ let width = 0;
+ for (const line of measured) width = Math.max(width, line.width);
+ return { lines: measured, width, height: measured.length * fontSize * lineHeight };
+}
+
+/** Wrap with `layoutText`, or with the character count when there is no font. */
+function layoutBlock(font, text, options) {
+ if (text === '') return { lines: [], width: 0, height: 0 };
+ if (font) {
+ const layout = layoutText(font, text, options);
+ return { lines: layout.lines, width: layout.width, height: layout.height };
+ }
+ return wrapByCount(text, options);
+}
+
+/**
+ * Offset of one character inside its vertical cell, in em.
+ *
+ * Shared by `drawText` and `liveText` so a glyph that needs a nudge lands in
+ * the same place on screen and in the export.
+ */
+function verticalOffsetOf(ch) {
+ return VERTICAL_OFFSETS.get(ch) ?? NO_OFFSET;
+}
+
+/**
+ * Vertical (縦書き) layout: each paragraph becomes one column, characters stacked
+ * top to bottom, columns running right to left. A paragraph longer than the
+ * available height is split into more columns.
+ *
+ * Closing punctuation hangs: when `、` or `。` (or `,`/`.`) would fall at the top
+ * of a new column, it stays on the previous one and is drawn just below that
+ * column's last cell instead. Such a hanging character does not count toward
+ * the column's length, so it never makes the block a cell taller or adds a
+ * column of its own. `lines[i].hang` reports how many characters on that line
+ * are hanging.
+ *
+ * The other 禁則 cases are repaired too: ー, small kana and closing brackets are
+ * kept off the top of a column by handing the preceding character down with
+ * them (追い出し), and an opening bracket is never left at the bottom of one.
+ */
+function layoutVertical(text, { fontSize, lineHeight, maxHeight }) {
+ const limit = Number.isFinite(maxHeight) && maxHeight > 0
+ ? Math.max(1, Math.floor(maxHeight / fontSize))
+ : Infinity;
+ const columns = [];
+ for (const paragraph of String(text).split(/\r\n|\r|\n/)) {
+ const chars = [...paragraph];
+ if (chars.length === 0) {
+ columns.push({ text: '', count: 0 });
+ continue;
+ }
+ let i = 0;
+ while (i < chars.length) {
+ // Fill the column cell by cell, then repair its two ends for 禁則処理.
+ const cells = [];
+ const hanging = [];
+ while (i < chars.length && cells.length < limit) {
+ cells.push(chars[i]);
+ i += 1;
+ }
+ // 行末禁則: an opening bracket may not close a column, so hand it over to
+ // the next one (it will open that column instead).
+ while (cells.length > 1 && COLUMN_END_FORBIDDEN.has(cells[cells.length - 1])) {
+ cells.pop();
+ i -= 1;
+ }
+ // 行頭禁則 (追い出し): ー, small kana and closing brackets may not open a
+ // column; move the preceding character down to keep them company.
+ if (i < chars.length && cells.length > 1
+ && COLUMN_START_FORBIDDEN.has(chars[i]) && !HANGING_PUNCTUATION.has(chars[i])) {
+ cells.pop();
+ i -= 1;
+ }
+ // 行頭禁則 (ぶら下げ): a closing mark that would open the next column
+ // stays on this one, drawn just below its last cell.
+ while (i < chars.length && HANGING_PUNCTUATION.has(chars[i])) {
+ hanging.push(chars[i]);
+ i += 1;
+ }
+ columns.push({ text: cells.join('') + hanging.join(''), count: cells.length });
+ }
+ }
+ const longest = columns.reduce((max, column) => Math.max(max, column.count), 0);
+ return {
+ lines: columns.map((column) => ({
+ text: column.text,
+ width: fontSize,
+ hang: [...column.text].length - column.count,
+ })),
+ // The block is exactly as wide as the columns that are drawn: `step` apart,
+ // each cell one em wide (not one whole `step`, which would leave a leading
+ // of dead space on the right and push the text off-centre).
+ width: columns.length === 0
+ ? 0
+ : (columns.length - 1) * fontSize * lineHeight + fontSize,
+ height: longest * fontSize,
+ vertical: true,
+ };
+}
+
+/** Text column in pixels: a fraction of the image, never wider than the image. */
+function resolveColumn(state, outWidth, padding) {
+ const fraction = toNonNegative(state.maxWidth, DEFAULT_MAX_WIDTH);
+ if (!(fraction > 0)) return 0; // 0 means "do not wrap", as in `layoutText`
+ return Math.min(fraction * outWidth, Math.max(0, outWidth - 2 * padding));
+}
+
+/** Ascent above and descent below the baseline, in pixels. */
+function baselineMetrics(font, fontSize) {
+ const unitsPerEm = font?.unitsPerEm;
+ if (Number.isFinite(font?.ascender) && Number.isFinite(font?.descender) && unitsPerEm > 0) {
+ return {
+ ascent: (font.ascender * fontSize) / unitsPerEm,
+ descent: (-font.descender * fontSize) / unitsPerEm,
+ };
+ }
+ return { ascent: fontSize * FALLBACK_ASCENT, descent: fontSize * FALLBACK_DESCENT };
+}
+
+/** Widest line of the block, which is the width the alignment works against. */
+function blockWidthOf(lines) {
+ let width = 0;
+ for (const line of lines) width = Math.max(width, toFinite(line?.width, 0));
+ return width;
+}
+
+/** Sideways shift of one line inside the block. */
+function alignOffset(align, blockWidth, lineWidth) {
+ const slack = Math.max(0, blockWidth - lineWidth);
+ if (align === 'center') return slack / 2;
+ if (align === 'right') return slack;
+ return 0;
+}
+
+/** Width of a string by character count, in pixels. */
+function measureFallback(text, fontSize) {
+ let width = 0;
+ for (const ch of String(text ?? '')) {
+ width += isWideChar(ch) ? fontSize * FALLBACK_WIDE_EM : fontSize * FALLBACK_NARROW_EM;
+ }
+ return width;
+}
+
+/** Characters that usually take a full em in a Japanese font. */
+function isWideChar(ch) {
+ const code = ch.codePointAt(0);
+ for (const [start, end] of WIDE_RANGES) {
+ if (code >= start && code <= end) return true;
+ }
+ return false;
+}
+
+// --- geometry ----------------------------------------------------------------
+
+/**
+ * The bubble outline as path commands.
+ *
+ * Both renderers go through here, which is the point: the canvas preview, the
+ * PNG and the SVG are three views of one geometry rather than three
+ * implementations of it.
+ *
+ * @returns {Array<object>} empty when there is nothing to draw
+ */
+function bubbleCommands(box, state, scale) {
+ const shape = bubbleOf(state);
+ const rect = {
+ x: toFinite(box?.x, 0),
+ y: toFinite(box?.y, 0),
+ w: toNonNegative(box?.w, 0),
+ h: toNonNegative(box?.h, 0),
+ };
+ if (shape === 'none' || !(rect.w > 0) || !(rect.h > 0)) return [];
+
+ if (shape === 'think') return thinkCommands(rect, state, scale);
+
+ const radius = toNonNegative(state.radius, DEFAULT_RADIUS) * scale;
+ const edges = shape === 'shout'
+ ? shoutEdges(rect, scale)
+ : shape === 'rect'
+ ? rectEdges(rect)
+ : roundEdges(rect, radius);
+
+ return edgesToCommands(withTail(edges, rect, state, scale));
+}
+
+/** Clockwise rectangle, starting at the top-left corner. */
+function rectEdges(box) {
+ const { x, y, w, h } = box;
+ const tl = { x, y };
+ const tr = { x: x + w, y };
+ const br = { x: x + w, y: y + h };
+ const bl = { x, y: y + h };
+ return [
+ lineEdge(tl, tr),
+ lineEdge(tr, br),
+ lineEdge(br, bl),
+ lineEdge(bl, tl),
+ ];
+}
+
+/**
+ * Rounded rectangle as straight sides plus quarter-circle corners.
+ *
+ * Keeping the sides straight (rather than approximating the whole outline with
+ * a polyline) is what lets the tail attach to a real straight edge; the radius
+ * is clamped so the corners can never cross each other.
+ */
+function roundEdges(box, radius) {
+ const { x, y, w, h } = box;
+ const r = clamp(radius, 0, Math.min(w, h) / 2);
+ if (!(r > 0.5)) return rectEdges(box);
+
+ const k = r * KAPPA;
+ const p = (px, py) => ({ x: px, y: py });
+ const corner = (from, c1, c2, to) => cubicEdge(p(...from), p(...c1), p(...c2), p(...to));
+ return [
+ lineEdge(p(x + r, y), p(x + w - r, y)),
+ corner([x + w - r, y], [x + w - r + k, y], [x + w, y + r - k], [x + w, y + r]),
+ lineEdge(p(x + w, y + r), p(x + w, y + h - r)),
+ corner([x + w, y + h - r], [x + w, y + h - r + k], [x + w - r + k, y + h], [x + w - r, y + h]),
+ lineEdge(p(x + w - r, y + h), p(x + r, y + h)),
+ corner([x + r, y + h], [x + r - k, y + h], [x, y + h - r + k], [x, y + h - r]),
+ lineEdge(p(x, y + h - r), p(x, y + r)),
+ corner([x, y + r], [x, y + r - k], [x + r - k, y], [x + r, y]),
+ ];
+}
+
+/**
+ * Star-burst: a rectangle whose four sides zig-zag outwards.
+ *
+ * The tooth depth and pitch are fractions of the bubble, so a big "shout" and a
+ * small one have the same number of spikes instead of the big one looking like
+ * a saw.
+ */
+function shoutEdges(box, scale) {
+ const { x, y, w, h } = box;
+ const amp = Math.min(w, h) * 0.07;
+ const pitch = Math.max(scale, Math.min(w, h) * 0.22);
+ const corners = [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }];
+ const normals = [{ x: 0, y: -1 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }];
+
+ const points = [];
+ for (let side = 0; side < 4; side++) {
+ const a = corners[side];
+ const b = corners[(side + 1) % 4];
+ const normal = normals[side];
+ const length = Math.hypot(b.x - a.x, b.y - a.y);
+ const teeth = Math.max(2, Math.round(length / pitch));
+ points.push(a);
+ for (let i = 0; i < teeth; i++) {
+ if (i > 0) points.push(lerp(a, b, i / teeth));
+ const out = lerp(a, b, (i + 0.5) / teeth);
+ points.push({ x: out.x + normal.x * amp, y: out.y + normal.y * amp });
+ }
+ }
+
+ return points.map((from, i) => lineEdge(from, points[(i + 1) % points.length]));
+}
+
+/**
+ * Cloud outline for a thought bubble, plus how far its scallops bulge out.
+ *
+ * The four sides of the box are replaced by a ring of outward scallops: a
+ * rounded rectangle is sampled evenly by arc length and each pair of samples is
+ * joined by a half-ellipse bulging away from the centre.
+ */
+function thinkEdges(box, scale) {
+ const { x, y, w, h } = box;
+ const cx = x + w / 2;
+ const cy = y + h / 2;
+ const ring = roundedRectRing(box, Math.min(w, h) * 0.28);
+ const perimeter = ringLength(ring);
+ // Fluffier: more scallops, each bulging further, so it reads as a soft cloud.
+ const pitch = Math.max(scale * 6, Math.min(w, h) * 0.30);
+ const count = Math.max(6, Math.round(perimeter / pitch));
+ const points = sampleRing(ring, count);
+ const amp = Math.min((perimeter / count) * 0.55, Math.min(w, h) * 0.26);
+
+ const edges = [];
+ let outer = amp;
+ for (let i = 0; i < points.length; i++) {
+ const a = points[i];
+ const b = points[(i + 1) % points.length];
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
+ // Bulge perpendicular to the edge, not away from the centre: on a wide box
+ // the centre direction points sideways along the top and bottom edges, which
+ // is what tilted those scallops. The sign is flipped so it always faces out,
+ // which makes every edge scallop the same way the left and right ones do.
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const length = Math.hypot(dx, dy) || 1;
+ let nx = -dy / length;
+ let ny = dx / length;
+ if (nx * (mid.x - cx) + ny * (mid.y - cy) < 0) {
+ nx = -nx;
+ ny = -ny;
+ }
+ // A small, *deterministic* wobble so the scallops are not all identical - the
+ // even size read as too regular. Same index, same bubble, every render.
+ const scallopAmp = amp * (0.72 + 0.56 * scallopJitter(i));
+ if (scallopAmp > outer) outer = scallopAmp;
+ edges.push(scallopEdge(a, b, { x: nx, y: ny }, scallopAmp));
+ }
+ return { edges, amp: outer };
+}
+
+/** A closed cloud, followed by the tail: two or three shrinking puffs. */
+function thinkCommands(box, state, scale) {
+ const cloud = thinkEdges(box, scale);
+ const commands = edgesToCommands(cloud.edges);
+ for (const puff of thoughtTrail(box, state, scale, cloud.amp)) {
+ commands.push(...circleCommands(puff.x, puff.y, puff.r));
+ }
+ return commands;
+}
+
+/**
+ * The circles that trail away from a thought bubble instead of a pointed tail.
+ *
+ * They start just outside the cloud (`outer`) and shrink as they go, ending
+ * inside the room `layoutCaption` reserves for the tail.
+ */
+function thoughtTrail(box, state, scale, outer) {
+ const tail = tailOf(state);
+ if (tail === 'none') return [];
+ const dir = tailDirection(tail);
+ const diagonal = dir.x !== 0 && dir.y !== 0;
+ const nx = dir.x * (diagonal ? Math.SQRT1_2 : 1);
+ const ny = dir.y * (diagonal ? Math.SQRT1_2 : 1);
+ const anchor = {
+ x: dir.x < 0 ? box.x : dir.x > 0 ? box.x + box.w : box.x + box.w / 2,
+ y: dir.y < 0 ? box.y : dir.y > 0 ? box.y + box.h : box.y + box.h / 2,
+ };
+ // Sized from the font, not the leftover room, so the puffs stay visible at any
+ // bubble size; they start just outside the cloud (`outer`) and shrink away.
+ const unit = resolveFontSize(state, scale);
+ // 80% of the first cut, which read a touch large.
+ const r1 = unit * 0.48;
+ const r2 = unit * 0.34;
+ const r3 = unit * 0.21;
+ const gap = unit * 0.5;
+ const d1 = outer + r1;
+ const d2 = d1 + r1 + gap;
+ const d3 = d2 + r2 + gap;
+ return [
+ { x: anchor.x + nx * d1, y: anchor.y + ny * d1, r: r1 },
+ { x: anchor.x + nx * d2, y: anchor.y + ny * d2, r: r2 },
+ { x: anchor.x + nx * d3, y: anchor.y + ny * d3, r: r3 },
+ ];
+}
+
+/** One outward scallop: a half-ellipse from `from` to `to` bulging along `normal`. */
+function scallopEdge(from, to, normal, amp) {
+ const k = (4 / 3) * amp;
+ return cubicEdge(
+ from,
+ { x: from.x + normal.x * k, y: from.y + normal.y * k },
+ { x: to.x + normal.x * k, y: to.y + normal.y * k },
+ to,
+ );
+}
+
+/**
+ * A small deterministic wobble in 0..1 for the i-th scallop of a cloud.
+ *
+ * It is derived from the index alone, so a bubble draws its scallops exactly
+ * the same in the live preview, a screenshot, and the SVG export.
+ */
+function scallopJitter(i) {
+ const x = Math.sin((i + 1) * 12.9898) * 43758.5453;
+ return x - Math.floor(x);
+}
+
+/** A circle as `M`/`C`/`Z` commands, so it can join the bubble's one path. */
+function circleCommands(cx, cy, r) {
+ const k = r * KAPPA;
+ return [
+ { type: 'M', x: cx + r, y: cy },
+ { type: 'C', x1: cx + r, y1: cy + k, x2: cx + k, y2: cy + r, x: cx, y: cy + r },
+ { type: 'C', x1: cx - k, y1: cy + r, x2: cx - r, y2: cy + k, x: cx - r, y: cy },
+ { type: 'C', x1: cx - r, y1: cy - k, x2: cx - k, y2: cy - r, x: cx, y: cy - r },
+ { type: 'C', x1: cx + k, y1: cy - r, x2: cx + r, y2: cy - k, x: cx + r, y: cy },
+ { type: 'Z' },
+ ];
+}
+
+/** Points around a rounded rectangle, clockwise from the top-left arc. */
+function roundedRectRing(box, radius) {
+ const { x, y, w, h } = box;
+ const r = clamp(radius, 0, Math.min(w, h) / 2);
+ const points = [];
+ const arc = (cx, cy, from, to) => {
+ const steps = 6;
+ for (let i = 1; i <= steps; i++) {
+ const t = from + (to - from) * (i / steps);
+ points.push({ x: cx + Math.cos(t) * r, y: cy + Math.sin(t) * r });
+ }
+ };
+
+ points.push({ x: x + r, y });
+ points.push({ x: x + w - r, y });
+ arc(x + w - r, y + r, -Math.PI / 2, 0);
+ points.push({ x: x + w, y: y + h - r });
+ arc(x + w - r, y + h - r, 0, Math.PI / 2);
+ points.push({ x: x + r, y: y + h });
+ arc(x + r, y + h - r, Math.PI / 2, Math.PI);
+ points.push({ x, y: y + r });
+ arc(x + r, y + r, Math.PI, Math.PI * 1.5);
+ return points;
+}
+
+/** Total length of a closed polyline's perimeter. */
+function ringLength(points) {
+ let total = 0;
+ for (let i = 0; i < points.length; i++) {
+ total += distance(points[i], points[(i + 1) % points.length]);
+ }
+ return total;
+}
+
+/** `count` points spaced evenly by arc length around a closed polyline. */
+function sampleRing(points, count) {
+ const n = points.length;
+ const total = ringLength(points);
+ if (!(total > 0) || !(count > 0)) return [];
+ const out = [];
+ let seg = 0;
+ let start = 0;
+ let length = n > 1 ? distance(points[0], points[1 % n]) : 0;
+ for (let i = 0; i < count; i++) {
+ const target = (total * i) / count;
+ while (seg < n - 1 && start + length < target) {
+ start += length;
+ seg += 1;
+ length = distance(points[seg], points[(seg + 1) % n]);
+ }
+ const t = length > 0 ? clamp((target - start) / length, 0, 1) : 0;
+ out.push(lerp(points[seg], points[(seg + 1) % n], t));
+ }
+ return out;
+}
+
+/**
+ * Replace part of the edge the tail points at with a spike.
+ *
+ * The spike is inserted *into* the outline, not appended to it: the two sides
+ * of the tail and the bubble become one continuous border. The straight edge
+ * nearest the tail's anchor is used, which for every shape except the shout is
+ * the obvious side and for the shout is the nearest zig-zag segment.
+ */
+function withTail(edges, box, state, scale) {
+ const tail = tailOf(state);
+ if (tail === 'none' || edges.length === 0) return edges;
+
+ const tailLength = Math.min(resolveFontSize(state, scale) * 0.9, Math.min(box.w, box.h) * 0.5);
+ if (!(tailLength > 0)) return edges;
+
+ const dir = tailDirection(tail);
+ // A diagonal tail leaves from a corner; an axis-aligned one leaves from the
+ // middle of the side it points at.
+ const anchor = {
+ x: dir.x < 0 ? box.x : dir.x > 0 ? box.x + box.w : box.x + box.w / 2,
+ y: dir.y < 0 ? box.y : dir.y > 0 ? box.y + box.h : box.y + box.h / 2,
+ };
+ const diagonal = dir.x !== 0 && dir.y !== 0;
+ const normal = {
+ x: dir.x * (diagonal ? Math.SQRT1_2 : 1),
+ y: dir.y * (diagonal ? Math.SQRT1_2 : 1),
+ };
+
+ const index = nearestEdge(edges, anchor, box, tail);
+ if (index < 0) return edges;
+
+ const edge = edges[index];
+ const length = distance(edge.from, edge.to);
+ const base = Math.min(tailLength * 0.7, length * 0.45);
+ if (!(base > 0) || !(length > 0)) return edges;
+
+ // The spike sits at the point of the edge nearest the anchor - the middle for
+ // a side, the corner for a diagonal - kept far enough in that both feet land
+ // on the edge itself.
+ const t = clamp(projectionT(edge, anchor), base / length, 1 - base / length);
+ const middle = lerp(edge.from, edge.to, t);
+ // A diagonal spike advances `tailLength` on *each* axis, so it reaches as far
+ // as a side tail does and reads as a proper corner.
+ const reach = tailLength * (diagonal ? Math.SQRT2 : 1);
+ const tip = { x: middle.x + normal.x * reach, y: middle.y + normal.y * reach };
+ const leftFoot = lerp(edge.from, edge.to, t - base / length);
+ const rightFoot = lerp(edge.from, edge.to, t + base / length);
+ const replacement = [
+ lineEdge(edge.from, leftFoot),
+ lineEdge(leftFoot, tip),
+ lineEdge(tip, rightFoot),
+ lineEdge(rightFoot, edge.to),
+ ];
+ return edges.slice(0, index).concat(replacement, edges.slice(index + 1));
+}
+
+/** Index of the straight edge closest to the tail's anchor, or `-1`. */
+function nearestEdge(edges, anchor, box, tail) {
+ let best = -1;
+ let bestDistance = Infinity;
+ for (let i = 0; i < edges.length; i++) {
+ const edge = edges[i];
+ if (edge.kind !== 'line') continue;
+ const middle = { x: (edge.from.x + edge.to.x) / 2, y: (edge.from.y + edge.to.y) / 2 };
+ if (!onSide(middle, box, tail)) continue;
+ const d = distance(middle, anchor);
+ if (d < bestDistance) {
+ bestDistance = d;
+ best = i;
+ }
+ }
+ return best;
+}
+
+/** Is `point` on the side of the box the tail points at? */
+function onSide(point, box, tail) {
+ const dir = tailDirection(tail);
+ const midX = box.x + box.w * 0.5;
+ const midY = box.y + box.h * 0.5;
+ if (dir.x < 0 && point.x > midX) return false;
+ if (dir.x > 0 && point.x < midX) return false;
+ if (dir.y < 0 && point.y > midY) return false;
+ if (dir.y > 0 && point.y < midY) return false;
+ return true;
+}
+
+/** How far along `edge` (0..1) the point nearest `point` falls. */
+function projectionT(edge, point) {
+ const dx = edge.to.x - edge.from.x;
+ const dy = edge.to.y - edge.from.y;
+ const lengthSq = dx * dx + dy * dy;
+ if (!(lengthSq > 0)) return 0.5;
+ return ((point.x - edge.from.x) * dx + (point.y - edge.from.y) * dy) / lengthSq;
+}
+
+/** One straight edge of a closed outline. */
+function lineEdge(from, to) {
+ return { kind: 'line', from, to };
+}
+
+/** One cubic edge of a closed outline. */
+function cubicEdge(from, c1, c2, to) {
+ return { kind: 'cubic', from, c1, c2, to };
+}
+
+/** Walk the edges into `M`/`L`/`C`/`Z` commands. */
+function edgesToCommands(edges) {
+ if (edges.length === 0) return [];
+ const commands = [{ type: 'M', x: edges[0].from.x, y: edges[0].from.y }];
+ for (const edge of edges) {
+ if (edge.kind === 'line') {
+ commands.push({ type: 'L', x: edge.to.x, y: edge.to.y });
+ } else {
+ commands.push({
+ type: 'C',
+ x1: edge.c1.x, y1: edge.c1.y,
+ x2: edge.c2.x, y2: edge.c2.y,
+ x: edge.to.x, y: edge.to.y,
+ });
+ }
+ }
+ commands.push({ type: 'Z' });
+ return commands;
+}
+
+/** Replay commands into a 2D context. */
+function applyCommands(ctx, commands) {
+ ctx.beginPath();
+ for (const command of commands) {
+ if (command.type === 'M') ctx.moveTo(command.x, command.y);
+ else if (command.type === 'L') ctx.lineTo(command.x, command.y);
+ else if (command.type === 'C') {
+ ctx.bezierCurveTo(command.x1, command.y1, command.x2, command.y2, command.x, command.y);
+ } else if (command.type === 'Z') {
+ ctx.closePath();
+ }
+ }
+}
+
+/** Print commands as SVG path data. */
+function commandsToPathData(commands, places) {
+ const parts = [];
+ for (const command of commands) {
+ if (command.type === 'M') {
+ parts.push(`M${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`);
+ } else if (command.type === 'L') {
+ parts.push(`L${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`);
+ } else if (command.type === 'C') {
+ parts.push(
+ `C${formatNumber(command.x1, places)} ${formatNumber(command.y1, places)}`
+ + ` ${formatNumber(command.x2, places)} ${formatNumber(command.y2, places)}`
+ + ` ${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`,
+ );
+ } else if (command.type === 'Z') {
+ parts.push('Z');
+ }
+ }
+ return parts.join(' ');
+}
+
+// --- shared helpers ----------------------------------------------------------
+
+/** The family list for a caption, or the system-only list before the font loads. */
+function fontFamilyStack(caption, loaded) {
+ const stack = captionFontStack();
+ if (loaded && caption?.font !== 'system') return stack;
+ return dropLeadingFamily(stack);
+}
+
+/** `captionFontStack()` minus its first family, for a plain system fallback. */
+function dropLeadingFamily(stack) {
+ let quote = null;
+ for (let i = 0; i < stack.length; i++) {
+ const ch = stack[i];
+ if (quote) {
+ if (ch === '\\') i++;
+ else if (ch === quote) quote = null;
+ } else if (ch === "'" || ch === '"') {
+ quote = ch;
+ } else if (ch === ',') {
+ const rest = stack.slice(i + 1).trim();
+ return rest === '' ? 'sans-serif' : rest;
+ }
+ }
+ return 'sans-serif';
+}
+
+/** Font size in output pixels, always positive and finite. */
+function resolveFontSize(caption, scale) {
+ const base = toPositive(caption?.fontSize, DEFAULT_FONT_SIZE);
+ return toPositive(base * positiveScale(scale), DEFAULT_FONT_SIZE);
+}
+
+function bubbleOf(caption) {
+ const value = caption?.bubble;
+ if (value === 'round' || value === 'rect' || value === 'shout'
+ || value === 'whisper' || value === 'think' || value === 'none') return value;
+ return 'round';
+}
+
+function tailOf(caption) {
+ const value = caption?.tail;
+ return TAIL_VALUES.has(value) ? value : 'left';
+}
+
+const TAIL_VALUES = new Set([
+ 'left', 'right', 'top', 'bottom',
+ 'topLeft', 'topRight', 'bottomLeft', 'bottomRight',
+ 'none',
+]);
+
+/**
+ * Which way a tail leaves the box, as a direction where each axis is -1, 0 or
+ * 1 (0 for `none` or anything unknown). A diagonal tail carries a sign on both
+ * axes, so `bottomLeft` is `{ x: -1, y: 1 }`.
+ */
+function tailDirection(tail) {
+ switch (tail) {
+ case 'left': return { x: -1, y: 0 };
+ case 'right': return { x: 1, y: 0 };
+ case 'top': return { x: 0, y: -1 };
+ case 'bottom': return { x: 0, y: 1 };
+ case 'topLeft': return { x: -1, y: -1 };
+ case 'topRight': return { x: 1, y: -1 };
+ case 'bottomLeft': return { x: -1, y: 1 };
+ case 'bottomRight': return { x: 1, y: 1 };
+ default: return { x: 0, y: 0 };
+ }
+}
+
+function alignOf(caption) {
+ const value = caption?.align;
+ return value === 'center' || value === 'right' ? value : 'left';
+}
+
+function colorOf(value, fallback) {
+ return typeof value === 'string' && value !== '' ? value : fallback;
+}
+
+/** Scale is a multiplier: anything that is not a positive number means 1. */
+function positiveScale(value) {
+ return Number.isFinite(value) && value > 0 ? value : 1;
+}
+
+function toFinite(value, fallback) {
+ return Number.isFinite(value) ? value : fallback;
+}
+
+function toPositive(value, fallback) {
+ return Number.isFinite(value) && value > 0 ? value : fallback;
+}
+
+function toNonNegative(value, fallback) {
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
+}
+
+function clamp(value, low, high) {
+ if (high < low) return low;
+ return Math.min(Math.max(value, low), high);
+}
+
+/** Decimal string for both `ctx.font` and SVG, never `NaN` and never `-0`. */
+function formatNumber(value, places = 2) {
+ if (!Number.isFinite(value)) return '0';
+ const decimals = Number.isFinite(places) ? Math.max(0, Math.min(8, Math.floor(places))) : 2;
+ const factor = Math.pow(10, decimals);
+ const rounded = Math.round(value * factor) / factor;
+ return Object.is(rounded, -0) ? '0' : String(rounded);
+}
+
+/**
+ * Escape character data.
+ *
+ * Quotes are escaped too even though element content allows them: the caption
+ * is user text, and the extra entities cost nothing next to never emitting a
+ * document a stricter parser could object to.
+ */
+function escapeText(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&apos;');
+}
+
+/**
+ * Escape an attribute value.
+ *
+ * Only the double quote matters inside a double-quoted attribute, so single
+ * quotes survive and a family stack such as `'Hiragino Maru Gothic ProN',
+ * sans-serif` stays readable in the file.
+ */
+function escapeAttribute(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;');
+}
+
+function distance(a, b) {
+ return Math.hypot(b.x - a.x, b.y - a.y);
+}
+
+function lerp(a, b, t) {
+ return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
+}