クローズドなレイアウトエンジンを再現するには、それを測るしかありません。Microsoft Word には都合よく二つの窓があります。COM は構造のオラクル、PDF に書き出した結果は最終描画のオラクルになる。ずっとこの二つで測ってきました。
ところが、この PDF が系統的に嘘をつくことが分かりました。w:sz w:val="21"(=10.5pt)と書いた文書を Word が PDF に出すと、スパンのサイズは 10.56 で返ってきます。0.57% 太い。そしてこれは、五つある罠の一つ目でしかありませんでした。
セル幅を 1 twip 刻みで振った 14 腕 × 502 セル = 7028 サンプルの掃引で、五つが同時に姿を現しました。どれも「もっともらしく間違った表」を作れるものばかりです。順に並べます。
測り方
掃引用の docx をプログラムで生成し、Word COM の ExportAsFixedFormat(path, 17) で PDF にし、PyMuPDF の rawdict から文字ごとの bbox を読みます。送りは必ず隣接文字の原点差で取ります。
adv = [chars[i+1]["bbox"][0] - chars[i]["bbox"][0]
for i in range(len(chars)-1)]
行 bbox の x1 - x0 を字数で割ってはいけません。最後のグリフのインクは送り箱の外へはみ出すので、それは送りではないからです。これは前提であって、罠のうちには数えていません。
罠1:フォントサイズは 1/600 インチにスナップされる
10.5pt と書いた文字が 10.56 で返ってくる。8pt は 8.04、10pt は 9.96。規則はこれです。
size_pdf = round(pt × 600 / 72) × 72 / 600
10.5pt → round(87.50) = 88 → 88 × 72/600 = 10.56
8.0pt → round(66.67) = 67 → 67 × 72/600 = 8.04
10.0pt → round(83.33) = 83 → 83 × 72/600 = 9.96
600 dpi はプリンタの解像度です。Word はフォントサイズをデバイス単位へ丸めてから書き出しています。
これが厄介なのは、比では消えるところです。送りを span["size"] で割って em 単位で扱っている限り影響はゼロで、フォントメトリクスの導出はこの罠を踏まずに何年でも進みます。絶対値を pt で読んだときにだけ、すべての送りが 0.57% 太る。
教訓:送りは常に span size で割る。PDF から絶対 pt を持ち出さない。
罠2:折り返しに使う幅は、描いている幅ではない
では 10.56 が「Word の実際の送り」なのかというと、そうでもありません。ここで次に詰みます。
セル幅を 1 twip ずつ振った 502 個の一列表を作り、どのセルにも同じ全角ランを左揃えで入れます。左揃えなので Word は行を伸ばしません。1 行目の字数が k から k+1 に変わる幅が、そのセルの折り返し予算を twip 単位で刺してくれます。
k 字入る条件を k·a + C ≤ w < (k+1)·a + C と書けば、502 個のセルが 502 組の不等式をくれます。送り a と定数 C は、点ではなく領域として解けます。
答えは a ∈ [10.495, 10.505]。指定どおりの 10.500 でした。そして描画上の 10.560 は、14 腕すべてで空区間として棄却されました。どんな C を選んでも 502 個の不等式を同時には満たせません。
Word は公称サイズで折り、スナップ後のサイズで描いています。折る側と描く側が別の数を使っている。
教訓:描画上の送りから折り返し予算を導かない。
罠3:掃引窓が一つだと、1 字の幅と定数が分離しない
最初は 0.5pt 格子で、一本の窓だけを振っていました。遷移は 53.00 / 63.50 / 74.00 / 84.50 / 95.00 / 105.50 / 116.00pt に出て、差はすべてきっかり 10.50。C = w* − (k+1)×10.50 は 7 本すべてで 11.00 に揃いました。完璧に見えました。
ところが同じ 7 本は「10.56/字 + 定数 10.3」でも説明がつきます。0.5pt 格子の上では、両者は 7 本の範囲でまったく同じ折り位置を予測するからです。1 字あたり 0.06 の差が積もって格子をまたぐには、もっと長いベースラインが要ります。
そこで窓を二つ、離して置きました(50–62.5pt と 112.5–125pt)。6 字ぶん離れた二つの遷移が、0.06 の差を 0.36 に育てて分離してくれます。
教訓:係数と定数を同時に推定するなら、遠く離れた二点を取る。一窓での「完璧な一致」は、正しさの証拠ではなく縮退の証拠かもしれません。
罠4:PDF テキスト抽出器は空白グリフを捏造する
PyMuPDF(MuPDF)は、隣り合うグリフの隙間が十分に広いと、そこに空白文字を挿入して返します。CJK と欧字の境(Word が入れる和欧間アキ)は、それだけで閾値を超えます。
字間設定を 8 段階に振った同じ probe で、w:spacing が 0 と −10 の行にだけ ' ' が入りました。残り 6 行には入りません。文字位置で索いていた解析表は、その 2 行だけ列が一つずれ、「4 の送り」の欄に和欧間アキの値を出しました。値は 2.100 や 7.083 のようにもっともらしく、しかも行ごとに違うので、ノイズにしか見えません。
教訓:文字単位の解析は、位置ではなく文字で索く。ch.index("あ") であって adv[4] ではない。
罠5:Word は整数 twip で計算する — 浮動小数点では「ちょうど」が入らない
セル幅 52.8pt、左右の内側余白が各 5.4pt。内寸は 42.00pt で、10.5pt の全角 4 字がちょうど収まります。Word は整数 twip で計算するので、収まります。
1056 twip − 108 − 108 = 840 = 4 × 210 ちょうど
f32 の pt では収まりません。
52.8f32 − 5.4f32 − 5.4f32 = 41.99999904
4 字目に必要な 42.00 に 1 ulp 足りず、最後の一字が次の行へ落ちます。
これは丸め誤差の小話ではありません。和文のフォーム表は列幅を「N 字ちょうど」で決めるので、ちょうど収まる場合が例外ではなく常態です。7028 セルの掃引で予測を外したのは 3 セルだけで、その 3 つはすべてこのケースでした。予算を twip 格子へ丸めた瞬間に 7028/7028 になりました。
教訓:比較の前に予算を twip へ丸める。Word の算術は整数です。
まとめ
| 素直に読むと | 実測 | |
|---|---|---|
| 10.5pt のスパンサイズ | 10.50 | 10.56(1/600 インチ丸め) |
| 折り返しに使う送り | 描画と同じ 10.56 | 10.500(公称 em) |
| 一窓の掃引 | 係数も定数も決まる | 縮退していて決まらない |
| 抽出器の返す文字列 | 文書の文字そのもの | 空白が捏造されうる |
| ちょうど収まる幅 | 収まる | f32 では 1 ulp 足りない |
五つのうち四つは、それ単独では小さな誤差にしか見えません。厄介なのは、これらが互いに打ち消し合うことです。0.57% 太い送りと、少し広すぎる予算は、同じ位置で折れてしまう。だから片方だけを直すと、結果はかえって悪化します。
掃引して領域として解く、というのは、要するに「打ち消し合っている二つを同時に見る」ための手続きでした。一点で合わせにいくと、合った瞬間に何が起きているのか分からなくなります。
計測環境:Windows 11 / Microsoft Word / PyMuPDF。掃引は 14 腕 × 502 セル、幅刻み 1 twip、合計 7028 サンプル。

コメントを残す