要約
Oxi は、Microsoft Word / Excel / PowerPoint / PDF をブラウザ上で サーバーレスに パース・レンダリング・編集する OSS スイートを Rust + WebAssembly で書いている個人プロジェクトだ。「LibreOffice はレイアウトを壊す、Google Docs はサーバーが要る、その間に空席がある」という素直な問題意識から始めて、現在は実 .docx 235 ドキュメント・410 ページに対し Word との pixel-level SSIM 平均 0.8932 まで到達している。本記事は、そこに至るまでに踏んだ方法論上の罠と、技術的に面白かった発見をエンジニア向けに記録するためのものだ。
- DLL 逆アセンブルなしで Word 互換を作るとはどういうことか (clean-room + COM API 黒箱測定)
- 「SSIM 単一ゲート」が破綻し Phase-based 方法論に再設計した経緯
- GDI の整数ピクセル丸めが 60 文字で 10.8pt 累積する、という古典的な罠
- “全て測れる” というプロジェクト初期の axiom が経験的に falsify された (R30, R33 事件)
なぜ “ブラウザで Word” なのか
2026 年現在、EU 公共セクターの脱 Microsoft 365 が 制度として 動き出している。フランス DINUM 指令 (2026-04-08) は 250 万台の公共 PC を 2027 年までに自由ソフトウェアスタックに移行することを義務化、スイス連邦官房は 2026-04-18 に Microsoft 365 依存削減を公式表明、ドイツの ZenDiS OpenDesk は Schleswig-Holstein / Thüringen / Baden-Württemberg、そして米国制裁で MS アクセスを止められた国際刑事裁判所で既に本番稼働している。
これら全ての移行に共通する 欠けているピース は同じだ:「既存の .docx を Word と 見分けがつかないように 開けるレンダリングエンジン」。これが無いと、移行は「アプリ切り替え」ではなく「全文書の見え方監査」になり、組織は人員を充てられない。LibreOffice が 20 年公共セクター展開で苦戦している理由は、機能が弱いからではなく、per-document の視覚差分がプロジェクト化を強制する からだ。
Oxi のゴールは「より良い移行ツール」ではなく 移行問題そのものの溶解 で、その手段が SSIM = 1.0 への機械的収束ループ (内部では Ra と呼んでいる) だ。
アーキテクチャ — なぜ Rust + WASM か
crates/ oxi-common/ Shared OOXML utilities (ZIP, XML, relationships) oxidocs-core/ .docx engine — parser, IR, layout, font metrics, editor oxicells-core/ .xlsx engine — parser, IR, editor oxislides-core/ .pptx engine — parser, IR, editor oxipdf-core/ PDF 1.7 engine — parser, text extraction, generator oxihanko/ Japanese digital stamp (hanko) generator + PAdES signer oxi-wasm/ WebAssembly bindings (wasm-bindgen) web/ Web demo (vanilla JS + Canvas)
IR (Intermediate Representation) は意図的に format-agnostic に設計してある:
Document → Page → Block (Paragraph | Table | Image) → Run
LibreOffice は ODF をネイティブ、OOXML を import として扱うから round-trip で OOXML が壊れる。Word は逆の理由で ODF を壊す。Oxi は最初から「どちらの format も IR を所有しない」設計にしてあり、これは v2 で計画している .odt 並列対応の前提でもある。
WASM を選んだ実利的な理由:
- サーバー費用ゼロ: 全処理が client-side。SaaS でなく成立する
- プライバシー: 文書がデバイスから出ない。法務 / 医療 / 政府文書で本質的
- バイナリサイズ: Suite 全体で約 1.4 MB の .wasm
- メモリ安全: Word ドキュメントは敵対的入力になりうる (公官庁の zip-bomb 級 .xlsx 等)。Rust の safety は実利
“100% Clean-Room” — DLL を読まずに互換を作る方法
Oxi の仕様は 2 つのソースだけ から導出している:
- 公開標準 — OOXML (ISO/IEC 29500 / ECMA-376), PDF (ISO 32000)
- 黒箱測定 — Microsoft Office COM API で出力値を直接観測
典型的な測定スクリプトはこんな形 (Word VBA / pywin32 で COM 経由):
y1 = doc.Paragraphs(1).Range.Information(6) # wdVerticalPositionRelativeToPage y2 = doc.Paragraphs(2).Range.Information(6) gap = y2 - y1 # = line_height + spacing (twips 単位で)
大事なのは 「Format.LineSpacing は使うな」 という点。これは「設定値」を返すだけで、Word が実際にレンダリングした line height ではない。Word はフォントメトリクス・docGrid・table cell 文脈などを合成した結果として「実 line height」を決めており、設定値とは一致しない。実描画位置の差分 から逆算するのが正しい方法だ。
Microsoft の Open Specification Promise により OOXML 実装に対する特許不行使が宣言されている。DLL を触らずに COM の出力だけを見るアプローチは、法的にも技術的にも clean-room を維持できる。
面白かった技術的発見
1. GDI の整数ピクセル丸めは 60 文字で 10.8pt 累積する
Word のテキストエンジンは GDI ベースで、文字幅を整数ピクセルに丸め、line height は ascent と descent を 別々に丸めてから加算する。Calibri 11pt で 1 文字あたり 0.18pt の差が、60 文字で 10.8pt の累積誤差になる。これは「行が折り返す位置」「ページが切れる位置」を変える程度の大きさだ。
つまり Word 互換レンダラを作るには、現代的な float メトリクスで真面目に組版した結果を返すのは 不正解 で、GDI と同じ整数丸めをそのまま再現する必要がある。Oxi は .docx 用に GDI engine、.odt と PDF 出力用に DirectWrite engine の dual font engine を持ち、同じ FontEngine trait で差し替えている。
FontEngine trait ├── GdiEngine — Word-compatible (integer px rounding) └── DWriteEngine — Cross-platform (floating-point precision)
2. Word は文字幅を 10 twips (0.5pt) 単位に丸める
OOXML 仕様には書いていないが、COM 測定で確認した経験則: Word は文字幅を計算したあと 10 twips (= 0.5pt) 単位に丸めている。13 のフォント / サイズ組合せ・181 文字で確認済み。式は:
width_twips = round(advance × fontSize × 20 / UPM) width_10tw = round(width_twips / 10) × 10
これを実装した瞬間に net +0.041 SSIM (mid-paragraph page break) や net +0.66 (table cell line_height reset) のような 段差的改善 が連鎖して、3 週間で 0.7884 → 0.8584 に上がった。
3. 複数行間隔は CEIL で累積する
これも仕様には無い。MS Mincho 10.5pt × 1.15 倍を line spacing に指定したとき、計算上は 310.5 twips になるが Word は 切り上げで 320 twips (16.0pt) として扱う。ROUND ではなく CEIL。さらに複数行にわたって累積する位置計算も CEIL を継承する。これも 8/9 位置で COM 確認した上で実装した。
4. is_fullwidth は OOXML が言うより広い
Unicode の East_Asian_Width=F/W だけ拾うと不足で、Word は実際には Arrows / Math Operators / Letterlike Symbols 等 7 つの追加ブロックを fullwidth 扱いする。これを直さないと「→」が西欧字幅で扱われて隣の CJK 文字に 重なる という症状が出る。Oxi は判定テーブルに 7 ブロックを追加して直した。
5. Information(6) は段落開始 Y を返さない
これは方法論側の罠で、後述の R30 事件のコア。doc.Paragraphs(N).Range.Information(6) は段落の active end 位置 を返す。複数ページにまたがる段落だと「次のページの Y」が返ってきて、per-paragraph page index が 1 ページずれる。正解は doc.Range(rng.Start, rng.Start).Information(N) で範囲を 0 幅に collapse してから問い合わせること。
プロジェクト初期の axiom が経験で falsify された話
Oxi の Ra loop は当初「No Excuses by Design」という強い前提で設計されていた:
- レイアウト差分に正当な excuse は存在しない
- 全ての値は COM API で測定できる
- 仕様空間は有限、測定結果は永続資産になる
- 1 つの仕様修正は複数文書を同時に改善する (収束的構造)
- 「できない」のではなく「まだやっていないだけ」
これは Session 38-45 (約 1 ヶ月) で 3 つが経験的に偽だと判った。
R21 plateau — bottom-5 ゲートが動かなくなる
初期の merge gate は「文書ごとの SSIM 下位 5 文書の合計が単調増加すること」だった。これは合理的に見えるが、bottom-5 の各文書がそれぞれ別の構造的問題 (table charGrid、textbox 内 wrap、vertical writing、…) を抱えていて、マルチ週の refactor が無いと 1 文書も動かない。結果として gate がブロックし、PR が積めない期間が続いた。
R30 — 測定 API そのものにバグがあった
前述の Information(6) 問題。COM API は「全て測れる」前提だったが、API そのものが multi-page 段落に対して active end を返す挙動だったため、月単位の測定データに 1 ページ ずれが混入していた。「測定は永続資産」も 測定方法ごと再検査される 必要があることが判った。
R33 — 「minimal-case 仕様正しい修正」が 41 ページ regress
仕様を minimal repro で確認して仕様通りに直したのに、本番 baseline で 41 ページが 劣化 した。原因は「仕様が単一 context (例: 単独段落) でしか derive されていなかった」こと。実際には Word は font-cascade × szCs × per-context wrap rule の 合成 でレンダリングしており、minimal-case の正解が他 context の不正解になる。
この教訓から、Oxi の Critical Rules に追加されたのが「EXCEPTION stacking 禁止」: 仕様に per-doc / per-font の carve-out が必要になったら、その仕様は 不完全 ではなく 間違っている ので、richer input space から re-derive する。例外を積むと R33 が再生産される。
Phase-based gate への再設計
2026-04-28 に方法論を再設計した。SSIM = 1.0 は 結果指標 (outcome) であって 信号 (signal) ではない、と認めた。結果指標を直接 gate にすると上記 R21 のように動けなくなる。代わりに cause-attributable な信号 を段階的に積む:
| Phase | Primary gate | Why this signal |
|---|---|---|
| 1 (現在) | Pagination correctness — Word の段落 N が Oxi で同じ page N に乗るか | ページ割れ 1 個で 47 ページ低 SSIM になる cascade を root から切る |
| 2 | Element IoU mean ≥ 0.99 — 要素 bbox の IoU | 位置 (≠ pixel) の精度。pagination 後の唯一の構造誤差 |
| 3 | SSIM mean ≥ 0.99 + bottom-N floor | 位置が合った後、pixel diff が残る = font hinting / sub-pixel |
SSIM は 全 phase で常に追跡 するが、gate になるのは Phase 3。Phase 1 中は SSIM の > 0.005 劣化 だけ regression sentinel として効かせる。これで「root の page-break バグを直す = 47 ページが同時に SSIM 動く」のような複合的改善が gate を通せるようになる。
進捗を見ると、再設計後 5 週間で:
- Phase 1 pass_rate: 開始時 25/55 → 46/55 (83.6%)
- SSIM mean: 0.8699 → 0.8932 (+0.0233)
- 個別 PR は「pagination が動いた」「IoU が増えた」で merge 可能、SSIM が必ずしも動かなくても良い
この期間に Word の vMerge セル高さ計算・fixed table 幅保持・widow control 継承・grid-snapped 行が下マージンに食い込む挙動など、SSIM gate なら絶対に着手できなかった root cause を 1〜2 行で 直せた PR が連続している。
verify pipeline で踏んだ罠 (stale binary 事件)
2026-04-26 と 2026-05-07 に同じパターンの事故を起こした。Oxi は 3 つの render path を持っている:
- WASM (
crates/oxi-wasm/pkg/) — browser editor 用 - Native GDI renderer (
tools/oxi-gdi-renderer/) — Phase 1 gate (pagination 測定) 用 - Native DWrite renderer (
tools/oxi-dwrite-renderer/) — pipeline.verify の default
cargo build を gdi-renderer dir で走らせても dwrite-renderer は 再ビルドされない。これに気付かず古い DWrite binary で verify を回し、pre-patch baseline と pre-patch output を比較して「0 改善 / 0 劣化」という偽陽性レポートを出した。実際の差分は -0.0911 で、後で revert する羽目になった。
教訓は単純で、CLAUDE.md にハードコード:
- verify の前は 両方の renderer を rebuild、cache (oxi_png/, pagination_oxi/) を 消す
- WASM rebuild だけでは不十分 (verify pipeline は WASM を使わない)
- “0 改善” は疑え。stale binary は同じ値を返すから 0 になる