【解説】Canvas で縦書き日本語組版を実装する!詠み人の描画方法
2026-04-16
はじめに
和歌・短歌・俳句を縦書きの美しい画像にして SNS に投稿できるツール 詠み人 を作ったとき、最大の悩みどころは「縦書きレイアウトをどう描画するか」でした。
ブラウザには writing-mode: vertical-rl という CSS プロパティがあり、HTML 上で縦書きを表現することはできます。けれども詠み人では最終的に Canvas で1文字ずつ描画する 方針にしました。この記事では、その判断と、ハマった特殊文字の扱いをまとめます。
CSS writing-mode ではなく Canvas 描画を選んだのは、「最終成果物が画像である」という用途の特殊性からでした。句読点の位置調整、長音・括弧の90度回転、フォント読み込み待機など、日本語組版固有の細かいルールを自前でコントロールする必要があり、結果的に Canvas のほうが制御しやすかったです。
環境
| 項目 | 技術 |
|---|---|
| フレームワーク | Nuxt 4 + Vue 3 |
| 言語 | TypeScript |
| 描画 | HTML Canvas 2D |
| フォント | Noto Serif JP + 同梱フォント(力弱・行書・隷書) |
なぜ CSS writing-mode を選ばなかったのか
詠み人は最終的に「1枚の PNG 画像」を生成するツールです。ユーザーはそれを Twitter/X にシェアしたり、画像としてダウンロードしたりします。
「実装者が描画の全てをコントロールしたい」という要望にいちばん素直に応えてくれたのが、Canvas で1文字ずつ fillText する方式でした。後々背景を追加するなどを考えた際にも Canvas は扱いやすいと考えました。
基本のカラムレイアウト
縦書きは「右から左へ」行が並びます。詠み人ではこれを次のように実装しています。
validLines.forEach((line, ci) => {
const x = poemStartX - ci * colW
const chars = [...line]
const fs = Math.min(46, Math.floor((H - 130) / Math.max(chars.length, 1)))
const lh = fs + 6
ctx.fillStyle = style.text
ctx.font = `400 ${fs}px ${fontFamily}`
ctx.textAlign = 'center'
chars.forEach((ch, ci2) => {
drawVerticalChar(ctx, ch, x, startY + ci2 * lh, fs, fontId)
})
})
行ごとに x座標を colW だけ左にずらしていくのが「右から左へ」の実現です。各列の中では、文字を上から下に lh(= フォントサイズ + 6px)の間隔で配置していきます。
行の文字数に応じてフォントサイズを Math.floor((H - 130) / chars.length) で動的に決めているのもポイントです。長い句でも必ずキャンバス内に収まるように、フォントサイズのほうを縮める 方針にしました。
特殊文字の扱い — 詠み人最大のハマりどころ
縦書き日本語組版で一筋縄でいかないのが、句読点・括弧・長音符 などの特殊文字です。これらを素直に fillText すると、向きや位置が崩れて読めない画像になります。
詠み人では drawVerticalChar の中でこれらを個別処理しています。
const ROTATE_CHARS = new Set('ー〜「」『』…・()')
const TOP_RIGHT_CHARS = new Set('、。')
function drawVerticalChar(ctx, ch, x, y, fontSize, fontId) {
if (TOP_RIGHT_CHARS.has(ch)) {
// 句読点: 右上に小さめに配置
ctx.save()
const size = fontSize * 0.5
ctx.font = ctx.font.replace(/\d+px/, `${size}px`)
ctx.fillText(ch, x + fontSize * 0.35, y - fontSize * 0.3)
ctx.restore()
} else if (ROTATE_CHARS.has(ch)) {
// 長音・括弧等: 90度回転
ctx.save()
ctx.translate(x, y - fontSize * 0.35)
ctx.rotate(Math.PI / 2)
ctx.fillText(ch, 0, 0)
ctx.restore()
} else {
ctx.fillText(ch, x, y)
}
}
句読点は右上に半分サイズで置く
日本の伝統的な縦書き組版では、「、」と「。」は マス目の右上に小さく 配置します。詠み人ではサイズを 50%、座標を (x + fontSize * 0.35, y - fontSize * 0.3) に補正することで、この配置を再現しました。
長音・括弧は90度回転
「ー」「〜」「「」「()」などは、横書きでそのまま描くと縦書きの文脈で読みにくくなります。詠み人では ctx.rotate(Math.PI / 2) で 90度時計回りに回転 して描画しています。translate で座標を中心にずらしてから回転させるのがコツです。
このあたりは日本語組版の JIS X 4051 や W3C の日本語組版要件を全部実装しようとすると際限がないので、詠み人に実際に入力される文字種に限って割り切る 方針を取りました。
フォントによって位置が異なる
回転処理をさらに厄介にするのが、フォントごとにグリフの中心位置が微妙にずれる という問題です。詠み人では明朝(Noto Serif JP)・力弱(851CHIKARA-YOWAKU)・行書(AoyagiKouzanT)・隷書(AoyagiReisyosimo)の4フォントを選択できますが、このうち隷書だけは回転文字の描画位置がずれます。
実装ではフォント ID を見て隷書のときだけ X 軸方向にオフセットを入れています。
const offsetX = fontId === 'reisyo' ? -fontSize * 0.15 : 0
ctx.translate(x + offsetX, y - fontSize * 0.35)
ctx.rotate(Math.PI / 2)
-fontSize * 0.15 という補正値は、隷書フォントの「ー」や「〜」を実際に描画しながら目視で合わせた数値です。フォント間でグリフメトリクスが統一されていないため、汎用的な計算式を導出するのは難しく、フォントを追加するたびにこの分岐が増える可能性があります。現状は4フォント固定なので fontId === 'reisyo' の1分岐で収まっていますが、フォント選択肢を増やす場合はオフセットのテーブル化が必要になるでしょう。私は 必要最低限で実装する という原則で実装を進めています。現時点で複雑にならないことが最優先です。
フォントの読み込み待機を忘れずに
Canvas 描画で地味にハマるのが「指定したフォントがまだ読み込まれていないとデフォルトフォントで描画される」問題です。特に Google Fonts のような外部フォントを使う場合、初回描画時に fillText が意図しないフォントで動き、2回目以降は正常、という不安定な挙動を起こします。
詠み人では描画開始前に document.fonts.load() を明示的に待機しています。
await document.fonts.load(`400 46px ${fontFamily}`)
await document.fonts.load(`300 13px ${fontFamily}`)
本文用(46px)と作者名用(13px)の両方をロードしないと、作者名だけがデフォルトフォントになる、という微妙なバグが出ます。フォントウェイトが異なる(本文は 400、作者名は 300)ため、それぞれ個別に await しています。
まとめ
- Canvas での縦書き実装は、画像生成ツール という用途では CSS より素直
- 句読点は 50% サイズで右上配置、長音・括弧は 90° 回転、という特殊文字処理が必須
- 日本語組版の全仕様を追うのではなく、自分のツールに実際に入力される文字 に絞って割り切る
document.fonts.load()を複数サイズで await することで、フォント未ロード起因のバグを回避
縦書きは奥が深い世界ですが、画像生成用途に限れば Canvas で十分戦えます。