WinMergeでフォルダ差分を出してExcelにまとめるまでをPythonで自動化・改

# 背景
以前、Pythonを使ってWinMergeの比較結果を一発でExcelにまとめる、という記事を書かせていただいた者です。

https://qiita.com/y-tetsu/items/5eb076ec9787fad70958

相変わらず普段、WinMergeのお世話になっています。

あれからしばらく経ちましたが、その後も少しずつメンテを行い改良を重ねてきました。ある程度キリがいいところまで来たので、今回はその進捗をメモしておこうと思います。


# やったこと
まず最初に、今回やった前回からの改善点を挙げます。

- 差分ファイルのシートに行番号を必ず入れる
- 文字列の差分箇所を必ず赤色で強調する
- 異なるフォルダにある同名ファイルの差分出力に対応する
- 差分ファイルに表を追記する処理を高速化する

# できたもの
次に、今回できたものは前回同様にコマンドライン上で実行するスクリプトになります。

以下のようにスクリプト(*winmerge_xlsx.py*)を実行すると、フォルダ同士の比較結果をまとめたExcelファイルが得られます。

(実行方法)
```
python winmerge_xlsx.py <比較元フォルダパス> <比較先フォルダパス> [<出力Excelファイル名>]
```

:::note warn
3つ目の引数は省略可能です。省略した場合は実行フォルダに**output.xlsx**が結果として出力されます。こちらも前回と同じです。
:::

## 出力結果のサンプル
出力結果のExcelファイルには、ソースの差分が以下のようにまとめられています。

(表紙)
![title.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/04272bd1-30d7-da39-d2d0-43cea96db61d.png)

(差分)
![diff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/23f8226a-b69a-c3b5-be18-a8a295c697de.png)

## スクリプト
今回作ったPythonスクリプトです。300行を超えました！

折りたたんでおきますので、必要な方は展開してからコピペして下さい。

<details><summary>★クリックで展開★ WinMergeの差分レポートをエクセルに出力するスクリプト</summary>

```winmerge_xlsx.py
import sys
import os
import shutil
from pathlib import Path
import subprocess
import win32com.client

WINMERGE_EXE = r'C:\Program Files\WinMerge\WinMergeU.exe'  # WinMergeへのパス
WINMERGE_OPTIONS = [
    '/minimize',                             # ウィンドウ最小化で起動
    '/noninteractive',                       # レポート出力後に終了
    '/cfg',
    'Settings/DirViewExpandSubdirs=1',       # 自動的にサブフォルダーを展開する
    '/cfg',
    'Settings/ViewLineNumbers=1',            # 行番号を表示する
    '/cfg',
    'Settings/WordDifferenceTextColor=255',  # 差分単語を赤色で表示
    '/cfg',
    'ReportFiles/ReportType=2',              # シンプルなHTML形式
    '/cfg',
    'ReportFiles/IncludeFileCmpReport=1',    # ファイル比較レポートを含める
    '/r',                                    # すべてのサブフォルダ内のすべてのファイルを比較
    '/u',                                    # 最近使用した項目リストに追加しない
    '/or',                                   # レポートを出力
]

xlUp = -4162
xlOpenXMLWorkbook = 51
xlCenter = -4108
xlContinuous = 1

SUMMARY_WS_NUM = 1        # 一覧シートのワークシート番号
SUMMARY_START_ROW = 6     # 一覧シートの表の開始行
SUMMARY_NAME_COL = 'A'    # 一覧シートの表の名前列
SUMMARY_FOLDER_COL = 'B'  # 一覧シートの表のフォルダー列

HOME_POSITION = 'A1'  # ホームポジション

DIFF_START_ROW = 2                                            # 差分シートの開始行
DIFF_ZOOM_RATIO = 85                                          # 差分シートのズームの倍率
DIFF_FORMATS = {                                              # 差分シートの書式設定
    'no': [                                                   # 行番号列
        {'col': 'A', 'width': 5},                             # 左側
        {'col': 'C', 'width': 5},                             # 右側
    ],
    'code': [                                                 # ソースコード列
        {'col': 'B', 'width': 100, 'font': 'ＭＳ ゴシック'},  # 左側
        {'col': 'D', 'width': 100, 'font': 'ＭＳ ゴシック'},  # 右側
    ],
    'extra': [                                                # 追加列
        {'col': 'E', 'width': 60, 'header': 'コメント'},
    ],
}


class WinMergeXlsx:
    """WinMergeの差分レポートをエクセルに出力
    """
    def __init__(self, base, latest, output='./output.xlsx'):
        self.base = Path(base).absolute()
        self.latest = Path(latest).absolute()
        self.output = Path(output).absolute()

        parent = str(self.output.parent)
        stem = str(self.output.stem)
        self.output_html = Path(parent + '/' + stem + '.html')
        self.output_html_files = Path(parent + '/' + stem + '.files')

        self.sheet_memo = {}
        self.sheet_count = {}

    def generate(self):
        """レポート生成
        """
        self._setup()
        self._generate_html_by_winmerge()
        self._convert_html_to_xlsx()

    def _setup(self):
        """準備
        """
        self._setup_excel_application()
        self._setup_output_files()

    def _setup_excel_application(self):
        """エクセルアプリケーションの準備
        """
        try:
            if win32com.client.GetObject(Class='Excel.Application'):
                self.__message_and_exit('Excelを閉じて下さい。')
        except win32com.client.pywintypes.com_error:
            pass

    def _setup_output_files(self):
        """出力するファイルの準備
        """
        # htmlレポート
        if (os.path.exists(self.output_html)):
            try:
                os.remove(self.output_html)
            except PermissionError:
                message = str(self.output_html) + 'へのアクセス権がありません。'
                self.__message_and_exit(message)
        # WinMergeの中間ファイル
        if (os.path.isdir(self.output_html_files)):
            try:
                shutil.rmtree(self.output_html_files)
            except PermissionError:
                message = str(self.output_html_files) + 'へのアクセス権がありません。'
                self.__message_and_exit(message)
        # エクセルレポートファイル
        if (os.path.exists(self.output)):
            try:
                os.remove(self.output)
            except PermissionError:
                message = str(self.output) + 'へのアクセス権がありません。'
                self.__message_and_exit(message)

    def __message_and_exit(self, message):
        """メッセージを表示して終了
        """
        print('\nError : ' + message)
        sys.exit(-1)

    def _generate_html_by_winmerge(self):
        """WinMergeにてhtmlレポート生成
        """
        command = [
            WINMERGE_EXE,
            str(self.base),         # 比較元のフォルダ
            str(self.latest),       # 比較先のフォルダ
            *WINMERGE_OPTIONS,      # WinMergeのコマンドライン実行オプション
            str(self.output_html),  # レポートのパス
        ]
        print(' '.join(command))
        subprocess.run(command)

    def _convert_html_to_xlsx(self):
        """htmlレポートをエクセルファイルに変換する
        """
        try:
            self._open_book()
            self._format_summary_sheet()
            self._copy_html_files()
            self._format_diff_sheets()
            self._set_home_position(self.summary_ws)
            self._save_book()

        finally:
            self.excel.Quit()

    def _open_book(self):
        """ブックを開く
        """
        self.excel = win32com.client.Dispatch('Excel.Application')
        self.wb = self.excel.Workbooks.Open(self.output_html)
        self.summary_ws = self.wb.Worksheets(SUMMARY_WS_NUM)

    def _format_summary_sheet(self):
        """一覧シートの書式調整
        """
        ws = self.summary_ws
        end_row = ws.Cells(ws.Rows.Count, 1).End(xlUp).Row

        # 同名シート有無の確認
        for row in range(SUMMARY_START_ROW, end_row+1):
            name_cell = ws.Range(SUMMARY_NAME_COL + str(row))
            if not name_cell.Value:
                break
            if name_cell.Hyperlinks.Count > 0:
                sheet_name = name_cell.Value
                if sheet_name in self.sheet_memo:
                    self.sheet_memo[sheet_name] += 1
                else:
                    self.sheet_memo[sheet_name] = 1

        # htmlファイルとハイパーリンクの設定
        for row in range(SUMMARY_START_ROW, end_row+1):
            name_cell = ws.Range(SUMMARY_NAME_COL + str(row))
            if not name_cell.Value:
                break

            if name_cell.Hyperlinks.Count > 0:
                sname_src = sname_dst = name_cell.Value
                if sname_src in self.sheet_count:
                    self.sheet_count[sname_src] += 1
                else:
                    self.sheet_count[sname_src] = 1

                if self.sheet_memo[sname_src] >= 2:
                    sname_dst += f'_{self.sheet_count[sname_src]}'

                self._change_hyperlink(name_cell, sname_dst)
                folder_cell = ws.Range(SUMMARY_FOLDER_COL + str(row)).Value
                self._rename_html_files(sname_src, sname_dst, folder_cell)

    def _change_hyperlink(self, name_cell, name_dst):
        """ハイパーリンクの修正
        """
        for hl in name_cell.Hyperlinks:
            hl.Address = ''
            hl.SubAddress = name_dst + '!' + HOME_POSITION
            hl.TextToDisplay = name_dst

    def _rename_html_files(self, name_src, name_dst, folder):
        """htmlレポートのリネーム
        """
        sheet_name = folder.replace('\\', '_') + '_' + name_src if folder else name_src
        src = f'{self.output_html_files}/{sheet_name}.html'
        dst = f'{self.output_html_files}/{name_dst}.html'
        os.rename(src, dst)
        if sheet_name != name_dst:
            print(sheet_name + ' ---> ' + name_dst)

    def _copy_html_files(self):
        """htmlレポートをエクセルにコピー
        """
        g = self.output_html_files.glob('**/*.html')
        for count, html in enumerate(g, 1):
            diff_wb = self.excel.Workbooks.Open(html)
            diff_ws = diff_wb.Worksheets(1)
            diff_ws.Copy(Before=None, After=self.wb.Worksheets(count))

    def _format_diff_sheets(self):
        """差分シートの書式調整
        """
        for i in range(DIFF_START_ROW, self.wb.Worksheets.Count+1):
            ws = self.wb.Worksheets(i)
            self._set_zoom(ws)
            self._freeze_panes(ws)
            self._remove_hyperlink_from_no(ws)
            self._set_format(ws)
            self._set_home_position(ws)

    def _set_zoom(self, ws):
        """拡大率を設定する
        """
        ws.Activate()
        self.excel.ActiveWindow.Zoom = DIFF_ZOOM_RATIO

    def _freeze_panes(self, ws):
        """ウィンドウ枠を固定する
        """
        ws.Activate()
        ws.Range('A' + str(DIFF_START_ROW)).Select()
        self.excel.ActiveWindow.FreezePanes = True

    def _remove_hyperlink_from_no(self, ws):
        """行番号のハイパーリンクを削除する
        """
        for f in DIFF_FORMATS['no']:
            end_row = ws.Cells(ws.Rows.Count, 1).End(xlUp).Row
            r1 = f['col'] + ':' + f['col']
            r2 = f['col'] + str(DIFF_START_ROW) + ':' + f['col'] + str(end_row)
            ws.Range(r1).Hyperlinks.Delete()
            ws.Range(r2).Interior.Color = int('F0F0F0', 16)
            ws.Range(r2).Font.Size = 12

    def _set_format(self, ws):
        """差分シートの書式調整
        """
        for key in DIFF_FORMATS.keys():
            for f in DIFF_FORMATS[key]:
                r = f['col'] + ':' + f['col']
                ws_range = ws.Range(r)
                if 'width' in f:
                    ws_range.ColumnWidth = f['width']
                if 'font' in f:
                    ws_range.Font.Name = f['font']
                if 'header' in f:
                    self._set_extra_table(ws, f)

    def _set_extra_table(self, ws, f):
        """表を追加する
        """
        end_row = max(ws.Cells(ws.Rows.Count, 1).End(xlUp).Row, ws.Cells(ws.Rows.Count, 3).End(xlUp).Row)

        # 表の見出し
        ws_range = ws.Range(f['col'] + '1')
        ws_range.Value = f['header']
        ws_range.VerticalAlignment = xlCenter
        ws_range.HorizontalAlignment = xlCenter
        ws_range.Interior.Color = int('CCFFCC', 16)

        # 表の罫線
        ws_range = ws.Range(f['col'] + '1:' + f['col'] + str(end_row))
        ws_range.Borders.Color = int('000000', 16)
        ws_range.Borders.LineStyle = xlContinuous

        # 表の中身を一旦"-"で埋める
        ws_range = ws.Range(f['col'] + '2:' + f['col'] + str(end_row))
        ws_range.Value = '-'
        ws_range.Interior.Color = int('E0E0E0', 16)

        # 差分がある箇所を空欄に変更する
        code_col = DIFF_FORMATS['code'][0]['col']
        target = code_col + str(DIFF_START_ROW) + ':' + code_col + str(end_row)
        row = DIFF_START_ROW
        group = 0
        for cell in ws.Range(target):
            if cell.Interior.Color != int('FFFFFF', 16):
                group += 1
            else:
                if group:
                    ws_range = ws.Range(f['col'] + str(row-group) + ':' + f['col'] + str(row-1))
                    ws_range.Value = ''
                    ws_range.Interior.Color = int('FFFFFF', 16)
                    group = 0
            row += 1
        if group:
            ws_range = ws.Range(f['col'] + str(end_row-group+1) + ':' + f['col'] + str(end_row))
            ws_range.Value = ''
            ws_range.Interior.Color = int('FFFFFF', 16)

    def _set_home_position(self, ws):
        """ホームポジションを設定する
        """
        ws.Activate()
        ws.Range(HOME_POSITION).Select()

    def _save_book(self):
        """ブックを保存する
        """
        self.wb.SaveAs(str(self.output), FileFormat=xlOpenXMLWorkbook)
        print('xlsxへの変換が完了しました。')


if __name__ == '__main__':
    if len(sys.argv) < 3:
        print(f'Usage : {sys.argv[0]} <base> <latest> [<output>]')
        sys.exit(1)

    import time

    start = time.perf_counter()
    WinMergeXlsx(*sys.argv[1:4]).generate()
    end = time.perf_counter()

    print(f'elp = {end-start:.3f}s')
```
</details>

以下のGitHubにも、同じものを置いております。テスト用のフォルダも必要でしたらこちらからどうぞ。

https://github.com/y-tetsu/winmerge_xlsx


## 改善点
それでは、今回の改善点を簡単に説明したいと思います。

### 差分ファイルのシートに行番号を必ず入れる
WinMerge実行時のオプションに**行番号表示**を明示的に入れました。人によりWinMerge本体の設定が非表示となっていると、「行番号が出ない」といったケースがあったため対応しています。

「行番号なんていらねぇよ」という方には、大変すまないことをしてしまったと思います。お手数ですが、数字を0に変えて下さい。

```python
WINMERGE_OPTIONS = [
    :
    :
    '/cfg',
    'Settings/ViewLineNumbers=1',            # 行番号を表示する
```

(イメージ)
![row_num.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/88ee353f-a655-4745-0448-8bd8dd880e9a.png)

### 文字列の差分箇所を必ず赤色で強調する
WinMerge実行時のオプションに文字列の差分箇所の**赤色表示**を明示的に入れました。こちらも先ほどと同様に、「人によって差分が強調されずわかりにくい」といったケースがあったため対応しています。

「赤色なんて気に食わねぇよ」という方には、大変すまないことをしてしまったと思います。お手数ですが、数字をお好みのカラーコードに変えて下さい。

```python
WINMERGE_OPTIONS = [
    :
    :
    '/cfg',
    'Settings/WordDifferenceTextColor=255',  # 差分単語を赤色で表示
```

(イメージ)
![red.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/c28d2e6a-0b97-b8e2-ece5-f1cc3a03cda0.png)

:::note warn
WinMerge用のカラーコードは、B(青)・G(緑)・R(赤)の順番でそれぞれの16進数の値(0x00～0xFF)をつなげて、10進数に直せば算出できます。

(文字列差分を真っ赤にする場合)
0x0000FF → 255を設定

(文字列差分を真っ黄緑にする場合)
0x00FF00 → 65280を設定

(文字列差分を真っ青にする場合)
0xFF0000 → 16711680を設定
:::

### 異なるフォルダにある同名ファイルの差分出力に対応する
こちらは機能改善になります。以前は制約事項としていた部分を解消しました。

以下のように、異なるフォルダで同名のファイルがあった場合は、ファイル名末尾に"_通し番号"を付与して対処するようにいたしました。
![same.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/320e6649-bca1-3fd6-3b79-34d24b61569d.png)

`_format_summary_sheet`メソッドと`_rename_html_files`メソッドが大きく変わっておりますが、つまらない差分なので説明を割愛させていただきます。

### 差分ファイルに表を追記する処理を高速化する
差分ファイルの右横の、表を作成する処理に**大半の時間を費やしている**ことが分かりましたので、少し高速化を施しました。

![extra_table.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/b88e2d5f-9ab5-4725-14be-3768a9c7713f.png)

`_set_extra_table`メソッドが該当しますが、この処理が遅い要因としては、セルへのアクセスが大量に発生する点が挙げられます。差分がない箇所は"-"で埋めているのですが、セル1つ1つに対して何らか処理するのはとてもコストが高そうです。

そこで、今回は以下の4点を対策しました。
1. セルの`Range`アクセスを一旦変数に入れて使いまわしながらプロパティアクセスすることで、毎回ワークシートからプロパティまで辿ってアクセスしていた分のオーバーヘッドを減らす
2. 差分がある箇所より差分がない箇所の方が多いとして、一旦、表全体を"-"(差分なし)で一括で埋めることで、個別のセルアクセス回数を減らす
3. 行番号をインクリメントし毎回座標からセルアクセスしていたものを、あらかじめ`Range`でまとめて範囲指定してからイテレートすることで、セルアクセスまでのオーバーヘッドを減らす
4. 差分がある行が連続している範囲をグループ化して、セルのプロパティをまとめて変更することで、個別のセルアクセス数を減らす

関係するコードは以下の部分です。
```python
    def _set_extra_table(self, ws, f):
        """表を追加する
        """
        end_row = max(ws.Cells(ws.Rows.Count, 1).End(xlUp).Row, ws.Cells(ws.Rows.Count, 3).End(xlUp).Row)

        # 表の見出し
        ws_range = ws.Range(f['col'] + '1')
        ws_range.Value = f['header']
        ws_range.VerticalAlignment = xlCenter
        ws_range.HorizontalAlignment = xlCenter
        ws_range.Interior.Color = int('CCFFCC', 16)

        # 表の罫線
        ws_range = ws.Range(f['col'] + '1:' + f['col'] + str(end_row))
        ws_range.Borders.Color = int('000000', 16)
        ws_range.Borders.LineStyle = xlContinuous

        # 表の中身を一旦"-"で埋める
        ws_range = ws.Range(f['col'] + '2:' + f['col'] + str(end_row))
        ws_range.Value = '-'
        ws_range.Interior.Color = int('E0E0E0', 16)

        # 差分がある箇所を空欄に変更する
        code_col = DIFF_FORMATS['code'][0]['col']
        target = code_col + str(DIFF_START_ROW) + ':' + code_col + str(end_row)
        row = DIFF_START_ROW
        group = 0
        for cell in ws.Range(target):
            if cell.Interior.Color != int('FFFFFF', 16):
                group += 1
            else:
                if group:
                    ws_range = ws.Range(f['col'] + str(row-group) + ':' + f['col'] + str(row-1))
                    ws_range.Value = ''
                    ws_range.Interior.Color = int('FFFFFF', 16)
                    group = 0
            row += 1
        if group:
            ws_range = ws.Range(f['col'] + str(end_row-group+1) + ':' + f['col'] + str(end_row))
            ws_range.Value = ''
            ws_range.Interior.Color = int('FFFFFF', 16)
```

手元の環境では10ファイルの差分抽出について、以前は約70秒かかっていたところ、対策後は約40秒と**1.7倍ほど高速化**できました。(もっと速くないと、現場ではしんどいんですけどね)



# 動作確認環境
- Excel
- WinMerge Version 2.16.12.1
- python 3.10.5
- pywin32 304

## pywin32のインストール方法
事前に以下を実行して、**pywin32**をインストールしておいて下さい。
```
pip install pywin32
```


# まとめ
前回からの改善点を4点ご紹介させていただきましたが、いかがでしたでしょうか。

処理の高速化については色々ともがいてみたのですが、大量のソース差分を出すにはまだまだ非力なスクリプトかもしれません。

当初`pywin32`をやめて`openpyxl`に乗り換えることで高速化できないか？とも検討したのですが、そちらではどうも文字列の差分に色を付けられないようで断念しました。何卒、処理速度についてはご容赦いただければ幸いです。

今後も少しずつ改善していければと考えておりますので、ご意見などもしあれば是非お願いします。

皆様にとって、本記事が何かの足しになりましたら大変喜ばしく思います。

その後、改善した内容を以下にまとめましたので、よかったらこちらも見てみて下さい。

https://qiita.com/y-tetsu/items/38c5642bc571e097fd6a