Taming Image Exclusion in a Vertical Typesetting Engine
mejiroという縦書きの日本語組版エンジンを書いている。行分割、禁則処理、ルビ、ページネーションをJavaScriptで処理して、Webブラウザ上に日本語の縦書きテキストをページ単位で表示するライブラリだ。
縦書き組版エンジンとは
CSSのwriting-mode: vertical-rlを使えばブラウザでも縦書きは表示できる。だがページ分割や画像の回り込みが絡むと、CSSだけでは制御しきれない。mejiroは行分割からページ配置までをJavaScript側で計算し、結果をDOMに反映する。
このエンジンに画像の回り込み(exclusion)を実装した。雑誌や書籍でよく見る、画像を避けてテキストが流れるレイアウトだ。横書きならCSSのshape-outsideで実現できるが、縦書きでは実用に耐えない。自前で書くしかなかった。
リポジトリにデモアプリケーションが同梱されていて、yarn devで起動できる。夏目漱石の「吾輩は猫である」のEPUBが付属しており、起動するとそのまま見開き表示される。画像のプレースホルダーをドラッグで移動・リサイズすると、テキストの回り込みがリアルタイムに再計算される。requestAnimationFrameで間引いているだけで、画像の位置が変わるたびにslot計算・行分割・ページ配置をすべてやり直している。

縦書きの列はどう並ぶか
まず縦書きの空間を整理しておく。
縦書きでは**列(column)**が右から左に並ぶ。1つの列は、文字が上から下に流れる縦長の領域だ。列と列の間隔——linePitch——はfontSize × lineHeightで決まる。
linePitchとは
隣り合う列の中心から次の列の中心までの距離。横書きの「行間」にあたる概念だが、縦書きでは横方向の距離になる。本文が16pxでlineHeightが1.8なら、linePitchは28.8px。
読む方向は右から左。列0が最初に読む列で、ページの右端にある。列が進むごとに左に移動する。全列のlinePitchが同じなら、列の位置は単純な掛け算——列インデックス × linePitch——で求まる。
画像を避ける
ページの右上に画像があるとしよう。画像は横方向に列3つぶんの幅、縦方向にページの半分くらいの大きさだとする。
列0は画像に重ならないので全高が使える。列1〜3は画像と重なるので、画像の下の部分だけテキストが入る。列4以降は画像に重ならないので全高が使える。
この「画像と重なる列はどこか、重ならない隙間はどこか」を計算するのがExclusionEngineの仕事だ。
実際にデモで画像を置くとこうなる。右ページの見出し付近に画像を配置すると、テキストが画像の下に回り込む。

slotという考え方
ExclusionEngineは列ごとにslot——テキストを配置できる領域——を計算する。
画像がない列のslotは1つだけ。列の全高がそのままslotになる。画像がある列は、画像の上と下に隙間ができるので、1つの列に2つのslotが生まれることもある。
// ExclusionEngineが返す、テキストを配置できる領域
interface ColumnSlot {
xPos: number; // 右端からの横方向オフセット
yStart: number; // 上端からの縦方向オフセット
height: number; // テキストに使える高さ(= 縦書きでの行の長さ)
}実際のExclusionEngineのコードでは、列ごとに画像の矩形との重なりを判定し、重なりの区間をマージして、残った隙間をslotとして返している。
// 列の中で画像に占められていない隙間を収集
const gaps: ColumnSlot[] = [];
let prevEnd = 0;
for (const [top, bottom] of merged) {
const gapH = top - prevEnd;
if (gapH >= MIN_GAP_HEIGHT) {
gaps.push({ xPos, yStart: prevEnd, height: gapH });
}
prevEnd = bottom;
}
// 最後の画像の下にも隙間があれば追加
const tailGap = lineWidth - prevEnd;
if (tailGap >= MIN_GAP_HEIGHT) {
gaps.push({ xPos, yStart: prevEnd, height: tailGap });
}MIN_GAP_HEIGHTは8px。これより小さい隙間は捨てる。数ピクセルの隙間に文字を押し込んでも読めないからだ。
ExclusionEngineが返すのは、slotの配列と、各slotのテキスト幅を格納したFloat32Arrayだ。slotの高さがそのままテキスト幅になる——縦書きでは「列の高さ」が「行の長さ」に対応するためだ。
2つのレンダリングモード
slot位置が決まれば、あとはDOMに配置する。ここで、画像があるページとないページでレンダリング方法が根本的に異なる。
縦中横(text-combine-upright)とは
縦書きの中で半角英数字を横に並べる処理。「2026年」の「2026」を、1文字分の枠に横向きで収める。CSSのtext-combine-uprightプロパティで実現する。
画像がないページは、CSSのwriting-mode: vertical-rlにテキストを流し込めばいい。ブラウザが列の配置、行間、段落間ギャップ、ルビ、縦中横を自動処理してくれる。
画像があるページではこれが使えない。画像による「穴あき」——列の一部だけにテキストが入る状態——はCSS vertical-rlで表現できない。
画像をページの中央付近に置くと、列の上と下にテキストが分かれて配置される。1つの列に2つのslotが存在する状態だ。

各slotの座標をJavaScriptで計算し、position: absoluteで1つずつ配置する。
デモのレンダリングコードはこうなっている。
function renderSlotPage(contentEl: HTMLElement, result: PageResult): void {
// 親コンテナをhorizontal-tbに切り替え、絶対配置の基準にする
contentEl.style.writingMode = 'horizontal-tb';
contentEl.style.position = 'relative';
for (let i = 0; i < result.lines.length; i++) {
const line = result.lines[i];
const slot = result.slots[i];
if (slot.height <= 0) continue;
// 各slotをposition: absoluteで配置し、中身だけvertical-rlで縦書きにする
const col = document.createElement('div');
col.className = 'exclusion-column';
col.style.right = `${slot.xPos}px`; // 右端からの横位置
col.style.top = `${slot.yStart}px`; // 上端からの縦位置
col.style.height = `${slot.height}px`; // テキストに使える高さ
col.style.fontSize = `${line.fontSize}px`;
for (const seg of line.segments) renderSegmentToDOM(col, seg);
contentEl.appendChild(col);
}
}コンテナのwritingModeをhorizontal-tbに切り替えている点に注目してほしい。親を横書きにしておいて、各slotの<div>をposition: absoluteで配置し、その中だけvertical-rlで縦書きにする。CSSの自動レイアウトを切って、JavaScriptが計算した座標で制御する。
1つのページ結果が、通常モード用のデータ(段落構造をそのまま持つRenderPage)と、slot用のデータ(フラットな行リストlinesと座標配列slots)を同時に持っている。
interface PageResult {
page: RenderPage; // 通常モード: CSS vertical-rlに流し込む用
lines: PageLine[]; // slotモード: フラットな行リスト
slots: ColumnSlot[]; // slotモード: 行ごとの座標
hasImages: boolean; // どちらを使うかのヒント
}見出しでピッチが変わると
ここまでは全列のlinePitchが同じ前提だった。見出しを可変フォントサイズにすると、この前提が崩れる。
h2の見出しを本文の1.5倍(24px)で表示するとしよう。見出し列のlinePitchは24 × 1.8 = 43.2px、本文は16 × 1.8 = 28.8px。差は14.4px。
見出しの後ろにある全列が14.4pxぶん左にシフトする。段落間のギャップも加算される。列が増えるほどズレは累積する。同じ列数でも、物理的な幅が一致しない。
ExclusionEngineは均一pitchで列の位置を計算している。見出しの入ったページでは、Engineが「列5はここ」と言う位置と、実際に列5がレンダリングされる位置がズレる。
累積オフセットで補正する
ExclusionEngine自体を可変pitch対応にする選択肢もあった。だがそうするとEngineの複雑性が跳ね上がる。代わりに、Engineの外側で座標を補正する方式を採った。
buildLineMetrics()が累積オフセットを計算する。各行について「前の行のpitch超過分 + 段落ギャップ」を積み上げたFloat32Arrayだ。
この累積値で画像座標を補正してからExclusionEngineに渡す。Engineの内部は均一pitchのまま、外側で帳尻を合わせる。
ここで扱う座標系が3つになる。
| 座標系 | 方向 | 用途 |
|---|---|---|
CSS left | 左からの距離 | 画像オーバーレイのDOM配置 |
CSS right | 右からの距離 | 列の絶対配置 |
| Engine内部 | 列インデックス × linePitch | 画像と列の重なり判定 |
画像のleft座標をEngineの座標に変換し、Engineの計算結果をCSSのright座標に戻す。変換のたびに方向が反転する。
行分割を2回走らせる
画像が重なった列はslotが短くなる。slotが短くなると、その列に入るテキスト量が減る。テキスト量が変わると行分割の結果が変わる。行分割の結果が変わると行数が変わり、メトリクスも変わる。
行分割(line breaking)とは
テキストを行に分割する処理。「この幅に収まるところで折り返す」という問題だが、日本語では禁則処理(句読点が行頭に来ないようにする)やルビの幅を考慮する必要がある。mejiroのcomputeBreaks()はこれらを含む行分割を行い、各行の折り返し位置を返す。
つまり行分割を2回走らせる必要がある。
フェーズ1(Pre-reflow): まず通常の行分割結果からメトリクスと累積オフセットを計算する。そのオフセットで画像座標を補正し、ExclusionEngineに渡す。Engineは各slotの高さ(= テキスト幅)を返す。
フェーズ2(Post-reflow): フェーズ1で得たslotの高さを各行の幅として、行分割を再実行する。画像横の行は短い幅で折り返されるので行数が変わりうる。新しい行数でメトリクスを再計算し、最終的なslot配置を決める。
フェーズ1の結果でフェーズ2の配置を作ると微妙にズレる。行数が1つ変わるだけで累積オフセットが変わるからだ。
見開きにまたがる
見開き(スプレッド)とは
日本語の書籍は見開き——右ページと左ページのペア——を基本単位とする。縦書きでは右ページから読み始め、左ページへ進む。
画像はどちらのページにも置ける。

mejiroのSpreadExclusionEngineは、右ページの左上を原点として画像座標を受け取る。x座標が負なら画像が左ページにはみ出す。
内部では右ページ用と左ページ用の2つのExclusionEngineを作り、画像を振り分けて座標変換してから、それぞれにcompute()を呼ぶ。結果のslotと行幅を連結して、1つの連続したデータとして返す。テキストは右ページの列0から流れ始め、右ページの最後の列を過ぎたら左ページの列0に続く。
画像がページの綴じ目をまたぐケースでは、画像を綴じ目で分割して右ページ側と左ページ側に分ける。それぞれの側で独立して累積オフセットの補正を適用する。
画像があるページとないページでslotの計算方法が異なるため、右ページに画像あり・左になし、その逆、両方あり、両方なしの4パターンの分岐が生まれる。見開き表示は日本語書籍リーダーの基本要件なので、この分岐は避けられない。
縦書きの座標を扱うということ
CSSには縦書きの仕様がある。writing-mode: vertical-rlは列を右から左に流す。仕様として存在するが、ページという概念との組み合わせが弱い。CSS Paged Mediaの仕様はあるがブラウザの実装は限定的で、shape-outsideによる回り込みも縦書きでは不安定だ。
だからJavaScriptで組版エンジンを書くことになる。CSSが得意な部分——通常のテキスト描画、ルビ、縦中横——はCSSに任せ、CSSが苦手な部分——ページ分割、可変pitch、画像回り込み——をJavaScriptが引き受ける。2つのレンダリングモードが共存するのは、この役割分担の結果だ。
縦書きの座標は、横書きの延長線上にない。列の流れる方向が逆で、「行の幅」が90度回転していて、座標系が3つある。そこに可変フォントサイズと画像回り込みを載せると、横書きでは意識しなかった前提がひとつずつ外れていく。