Posted at

Ruby で文字列を比較して、差分を強調表示させる

More than 3 years have passed since last update.


やりたい事

二つの入力を比較して、マッチしていない部分を強調して表示させたい。

例えば、「AbcDefGh」と「abeffh」という入力があったら、以下のようなイメージで表示させたい。

image

これでパッと見で差分がわかるようにする!


diff-lcs で文字列を比較する

どうやって差分とろうかなあと考えていたら、diff-lcs で簡単に出来た。

gem 'diff-lcs'

インストールして、コンソールで実行してみる。

Diff::LCS.sdiff('AbcDefGh', 'abefffh')

=> [["!", [0, "A"], [0, "a"]],
["=", [1, "b"], [1, "b"]],
["-", [2, "c"], [2, nil]],
["-", [3, "D"], [2, nil]],
["=", [4, "e"], [2, "e"]],
["=", [5, "f"], [3, "f"]],
["!", [6, "G"], [4, "f"]],
["+", [7, nil], [5, "f"]],
["=", [7, "h"], [6, "h"]]]

んー便利。

ただの多次元配列に見えるけど、要素一つ一つが Diff::LCS::ContextChange のオブジェクトになっている。

Diff::LCS.sdiff('AbcDefGh', 'abefffh').first

#=> ["!", [0, "A"], [0, "a"]]

Diff::LCS.sdiff('AbcDefGh', 'abefffh').first.class
#=> Diff::LCS::ContextChange

最初の !Diff::LCS 内で action と呼ばれていて、アンマッチを表している。他にも = はマッチ、+ は追加、- は削除を表している。

また、0Diff::LCS では positionAaelement と呼ばれていて、文字列の位置と文字(値)を表している。

これらは、アクセッサが用意されていて、

Diff::LCS.sdiff('AbcDefGh', 'abefffh').first.action

#=> "!"
Diff::LCS.sdiff('AbcDefGh', 'abefffh').first.old_position
#=> 0
Diff::LCS.sdiff('AbcDefGh', 'abefffh').first.new_element
#=> "a"

こんな感じで取り出せる。

構成は [action, [old_position, old_element], [new_position, new_element]] となっている。


差分がある文字だけ取り出す

便利な便利な Diff::LCS#sdiff には、ブロックも渡せるみたい。

Diff::LCS.sdiff('AbcDefGh', 'abefffh') {|context_change| context_change.old_element if context_change.action != '=' }

#=> ["A", nil, "c", "D", nil, nil, "G", nil, nil]

おー取り出せた。

Diff::LCS::Change#unchanged? が用意されているので、

Diff::LCS.sdiff('AbcDefGh', 'abefffh') {|context_change| context_change.old_element unless context_change.unchanged? }

#=> ["A", nil, "c", "D", nil, nil, "G", nil, nil]

これで OK


HTML ファイルを出力させる

もう出来そうな雰囲気がしてきたので、見た目が

image

となるような HTML ファイルを出力する Ruby スクリプトを作る。

require 'diff-lcs'

str1, str2 = *ARGV

def decorate_diff(str1, str2)
Diff::LCS.sdiff(str1, str2) {|context_change|
if context_change.unchanged?
context_change.old_element
elsif !context_change.old_element.nil?
"<span>#{context_change.old_element}</span>"
end
}.join('')
end

puts <<EOS
<html>
<head>
<style type="text/css">
span {
font-weight: bold;
color: crimson;
}
</style>
</head>
<body>
<ol>
<li>
#{decorate_diff(str1, str2)}</li>
<li>
#{decorate_diff(str2, str1)}</li>
<ol>
</body>
</html>
EOS

んー出来た!

ただ、これだけだとちょっと差分が見づらいので、片方にしかない文字を取り消し線で消すようにすると、、、

...

def decorate_diff(str1, str2)
Diff::LCS.sdiff(str1, str2) {|context_change|
if context_change.unchanged?
context_change.old_element
elsif context_change.deleting?
"<span class='delete'>#{context_change.old_element}</span>"
elsif !context_change.adding?
"<span>#{context_change.old_element}</span>"
end
}.join('')
end

...
<style type="text/css">
span.delete {
text-decoration: line-through;
}
...

image

出来た!!

もうちょっと見た目は工夫しないとだけど、、、当初の目的は果たせた!!