diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
| commit | e332019acb312ec64893c26cf3d797d5ce472f26 (patch) | |
| tree | d97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src/caption.js | |
| parent | 99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff) | |
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src/caption.js')
| -rw-r--r-- | public/bluebey-studio/src/caption.js | 896 |
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, '&') + .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, '>') + .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 }; +} |
