.docx を Word 変更履歴(修正モード)で編集する
概要
.docx ファイルをプログラムで編集し、変更箇所が Microsoft Word で変更履歴として表示されるようにします。削除は赤色の取り消し線、挿入は赤色の下線、そして余白に作成者/日付のメタデータが表示されます。これは、lxml を用いた OOXML の w:ins / w:del 要素の直接 XML 操作によって実現されます。python-docx の高レベル API は変更履歴をサポートしていないため、これを使用しません。
依存関係
pip install python-docx lxml
python-docx>= 1.2.0 — 初期調査(段落スタイル、実行書式)にのみ使用。実際の変更履歴書き込みには使用しません。lxml>= 5.0 — 実際の処理を担うライブラリ。.docxZIP 内のword/document.xmlを解析・修正します。
どちらも純 Python でインストール可能で、システムレベルの依存関係はありません。
使用するタイミング
- ユーザーが
.docxを編集し、Word で変更箇所を確認したい場合(修正モード / トラックチェンジ) - レビュアーが個々の編集を承諾/拒否できる改訂原稿を送る必要がある場合
- 直接テキスト置換では不十分で、変更履歴が重要な場合
使用すべきでないタイミング
- ユーザーが変更履歴を付けずに最終テキストを置換したいだけの場合 —
python-docxを直接使用 - ファイルが
.doc(旧バイナリ形式)の場合 — 非対応 - ヘッダー/フッター/脚注を変更履歴付きで編集したい場合 — この手法は本文段落のみ対応
連携
このスキルは単独で使用することも、office-docx-skills 内の他のスキルと組み合わせて使用することもできます。
word-default-formattingと併用:改訂テキストにもパッケージの既定の Word 書式プロファイルを適用する場合。word-formula-writingと併用:数式関連の編集を Word ネイティブ編集可能な数式と表示可能な変更履歴で表現する場合。- 書式と数式の両方が重要な場合は、3 つすべてのスキルを同時に使用します。
OOXML 変更履歴の構造
.docx は ZIP アーカイブで、word/document.xml を含みます。変更履歴は、w:p(段落)内の w:r(ラン)要素をラップする 2 つの要素を使用します。
<!-- 削除:元のランをラップし、テキストタグは w:delText になる -->
<w:del w:id="102" w:author="Claude" w:date="2026-04-17T10:00:00Z">
<w:r>
<w:rPr><!-- 元の書式を保持 --></w:rPr>
<w:delText xml:space="preserve">古いテキスト</w:delText>
</w:r>
</w:del>
<!-- 挿入:新しいランをクローンした書式でラップ -->
<w:ins w:id="103" w:author="Claude" w:date="2026-04-17T10:00:00Z">
<w:r>
<w:rPr><!-- 元のランからクローン --></w:rPr>
<w:t xml:space="preserve">新しいテキスト</w:t>
</w:r>
</w:ins>
重要なルール:
w:delとw:insはw:pの直接子要素で、w:pPrと兄弟関係- それぞれ一意の
w:id(整数)が必要で、ドキュメント内の既存 ID と衝突してはいけません w:authorとw:dateは Word の余白注釈に表示されますw:del内ではテキスト要素は必ずw:delText(w:tではない)にしなければなりませんw:ins内ではテキスト要素は通常のw:tです- 実行プロパティ(
w:rPr)は、フォント/サイズ/太字を保持するために元のランからクローンする必要があります
コア実装
再利用可能な実装は、このスキルと同階層の tracked_change_editor.py にあります。直接インポートして使用したい場合は、このヘルパーファイルを作業ディレクトリにコピーしてください。
from tracked_change_editor import TrackedChangeEditor
editor = TrackedChangeEditor('input.docx', author='あなたの名前')
for i, p in enumerate(editor.body_paras):
if '対象テキスト' in editor._get_para_text(p):
editor.replace_paragraph_text(i, '置換テキスト')
break
editor.save('output.docx')
tracked_change_editor.py は以下の重要なメカニズムを処理します:
- 新しい
w:ins/w:delノードを作成する前に、既存のリビジョン ID をスキャン - 段落の既定
w:rPrをクローンして段落書式を保持 - 安全な一時ファイルに書き込んでから最終
.docxに移動
使用フロー
editor = TrackedChangeEditor('input.docx', author='あなたの名前')
# テキスト内容で目的の段落を見つける
for i, p in enumerate(editor.body_paras):
if '対象テキスト' in editor._get_para_text(p):
editor.replace_paragraph_text(i, '置換テキスト')
break
editor.save('output.docx') # 必ず新しいファイルに保存
重要な注意点 — 編集前に必ずお読みください
0. すべての編集を 1 つのスクリプトで行うか、最後に保存したファイルから連鎖させる
7 つの編集を行う必要がある場合、すべてを単一の TrackedChangeEditor インスタンスに対する単一のスクリプト実行で行い、最後に 1 回保存してください。以下のようにしないでください:
- スクリプト A を実行 →
_revised.docxに保存 - スクリプト B を元のファイルに対して実行 →
_revised.docxに保存(スクリプト A の作業を上書き)
どうしても複数のスクリプト実行に分割する必要がある場合、後続の各実行は前回の出力ファイルを読み込む必要があります:
# ラウンド 1
editor = TrackedChangeEditor('original.docx')
# ... 編集 ...
editor.save('revised.docx')
# ラウンド 2 — revised.docx を読み込む(original ではない)
editor2 = TrackedChangeEditor('revised.docx')
# ... さらに編集 ...
editor2.save('revised.docx')
なぜ重要か: 元のファイルから読み込むと、以前のラウンドのすべての変更履歴が破棄されます。これは最もよくある間違いで、エラーも出ずに作業が静かに消えてしまいます。
0b. 新しい段落の挿入:テキストだけでなくセクションを見つける
新しい段落(例:参考文献エントリ)を挿入するとき、ドキュメント全体からテキスト一致を検索してはいけません。本文中の引用には参考文献エントリと同じ著者名が含まれます。if 'Acharya' in text のような単純な検索は、本文段落に最初に一致し、参考文献をセクション 2 の途中に挿入してしまいます。
正しい方法 — まずセクション境界を見つける:
# 参考文献セクションの開始位置を見つける
refs_start_idx = None
for i, p in enumerate(editor.body_paras):
t = editor._get_para_text(p)
if t.strip() == 'References':
refs_start_idx = i
break
# 参考文献セクション内のみを検索
for i in range(refs_start_idx + 1, len(editor.body_paras)):
t = editor._get_para_text(editor.body_paras[i])
if t.startswith('Acharya') and 'Johnson' in t:
# この段落の後に挿入
break
なぜ重要か: 著者名は文中引用と参考文献エントリの両方に現れます。セクションを考慮した検索をしないと、新しい参考文献段落が本文に挿入されてしまい、ユーザーが Word で開くまで気づきにくい破損が発生します。
0c. 学術論文の段落書き換え:すべての引用を保持する
段落のテキストを置換するときは、元の段落にあるすべての (著者 年) 形式の引用を保持しなければなりません。学術論文は本文中の引用と参考文献リストが一致している必要があります。引用を削除すると、参考文献エントリが孤立したり、論旨が弱まったりします。
正しい方法 — 書き換え前に抽出:
import re
old_text = editor._get_para_text(editor.body_paras[idx])
old_cites = re.findall(r'\([^)]*\d{4}[a-z]?\)', old_text)
# old_cites のすべてのアイテムが new_text に含まれるように新しいテキストを書く
# 書き換え後、確認:
for c in old_cites:
assert c in new_text, f'引用が失われました: {c}'
なぜ重要か: (Malmendier and Nagel 2011; Choi, Gao, and Jiang 2020) を削除して書き換えた導入部は、論文の引用の整合性を静かに壊します。参考文献リストにはこれらのエントリが残っていますが、本文中にそれらを指すものは何もありません。査読者や編集者はこれを指摘します。
1. 既存の変更履歴を含む段落
段落に以前の編集による w:ins/w:del がすでに含まれている場合、_collect_direct_runs は直接の子 w:r のみを取得します。既存の w:ins/w:del ラッパー内のランはスキップされます。これにより変更履歴のネストを防げますが、置換は「承諾済み」のテキスト部分のみをカバーします。過去の変更が多く含まれる段落の場合は、Word ですべての変更を承諾してから再編集してください。
2. 常に新しいファイルに保存する
元のファイルを上書きしないでください。_revised.docx として保存し、ユーザーに Word で確認させてから手動で置き換えてもらいます。
3. 目次は自動更新されない
python-docx / lxml は Word の目次を更新できません。編集後、ユーザーは Word で開き、目次を右クリック → フィールド更新を行う必要があります。
4. リビジョン ID の一意性
_find_max_id() メソッドは、既存のすべての w:ins、w:del、w:rPrChange、w:pPrChange、w:sectPrChange 要素をスキャンします。新しい ID は最大値からインクリメントされます。ID が衝突すると、Word はリビジョン履歴を破損する可能性があります。
5. 書式の保持
_get_default_rpr は段落内の最初のランから w:rPr をクローンします。これは段落全体が均一な書式の場合に機能します。段落内で書式が混在している場合(例:太字の「キーワード:」とそれに続く通常テキスト)、段落全体を置換するのではなく、各ランごとに外科的に置換してください。
6. 往復の忠実性
lxml による保存では、XML の見た目に軽微な違いが生じることがあります(引用符スタイル " と '、改行コード \r\n と \n など)。内容と書式は保持されます。ZIP 圧縮レベルの違いによりファイルサイズが変わる場合がありますが、問題ありません。
検証チェックリスト
保存後、Word で開いて以下を確認してください:
- 削除テキストが赤色の取り消し線で表示される
- 挿入テキストが赤色の下線で表示される
- 作成者名が余白の注釈に表示される
- 周辺の段落は元の書式を保持している
- 重複またはネストしたリビジョンマークがない
- 選択した作成者の挿入数を期待値と一致させる — 検証スクリプトを実行して、作成者名を持つ
w:insをカウントし、それぞれの最初の 100 文字を表示します。カウントが期待値より少ない場合、以前の編集が失われています(注意点 0 を参照)。 - 本文中に参考文献エントリがない — References 見出しより前の段落をスキャンし、完全な書誌エントリ(著者、年、ジャーナル、巻、ページ)のように見える段落がないか確認します。見つかった場合、参考文献が誤った場所に挿入されています(注意点 0b を参照)。
# 保存後検証スニペット
target_author = 'Codex'
with zipfile.ZipFile('output.docx') as z:
xml = z.read('word/document.xml')
root = etree.fromstring(xml)
nsmap = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
author_ins = [e for e in root.findall('.//w:ins', nsmap)
if e.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}author') == target_author]
print(f'{target_author} の挿入数: {len(author_ins)}')
for i, ins in enumerate(author_ins):
texts = ''.join(t.text for t in ins.findall('.//w:t', nsmap) if t.text)
print(f' [{i+1}] {texts[:100]}')