aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/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 /public/bluebey-studio/src/caption.js
parent0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff)
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'public/bluebey-studio/src/caption.js')
-rw-r--r--public/bluebey-studio/src/caption.js1321
1 files changed, 0 insertions, 1321 deletions
diff --git a/public/bluebey-studio/src/caption.js b/public/bluebey-studio/src/caption.js
deleted file mode 100644
index 71759ea..0000000
--- a/public/bluebey-studio/src/caption.js
+++ /dev/null
@@ -1,1321 +0,0 @@
-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 };
-}