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