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