【Ionic + Electron】Resemble.jsを使って画像比較を行うデスクトップアプリを作成

# はじめに

[Resemble.js](https://github.com/HuddleEng/Resemble.js)なるものがとても手軽で便利そうだったので、使ってみました。
Node上で動作する画像比較Javascriptライブラリです。

E2Eテスト等で機械的に吐き出されるスクリーンキャプチャに対して前回分との比較&差分をイメージで出力とかやりたい時に便利そうです。

## Resemble.jsの基本的な使い方

使い方はとてもシンプルでした。

```javascript
// 比較したい画像のパスを指定
const image1 = fs.readFileSync(path1);
const image2 = fs.readFileSync(path2);
  
resemble(image1).compareTo(image2).onComplete(data => {
  // 不一致率で指定した値を上回る時のみ画像を出力することができる
  if (data.misMatchPercentage >= 0.01) {
    console.log('差分を検知しました。')
    const fileName = 'diff_' + name;
    fs.writeFileSync(outputPath + fileName, data.getBuffer());
  }
});
```

実行結果のイメージは以下のような感じです。

![compare.png](https://qiita-image-store.s3.amazonaws.com/0/164245/b04ed62e-9715-86cf-3be4-b6607f1f45b0.png)

２つのファイルを比較して、違いが発見できたところに色をつけてくれます。

# アプリを作るまでにやったこと

実際にResemble.jsを使ってデスクトップアプリを作ってみます。

こんな感じです。

![image-diff-app-proto.gif](https://qiita-image-store.s3.amazonaws.com/0/164245/7cb87a7b-170a-eb49-07ab-4bacb5fd2aad.gif)

要件は以下になります。

* ２つの入力フォルダと１つの出力フォルダをダイアログで指定できる
* ２つの入力フォルダ下に存在する同名画像ファイルを比較できる
* 実行前に入力フォルダ内にある画像を一覧で確認できる
* 差分が検知された場合、差分を確認できるように画像で結果を出力する
* 出力された画像を一覧で確認できる


## プロジェクト作成

実行環境

```bash
$ ionic info

cli packages: (/Users/name/.nodebrew/node/v8.1.2/lib/node_modules)

    @ionic/cli-utils  : 1.19.1
    ionic (Ionic CLI) : 3.19.1

local packages:

    @ionic/app-scripts : 3.1.8
    Ionic Framework    : ionic-angular 3.9.2

System:

    Node : v8.1.2
    npm  : 5.5.1 
    OS   : macOS High Sierra

Misc:

    backend : legacy
```

下記コマンドを順番に実行します。

```bash
# プロジェクト生成
$ ionic start image-diff-app blank
$ cd image-diff-app

# Electronインストール
$ npm install --save-dev electron electron-packager

# Resemble.jsをインストール
$ npm install --save resemblejs

# クイックスタートキットをダウンロード&抽出
$ git clone https://github.com/electron/electron-quick-start
$ mv electron-quick-start/main.js .
$ mv electron-quick-start/renderer.js .

# 不要ファイルを削除
$ rm -rf temp-dir electron-quick-start/

# Ionicビルド。wwwフォルダにビルド成果物が吐き出される。
$ ionic build
```

Electronが「www/index.html」を読み込むようにmain.jsを編集します。

```javascript
// Old
// mainWindow.loadURL(url.format({
//     pathname: path.join(__dirname, 'index.html'),
//     protocol: 'file:',
//     slashes: true
// }))

// New
mainWindow.loadURL(url.format({
    pathname: path.join(__dirname, 'www/index.html'),
    protocol: 'file:',
    slashes: true
}))
```

electron実行用のscriptとmainプロパティをpackage.jsonに追加します。

```javascript
{
  // 省略
  "main": "main.js",
  "scripts": {
    "clean": "ionic-app-scripts clean",
    "build": "ionic-app-scripts build",
    "lint": "ionic-app-scripts lint",
    "ionic:build": "ionic-app-scripts build",
    "ionic:serve": "ionic-app-scripts serve",
    "start": "electron .",
    "electron:build": "electron-packager . --overwrite --out='./dist"
  },
  // 省略
}
```

下記コマンド実行でIonic BlankアプリがElectronアプリとして起動します。

```bash
$ npm start
```

<img width="912" alt="blank-app-on-electron.png" src="https://qiita-image-store.s3.amazonaws.com/0/164245/61a582c7-bdd5-b746-5d49-6a39fa599180.png">

## Webpack.config.jsをカスタム

Node APIやElectron APIをIonicコードから使えるようにWebpack設定をカスタマイズします。

```bash
# カスタム設定ファイル置き場を作成
$ mkdir config

# Ionicが持つ設定ファイルをコピーして持ってくる
$ cp ./node_modules/@ionic/app-scripts/config/webpack.config.js ./config/webpack.config.js
````

下記のようなexternal設定をdevConfig,prodConfigに追加します。

```javascript
  externals: [
    (function () {
        var IGNORES = ["fs","child_process","electron","path","assert","cluster","crypto","dns","domain","events","http","https","net","os","process","punycode","querystring","readline","repl","stream","string_decoder","tls","tty","dgram","url","util","v8","vm","zlib"];
        return function (context, request, callback) {
            if (IGNORES.indexOf(request) >= 0) {
                return callback(null, "require('" + request + "')");
            }
            return callback();
        };
    })()
  ],
```

カスタム設定を有効にするようにpackage.jsonに追記します。

```json
  "config": {
    "ionic_bundler": "webpack",
    "ionic_webpack": "./config/webpack.config.js"
  },
```

ちなみに上記の設定がないとElectron実行時に

```
fs.existsSync is not a function
```

みたいなエラーが出てました。ここを解決するのが辛かった。

## 入力フォルダを指定する＆画像を一覧で表示する

説明しやすさ優先のためコンポーネント分割はしません。サービスも切り出しません。

home.htmlを編集します。

```html:home.html
<!-- メインヘッダー -->
<ion-header>
  <ion-navbar>
    <ion-title>
      Image Diff App
    </ion-title>
  </ion-navbar>
</ion-header>

<!-- コンテンツ -->
<ion-content padding>
  <ion-row justify-content-center>
    <!-- Windowサイズに合わせて良い感じになるように -->
    <ion-col col-12 col-sm-12 col-md-12 col-lg-6 col-xl-6>
      <!-- フォルダパス入力 -->
      <ion-item>
        <ion-label>フォルダ１</ion-label>
        <ion-input type="text" [(ngModel)]="path1" (click)="onInput1()" required clearInput readonly></ion-input>
      </ion-item>
      <!-- 読み込んだパス以下にある画像ファイルを表示 -->
      <ion-card class="image-grid-card" *ngIf="files1.length !== 0">
        <ion-grid>
          <ion-row>
            <ion-col *ngFor="let file of files1;" col-4>
              <img [src]="file.path" />
            </ion-col>
          </ion-row>
        </ion-grid>
      </ion-card>
    </ion-col>
    <!-- Windowサイズに合わせて良い感じになるように -->
    <ion-col col-12 col-sm-12 col-md-12 col-lg-6 col-xl-6>
      <!-- フォルダパス入力 -->
      <ion-item>
        <ion-label>フォルダ２</ion-label>
        <ion-input type="text" [(ngModel)]="path2" (click)="onInput2()" required clearInput readonly></ion-input>
      </ion-item>
      <!-- 読み込んだパス以下にある画像ファイルを表示 -->
      <ion-card class="image-grid-card" *ngIf="files2.length !== 0">
        <ion-grid>
          <ion-row>
            <ion-col *ngFor="let file of files2;" col-4>
              <img [src]="file.path" />
            </ion-col>
          </ion-row>
        </ion-grid>
      </ion-card>
    </ion-col>
  </ion-row>
</ion-content>

```

必要なパッケージをimportします。electronやpath,fsが使えているところがみそ。

```typescript:home.ts
import { Component } from '@angular/core';
import * as electron from 'electron';
import * as fs from 'fs';
import * as Path from 'path';
```

処理を記述します。

```typescript:home.ts
// フォルダパス
path1: string = '';
path2: string = '';

// 読み込んだイメージファイルリスト
files1: { name: string, path: string }[] = [];
files2: { name: string, path: string }[] = [];

// 入力欄１クリック処理
onInput1() {
  this.showDialog()
    .then(directory => {

      this.path1 = '';
      this.files1 = [];

      this.path1 = directory;
      this.files1 = this.readImageFiles(directory);
    });
}

// 入力欄１クリック処理
onInput2() {
  this.showDialog()
    .then(directory => {

      this.path2 = '';
      this.files2 = [];

      this.path2 = directory;
      this.files2 = this.readImageFiles(directory);
    });
}

/** 
 * ディレクトリ参照ダイアログを表示して、選択した結果を返す 
 */
private showDialog(): Promise<string> {
  return new Promise((resolve, reject) => {
    const dialog = electron.remote.dialog;
    const browserWindow = electron.remote.BrowserWindow;
    const focusedWindow = browserWindow.getFocusedWindow();
    dialog.showOpenDialog(focusedWindow, {
      properties: ['openDirectory']
    }, directories => {
      if (directories && directories.length === 1) {
        resolve(directories[0]);
      } else {
        reject();
      }
    });
  });
}

/**
 * 指定されたパス以下の画像ファイルを取得する
 */<img width="1257" alt="selected-images.png" src="https://qiita-image-store.s3.amazonaws.com/0/164245/e9e96c43-475c-fb21-3fb5-3881aca1907b.png">

private readImageFiles(directory: string) {
  const files: { name: string, path: string }[] = [];
  this.buildTree(directory, files);
  return files;
}


/**
 * ディレクトリで潜って、ファイルで拡張子確認
 */
private buildTree(startPath, files) {
  const entries = fs.readdirSync(startPath);
  entries.forEach((file) => {
    const path = Path.join(startPath, file);
    if (fs.lstatSync(path).isDirectory()) {
      this.buildTree(path, files);
    } else if (file.match(/\.png|PNG|jpeg|jpg|JPG|JPEG$/)) {
      files.push({
        name: file,
        path: path
      });
    }
  });
}
```

動作イメージ

<img width="1257" alt="show-dialog.png" src="https://qiita-image-store.s3.amazonaws.com/0/164245/a8c4c554-21b2-83a2-366c-b8308ad67a13.png">

<img width="1257" alt="selected-images.png" src="https://qiita-image-store.s3.amazonaws.com/0/164245/03c6cc4c-4852-bf3e-ee8e-e8c2ad94ac02.png">

## Ionic(レンダープロセス)とメインプロセスでプロセス間通信を行う

main.jsに画像比較の処理を追加します

```javascript:main.js
ipcMain.on('compareImageFiles', (event, args) => {

  const outputPath = args.outputPath;
  const inFiles1 = args.files1;
  const inFiles2 = args.files2;


  const files1 = {};
  const files2 = {};

  // 比較しやすい形式に変換
  for (let file of inFiles1) {
    files1[file.name] = file.path;
  }

  for (let file of inFiles2) {
    files2[file.name] = file.path;
  }

  const tasks = [];

  // 比較
  Object.keys(files1).forEach(name => {
    if (name in files2) {
      tasks.push(getDiff(name, files1[name], files2[name], outputPath));
    }
  });

  Promise.all(tasks).then(result => {
    event.sender.send('compareImageFiles_fin', result.filter(path => {
      return (path !== undefined);
    }));
  });

});

const getDiff = (name, path1, path2, outputPath) => {
  return new Promise((resolve, reject) => {
    const image1 = fs.readFileSync(path1);
    const image2 = fs.readFileSync(path2);
  
    resemble(image1).compareTo(image2).onComplete(data => {
      if (data.misMatchPercentage >= 0.01) {
          console.log('差分を検知しました。')
          const fileName = 'diff_' + name;
          fs.writeFileSync(outputPath + fileName, data.getBuffer());
          resolve({
            name: fileName,
            path: outputPath + fileName
          });
      } else {
        resolve();
      }
    });
  });
};
```

home.htmlに出力先フォルダパスを入力する欄と結果を表示する領域を追加します。

```html:home.html

  <ion-row justify-content-center>
    <ion-col col-12 col-sm-12 col-md-12 col-lg-9 col-xl-9>
      <!-- 出力先フォルダパス入力 -->
      <ion-item>
        <ion-label>出力先フォルダ</ion-label>
        <ion-input type="text" [(ngModel)]="outputPath" (click)="onOutputPath()" required clearInput readonly></ion-input>
      </ion-item>
      <!-- 比較開始ボタン -->
      <button ion-button outline block round (click)="onStartComparison()">比較開始</button>
      <!-- 比較結果の画像を表示する -->
      <ion-card class="image-grid-card" *ngIf="outputFiles.length !== 0">
        <ion-grid>
          <ion-row>
            <ion-col *ngFor="let file of outputFiles;" col-4>
              <img [src]="file.path" />
            </ion-col>
          </ion-row>
        </ion-grid>
      </ion-card>
    </ion-col>
  </ion-row>

```

home.tsに出力先指定の処理と、プロセス間通信用の処理を追加します。

```typescript:home.ts

  outputPath: string = '';
  outputFiles: { name: string, path: string }[] = [];

  // コンストラクター
  constructor(private loadingCtrl: LoadingController) { }

  // 出力先フォルダ入力欄クリックして処理
  onOutputPath() {
    this.showDialog()
      .then(directory => {
        this.outputPath = directory;
      });
  }

  onStartComparison() {
    // ファイルがない場合または出力先が未指定の場合は処理を開始しない
    if (this.files1.length === 0 || this.files2.length === 0 || this.outputPath === '') {
      return;
    }

    const loading = this.loadingCtrl.create();

    loading.present().then(() => {
      // メインプロセスの処理を呼び出す
      electron.ipcRenderer.send('compareImageFiles', {
        outputPath: this.outputPath,
        files1: this.files1,
        files2: this.files2
      });
      electron.ipcRenderer.on('compareImageFiles_fin', (event, result) => {
        this.outputFiles = result;
        loading.dismiss();
      });
    });

  }
```

実装は以上になります。

実行結果イメージ

<img width="1243" alt="result.png" src="https://qiita-image-store.s3.amazonaws.com/0/164245/35fc4e23-7e51-9ed3-f22c-d5f3fe93743b.png">

## 配布用にexeとappを作ってみる

```bash
# パッケージングツールをインストール
$ npm -g install electron-packager

# exeを作るための準備（いらないならここの手順は不要）
$ brew update
$ brew cask install xquartz
$ brew install wine

# appを作成
$ electron-packager . ImageDiffApp --platform=darwin --arch=x64 --version=1.8.2

# exeを作成(動作確認は取れていません)
$ electron-packager . ImageDiffApp --platform=win32 --arch=x64 --version=1.8.2

```

## 終わりに

Githubリポジトリはこちらです。 -> [image-diff-app](https://github.com/yosshitaku067/image-diff-app)

今回はResemble.jsというライブラリを使って画像比較用のアプリを作成しました。
手軽に実行できる点が何よりの強みかなと思います。

また、今回はIonic+Electronを土台に採用しました。
やりたいことが決まっており、UIデザインにそこまでこだわりがなくサクッと作りたい時は、
Ionicはとてもいい感じの選択肢かなとここ一年くらい感じています。

Ionic本が発売されたこともあり、周囲にもIonic気になるという人が少しづつ本当に少しづつ増えてきています。
個人的にはインハウスアプリを作る時なんかにおすすめなので、各社の社内SEさんどうでしょうか？

流行りまくって「このアプリIonic臭やばいwww」とか聞いてみたい。