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 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'; 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 `` * 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 `` 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 ``, 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 ``: the outline path lays glyphs out // horizontally, and a column of glyphs reads fine as live text. if (layout.vertical) usedOutlines = false; const parts = [ '`, ]; 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(``); } 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(``); } else { parts.push(liveText(state, layout, font, textColor, places)); } } parts.push(''); 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 on its own line, upright. 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 y = top + j * fontSize + metrics.ascent; ctx.fillText(chars[j], x, 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 `` 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) { const attributes = [ `font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`, `font-size="${formatNumber(fontSize, places)}"`, state.bold ? 'font-weight="bold"' : '', `fill="${escapeAttribute(fill)}"`, 'text-anchor="start"', ].filter(Boolean).join(' '); const tspans = []; 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 y = top + j * fontSize + metrics.ascent; tspans.push(`${escapeText(chars[j])}`); } } return `${tspans.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 `` + `${escapeText(line.text)}`; }).join(''); return `${tspans}`; } /** * 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); } /** * 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. */ 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(''); continue; } for (let i = 0; i < chars.length; i += limit) { columns.push(chars.slice(i, i + limit).join('')); } } const longest = columns.reduce((max, column) => Math.max(max, [...column].length), 0); return { lines: columns.map((column) => ({ text: column, width: fontSize })), width: columns.length * fontSize * lineHeight, height: longest * fontSize, }; } /** 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} 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * 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, '&') .replace(//g, '>') .replace(/"/g, '"'); } 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 }; }