python で２つの PDF のテキストの差分を検出

python で２つの PDF のテキストの差分を検出する方法のメモ。
「[JavaScript で２つの PDF のテキストの差分を検出](https://qiita.com/dakikd/items/f480ffdf1d9b9abd6e42)」の python 版ですが、処理速度の改善を行ってみました。


# やりたいこと
- 2つの PDF ファイルでテキスト要素単位で差分を検出（※文字単位ではない）
- ページが異なってもテキスト要素が同じ文字列であれば差分とみなさない
- 編集距離の計算範囲を制御


# プログラム
PDF の解析には [PyMuPDF](https://pymupdf.readthedocs.io/en/latest/) を使用しています。

diff() で差分検出を行いますが、パラメータに指定した値のテキスト要素数の範囲でマッチング処理を行うため、同じテキストでも範囲外のものはマッチング処理の対象外差分として扱います。

## 差分検出プログラム本体
```python:pdf_text_matcher.py
import re
import fitz


class PdfTextMatcher:
    def __init__(self, pdf1, pdf2):
        self.pdf1 = pdf1
        self.pdf2 = pdf2

        self.re_strip = re.compile(r'(?:^\s+|\s+$)', flags=re.MULTILINE)
        self.text_objs1 = self.get_text_objs(pdf1)
        self.text_objs2 = self.get_text_objs(pdf2)

        self.matches = {}
        self.unmatches1 = []
        self.unmatches2 = []


    def get_text_objs_from_page(self, pdf, page_id):
        page = pdf[page_id]
        blocks = page.get_text('blocks')

        text_objs = []
        for i, obj in enumerate(blocks):
            text = obj[4]
            text = self.re_strip.sub('', text)
            if text == '': continue

            id = f"id_{page_id}_{i}"
            text_obj = {'id': id, 'page': page_id, 'text': text, 'obj': obj}
            text_objs.append(text_obj)

        return text_objs


    def get_text_objs(self, pdf):
        num_pages = pdf.page_count
        text_objs = []

        for p in range(0, num_pages):
            page_text_objs = self.get_text_objs_from_page(pdf, p)
            text_objs.extend(page_text_objs)

        return text_objs


    def match_list(self, obj):
        objs = []

        while True:
            if obj is None:
                break

            #if type(obj) == tuple:
            objs.append(obj[1])
            obj = obj[0]

        objs.reverse()
        return objs


    def diff(self, max_range=100):
        text_objs1 = self.text_objs1
        len1 = len(text_objs1)

        text_objs2 = self.text_objs2
        len2 = len(text_objs2)

        delta_len = len2 - len1

        #    _ a b c ...
        #  _ 0 1 2 3
        #  a 1 0 1 2
        #  c 2 1 2 1
        #  ...

        col_prev = [{}] * (len1 + 1)
        col_cur = [{}] * (len1 + 1)

        for r in range(0, len1+1):
            col_prev[r] = { 'cost': r, 'matches': None }

        for c in range(1, len2+1):
            col_cur[0] = { 'cost': c, 'matches': None }

            # 範囲
            if delta_len >= 0:
                min_row = c - delta_len - max_range - 1
                max_row = c + max_range + 1
            else:
                min_row = c - max_range - 1
                max_row = c + (- delta_len) + max_range + 1

            if min_row <= 0: min_row = 1
            if max_row > len1: max_row = len1

            for r in range(min_row, max_row+1):
                # コスト
                # 左上
                cost_lu = col_prev[r-1]['cost'] + 1
                matches_lu = col_prev[r-1]['matches']
                if text_objs1[r-1]['text'] == text_objs2[c-1]['text']:
                    cost_lu = col_prev[r-1]['cost']
                    matches_lu = (matches_lu, (r-1, c-1))

                # 上
                if col_cur[r-1].get('cost') is None: cost_u = None
                else: cost_u = col_cur[r-1]['cost'] + 1

                # 左
                if col_prev[r] is not None and col_prev[r].get('cost') is None:
                    cost_l = None
                else:
                    cost_l = col_prev[r]['cost'] + 1

                # 経路
                if cost_u is not None and (cost_l is None or cost_u <= cost_l) and cost_u <= cost_lu:
                    # 上
                    col_cur[r] = {
                        'cost': cost_u,
                        'matches': col_cur[r-1]['matches'],
                    }
                elif cost_l is not None and (cost_u is None or cost_l <= cost_u) and cost_l <= cost_lu:
                    # 左
                    col_cur[r] = {
                        'cost': cost_l,
                        'matches': col_prev[r]['matches'],
                    }
                else:
                    # 左上
                    col_cur[r] = {
                        'cost': cost_lu,
                        'matches': matches_lu
                    }

            if c < len2:
                tmp = col_prev
                col_prev = col_cur
                col_cur = tmp

        # 結果
        matches = col_cur[len1]['matches']
        match_ids = self.match_list(matches)
        self.matches = [(text_objs1[m[0]], text_objs2[m[1]])
                        for m in match_ids]

        # match
        matches1 = {}
        matches2 = {}
        for m in match_ids:
            obj1 = text_objs1[m[0]]
            obj2 = text_objs2[m[1]]
            matches1[obj1['id']] = obj2
            matches2[obj2['id']] = obj1

        # unmatch
        for obj in text_objs1:
            if matches1.get(obj['id']): continue
            self.unmatches1.append(obj)

        for obj in text_objs2:
            if matches2.get(obj['id']): continue
            self.unmatches2.append(obj)

        return [self.matches, self.unmatches1, self.unmatches2]
```

## 呼び出し側のプログラム例
```python:test.py
import time
import sys
import fitz
from pdf_text_matcher import PdfTextMatcher

pdf1 = fitz.open(sys.argv[1])
pdf2 = fitz.open(sys.argv[2])

from_time = time.time()

m = PdfTextMatcher(pdf1, pdf2)
result = m.diff()

to_time = time.time()

print('match', result[0])
print('unmatch1', [[obj['page'], obj['text']] for obj in result[1]])
print('unmatch2', [[obj['page'], obj['text']] for obj in result[2]])

print(f"time: {to_time - from_time}")
```

## 実行例
### PDF ファイル1
1ページ目
```text:
あああああ
いいいいい
ううううう
えええええ
おおおおお
かかかかか
ききききき
くくくくく
けけけけけ
こここここ
さささささ
ししししし
```
2ページ目
```text:
すすすすす
せせせせせ
そそそそそ
たたたたた
ちちちちち
つつつつつ
ててててて
ととととと
```
### PDF ファイル2
1ページ目
```text:
あああああ
い
ううううう
え
おおおおお
＃＃＃＃＃
％％％％％
かかかかか
ききききき
くくくくく
けけけけけ
```
2ページ目
```text:
％％％％％
さささささ
ししししし
すすすすす
せせせせせ
そそそそそ
＃＃＃＃＃
％％％％％
たたたたた
ちちちちち
つつつつつ
ててててて
```

### 実行結果
※見やすさのため加工しています
```text:
match [
  ({'id': 'id_0_2', 'page': 0, 'text': 'あああああ', 'obj': ...},
   {'id': 'id_0_2', 'page': 0, 'text': 'あああああ', 'obj': ...}),
  ({'id': 'id_0_8', 'page': 0, 'text': 'ううううう', 'obj': ...},
   {'id': 'id_0_8', 'page': 0, 'text': 'ううううう', 'obj': ...}),
   ...,
  ({'id': 'id_0_32', 'page': 0, 'text': 'さささささ', 'obj':...},
   {'id': 'id_1_5', 'page': 1, 'text': 'さささささ', 'obj': ...}),
  ({'id': 'id_0_35', 'page': 0, 'text': 'ししししし', 'obj': ...},
   {'id': 'id_1_8', 'page': 1, 'text': 'ししししし', 'obj': ...}), 
   ...,
  ({'id': 'id_1_20', 'page': 1, 'text': 'ててててて', 'obj': ...)},
   {'id': 'id_1_35', 'page': 1, 'text': 'ててててて', 'obj': ...)
]

unmatch1 [[0, 'いいいいい'], [0, 'えええええ'], [0, 'こここここ'], [1, 'ととととと']]

unmatch2 [[0, 'い'], [0, 'え'], [0, '＃＃＃＃＃'], [0, '％％％％％'], [1, '％％％％％'], [1, '＃＃＃＃＃'], [1, '％％％％％']]
```
'さささささ'、'ししししし' はファイル1とファイル2とでページは異なりますが、マッチしていると判定されています。
また、テキスト要素単位で差分を検出するため、ファイル1の5文字の 'いいいいい' とファイル2の1文字の 'い' が差分となっています。