契約書をPDFで送り、相手が手を入れたPDFが戻ってくる。このやり取りでは、Wordの変更履歴のような記録は残りません。何が変わったかを知る手がかりは、2つのファイルの差分だけです。日本では、こういうときの見せ方として新旧対照表がおなじみです。変更のあった条文だけを左右に並べ、変わった部分に傍線を引いたあの表です。
この記事では、PyMuPDFと標準ライブラリの difflib で2つのPDFの差分を取り、変更を1文字単位で拾って、新旧対照表のPDFを書き出すところまでを作ります。日本語ならではのハマりどころ(単語の区切りがない、行末で文が切れる、autojunk)は、実際に測った数字と一緒に見ていきます。題材は、デスクトップアプリ osFoundry のPDF編集画面の比較機能を紹介した動画で使ったのと同じテストファイルです。この比較機能も、PDFの中の文字を比べて、日本語の契約書では変わった文を条文ごとに3件挙げました。ここではその3件を、さらに文の中まで細かく分けます。
題材の契約書
6つの条文からなる、架空のデザイン業務委託契約書です(表示例)。PyMuPDFで生成した、文字のデータを持ったPDFで、戻ってきた版では3つの条文が変わっています。
| 条文 | 送った版 | 戻ってきた版 |
|---|---|---|
| 第3条 支払 | 受領後30日以内に支払う。遅れた場合は、月2%の遅延損害金を加える。 | 受領後60日以内に支払う。 |
| 第4条 修正 | 2回までの修正を含む。 | 回数の制限なく修正を含む。 |
| 第5条 権利 | 完成したロゴの権利は、報酬の全額の支払いをもって甲に移る。 | 完成したロゴと全ての案の権利は、業務の開始をもって甲に移る。 |
空白で区切ると、文ごと「置換」になる
英語の差分なら、テキストを空白で単語に分けて SequenceMatcher に渡せば、だいたい用が足ります。PyMuPDFの page.get_text("words") も、この「空白から空白まで」を1語として返します。
日本語でこれをやると、1文がまるごと1語になります。上の2つのPDFで試すと、差分は3件で、どれも文全体の置き換えでした。
甲は、請求書の受領後30日以内に支払う。遅れた場合は、月2%の遅延損害金を加える -> 甲は、請求書の受領後60日以内に支払う。
報酬には、2回までの修正を含む。 -> 報酬には、回数の制限なく修正を含む。
完成したロゴの権利は、報酬の全額の支払いをもって甲に移る。 -> 完成したロゴと全ての案の権利は、業務の開始をもって甲に移る。
どの条文が変わったかはわかります。ただ、「30」が「60」になったことは、結局自分の目で探すことになります。1行目の末尾に「。」がないのも気になるところで、これは次の落とし穴につながります。
1文字ずつ、座標付きで取り出す
文の中まで比べるには、1文字単位にします。あとでPDFに印を付けたいので、文字と一緒にその位置(矩形)も取っておきます。get_text("rawdict") を使うと、1文字ごとにバウンディングボックスが取れます。
import difflib, re, sys, unicodedata
import pymupdf
def char_stream(path):
"""All characters of a PDF in reading order, NFKC-normalised, each with its page and box."""
chars = []
for pno, page in enumerate(pymupdf.open(path)):
for block in page.get_text('rawdict', sort=True)['blocks']:
for line in block.get('lines', []):
for span in line['spans']:
for ch in span['chars']:
for c in unicodedata.normalize('NFKC', ch['c']):
chars.append((c, pno, pymupdf.Rect(ch['bbox'])))
chars.append(('\n', pno, None)) # a line break is only whitespace
return chars
NFKCで正規化しているのは、片方が全角の「60」、もう片方が半角の「60」といった表記ゆれを、変更として拾わないためです。1文字が複数文字に展開されても、元の文字の矩形を引き継ぐので、ページ上の位置とはずれません。
ここで大事なのが行末の扱いです。PDFの中の日本語は、行の途中でもおかまいなしに折り返されます。このテストファイルでも、第3条は「加える」で1行目が終わり、「。」だけが次の行に送られています。get_text("words") は行をまたいで1語にしないので、「。」だけが別の語になります。先ほどの1行目の末尾に「。」がなかったのはこのためです。行末を英語と同じように空白でつなぐと、今度は表示が「加える 。」になります。行末は空白ではなく区切りの印(\n)として残しておき、表示するときに、両側が日本語の文字なら詰め、それ以外なら空白にします。
CJK = ' -ヿ一-鿿-' # Japanese punctuation, kana, kanji, full-width forms (not Hangul)
def span(text, toks, i, j):
if i == j:
return ''
s = text[toks[i][0]:toks[j - 1][1]]
s = re.sub(f'(?<=[{CJK}])\n(?=[{CJK}])', '', s)
return s.replace('\n', ' ')
比べる単位(トークン)は、英数字の並びはひとまとめ、それ以外は1文字ずつにします。こうすると「30」は「3」と「0」に割れず、「30」→「60」の形で出ます。
TOKEN = re.compile(r"[0-9A-Za-z][0-9A-Za-z.,%$'’-]*(?<![.,])|\S")
def tokens(chars):
text = ''.join(c for c, _, _ in chars)
return text, [(m.start(), m.end(), m.group()) for m in TOKEN.finditer(text)]
「の」1文字の隙間でバラバラになる
1文字単位にすると、今度は細かくなりすぎます。そのまま SequenceMatcher にかけた結果は8件でした。
'30' -> '60'
'遅れた場合は、月2%の遅延損害金を加える。' -> ''
'2' -> ''
'まで' -> '数'
'' -> '制限なく'
'' -> 'と全ての案'
'報酬' -> '業務'
'全額の支払い' -> '開始'
「2回までの」→「回数の制限なく」という1つの変更が、新旧で共通の「回」と「の」を足場に3つに割れています。アルゴリズムとしては最小の差分で正しいのですが、人が赤を入れるならこうは書きません。
そこで、変更と変更の間にある変わっていない部分が1文字以下なら、1つの変更にまとめます。削除や挿入が「。」の前後どちらにも置ける場合は、句点で終わる側に寄せます(「。遅れた場合は…加える」ではなく「遅れた場合は…加える。」)。
PUNCT = set('.。.!?')
def hunks(old_toks, new_toks, gap_chars=1):
a = [t for _, _, t in old_toks]
b = [t for _, _, t in new_toks]
out = []
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b, autojunk=False).get_opcodes():
if tag == 'equal':
continue
if tag in ('delete', 'insert'):
seq, lo, hi = (a, i1, i2) if tag == 'delete' else (b, j1, j2)
k = 0
while hi + k < len(seq) and seq[lo + k] == seq[hi + k]:
k += 1
if seq[hi + k - 1] in PUNCT and seq[lo + k] not in PUNCT:
i1, i2, j1, j2 = (i1 + k, i2 + k, j1 + k, j2 + k)
break
if out:
pi1, pi2, pj1, pj2 = out[-1]
if sum(len(x) for x in a[pi2:i1]) <= gap_chars and sum(len(x) for x in b[pj2:j1]) <= gap_chars:
out[-1] = [pi1, i2, pj1, j2]
continue
out.append([i1, i2, j1, j2])
return out
これで5件になります。
1. replaced '30' -> '60'
2. deleted '遅れた場合は、月2%の遅延損害金を加える。' -> ''
3. replaced '2回までの' -> '回数の制限なく'
4. inserted '' -> 'と全ての案'
5. replaced '報酬の全額の支払い' -> '業務の開始'
同じ内容の英語版の契約書で取った差分も5件で、中身も1対1で対応しました。英語では、変更どうしの間がいちばん短いところでも「once」の4文字なので、このまとめ処理は一度も働きません。
| 区切り方 | 差分の件数 | 出方 |
|---|---|---|
| 空白で区切る | 3 | 文ごとの置換 |
| 1文字ずつ | 8 | 「の」「回」で分断 |
| 1文字ずつ+1文字以下の隙間をまとめる | 5 | 人が赤を入れる単位に近い |
新旧対照表にする
あとは表にするだけです。契約書は条文の見出し(第N条)で区切れるので、両方の版を条文ごとに分け、同じ見出しどうしで差分を取り、変わった条文だけを行にします。
新旧対照表の左右には慣習があります。官公庁の資料や契約書の改定で見かける形は、左が「改正後」、右が「改正前」で、変わった部分に傍線を引き、表題の横に「傍線部分は改正部分」と添えるものです。英語圏の比較ツールでよくある「左が古い、右が新しい」とは逆なので、ここは間違えやすいところです。
HEAD = {'ja': r'第\d+条', 'ko': r'제\d+조'}
def clauses(path, lang):
"""{heading line: clause text}. The heading is the first line of each clause."""
s = ''.join(c for c, _, _ in char_stream(path))
out = {}
for part in re.split(f'(?=(?:{HEAD[lang]}))', s):
if not re.match(HEAD[lang], part):
continue
head, _, body = part.partition('\n')
body = re.sub(f'(?<=[{CJK}])\n(?=[{CJK}])', '', body)
out[head.strip()] = re.sub(r'\s+', ' ', body).strip()
return out
各条文の差分の位置に <u> を差し込んだHTMLの表を作り、PyMuPDFの page.insert_htmlbox() でPDFに流し込みます。日本語の文字には、PyMuPDFに同梱のフォント(Droid Sans Fallback)が自動で使われ、PDFに埋め込まれました。全体は side_by_side.py にあります。
第3条の右の列には、なくなった一文がまるごと傍線付きで残り、左の列には対応する文がありません。消えた文が紙面で見えるのが、この形のいいところです。
戻ってきたPDFにも印を付ける
トークンはそれぞれ文字の矩形を持っているので、変更箇所をそのまま元のPDFにハイライトの注釈として書き戻せます。同じ行の矩形は1つにまとめ、変更前と変更後の文をコメントに入れます。削除はハイライトする文字が残っていないので、直前の文字の右にコメントのアイコンを置きます。
ふつうのPDFの注釈なので、どのビューアーでも見えます。気になる箇所に質問を書き足して、そのまま相手に返せます。
見た目ではなく文字で比べる理由
PDFの比較というと、ページを画像にして重ね合わせる方法も思いつきます。英語版のテストファイルで、戻ってきた版と、同じ文面を12ptで幅300ptの段に組み直した版の2つを用意して、比べ方ごとの結果を測りました。画像は72dpiのグレースケールで描き、40階調より大きく違うピクセルを含む行を数えています。
| 新しい版 | 画像の差分 | 行単位の差分 | トークン単位の差分 |
|---|---|---|---|
| 戻ってきたまま | 792行中30行、3か所 | 3か所・3行 | 5件 |
| 同じ文面を組み直したもの | 792行中284行、20か所 | 6か所・9行 | 5件 |
文面が同じでも、改行位置が変わるだけで画像の差分はページの大半に広がり、行単位の差分も倍になります。トークン単位の差分は、空白も改行も見ていないので5件のままです。相手の環境でフォントや余白が変わるのはよくあることなので、契約書の比較は文字で取るのが確実です。
長い契約書と autojunk
SequenceMatcher には autojunk という仕組みがあり、既定で有効です。比べる相手の要素数が200以上のとき、全体の1%を超えて出てくる要素は、一致を探す足場から外されます。ソースコードの空行のようなものを想定した仕組みですが、日本語の契約書では「の」「。」はもちろん、「甲」「乙」も頻出します。1ページのテストファイル(新しい版で238トークン)でも18種類の文字が外され、差分の件数は8件から6件に変わりました。この例では悪くなっていませんが、サインする前の書類で、結果がヒューリスティック次第で変わるのは避けたいところです。
切ると今度は速度が問題になります。差分の計算は最悪で要素数の2乗に近いので、数十ページの契約書では効いてきます。日本語版の契約書を10回、100回つなげて最後の1回だけを変えた版と比べ、M1のMac、Python 3.12で測りました。
| トークン数 | autojunk=False | autojunk=True | 条文ごと |
|---|---|---|---|
| 2,550 | 12ミリ秒 | 3ミリ秒 | 1ミリ秒 |
| 25,500 | 1,142〜1,564ミリ秒 | 163〜209ミリ秒 | 7〜10ミリ秒 |
25,500トークンの行は3回測った範囲です。ほかの処理が同時に動いていたときは、autojunk=False で最大4.2秒かかったこともありました。
解決策は表のいちばん右の列で、新旧対照表と同じ発想です。先に条文の単位で並べて対応を取り(同じ条文は1要素として一致するので軽い)、違う条文の中だけを1文字単位で比べます。autojunk=False のままで、3つの中でいちばん速くなりました。
できないこと
ここまでの方法は、すべてPDFの文字のデータが前提です。スキャンしたPDFには文字のデータがないので、先にOCRが要り、OCRの読み間違いはそのまま「変更」として出てきます。表の読み順がおかしいPDFも差分が荒れます。また、条文を丸ごと別の場所へ移した場合は、削除1件と挿入1件として出ます。移動として扱うには、削除と挿入どうしをもう一度突き合わせる処理が必要です。
コード一式(contract_diff.py、side_by_side.py、measure.py)は uvx --with pymupdf python で動きます。比較、コメント付け、保存までを画面の操作で行えるPDF編集画面は、osFoundryのダウンロードページ から入手できる無料のデスクトップアプリに入っていて、ほかの機能と同じく 自分のパソコンの中で動きます。

