
Oxiという無謀なプロジェクト
.docx をブラウザで開いて、Microsoft Word とピクセル単位で同じ見た目に組版する。Rust と WebAssembly で書いた Oxi というOSSで、私はそれをやろうとしている。
「だいたい同じ」ではない。ピクセル単位だ。Word が描いた1ページと、Oxi が描いた1ページを重ねて、画素ごとの一致率(SSIM)を測る。1.0 が完全一致。最初は 0.75 だった。今は 0.99 が見えている。
この 0.24 を埋める作業の大半は、華やかな機能追加ではない。Word という「仕様書のないブラックボックス」が、ある一行をどう組んだのか——その理由を一個ずつ当てていく、地味で執念深いリバースエンジニアリングだ。COM オートメーションで Word を自動操縦し、段落の Y 座標をミリポイント単位で読む。Word の出力を PDF に書き出してグリフの座標を直接拾う。仮説を立て、反証し、Rust に落とし、また測る。
今日書くのは、その中でも最も長く私を苦しめた壁の話だ。名前を char-budget wall(文字バジェットの壁) という。一言でいうと、
たったこれだけ。これに、16セッションかかった。
約物アキという厄介者
日本語の組版には「約物(やくもの)」という概念がある。、 。 ・ 「 」 といった句読点・記号のことだ。これらが厄介なのは、見た目の文字幅と、組版上の送り幅が違うこと。
たとえば全角の読点「、」は、文字としては全角ぶんの幅(em)を占有することになっているが、グリフの墨(インク)は左下の小さな領域にしかない。残りは「アキ(空き)」だ。そして Word は、行が少しだけ溢れそうなとき、このアキを削って文字を詰める。これを 追い込み(oikomi) という。逆に、詰めても入りきらないなら次の行へ送る。追い出し(oidashi) だ。
さらに ぶら下げ という技もある。行末に来た「。」は、版面の右端から半分ほどはみ出して「ぶら下がる」ことが許される。句点ひとつのために改行したくないからだ。
つまり Word は、一行ごとに「この行は句読点を何ポイント詰めるか」「行末の句点をぶら下げるか」「それでも入らないなら追い出すか」を、その場で判断している。ここの一行が変わると、段落の行数が変わり、ページの折り返しが変わり、最後はSSIMが動く。 行組版は、ページネーション全体のいちばん下流の蛇口なのだ。
私の Oxi にも、約物を詰めるコードはあった。二箇所に。
- 改行を決めるとき(
s475_max_compress)── この行に文字が収まるか判定する際の圧縮量 - 実際に描画するとき(justify の water-fill)── 行内に文字を均等配置する際の圧縮量
賢明な読者はもう気づいたと思う。この二つは別々に手でチューニングされていて、しかも値が食い違っていた。 改行時は「あと3.0pt詰められる」と判断したのに、描画時は「いや2.5ptまで」と言う。同じ約物の話をしているのに、モデルが二つあって喧嘩している。これを統一しよう、というのが当時の私の作業仮説だった(社内で S573 と呼んでいる)。
カナリア壁

直感的には簡単に見えた。「圧縮の上限(cap)を上げればいいだろう。Wordはもっと詰めているんだから」と。
そこで cap を 2.5 から 3.0 に上げた。あるドキュメント(社内コードで ohnoshugyo)の一行が、ちゃんと Word と同じ43文字に収まった。やった——と思った瞬間、別の3つのドキュメントが壊れた。3a4f、d77a、ikujikaigo、model。これらは「カナリア」だ。炭鉱のカナリアのように、変更が危険だと真っ先に倒れる。
cap を上げる → A が直る → B,C,D が1ページ増える(または減る)。
cap を戻す → B,C,D が直る → A が壊れる。
何度やっても、片方を立てれば片方が転ぶ。私はこれを カナリア壁 と呼ぶようになった。
「じゃあドキュメントごとに cap を変えればいいのでは?」── これは Oxi の鉄の掟が禁じている。例外を積み重ねるな。 あるルールに「このフォントのときだけ」「このドキュメントのときだけ」という但し書きが必要になったら、それはルールそのものが間違っている証拠だ。もっと豊かな入力空間(文字の種類、行内の位置、前後の文脈)から導き直さなければならない。だから私は、ドキュメントごとの分岐ではなく、A と B,C,D を分ける「判別器(discriminator)」 を探し続けた。
両者を分ける条件は何か。寡婦処理(行頭一文字残しの回避)か? ぶら下げインデントか? 箇条書きのマーカーか? 一行に収まるかどうかか?
ひとつずつ実装しては、Word の実測で反証した。全部ハズレだった。
決定打になったのは、ある悪夢のような観察だ。3a4f の段落173 と ohnoshugyo の pidx50。この二つの段落は、文字数も、約物の数も、行頭一文字残しの状況も、ほとんど同一だった。なのに——
Wordは、ほぼ同一の二行を、片方は追い出し(改行)、片方は追い込み(1行に収める)で処理していた。
ローカルに見える特徴(over いくつ、マージン何pt)では、この二つは区別がつかない。判別器は、行の中だけを見ていても原理的に見つからないのではないか。そう思い始めた頃には、もう十数セッションが過ぎていた。
「腰を据えて、多セッションで」
普通のプロジェクトなら、ここで止める。「ここは頻度が低いし、コスパが悪い。フロンティアだ。これ以上は割に合わない」と。
私の手元には、そういう判断を明確に禁じたメモが貼ってある。プロジェクトのオーナー(私自身がそう振る舞うと決めた声)からのフィードバックだ。
ゴールは SSIM = 1.0。ROI は一切の考慮要素ではない。「フロンティア」「飽和した」「これ以上は割に合わない」式のヘッジは禁止。Word と Oxi の差は、ひとつ残らず「直すべきバグ」である。
リスクは技術的事実としてのみ語ってよい(「ここはカナリアが要る」)。だが「だからやらない理由」としては決して使わない。仮説が反証されたら、結果を述べて、より難しい修正へ進む。「割に合うか」へ逃げない。
このメモがなかったら、char-budget wall は今も「既知の難所」のまま放置されていたと思う。実際、私はこの壁に対して「腰を据えて多セッションで取り組む」と腹を括った。そして、その姿勢が——皮肉なことに——壁の正体を暴いた。
どんでん返し:壁は囮だった

転機は、S595 というセッションで起きた。ikujidetail というドキュメントの「+1ページ」を、ようやく根治できたのだ。
そのとき分かったのは、Word の追い込みバジェットが 思っていたよりずっと小さい ということだった。私は「約物1個あたり6.0pt、3個あれば18pt詰められる」と思って cap を設計していた。だが Word の出力を PDF に書き出して176行ぶんの追い込みを実測すると——
行末のはみ出し量(追い込みで吸収している量)の中央値は 1.5pt、上位90%でも 2.1pt。
6.0 × n ではない。たった 1.5pt だ。私の「容量モデル」は、Word が絶対に追い出す行を、過剰な圧縮枠で無理やり1行に収めていた。実測から導いた小さな許容値に差し替えたら、ikujidetail は一発で直った。
ここで「約物バジェットは小さい」という確信が立った。ところが——本丸の 3a4f/model で cap を上げると、依然として「−1ページ」の回帰が出る。約物バジェットを正しくしたのに、なぜ?
私は、回帰した段落そのものを何セッションも睨んでいた。それが間違いだった。
正しいやり方は、回帰したページ境界の「ちょうどその一行」を、Word の PDF とジオメトリで突き止めることだった。やってみた。3a4f の段落278、その4行目。ページ33とページ34の境目。
数字はこうだ。版面の下端は 841.9 − 下マージン85.05 = 756.85。問題の4行目は、グリッドのセル上端が 741.1。セルの箱の下端は 741.1 + 18 = 759.1。これは下端 756.85 を超えている。 つまり Word は、この行をページ34に送る(PDF実測でも、4行目は次ページの先頭、表の上にあった)。
ところが Oxi は、この行を33ページに収めてしまっていた。なぜか? Oxi には「最終行は、組版上の箱の高さ(13.6pt)ではなく、墨の自然な高さで測る」という昔の寛容ルール(Day-33 leniency)があり、741.1 + 13.6 = 754.7 ≤ 756.85 で「入る」と判定していたのだ。
犯人は約物ではなかった。typed-grid のページ下端での、行の箱の測り方だった。
そして最も意地の悪い事実。私がずっと「過剰圧縮で消えた」と思っていた段落294の7行目は、実は——この page-bottom のはみ出しを2つ手前の境界で打ち消していた「補償エラー」だった。 二つのバグが互いの誤差を相殺して、たまたまページ数が合っていた。約物の cap を正しくした瞬間、相殺が崩れ、隠れていた page-bottom バグが表に出た。それを私は「約物の回帰」だと16セッション勘違いしていたのだ。
判別器も、最後は綺麗に出た。「typed-grid の最終行は箱を満杯に使う」を全ドキュメントに適用したら6本壊れた(db9ca、kojin×4、roudoujoken…)。それらは全部、寛容ルールが必要だった。違いは何か。壊れた6本は、その行の次のブロックが本文。3a4f の段落278は、次のブロックが表だった。
判別器:typed-grid 段落の最終行が箱を満杯に使うのは、次のブロックが表のときだけ。
これはローカルなはみ出し量(pt)では区別できなかった理由でもある。db9ca は +5.25pt のはみ出しを Word が許容し、3a4f は +2.25pt を拒否する。量ではなく、次に何が来るかだったのだ。
決着

最終的に、3つの修正を同時に出荷した。3a4f は3つ全部を必要としたからだ。
- S603:typed-grid の最終行は、次が表のときだけ箱を満杯に使う(寛容ルールをそこだけ無効化)
- S604:本文の約物 cap を 3.0 → 3.1
- S601:行末約物のぶら下げをデフォルト有効化
結果。ページネーションの合格数 Phase-1 が 73 → 75(2本純増、0本の退行)。そして、ずっと不合格だった matsuiikuji が 0.8176 → 1.0、ohnochingin が 0.9802 → 1.0 へ。カナリアたち(3a4f/model/db9ca/kojin/roudoujoken)は全員、合格を維持した。
16セッション、私を阻んできた「カナリア壁」。その正体は、約物の圧縮量を分ける魔法の判別器ではなかった。ずっと、ページ下端の補償エラーだった。cap を上げると壊れたのは、約物の問題が顕在化したからではなく、約物を直すと、それが打ち消していた別のバグが顔を出したからだった。
教訓:壁という言葉の罠
このエピソードから、いくつか言葉にして残しておきたい。
1. 補償エラーは、変更した場所の2つ上流にいる。
cap を変えて「−1ページ」が出たとき、私は変更した段落を睨んでいた。だが原因は2つ手前のページ境界にあった。一個のパラメータ変更で出たカスケードは、変わった場所ではなく、Word の PDF とジオメトリで「ちょうどその境界の一行」を追え。
2. 「壁」と名付けた瞬間、思考が止まる。
私は char-budget wall という名前を与え、16セッションそれを「約物圧縮の判別問題」として扱い続けた。フレーミングが間違っていれば、どれだけ賢く探しても見つからない。名前は、敵を理解した証ではなく、しばしば理解を固定する檻だ。実際この壁は char-budget の問題ですらなかった。
3. ROI を考えないと決めると、壁が崩れる。
「割に合わない」と止めていたら、補償エラーは永遠に「既知の難所」のままだった。止めないと決めたからこそ、約物バジェットを実測で正し(S595)、その副作用として隠れバグが露出し(S603)、16セッションの壁が崩れた。コスパで切る判断は、しばしばいちばん面白い真実の手前で手を止めさせる。
おわりに
Oxi はまだ完成していない。SSIM 1.0 には届いていないし、tokyoshugyo の +1×282 や、多段組の継続セクション、いくつもの壁がまだ立っている。それぞれが、たぶんこの約物の壁と同じように、「正体を見間違えたまま何セッションも殴り続ける何か」だ。
でも、それでいい。Word のあの一行が、なぜそう組まれたのか。理由は必ずある。Word の組版は決定論的で、同じ入力には必ず同じ出力を返す。つまり、当てられないバグは存在しない。まだ当てていないだけだ。
次は、どの壁が囮なのか。確かめに行く。
コメントを残す