diff options
Diffstat (limited to 'public/bluebey-studio/src/caption.js')
| -rw-r--r-- | public/bluebey-studio/src/caption.js | 87 |
1 files changed, 78 insertions, 9 deletions
diff --git a/public/bluebey-studio/src/caption.js b/public/bluebey-studio/src/caption.js index 1543f0e..a315971 100644 --- a/public/bluebey-studio/src/caption.js +++ b/public/bluebey-studio/src/caption.js @@ -59,6 +59,37 @@ const WIDE_RANGES = [ ]; /** + * 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 = 'ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮ'; + +/** + * 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 }); +VERTICAL_OFFSETS.set('ー', { dx: 0.22, dy: -0.3 }); +for (const ch of '「『(〔') VERTICAL_OFFSETS.set(ch, { dx: 0.14, dy: -0.12 }); +for (const ch of '」』)〕') VERTICAL_OFFSETS.set(ch, { dx: -0.14, dy: 0.12 }); + +/** 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 @@ -356,8 +387,9 @@ function drawText(ctx, layout, state, font) { const chars = [...lines[i].text]; const x = left + (lines.length - 1 - i) * step; for (let j = 0; j < chars.length; j++) { - const y = top + j * fontSize + metrics.ascent; - ctx.fillText(chars[j], x, y); + const offset = verticalOffsetOf(chars[j]); + const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize; + ctx.fillText(chars[j], x + offset.dx * fontSize, y); } } return; @@ -408,8 +440,9 @@ function liveText(state, layout, font, fill, places) { const chars = [...lines[i].text]; const x = left + (lines.length - 1 - i) * step; for (let j = 0; j < chars.length; j++) { - const y = top + j * fontSize + metrics.ascent; - tspans.push(`<tspan x="${formatNumber(x, places)}" y="${formatNumber(y, places)}">${escapeText(chars[j])}</tspan>`); + const offset = verticalOffsetOf(chars[j]); + 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(chars[j])}</tspan>`); } } return `<text ${attributes}>${tspans.join('')}</text>`; @@ -475,9 +508,26 @@ function layoutBlock(font, 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. */ function layoutVertical(text, { fontSize, lineHeight, maxHeight }) { const limit = Number.isFinite(maxHeight) && maxHeight > 0 @@ -487,18 +537,37 @@ function layoutVertical(text, { fontSize, lineHeight, maxHeight }) { for (const paragraph of String(text).split(/\r\n|\r|\n/)) { const chars = [...paragraph]; if (chars.length === 0) { - columns.push(''); + columns.push({ text: '', count: 0 }); continue; } - for (let i = 0; i < chars.length; i += limit) { - columns.push(chars.slice(i, i + limit).join('')); + let current = ''; + let count = 0; + for (const ch of chars) { + if (count >= limit) { + // A closing mark that would start a column hangs off the previous one. + if (HANGING_PUNCTUATION.has(ch)) { + current += ch; + continue; + } + columns.push({ text: current, count }); + current = ''; + count = 0; + } + current += ch; + count += 1; } + columns.push({ text: current, count }); } - const longest = columns.reduce((max, column) => Math.max(max, [...column].length), 0); + const longest = columns.reduce((max, column) => Math.max(max, column.count), 0); return { - lines: columns.map((column) => ({ text: column, width: fontSize })), + lines: columns.map((column) => ({ + text: column.text, + width: fontSize, + hang: [...column.text].length - column.count, + })), width: columns.length * fontSize * lineHeight, height: longest * fontSize, + vertical: true, }; } |
