python-docx で書式を崩さず Word を自動編集する — 1 byte も変えないための 6 つの設計
はじめに

書類は「一字一句、 書式を変えてはいけない」 という制約を持つものがあります。 フォントの揺れ、 改行の差、 蛍光ペンの範囲、 赤字の位置 — そのどれもがレビュアーの心証に直結します。
そんな書類が 数十ページ・百数十項目 あり、 AI が生成した修文案を 複数の docx から 1 本の master docx に転記する案件をやりました。 手作業なら 1〜2 週間、 ミス頻発、 神経すり減る系のタスクです。
これを Python で完全自動化し、 完成形 docx との XML レベル一致度 99.9% に到達するまでの試行錯誤を共有します。

課題: 「書式を絶対に変えない」 とは
対象は 2 列の表が連なる定型フォーマット。 左右のセルが対になって並びます。
入力
- master.docx: 上記フォーマットの文書 (空欄の項目テンプレートあり)
- 修文案 docx 群: 項目ごとに「【変更前 (Original)】 【変更後 (Refined)】」 形式の AI 提案
- 手作業の完成形 (もしあれば): 検証用の答え合わせ docx(手作業)
出力に求められる条件
- 各項目のタイトルと本文だけを書換、 それ以外は 1 byte たりとも変えない
- 元の状態を Word コメント機能で残す (元状態の証跡保存)
- 表セル内のフォーマット (赤字、 蛍光ペン、 フォント、 段落構造) を完全保持
- 開いたときに Word が「ファイルが壊れている可能性があります」 と出さない
以下、 ハマった順に紹介します。
失敗 1: python-docx で .save() した
最初は素直に python-docx を使い、 Document(path).save(path) で書き戻しました。
結果: 何も編集していないのに開くたびに微妙に書式が変わる。
python-docx 内部は lxml で OOXML をパース → 再シリアライズします。 この過程で:
- run の rPr 属性順序が変わる
- 空白文字の正規化が起きる
<w:t>のxml:space="preserve"が落ちる
SHA-256 比較 で差分を取ると一目瞭然でした。 .save() 禁止令を出しました。

失敗 2: lxml で段落を物理削除した
修文案で本文を書き換えるとき、 元の段落を <w:p> ごと XML 削除しました。
parent.remove(p) # 段落削除
結果: pack して保存後、 Word で開くと 「ファイルが壊れている可能性があります」 ダイアログが出る。 修復モードで開くと開けるけど、 何故か段落が再生成されてしまう。
原因: Word 2013+ には paraId / textId / rsidR という段落 ID が紐づいており、 これが commentRangeStart/End や w14:paraId 補助 XML と相互参照しています。 lxml で段落を消すとこの整合性が壊れます。
paraId を全段落で統一値にする、 削除した paraId を補助 XML からも消す、 など試しましたが どれも破損回避できず。

解決: lxml + Word COM ハイブリッド
行き着いた解は、 削除作業だけ Word 自身にやらせる方式です。
1. lxml で OOXML を直接編集
- 段落は削除せず、 「@@DEL@@」 マーカー文字列を埋め込む
- タイトル本文だけ書換 (rPr は完全保持)
- Word コメント (元状態の deepcopy) を追加
2. ZIP に pack して intermediate.docx 生成
3. pywin32 で Word を起動し、 docx を開く
4. Word COM で 「@@DEL@@」 を含む段落を検索 → Range.Delete()
- Word が paraId 整合性を自動管理 → 破損ゼロ
5. 自動破損チェック (OpenAndRepair=False で開けるか)
Word は段落削除のとき paraId 等の参照を全て自動で整理してくれる。 これが lxml ではどうしても再現できない部分でした。
設計思想 6 つ
1. タイトル本文だけを書換 (rPr 保持)
ナイーブに「全 <w:t> を最初の run に集約 → 新テキストを入れる」 という実装にすると、 タイトル末尾の補足部分の 赤字 (FF0000) が消えます。 1 件で気づかなかったのが、 多数の赤字が失われていて愕然としました。
正しいやり方:
- 段落を「・」 から 項目番号の閉じカッコ までの タイトル本文範囲 だけ特定
- その範囲の run のテキストのみ書換、 rPr はそのまま
- 末尾の補足 run (指標表記、 部署名等) には触らない → 赤字を完全保持
2. 三層マッチング (本文類似度を主スコアに)
修文案の各 item を master の対応見出しに割り当てるとき、 タイトル類似度だけだと swap が起きます。 例えば共通単語を含む 2 つの見出しがあると、 タイトル sim だけで取り違える。
主スコア: refined.before_text ↔ master.body の類似度
副スコア: refined.title_clean ↔ master.heading_text × 0.1
before_text は master 本文の verbatim quote なので類似度は 1.0 近く出ます。 タイトル単独の誤判定を回避できました。
3. 項目番号の文脈継承
master の見出しが「・XXX(番号)【記号】補足」 という形式に揃っていれば話は早いのですが、 実際は:
- 表のセルで「(1)-2 セクション名(番号)」 と書かれた section header だけ番号表記がある
- その下に並ぶ「・XXX」 形式の見出しには番号が省略されている
これらは 直前で出現した番号表記を継承 します。 段落を逆順走査して直近の番号を引き継ぐロジックを入れたところ、 7 件分追加で正しく所属チャンクに紐付きました。
4. 再掲の自動検出 + 短縮
別チャンクで同じ内容が再掲される項目があります。 完成形では本文を 「(番号 の再掲)」 の 1 行に短縮 していました。
検出パターン:
- リテラル
(再掲)(再掲) - サフィックス
※番号 の再掲 - 重複タイトル (同じテキストの見出しが複数チャンクに出現)
- 本文先頭 60 文字一致
最後の 2 つが地味に効きました。
5. 蛍光ペン範囲の正規化
完成形は青蛍光ペンを 項目番号の閉じカッコまで で止め、 それ以降の補足部分には蛍光ペンを付けていません。 master は run 全体に蛍光ペンが付いている場合があるので、 出力時に boundary で run を分割して正規化しました。 結果、 大半が完璧パターン。
6. 元状態は Word コメントに rPr ごと deepcopy
各見出しの「変更前」 状態は Word コメントに保存します。 単に文字列をコピーするだけだと書式情報が失われるので、 <w:p> を rPr 含めて copy.deepcopy() してコメント XML に直接配置しました。
注意点: コメント機能を有効化するには [Content_Types].xml と word/_rels/document.xml.rels に Override / Relationship を 追加のみ (既存行は不変)。 補助 XML (commentsExtended/Ids/Extensible/people.xml) は document.xml と 同じ全 namespace + mc:Ignorable を宣言する必要があります (Word 2013+ で省略すると破損)。
詰み回避ティップス
表セル末尾の段落は Word COM でも完全削除できない
p.Range.Delete() は段落のテキストを消しますが 段落マーク (改行) は残します。 結果として「テキスト空の段落 = 空段落」 が残る。 表セル境界で特に顕著。
Range.MoveEnd(wdCharacter, +1) で段落マークまで含めて削除しようとすると、 隣接段落のマークまで巻き込んで過剰削除になりました。 lxml で直接削除も試しましたが、 中間セパレータも巻き込んで失敗。
結論: 仕様として受容、 +1〜2 段落の差は構造的限界。 完成形と比較して 99% 以上一致できれば実用上問題なし。
master_unpacked は build 前に毎回ゼロから再 unpack
unpack 済みディレクトリに前回の編集結果が残っていると、 次回の見出し位置特定が refined 側のタイトルを拾ってしまい混線します。 必ず再 unpack する。
チャンクの type (定量/定性) は元データの参照表から判定する
見出しテキストから「指標記号 X だから定量か?」 などと推定すると 必ず一部誤判定 します。 type ごとに記号と指標の種別ごとの対応が異なる場合がある。 元データを真とする。
全/半角の閉じカッコ両対応
「()」 (全角) と「()」 (半角) が混在しているケースがありました。 正規表現は [((]? [))] で両対応にしておく。
Word COM 失敗時はゾンビプロセスを掃除
DispatchEx で別プロセスとして起動するのが安全。 失敗時は taskkill /F /IM WINWORD.EXE で WINWORD.EXE を清掃。 master を直接編集すると壊れたときの被害が大きいので、 必ず一時コピーしてから開く。

結果
完成形 (target) との比較:
| 指標 | 値 |
|---|---|
| 段落差 | -1 / 1000+ |
| 全体テキスト類似度 | 0.999 |
| 完全一致した項目 | 多数 (text_sim 1.000) |
| 赤字 run 数 | target と完全一致 |
| Word での破損 | なし |
別件の文書 (項目数 100+) でも:
| 指標 | 値 |
|---|---|
| 段落差 | -4 / 2000+ |
| 全体テキスト類似度 | 0.991 |
| コメント数 | 完全一致 ✓ |
| 赤字 run 数 | 完全一致 ✓ |
手作業 1〜2 週間が 数分 に短縮されました。
学んだメタ教訓
- 「正規表現 1 個で済む」 と思った時点で罠。 実データには表記揺れ・全半角混在・前方文脈依存・継承ルールが必ずある
- OOXML 規格と Word の挙動は別物。 規格上 OK でも Word が破損認定する箇所がある
- 完成形があれば チャンク単位 ON/OFF を自動学習 できる。 一度成功したパターンは別案件で再利用可能
- 段落物理削除は Word に任せる。 lxml で頑張ろうとすると必ず破損する
使ったもの
- Python 3.11
- lxml (OOXML パース・編集)
- pywin32 (Word COM 経由の段落削除)
- python-docx (補助的読取のみ。
.save()は禁止) - difflib (本文類似度マッチング)
書式厳守の docx 自動編集は地獄でしたが、 やり切ると気持ちいいです。 同じ案件で困ってる方の参考になれば。
時間を、ゆっくりに。 — longdrift