diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 21:46:21 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 21:46:21 +0900 |
| commit | 7329ba8c8901ee095ad7316b8554c5e44431eebc (patch) | |
| tree | 471d3994df66e38a3bd12b38d2899193985c9f2f /public/bluebey-studio/src/caption.js | |
| parent | 27220fe6aa19f8e63be3984be1bc3288002252f7 (diff) | |
bluebey-studio: 縦書きのセリフとナレーション枠、コック帽改善、小物に鉛筆とコップなど
セリフを縦書き可能にし、縦書きのナレーションボックスを追加。ハートでも下まぶたを閉じられるようにし、ふつうの目の瞳を少し鼻側へ。HairPlateは髪が無いとき非表示(iPhoneの色ずれ対策)。コック帽をトック帽に、鉛筆とコップを小物に追加。
Diffstat (limited to 'public/bluebey-studio/src/caption.js')
| -rw-r--r-- | public/bluebey-studio/src/caption.js | 84 |
1 files changed, 78 insertions, 6 deletions
diff --git a/public/bluebey-studio/src/caption.js b/public/bluebey-studio/src/caption.js index 15f5bf3..1543f0e 100644 --- a/public/bluebey-studio/src/caption.js +++ b/public/bluebey-studio/src/caption.js @@ -118,12 +118,19 @@ export function layoutCaption(caption, { width, height, scale = 1, font = null } const requested = toNonNegative(state.padding, DEFAULT_PADDING) * resolvedScale; const text = typeof state.text === 'string' ? state.text : ''; - const block = layoutBlock(font, text, { - fontSize, - maxWidth: resolveColumn(state, outWidth, requested), - lineHeight, - align: alignOf(state), - }); + 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. @@ -169,6 +176,7 @@ export function layoutCaption(caption, { width, height, scale = 1, font = null } lineHeight, padding, scale: resolvedScale, + vertical, usedOutlines: Boolean(font), }; } @@ -275,6 +283,9 @@ export function captionToSvg(caption, { width, height, scale = 1, font = null, r 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"' @@ -339,6 +350,19 @@ function drawText(ctx, layout, state, font) { ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; + if (layout.vertical) { + // Columns run right to left; each glyph sits on its own line, upright. + for (let i = 0; i < lines.length; i++) { + const chars = [...lines[i].text]; + const x = left + (lines.length - 1 - i) * step; + for (let j = 0; j < chars.length; j++) { + const y = top + j * fontSize + metrics.ascent; + ctx.fillText(chars[j], x, y); + } + } + return; + } + for (let i = 0; i < lines.length; i++) { const line = lines[i]; const x = left + alignOffset(align, blockWidth, line.width); @@ -371,6 +395,26 @@ function liveText(state, layout, font, fill, places) { const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2; const top = box.y + padding; + if (layout.vertical) { + const attributes = [ + `font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`, + `font-size="${formatNumber(fontSize, places)}"`, + state.bold ? 'font-weight="bold"' : '', + `fill="${escapeAttribute(fill)}"`, + 'text-anchor="start"', + ].filter(Boolean).join(' '); + const tspans = []; + for (let i = 0; i < lines.length; i++) { + const chars = [...lines[i].text]; + const x = left + (lines.length - 1 - i) * step; + for (let j = 0; j < chars.length; j++) { + const y = top + j * fontSize + metrics.ascent; + tspans.push(`<tspan x="${formatNumber(x, places)}" y="${formatNumber(y, places)}">${escapeText(chars[j])}</tspan>`); + } + } + return `<text ${attributes}>${tspans.join('')}</text>`; + } + const attributes = [ `font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`, `font-size="${formatNumber(fontSize, places)}"`, @@ -430,6 +474,34 @@ function layoutBlock(font, text, options) { return wrapByCount(text, options); } +/** + * Vertical (縦書き) layout: each paragraph becomes one column, characters stacked + * top to bottom, columns running right to left. A paragraph longer than the + * available height is split into more columns. + */ +function layoutVertical(text, { fontSize, lineHeight, maxHeight }) { + const limit = Number.isFinite(maxHeight) && maxHeight > 0 + ? Math.max(1, Math.floor(maxHeight / fontSize)) + : Infinity; + const columns = []; + for (const paragraph of String(text).split(/\r\n|\r|\n/)) { + const chars = [...paragraph]; + if (chars.length === 0) { + columns.push(''); + continue; + } + for (let i = 0; i < chars.length; i += limit) { + columns.push(chars.slice(i, i + limit).join('')); + } + } + const longest = columns.reduce((max, column) => Math.max(max, [...column].length), 0); + return { + lines: columns.map((column) => ({ text: column, width: fontSize })), + width: columns.length * fontSize * lineHeight, + height: longest * fontSize, + }; +} + /** Text column in pixels: a fraction of the image, never wider than the image. */ function resolveColumn(state, outWidth, padding) { const fraction = toNonNegative(state.maxWidth, DEFAULT_MAX_WIDTH); |
