PDFの画像比較をリファクタリングに活かす

> 今年で2回目となるラクスのアドベントカレンダーです :christmas_tree: 言い出しっぺの法則により今年もトップバッターになってしまいましたが、今年は新たに [RAKUS Developers Blog](http://tech-blog.rakus.co.jp/) が始まるなど少しずつ情報共有の取り組みが進んできたので、来年こそは別のメンバーに譲りたいなと目論んでいます。何はともあれクリスマスまでお楽しみに :santa: :bell: 

さて、サービスが5年あるいは10年と成長を続けると、いわゆるレガシーコードとの闘いは避けて通れません。これから新たに開発するサービスであれば違うかもしれませんが、5年前、10年前に開発したサービスであれば自動テストも十分でない（そもそも書けない）ことも多く、かといってお金を生んでいる大事なコードだけに捨てることも難しく、うまく付き合っていくしかありません。

そんな中、今回 PDF の出力処理をリファクタリングするシーンで、「ユニットテストの代わりにリファクタリング前後の PDF 画像を比較する」という手法がそれなりに役立つことが分かったので、紹介したいと思います。


## 概要

ImageMagick を使って PDF ファイルを画像化して比較することで、リファクタリング前後で出力が変わっていないことを確認できる

## 確認環境

普段使っている Mac に [Homebrew](https://brew.sh/) で [Ghostscript](https://www.ghostscript.com/) と [ImageMagick](https://www.imagemagick.org/) をインストールして確認しました。Ghostscript は PDF を画像化するのに必要です。Windows や Linux でもこれらのソフトウェアをインストールできれば実行可能だと思いますが、未検証である点にご注意ください。

* macOS High Sierra

    ```shell-session
    $ sw_vers
    ProductName:    Mac OS X
    ProductVersion: 10.13.1
    BuildVersion:   17B48
    ```

* Ghostscript 9.22

    ```shell-session
    $ brew install gs
    $ gs --version
    9.22
```

* ImageMagick 7.0.7

    ```shell-session
    $ brew install imagemagick
    $ magick --version
    Version: ImageMagick 7.0.7-11 Q16 x86_64 2017-11-12
    ```

## PDFの画像化

まず PDF ファイルを画像に変換する方法を確認します。基本的には ImageMagick の `convert` コマンドを実行するだけで OK ですが、デフォルトだと解像度（dpi）の低い画像になってしまうので、`-density` オプションを使って適宜調整します。ただし、解像度を大きくすればするほど実行速度は遅くなるため、デグレ確認の画像一致に十分なほどほどの解像度にしましょう。

```shell-session
$ convert amazon.pdf amazon.png
```

![image.png](https://qiita-image-store.s3.amazonaws.com/0/33668/ffe9c42d-43d2-d854-cd68-a20359fe0657.png)

```shell-session
$ convert -density 200 amazon.pdf amazon.png
```

![image.png](https://qiita-image-store.s3.amazonaws.com/0/33668/32ae5495-309e-381c-b7fc-a5f8099afc87.png)

また、PDF ファイルによっては背景色が透明の画像が生成されてしまいます。後述する画像比較がうまくできなくなってしまうようなので、`-alpha off` オプションを指定して不透明にします。

```shell-session
$ convert -density 200 -alpha off amazon.pdf amazon.png
```

なお、`convert` コマンドは複数ページの PDF ファイルにも対応しています。複数ページの場合は、1つの PDF ファイルから連番の画像ファイルが生成される点に注意します。ちょっと扱いづらいです。

```shell-session
$ ls -1 amazon-*.png
amazon-0.png
amazon-1.png
amazon-2.png
```

## 画像の比較

2つの画像が同一かどうかを判定するには、ImageMagick の `composite` コマンドが使えます。次のようにリファクタリング前後の画像を入力として実行することで、差分画像（変更のない部分が黒色、変更のある部分が白色になった画像）を出力することができます。

```shell-session
$ composite -compose difference before.png after.png diff.png
```

例えば、先の Amazon の領収書のような帳票を出力する処理をリファクタリングしたときに、本来中央揃えにしなければならない見出しが左揃えに変わってしまったとしても、画像の差分からデグレを検出できることが分かります。

* before.png
    ![image.png](https://qiita-image-store.s3.amazonaws.com/0/33668/32ae5495-309e-381c-b7fc-a5f8099afc87.png)

* after.png
    ![image.png](https://qiita-image-store.s3.amazonaws.com/0/33668/9ec1e948-2f21-9544-f966-677b8fbd9e44.png)

* diff.png
    ![image.png](https://qiita-image-store.s3.amazonaws.com/0/33668/3e3dab1f-be88-150e-1fd0-789752f4c48f.png)

ただし、人間の目視ほどあてにならないものはないので、自動的に OK/NG を判定できるようにしたいところです。そのためには、`identify` コマンドで画素の平均値を算出します。差分がない場合は真っ黒（すべての画素が黒）になるため 0、逆に 1 ピクセルでも差分がある場合は白色が混ざるため 0 より大きくなり、差分が多ければ多いほど 65535 に近づきます。

```shell-session
$ identify -format "%[mean]" diff.png  # 差分がない場合（真っ黒）
0

$ identify -format "%[mean]" diff.png  # 差分がある場合（ところどころ白）
16970.5
```

## 一括検証スクリプト

以上をふまえると、リファクタリング前のプログラムで出力した PDF ファイル一式と、リファクタリング後のプログラムで出力した PDF ファイル一式をまとめて比較するスクリプトを作ることができます。

```bash:pdfdiff
#!/bin/bash

readonly BEFORE_DIR="before"
readonly AFTER_DIR="after"
readonly OUTPUT_DIR="output"
readonly IM_DENSITY="-density 200"

function convert_to_image() {
    local path=$1
    local output_dir=$2

    mkdir -p $output_dir
    convert -quiet $IM_DENSITY -alpha off $path $output_dir/image.png
    echo $(ls -1 $output_dir/*.png | wc -l)
}

function compare_pdf() {
    local pdf=$1
    local img1_dir=$OUTPUT_DIR/$pdf/before
    local img2_dir=$OUTPUT_DIR/$pdf/after
    local diff_dir=$OUTPUT_DIR/$pdf/diff
    mkdir -p $diff_dir

    local count1=$(convert_to_image $BEFORE_DIR/$pdf $img1_dir)
    local count2=$(convert_to_image $AFTER_DIR/$pdf $img2_dir)
    if [ $count1 -ne $count2 ]; then
        echo "$pdf: NG"
        return
    fi

    for path in $img1_dir/*.png; do
        png=$(basename $path)
        composite -quiet -compose difference $img1_dir/$png $img2_dir/$png $diff_dir/$png
        result=$(identify -format "%[mean]" $diff_dir/$png)
        if [ "$result" = "0" ]; then
            echo "$pdf/$png: OK"
        else
            echo "$pdf/$png: NG"
        fi
    done
}

for path in $BEFORE_DIR/*.pdf; do
    pdf=$(basename $path)
    compare_pdf $pdf
done
```

だいぶ決め打ちですが、次のようにカレントディレクトリの `before`, `after` 以下に PDF ファイルがあるという前提で、それぞれを画像化したファイルと差分画像ファイルを `output` ディレクトリ以下に生成し、差分の有無を標準出力で知らせてくれます。

```
├── before  # リファクタリング前のプログラムによる出力
│   ├── PDF出力1.pdf
│   ├── PDF出力2.pdf
│   └── ...
├── after   # リファクタリング後のプログラムによる出力
│   ├── PDF出力1.pdf
│   ├── PDF出力2.pdf
│   └── ...
├── output  # 一括検証スクリプトの出力
│   ├── PDF出力1.pdf
│   │   ├── before
│   │   │   └── image.png
│   │   ├── after
│   │   │   └── image.png
│   │   └── diff
│   │       └── image.png
│   ├── PDF出力2.pdf
│   │   ├── before
│   │   │   ├── image-0.png
│   │   │   ├── image-1.png
│   │   │   └── ...
│   │   ├── after
│   │   │   ├── image-0.png
│   │   │   ├── image-1.png
│   │   │   └── ...
│   │   └── diff
│   │       ├── image-0.png
│   │       ├── image-1.png
│   │       └── ...
```

```shell-session
$ pdfdiff
PDF出力1.pdf/image.png: OK
PDF出力2.pdf/image-0.png: OK
PDF出力2.pdf/image-1.png: NG
...
```

## まとめ

画像を差分比較するテクニック自体は以前から知られており、私もE2Eテストと組み合わせて画面のレイアウト崩れを検出しようと試みたことがありました。しかし、現在時刻に関連した表示や、画面表示のたびに再読込される非決定的なコンテンツがあり、なかなか誤検出を取り除くのが手間でうまくいかなかった記憶があります。

今回、データベースの内容から固定的に生成されるPDFに対して、リファクタリング前後の出力一致を確認するために画像差分を使える、という点に気付けたことは新たな学びでした。byte 単位で比較するわけではないので内部構造も含めた完全一致を保証するわけではありませんが、逆に内部構造が違っていても表示上は同じであることを担保したい場合には有用です。この手法が役立つシーンは限定的だとは思いますが、あなたのリファクタリング道具箱の片隅に加えてみてはいかがでしょうか。

## 参考情報

* [2枚の画像のdiff（差分）を超簡単に調べる方法](http://blog.mirakui.com/entry/20110326/1301111196)
* [GulpとGraphicsMagickでPDFを差分比較する。マルチページ・マルチファイル対応](https://qiita.com/y_hokkey/items/417e108e212210b4e635)