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 22:24:22 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 22:24:22 +0900
commit63c1a9dfa8b04ab9725a9a49ef34cd3fc6ade56c (patch)
tree9a5b0970585a927bb37db09d23ba7bb1fc4190be /public/bluebey-studio/src/caption.js
parent39a6c31beed18f0d1fc211fda919fae7ce2d9505 (diff)
bluebey-studio: 小物の3軸回転・線画対応と、まんがのコマ枠・共有リンク短縮
小物にX/Y/Zの3軸回転とフラット/線画スタイル対応を追加し、鉛筆の芯を円錐に埋め、ノートと消しゴムを追加。まんがは各コマに枠を描きzip出力を廃止。共有リンクは既定値の省略で約2,429→321文字に短縮。縦書きは句読点のぶら下げと長音・小書き文字の位置を補正。線画の帽子にトーンを付け、removeMeshでマテリアルを復元して切替時の破損を修正。
Diffstat (limited to 'public/bluebey-studio/src/caption.js')
-rw-r--r--public/bluebey-studio/src/caption.js87
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,
};
}