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

# 背景
普段、WinMergeのお世話になっています。

最近、業務の中でソースコードが含まれたフォルダ同士のWinMergeの比較結果を、**Excelにまとめたい**という状況になりました。

Pythonを使って一発で解決できないか？と思い、作ってみることにしました。

思ったよりうまくいったので、自分への覚書を兼ねて今回作った内容を記事に残しておきます。


# やったこと
Pythonを使って、大きく次の手順を自動化しました。
1. WinMergeをコマンドライン実行してHTML形式の差分のレポートを出力する
2. 1.のレポートを読み込み、書式を整える
3. 2.をExcelファイル(*.xlsx)に保存する

HTMLを読み込んで、書式を整えたりExcelに保存したりする部分には、**pywin32**というPythonパッケージを利用しています。


# できたもの
コマンドライン上で、今回作ったスクリプト(*winmerge_xlsx.py*)を以下のように実行すると、フォルダ同士の比較結果をまとめたExcelファイルが得られます。

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

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


## 実行している様子
![run2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/88f30661-009a-0532-7b86-16bd85287f55.gif)

デモでは`py`コマンドでPythonを実行しています。`python`でも同様に実行可能です。

## 出力結果の様子
### 出力Excelファイル
比較したファイルを表にまとめた**一覧シート**と、各ファイル毎の比較結果となる**差分シート**を、1つのExcelファイルにまとめたものになります。

一覧シートのA列のリンクから、各ファイルの差分シートへ飛べるようになっています。
![output2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/f169940f-4b30-ad59-7d44-1b5f39fac8ff.gif)

差分シートへは、任意の見出しを付けた**表**を追加することもできます。(上記例の場合は、E列の「コメント」欄)

### フォルダ構成
結果出力時のフォルダ構成は以下のとおりです。
```
C:\winmerge_xlsx
│  winmerge_xlsx.py … 実行スクリプト
│  output.html      … WinMergeが生成したファイル一覧
│  output.xlsx      … 今回の自動化で生成した最終結果
│
├─folder1                  … 比較元フォルダ
│  │  folder.txt
│  │
│  ├─inc
│  │      common.h
│  │
│  └─src
│         fizzbuzz.c
│         hello_world.c
│         log.txt
│
├─folder2                  … 比較先フォルダ
│  ├─inc
│  │      common.h
│  │
│  └─src
│         fizzbuzz.c
│         hello_world.c
│         log.txt
│
└─output.files             … WinMergeが生成したファイル差分
        fizzbuzz.c.html
        folder.txt.html
        hello_world.c.html
```


## スクリプト
今回作ったPythonスクリプトです。200行ちょいと、まあまあ長いです。

```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',
    '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:
    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')

    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):
        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)
        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):
        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):
        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:
                self._change_hyperlink(name_cell)
                folder_cell = ws.Range(SUMMARY_FOLDER_COL + str(row))
                if folder_cell.Value:
                    self._rename_html_files(name_cell.Value, folder_cell.Value)

    def _change_hyperlink(self, name_cell):
        for hl in name_cell.Hyperlinks:
            hl.Address = ''
            hl.SubAddress = name_cell.Value + '!' + HOME_POSITION

    def _rename_html_files(self, name, folder):
        sheet_name = folder.replace('\\', '_') + '_' + name
        src = f'{self.output_html_files}/{sheet_name}.html'
        dst = f'{self.output_html_files}/{name}.html'
        os.rename(src, dst)
        print(sheet_name + ' ---> ' + name)

    def _copy_html_files(self):
        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']
                if 'width' in f:
                    ws.Range(r).ColumnWidth = f['width']
                if 'font' in f:
                    ws.Range(r).Font.Name = f['font']
                if 'header' in f:
                    self._set_extra_table(ws, f)

    def _set_extra_table(self, ws, f):
        end_row = ws.Cells(ws.Rows.Count, 1).End(xlUp).Row
        r1 = f['col'] + '1'
        r2 = f['col'] + '1:' + f['col'] + str(end_row)
        ws.Range(r1).Value = f['header']
        ws.Range(r1).VerticalAlignment = xlCenter
        ws.Range(r1).HorizontalAlignment = xlCenter
        ws.Range(r1).Interior.Color = int('CCFFCC', 16)
        ws.Range(r2).Borders.Color = int('000000', 16)
        ws.Range(r2).Borders.LineStyle = xlContinuous
        for i in range(DIFF_START_ROW, end_row+1):
            code_col = DIFF_FORMATS['code'][0]['col']
            code_color = ws.Range(code_col + str(i)).Interior.Color
            if code_color == int('FFFFFF', 16):
                r = f['col'] + str(i)
                ws.Range(r).Value = '-'
                ws.Range(r).Interior.Color = int('E0E0E0', 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)

    WinMergeXlsx(*sys.argv[1:4]).generate()
```


### スクリプト説明
スクリプトの中身を1つ1つ説明していきたいと思います。

#### 初期化処理
```python
    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')
```
以下の引数を受け取り、それぞれ**pathlib**ライブラリのPathオブジェクトを使って絶対パスに直しています。

|引数名 |内容 |
|---|---|
|base |比較元のフォルダパスを指定する。出力結果の**左側**にコードが表示される。 |
|latest |比較先のフォルダパスを指定する。出力結果の**右側**にコードが表示される。 |
|output |出力ファイル名を指定する。拡張子は*.xlsxとする。省略した場合は**output.xlsx**とする。 |

また、WinMergeを実行後に読み込むことになる、HTMLのファイルとフォルダ名の絶対パスも準備しています。**stem**メソッドは、ファイル名から拡張子を除いた部分を取得するメソッドです。

#### 結果出力処理
```python
    def generate(self):
        self._setup()
        self._generate_html_by_winmerge()
        self._convert_html_to_xlsx()
```
以下を順に処理して結果を生成しています。

1. 処理実行の前準備を行う
2. WinMergeを実行してHTMLファイルを生成する
3. HTMLをExcelファイルに変換する


#### 前準備
```python
    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):
        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)
        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)
```

以下を順に処理して前準備を行っています。

1. Excelアプリケーションの起動チェック
2. 出力ファイルの削除

**pywin32**でExcel操作している間は、Excelアプリケーションがプログラムから自動操作され画面が勝手に動いてしまいます。本スクリプト実行の前にExcelを閉じておくようにすることで、そのような状態に陥らないようチェックしています。

また、本スクリプトで生成するファイルは毎回実行前に削除するようにしています。主にファイルを開いたまま実行しようとしてしまった場合は、結果が保存できないため、そのような状態に陥らないようチェックしています。


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

    def _generate_html_by_winmerge(self):
        command = [
            WINMERGE_EXE,
            str(self.base),         # 比較元のフォルダ
            str(self.latest),       # 比較先のフォルダ
            *WINMERGE_OPTIONS,      # WinMergeのコマンドライン実行オプション
            str(self.output_html),  # レポートのパス
        ]
        print(' '.join(command))
        subprocess.run(command)
```
**subprocess**を使ってWinMergeをコマンドライン実行し、フォルダ同士の比較レポート(HTML形式)を出力しています。WinMergeのコマンドライン実行オプションは以下を参考に設定しました。(大変参考になり、ありがとうございました)

https://qiita.com/mima_ita/items/ac21c0588080e73fc458

:::note warn
WinMergeの実行がうまくいかない場合は、インストールフォルダが異なっている可能性があります。WinMergeのインストールフォルダに合わせて、WINMERGE_EXEを編集して下さい。

また、WinMergeのバージョンが古いと、コマンドライン実行に対応していない場合があります。[動作確認環境](#動作確認環境)のバージョンより古くなっていないか確認して下さい。
:::


#### HTMLをExcelファイルに変換
```python
    def _convert_html_to_xlsx(self):
        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()
```

以下を順に処理してHTMLをExcelファイルに変換しています。

1. Excelアプリケーションを起動しブックを開く
2. 一覧シートの書式を整える
3. コード差分のHTMLファイルをブックにコピーする
4. 差分シートの書式を整える
5. ブックのホームポジションを整える(一覧シートの所定のセルに設定する)
6. ブックをExcelファイルに保存する

変換の途中で例外が発生した場合は、必ず起動したExcelアプリケーションを閉じてから終了するよう、`try～finally`で記述しています。

#### Excelアプリケーションを起動しブックを開く
```python
SUMMARY_WS_NUM = 1        # 一覧シートのワークシート番号

    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)
```
**pywin32**を使って、HTMLを読み込んでブックを開いています。一覧シートを先頭に、それ以外の差分シートはその後に追加する事を想定しています。


#### 一覧シートの書式を整える
```python
SUMMARY_START_ROW = 6     # 一覧シートの表の開始行
SUMMARY_NAME_COL = 'A'    # 一覧シートの表の名前列
SUMMARY_FOLDER_COL = 'B'  # 一覧シートの表のフォルダー列

    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:
                self._change_hyperlink(name_cell)
                folder_cell = ws.Range(SUMMARY_FOLDER_COL + str(row))
                if folder_cell.Value:
                    self._rename_html_files(name_cell.Value, folder_cell.Value)

    def _change_hyperlink(self, name_cell):
        for hl in name_cell.Hyperlinks:
            hl.Address = ''
            hl.SubAddress = name_cell.Value + '!' + HOME_POSITION

    def _rename_html_files(self, name, folder):
        sheet_name = folder.replace('\\', '_') + '_' + name
        src = f'{self.output_html_files}/{sheet_name}.html'
        dst = f'{self.output_html_files}/{name}.html'
        os.rename(src, dst)
        print(sheet_name + ' ---> ' + name)
```
ここでは一覧シートのハイパーリンクのアドレスを変更しています。

また、WinMergeの仕様により、差分を取ったファイルがサブフォルダに含まれていた場合、出力されるHTMLのファイル名は、元のファイル名にサブフォルダ名が"_"でつながれた形となります。

(フォルダ配置)
```
folder1
│  folder.txt
│
└─src
       fizzbuzz.c
```

(WinMerge出力例)
```
output.files
│  folder.txt.html
│
└─src_fizzbuzz.c.html
```
本スクリプトでは、この"_"でつながれたフォルダ名を取り除き、ファイル名のみの状態にリネームしています。これは、Excelファイルのシート名にサブフォルダ名を含めずに、ファイル名のみとするための処理となっています。

(リネーム後の出力例)
```
output.files
│  folder.txt.html
│
└─fizzbuzz.c.html
```

:::note warn
本スクリプトでは、サブフォルダ名を削除するため、同一名のファイルが異なるフォルダに存在する場合、いずれか1つ以外は削除されるため、正しく動作しません。ご注意ください。
:::


#### コード差分のHTMLファイルをブックにコピーする
```python
    def _copy_html_files(self):
        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))
```
**glob**を使って、`output_html_files`以下に含まれるすべてのHTMLファイルに対して処理しています。(サブフォルダ含む)

HTMLファイルが見つかるたびに、以下を処理します。
1. ExcelでHTMLファイルを読み込みブックを開く
2. 読み込んだブックの最初のシートを取得(シートは1つしかない)
3. 取得したシートを、出力結果用のブックの最後に追加する。


#### 差分シートの書式を整える
```python
DIFF_START_ROW = 2                                            # 差分シートの開始行

    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)
```
以下を順に処理して差分シートの書式を整えています。

1. シートの倍率を設定する
2. ウィンドウ枠を固定する
3. 行番号のハイパーリンクを削除する
4. 書式調整を行う
5. ホームポジションを整える

#### シートの倍率を設定する
```python
DIFF_ZOOM_RATIO = 85                                          # 差分シートのズームの倍率

    def _set_zoom(self, ws):
        ws.Activate()
        self.excel.ActiveWindow.Zoom = DIFF_ZOOM_RATIO
```

シートの倍率を`DIFF_ZOOM_RATIO`に設定します。(デフォルトは85%)

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

`DIFF_START_ROW`より上の行を固定します。(デフォルトは、1行名を見出しとして固定しています)

#### 行番号のハイパーリンクを削除する
```python
DIFF_FORMATS = {                                              # 差分シートの書式設定
    'no': [                                                   # 行番号列
        {'col': 'A', 'width': 5},                             # 左側
        {'col': 'C', 'width': 5},                             # 右側
    ],
}

    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
```

WinMergeのファイル差分レポートの行番号には、ハイパーリンクが追加されることがあります。本スクリプトではこのハイパーリンクは全て削除するようにしています。

ハイパーリンク削除後に、元の書式に合うよう、セルの塗りつぶしとフォントサイズを変更しています。

#### 書式調整を行う
```python
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': 'コメント'},
    ],
}

    def _set_format(self, ws):
        for key in DIFF_FORMATS.keys():
            for f in DIFF_FORMATS[key]:
                r = f['col'] + ':' + f['col']
                if 'width' in f:
                    ws.Range(r).ColumnWidth = f['width']
                if 'font' in f:
                    ws.Range(r).Font.Name = f['font']
                if 'header' in f:
                    self._set_extra_table(ws, f)

    def _set_extra_table(self, ws, f):
        end_row = ws.Cells(ws.Rows.Count, 1).End(xlUp).Row
        r1 = f['col'] + '1'
        r2 = f['col'] + '1:' + f['col'] + str(end_row)
        ws.Range(r1).Value = f['header']
        ws.Range(r1).VerticalAlignment = xlCenter
        ws.Range(r1).HorizontalAlignment = xlCenter
        ws.Range(r1).Interior.Color = int('CCFFCC', 16)
        ws.Range(r2).Borders.Color = int('000000', 16)
        ws.Range(r2).Borders.LineStyle = xlContinuous
        for i in range(DIFF_START_ROW, end_row+1):
            code_col = DIFF_FORMATS['code'][0]['col']
            code_color = ws.Range(code_col + str(i)).Interior.Color
            if code_color == int('FFFFFF', 16):
                r = f['col'] + str(i)
                ws.Range(r).Value = '-'
                ws.Range(r).Interior.Color = int('E0E0E0', 16)
```

書式の調整は`DIFF_FORMATS`で設定された`no`、`code`、`extra`のカテゴリで定義された配列に従って行います。

(DIFF_FORMATSの設定方法)
|カテゴリ名 |設定内容 |
|---|---|
|no |行番号列の書式の設定を格納した配列 |
|code |コード列の書式の設定を格納した配列 |
|extra |任意に追加したい列の書式の設定を格納した配列 |

また、設定可能な書式名には以下があります。

(書式の設定)
|書式名 |内容 |
|---|---|
|col |列名を示す文字列 |
|width |幅を示す数値 |
|font |フォント名を示す文字列 |
|header |任意に追加したい列の見出しを示す文字列 |

なお、`header`の書式を指定した場合、

(設定例)
```python
    'extra': [                                                # 追加列
        {'col': 'E', 'width': 60, 'header': 'コメント'},
    ],
```

該当する列に以下の様な見出しのついた表を追加します。必要に応じて、カスタマイズして下さい。


(出力結果)
![extra_table.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/2726aa37-33e1-fa25-81e8-a54e1578c422.png)

追加した表のコード部分に差分がない箇所(セル背景塗りつぶしの色が白(FFFFFF)の場合)は、セルの値を"-"とし灰色ハッチングしています。

#### ホームポジションを整える
```python
HOME_POSITION = 'A1'  # ホームポジション

    def _set_home_position(self, ws):
        ws.Activate()
        ws.Range(HOME_POSITION).Select()
```
引数で指定されたワークシートの選択セルを、`HOME_POSITION`(デフォルトはA1)に移動させています。一覧シートと差分シートともに本処理を実施しています。


#### ブックをExcelファイルに保存する
```python
xlOpenXMLWorkbook = 51

    def _save_book(self):
        self.wb.SaveAs(str(self.output), FileFormat=xlOpenXMLWorkbook)
        print('xlsxへの変換が完了しました。')
```
書式を整えた後のブックを、xlsx形式のファイルに保存しています。


**pywin32**の書式設定は以下を参考に設定しました。(大変参考になり、ありがとうございました)

https://qiita.com/feo52/items/150745ae0cc17cb5c866

https://qiita.com/kumarstack55/items/9ae3432446afca06497f


## リポジトリ
スクリプトとサンプルを含めたものを、以下のリポジトリにて公開しています。

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


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


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


## まとめ
**pywin32**を使うとPythonで簡単に、**HTML形式のファイルをExcel形式のファイルに変換できる**ことがわかりました。WinMergeのコマンドライン実行と、このHTML→Excel変換をつなげることで、差分まとめの作業が楽になりそうです。Excelの書式設定も簡単に行えて、**pywin32**はとても便利でした！

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

https://qiita.com/y-tetsu/items/8b1ec2f3fa1f085127da