aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/caption.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 21:46:21 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 21:46:21 +0900
commit7329ba8c8901ee095ad7316b8554c5e44431eebc (patch)
tree471d3994df66e38a3bd12b38d2899193985c9f2f /public/bluebey-studio/src/caption.js
parent27220fe6aa19f8e63be3984be1bc3288002252f7 (diff)
bluebey-studio: 縦書きのセリフとナレーション枠、コック帽改善、小物に鉛筆とコップなど
セリフを縦書き可能にし、縦書きのナレーションボックスを追加。ハートでも下まぶたを閉じられるようにし、ふつうの目の瞳を少し鼻側へ。HairPlateは髪が無いとき非表示(iPhoneの色ずれ対策)。コック帽をトック帽に、鉛筆とコップを小物に追加。
Diffstat (limited to 'public/bluebey-studio/src/caption.js')
-rw-r--r--public/bluebey-studio/src/caption.js84
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);