aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/caption.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-02 23:51:34 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-02 23:51:34 +0900
commite332019acb312ec64893c26cf3d797d5ce472f26 (patch)
treed97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src/caption.js
parent99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff)
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src/caption.js')
-rw-r--r--public/bluebey-studio/src/caption.js896
1 files changed, 896 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/caption.js b/public/bluebey-studio/src/caption.js
new file mode 100644
index 0000000..15f5bf3
--- /dev/null
+++ b/public/bluebey-studio/src/caption.js
@@ -0,0 +1,896 @@
+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],
+];
+
+/**
+ * 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 block = 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,
+ 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';
+ 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);
+
+ 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"`
+ : '';
+ 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';
+
+ 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;
+
+ 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);
+}
+
+/** 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 [];
+
+ 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]));
+}
+
+/**
+ * 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 === '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 };
+}