エイエイレトリック

なぐりがき

# difflib の Differ, HtmlDiff を使ったテキストの差分出力

日本語テキストの差分を python で可視化する方法を考えます。

ほぼ同じだけど微妙に違うファイル同士を比較する時、目diffだと限界があるので可視化するのが良いです。

なるべく楽に、 標準ライブラリの difflib で実装することを考えます。 この記事は python3.9 のドキュメント、コードの出力に基づきます。

Differ / ndiff

人が読むことのできる差分を作成します。 Differ クラスは SequenceMatcher クラスを利用して、行からなるシーケンスを比較したり、(ほぼ)同一の行内の文字を比較したりします。

https://docs.python.org/ja/3/library/difflib.html#difflib.Differ

一般的な context diff, unified diff (後述) に比べて、目視で差分が見やすいのが Differ です。 変更前/変更後をインラインで表示するため、対応関係がわかりやすいです。

行の最初の文字で追加・削除を判定できます。 - は変更前のみある行、+ は変更後のみある行を示します。 また、 ? からはじまる行で差分部分を指し示します。

import difflib
a = ["apple", "banana", "chololate", "dounuts"]
b = ["apples", "banana", "chololete", "dounut", "egg"]

for d in difflib.Differ().compare(a, b):
    print(d)

>>
- apple
+ apples
?      +

  banana
- chololate
?       ^

+ chololete
?       ^

- dounuts
?       -

+ dounut
+ egg

上の例だと

  • - apple : 変更前の行
  • + apples : 変更後の行
  • ? + : 変更後の追加である s+ で指し示している

といった感じです。

? の行を無視すればユニファイド形式に近い出力とみなせます。 ですが、ユニファイド形式と異なり、対応する変更前・変更後のペアで出力されるため、微妙な差分も把握しやすいです。

ちなみにドキュメントでは別の場所に記載されている difflib.ndiff ですが、中身は Differ().compare(a, b) と同じです。 (自分は気が付くまで時間がかかった)

しかしながら、Differは日本語の場合、全半角の関係でうまく示せない問題があります。

実装 (cpython/Lib/difflib.py) を見ればわかる通り、半角スペースで差分の位置を調整しているのが原因です。

print(
    "\n".join(difflib.ndiff(
        ["アップル", "バナナ", "チョコト", "ドーナツ", "English", "フォトグラフィ"],
        ["アップル", "ナナ", "チョコレート", "ドーナッツ", "EngIish", "フォトダラフィ"]
    ))
)

>>
  アップル
- バナナ
? -

+ ナナ
- チョコト
+ チョコレート
?    ++

- ドーナツ
+ ドーナッツ
?    +

- English
?    ^

+ EngIish
?    ^

- フォトグラフィ
?    ^

+ フォトダラフィ
?    ^

無理やり全角に対応する

unicodedata.east_asian_width() を使い、対応する文字が全角の場合は2文字分表示することで、日本語でも多少差分がわかりやすくなります。

コードは以下にまとめました。全角にのみ対応しているので、タブ文字など特殊な文字が含まれる場合はやはりズレます。

gist.github.com

参考: https://note.nkmk.me/python-unicodedata-east-asian-width-count/

HtmlDiff

直感的にわかりやすいのはやはり色付きの diff です。

HtmlDiff はHTMLで行間・行内の差分を出力できます。

make_table で表のHTML のみ、 make_file で完全な HTML ファイルを出力します。

make_tablemake_file の違いは、make_file の方が差分の色分けの説明が記述されるか否かぐらいです。

以下は jupyter notebook での make_file の出力です。

from IPython.display import HTML
import difflib
a = ["apple", "banana", "chololate", "dounuts"]
b = ["apples", "banana", "chololete", "dounut", "egg"]

HTML(
    difflib.HtmlDiff().make_file(a, b)
)

jupyter notebook だと HTML で結果を埋め込めるので可視化としても便利です。 また、文字を直接指定するため、全半角関係なく正しく差分をみることができます。

HTML(
    difflib.HtmlDiff().make_table(
        ["アップル", "バナナ", "チョコト", "ドーナツ", "English", "フォトグラフィ"],
        ["アップル", "ナナ", "チョコレート", "ドーナッツ", "EngIish", "フォトダラフィ"]
    )
)

make_file, make_table のjupyter notebook上での表示

context diff, unified diff

diff コマンド でも指定できる出力形式。

同じ行の微妙な差分を出力するにはあまり向いていないため、詳細は割愛します。

context_diff

コンテキスト形式は、変更があった行に前後数行を加えてある、コンパクトな表現方法です。変更箇所は、変更前/変更後に分けて表します。

https://docs.python.org/ja/3/library/difflib.html#difflib.context_diff

import difflib
from pprint import pprint

a = ["apple", "banana", "chololate", "dounuts"]
b = ["apples", "banana", "chololete", "dounut", "egg"]

pprint(list(difflib.context_diff(a, b)))

>>
['*** \n',
 '--- \n',
 '***************\n',
 '*** 1,4 ****\n',
 '! apple',
 '  banana',
 '! chololate',
 '! dounuts',
 '--- 1,5 ----\n',
 '! apples',
 '  banana',
 '! chololete',
 '! dounut',
 '! egg']

unified_diff

ユニファイド形式は変更があった行にコンテキストとなる前後数行を加えた、コンパクトな表現方法です。変更箇所は (変更前/変更後を分離したブロックではなく) インラインスタイルで表されます。

https://docs.python.org/ja/3/library/difflib.html#difflib.unified_diff

pprint(list(difflib.unified_diff(a, b)))

>>
['--- \n',
 '+++ \n',
 '@@ -1,4 +1,5 @@\n',
 '-apple',
 '+apples',
 ' banana',
 '-chololate',
 '-dounuts',
 '+chololete',
 '+dounut',
 '+egg']