Word の変更履歴(トラック変更)は、レビュー、校正、版の統合といった場面で発生する。誰がいつどの文を変えたのか、変更前は何だったのか、変更後は何になったのかが文書内に残る。これをプログラムで取り出し、条件で絞り込み、最終的な版を決めるのは、文書自動化のなかでも頻度の高い処理である。Spire.Doc for Python は変更履歴のマークを文書オブジェクトモデルにそのまま公開しており、読み出し・絞り込み・確定のいずれもオブジェクト操作として扱える。OOXML を解析する必要はない。
以下では、同一の業務委託契約書の二つのバージョンを例に用いる。contract_v1.docx(14 段落、6.3 KB)と contract_v2.docx(16 段落、6.4 KB、報酬・契約期間・解除条項に変更あり)。
このガイドでは、ドキュメント操作に Free Spire.Doc for Python を使用しています。
pip install Spire.Doc.Free
変更履歴のマークが付く位置
文書の階層は Document → Section → Paragraph → TextRange である。変更履歴は独立した表ではなく、段落とテキスト範囲に付くマークとして存在し、それぞれ四つのメンバーを持つ。
| メンバー | 意味 |
|---|---|
Paragraph.IsInsertRevision / TextRange.IsInsertRevision
|
そのオブジェクトが挿入の変更か |
Paragraph.IsDeleteRevision / TextRange.IsDeleteRevision
|
そのオブジェクトが削除の変更か |
InsertRevision.Type / DeleteRevision.Type
|
Insertion、Deletion、FormatChange、Moving、StyleDefinitionChange
|
InsertRevision.Author / .DateTime
|
レビュアー名とタイムスタンプ |
置換は対になったマークとして残る。文書中の "30 days written notice" が 60 日に変更されている場合、この段落の子オブジェクトは次のようになる。
'Either party may terminate this agreement with ' (通常テキスト)
'30' [削除]
'60' [挿入]
' days written notice' (通常テキスト)
'.' [削除]
削除側には変更前の原文が、挿入側には新しいテキストが残り、両者は同じ段落オブジェクトに属する。この構造に沿って対で処理すれば、変更前と変更後の両方のテキストを取得できる。
二つのバージョンを比較して変更履歴を生成する
二つのバージョンがあれば、Document.Compare が差分を元の文書に書き戻す。第一引数が比較対象の文書、第二引数が変更履歴に記録するレビュアー名である。
from spire.doc import *
from spire.doc.common import *
original = Document()
original.LoadFromFile("contract_v1.docx")
revised = Document()
revised.LoadFromFile("contract_v2.docx")
original.Compare(revised, "A. Fischer")
original.SaveToFile("contract_revised.docx", FileFormat.Docx2013)
original.Close()
revised.Close()
出力は contract_revised.docx、6.7 KB、変更箇所は合計 16。内訳は段落レベルが挿入 3・削除 1、テキスト範囲レベルが挿入 7・削除 5 である。段落レベルのマークは段落ごとの追加・書き換え(追加された "6. Data Protection" の見出しと本文)に対応し、テキスト範囲レベルのマークは文内の変更に対応する。84,000 が 92,000 になった箇所では、数字の部分だけがマークされる。
比較の挙動は CompareOptions で調整する。四引数のオーバーロードでは、第三引数がタイムスタンプ、第四引数がオプションである。
options = CompareOptions()
options.IgnoreFormatting = True
original.Compare(revised, "A. Fischer", DateTime.get_Now(), options)
IgnoreFormatting = True にすると比較は文字内容だけを対象にし、結果には元の書式がそのまま残る。既定ではレビュー版の書式も結果に反映される。サンプルではレビュアーが報酬の文を太字にしており、既定の比較ではこの太字が結果に現れる。このほかにも IgnoreComments、IgnoreFields、IgnoreHeadersAndFooters、IgnoreTables、CompareMoves があり、文書の種類に応じて対象範囲を選べる。
プログラムからの編集を変更履歴に残す
コード側で文書を編集する場合も、変更履歴を残せる。StartTrackRevisions(author) 以降の変更はすべてそのレビュアー名で記録され、StopTrackRevisions() で終了する。
document = Document()
document.LoadFromFile("contract_revised.docx")
document.StartTrackRevisions("M. Dubois")
target = document.Sections[0].Paragraphs.get_Item(1)
for k in range(target.ChildObjects.Count):
obj = target.ChildObjects.get_Item(k)
if isinstance(obj, TextRange) and "MSA-2026" in obj.Text:
obj.Text = obj.Text.replace("MSA-2026-0147", "MSA-2026-0152")
added = document.Sections[0].AddParagraph()
added.AppendText("The Vendor shall provide a monthly uptime report.")
added.ApplyStyle(BuiltinStyle.Normal)
document.StopTrackRevisions()
document.SaveToFile("contract_revised_2authors.docx", FileFormat.Docx2013)
document.Close()
テキスト範囲の書き換えも、段落の追加も、どちらも変更履歴に残る。既存のテキスト範囲を書き換えた場合は obj.IsInsertRevision がその場で True になり、マークをすぐに読める。追加した段落は保存時に文書へ書き込まれ、読み直せば同じように取得できる。変更数を数える処理は保存後のファイルを対象にすると、一覧が文書の実際の状態と一致する。
保存後の文書には 19 のマークがあり、二人のレビュアーに分かれる。段落構成も変わっている。追加されたデータ保護条項が二段落を占め、解除条項は二段落に分割され(旧文の削除と新文の挿入)、追加の報告条項が加わるため、段落数は 14 から 18 になる。
変更履歴の一覧を出力する
段落とその配下のテキスト範囲を走査し、マークが付いているものから Type、Author、DateTime とそのオブジェクト自身のテキストを取り出せば、一覧として出力できる。
rows = []
for j in range(document.Sections[0].Paragraphs.Count):
para = document.Sections[0].Paragraphs.get_Item(j)
if para.IsInsertRevision or para.IsDeleteRevision:
info = para.InsertRevision if para.IsInsertRevision else para.DeleteRevision
rows.append(["paragraph", j + 1, info.Type.name, info.Author, para.Text.strip()])
for k in range(para.ChildObjects.Count):
obj = para.ChildObjects.get_Item(k)
if isinstance(obj, TextRange) and (obj.IsInsertRevision or obj.IsDeleteRevision):
info = obj.InsertRevision if obj.IsInsertRevision else obj.DeleteRevision
rows.append(["text range", j + 1, info.Type.name, info.Author, obj.Text.strip()])
段落レベルの判定をテキスト範囲のループの外に置き、テキスト範囲側と if / elif で分けると、同じ変更が二重に計上されない。段落のマークとその内部のテキスト範囲のマークは同時に成立するため、並列した二つの if では一つの変更が二行になる。
出力した revision_report.csv(1,433 バイト、19 行)は、階層・段落番号・種類・レビュアー・時刻・テキストの六列で構成される。分布は次のとおりである。
by author: {'A. Fischer': 16, 'M. Dubois': 3}
by type: {'Insertion': 12, 'Deletion': 7}
p2 の契約番号が書き換えられ(削除と挿入が各 1 箇所)、p8 の報酬が 84 から 92 に変わり、p12 の 30 日が 60 日に変わって段落ごと組み替えられ、p14、p15、p18 は段落単位の追加である。六列に加えて、TextRange のテキストは比較にそのまま使える。削除側からは変更前の内容、挿入側からは変更後の内容が得られる。
確定:承認・拒否・部分採用
Document.AcceptChanges() はすべての変更履歴を本文に反映し、Document.RejectChanges() は文書を変更前の内容に戻す。
document = Document()
document.LoadFromFile("contract_revised_2authors.docx")
document.AcceptChanges()
document.SaveToFile("contract_accepted.docx", FileFormat.Docx2013)
document.Close()
承認すると 19 のマークがゼロになり、段落数は 18 から 17 になる。解除条項の削除部分と挿入部分が一つの段落に統合されるためである。拒否した場合は本文が初稿の内容に戻り、報酬は 84,000、契約期間は 12 か月に戻り、データ保護条項と報告条項は消える。
レビュアーが一人のときは、この二行で確定できる。複数人が往復した原稿では、部分採用がよく使われる。一人の変更だけを採用し、残りを差し戻す方法である。手順は AcceptChanges() の前に、著者ごとにマークを処理しておくことである。挿入側は段落から取り除き、削除側は元のテキストを通常のテキスト範囲として元の位置に戻す。
DROP = "M. Dubois"
section = document.Sections[0]
for j in range(section.Paragraphs.Count - 1, -1, -1):
para = section.Paragraphs.get_Item(j)
for k in range(para.ChildObjects.Count - 1, -1, -1):
obj = para.ChildObjects.get_Item(k)
if not isinstance(obj, TextRange):
continue
if obj.IsInsertRevision and obj.InsertRevision.Author == DROP:
para.ChildObjects.Remove(obj) # このレビュアーの追加を取り消す
elif obj.IsDeleteRevision and obj.DeleteRevision.Author == DROP:
plain = para.AppendText(obj.Text) # 元のテキストを通常のテキストとして複製
para.ChildObjects.Remove(obj)
para.ChildObjects.Insert(k, plain) # 元の位置に戻す
if para.Text.strip() == "":
section.Paragraphs.Remove(para) # 空になった段落を除去
document.AcceptChanges()
document.SaveToFile("contract_subset.docx", FileFormat.Docx2013)
document.Close()
削除側を複製して作り直しているのは、そのレビュアーが消した原文を文書内にそのまま残すためである。AppendText で作ったテキスト範囲にはマークが付かないので、Insert で元の位置に戻せば段落の他の内容も順序も変わらない。二重ループを逆順に回しているのは、削除と挿入で子オブジェクトの添字が動くためで、末尾から処理すれば添字は常に有効である。結果の contract_subset.docx は 16 段落で、契約番号は Contract No. MSA-2026-0147 に戻り、報酬は A. Fischer が変更した USD 92,000 が残る。
バッチ確定
同じ処理をディレクトリのループに入れれば、バッチ確定の骨格になる。
import os, time
started = time.perf_counter()
for filename in sorted(os.listdir("./batch_input")):
if not filename.lower().endswith(".docx"):
continue
document = Document()
document.LoadFromFile(os.path.join("./batch_input", filename))
document.AcceptChanges()
out_name = os.path.splitext(filename)[0].replace("_revised", "_final") + ".docx"
document.SaveToFile(os.path.join("./batch_output", out_name), FileFormat.Docx2013)
document.Close()
print(f"{time.perf_counter() - started:.2f} s")
ディレクトリに三人のレビュアーによる六つの改訂版を置いて処理したところ、0.23 秒で完了し、いずれも 17 のマークを持っていた。ループのなかでファイル名・マーク数・レビュアー・段落数・確定後の報酬額を batch_summary.csv(521 バイト)に記録している。どの原稿がどう処理されたかを後から確認できる。ループ内で毎回 Document.Close() を呼んで文書オブジェクトを解放すると、長時間の実行でもメモリ使用量が安定する。
使いどころとバージョン
この API 群は、変更履歴を含む文書であれば種類を問わず使える。契約書、報告書、提案書、多言語の原稿でも同じである。業務フローに組み込む例としては、文書管理システムへの登録前に未処理の変更が残っていないかを確認する、レビュー結果を構造化してチケットやデータベースに書き込む、複数人が往復して修正した原稿をまとめて確定させる、プログラムで文書を生成する際に変更の痕跡を残す(StartTrackRevisions)といった使い方がある。
ここまでの結果は Spire.Doc for Python 12.12.0(Free 版)に対応する。Free 版は一文書あたり 500 段落までを対象としており、それを超える文書には商用版が使える。商用版の spire-doc パッケージには Document.GetRevisionInfos() も用意されており、変更履歴をまとめて取得して個別に Accept() / Reject() できる。ここで示したオブジェクト走査は Free 版・商用版のどちらでもそのまま動作する。


