Python で保存する Excel ファイルのハッシュ値を固定する

_（結論だけ知りたい方は「解決方法」のセクションまでお進みください）_

## はじめに

Excel ファイルは、表形式の情報を整理する用途では（特に「テキストファイル」に馴染みのない方たちにとっては）もっとも親しまれている形式です。

いっぽうで各種プログラミング言語から読み書きするためのライブラリも存在しており、こと「非エンジニアに情報を提示する」という目的に絞れば、Excel ファイルで出力するのは悪い選択肢ではありません。

ところが、そのファイルを Git 管理しようとするとさぁ大変。中身が全く同じでも、バイナリレベルでは異なる Excel ファイルが生成されてしまうことがあるのです。おかげで「何も変更していないのにコンフリクトした！」という事象が多々発生します。

分かっています、Excel ファイルを Git 管理しようとするのが正気の沙汰ではないことくらい。でもそういう案件があったんですよ。本当に。そのときに学んだことを供養させてください。

## 素朴に書き出すと何が問題か

ここでは Python 3 で `openpyxl`（記事執筆時点で 3.0.6 が最新）を使ってファイルを書き出すことにします。バージョン管理が Mercurial なので一瞬面食らいますが、今でもきちんとメンテされているようですし、[Pandas でも採用されている](v)ので問題ないでしょう。

- ドキュメント: https://openpyxl.readthedocs.io/en/stable/
- ソースコード: https://foss.heptapod.net/openpyxl/openpyxl
- PyPI: https://pypi.org/project/openpyxl/

こんな感じに、ちょっと間を置いて2回ファイルを書き出してみます。

```python
from time import sleep

from openpyxl import Workbook


if __name__ == "__main__":
    book = Workbook()
    book.active["A1"] = "Hello world"

    book.save("hello1.xlsx")
    sleep(1)
    book.save("hello2.xlsx")
```

書き出したファイルのハッシュ値を比較してみると、異なっているのが分かります。

```console
$ sha1sum hello*.xlsx
091a1922ac5f2dc58e91ac3bbae32e3ca58c5ca5  hello1.xlsx
ed51a9901eed0a5d2f2c21dd6664bb8a07a9563c  hello2.xlsx
```

ファイルの中身が同じなら、ハッシュ値も全くおなじになっていてほしいですよね。
それをどう実現するか、というのが本記事の趣旨です。

## 解決方法：タイムスタンプの固定

種明かしをしてしまえば、ファイルコンテンツに含まれるタイムスタンプを固定するというのが解決方法になります。テキストファイルであれば OS が管理するファイル属性にタイムスタンプが含まれるだけですが、Excel の場合はファイルの中身にもタイムスタンプが書き込まれているんですね。

下記のようにすれば、常に完全に同一な（バイナリレベルで一致する）Excel ファイルが生成できます。

```python
from datetime import datetime
from time import sleep
from zipfile import ZipFile

from openpyxl import Workbook


def save_workbook(book: Workbook, path: str) -> None:
    # タイムスタンプをこの値に固定する
    timestamp = datetime(1980, 1, 1, 00, 00, 00)

    # Excel 管理のタイムスタンプ固定
    book.properties.created = timestamp
    book.properties.modified = timestamp

    # ファイル保存
    book.save(path)

    # ZIP 管理のタイムスタンプ固定
    with ZipFile(path, mode="a") as f:
        for info in f.infolist():
            info.date_time = timestamp.timetuple()[:6]
            f.fp.seek(info.header_offset)
            # ファイルの途中だが、タイムスタンプ変更してもヘッダー長変わらないので OK
            f.fp.write(info.FileHeader())
        f._didModify = True


if __name__ == "__main__":
    book = Workbook()
    book.active["A1"] = "Hello world"

    save_workbook(book, "hello_fixed1.xlsx")
    sleep(1)
    save_workbook(book, "hello_fixed2.xlsx")
```

固定できていますね！

```console
$ sha1sum hello_fixed*.xlsx
49f5b2f7b9e637ded7c3d81bdf2522b0d14d2e79  hello_fixed1.xlsx
49f5b2f7b9e637ded7c3d81bdf2522b0d14d2e79  hello_fixed2.xlsx
```

処理内容について、簡単に解説したいと思います。

なお前提知識として「xlsx ファイルは XML ファイルを Zip で固めたもの」というのを知っておくといいでしょう。

```console
$ unzip -d hello_fixed1.xlsx.d hello_fixed1.xlsx
Archive:  hello_fixed1.xlsx
  inflating: hello_fixed1.xlsx.d/docProps/app.xml
  inflating: hello_fixed1.xlsx.d/docProps/core.xml
  inflating: hello_fixed1.xlsx.d/xl/theme/theme1.xml
  inflating: hello_fixed1.xlsx.d/xl/worksheets/sheet1.xml
  inflating: hello_fixed1.xlsx.d/xl/styles.xml
  inflating: hello_fixed1.xlsx.d/_rels/.rels
  inflating: hello_fixed1.xlsx.d/xl/workbook.xml
  inflating: hello_fixed1.xlsx.d/xl/_rels/workbook.xml.rels
  inflating: hello_fixed1.xlsx.d/[Content_Types].xml
```

参考:

- [Officeファイルの成り立ちと最新形、そして標準化 (1/2)：XMLを取り込んだ最新Officeフォーマットとは（前編） - ＠IT](https://www.atmarkit.co.jp/ait/articles/0807/22/news131.html)
- [ECMA376のリファレンスを使ってxlsxファイルの中身を見る。 - 好きなことを書いていく](https://gototo.hatenablog.com/entry/2015/12/08/005218)
- [Excelファイル操作をプログラミングする前に、まずはxlsxをzipに変えて内部構造を見てみよう | ソフトウェア開発のギークフィード](https://www.geekfeed.co.jp/geekblog/exceldisassembly/)

### Excel 管理のタイムスタンプ固定

1つ目は Excel のレイヤーで管理されているプロパティで、作成日時と更新日時が記録されています。

技術的に言うと `docProps/core.xml` というファイルに記載されています。ファイルの中身を覗いてみるとこんな感じ（整形済み）。

```xml
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <dc:creator>openpyxl</dc:creator>
    <dcterms:created xsi:type="dcterms:W3CDTF">1980-01-01T00:00:00Z</dcterms:created>
    <dcterms:modified xsi:type="dcterms:W3CDTF">1980-01-01T00:00:00Z</dcterms:modified>
</cp:coreProperties>
```

`openpyxl` ではこれは `Workbook#properties.created`, `Workbook#properties.modified` 属性で設定されるようですので（ソース: [workbook/workbook.py#L67](https://foss.heptapod.net/openpyxl/openpyxl/-/blob/3.0.6/openpyxl/workbook/workbook.py#L67), [packaging/core.py#L76-78](https://foss.heptapod.net/openpyxl/openpyxl/-/blob/3.0.6/openpyxl/packaging/core.py#L76-78)）、それを保存直前に上書きしてあげればいいという寸法です。

```python
book.properties.created = timestamp
book.properties.modified = timestamp

book.save(path)
# ...
```

### ZIP 管理のタイムスタンプ固定

上記によって「ZIP 圧縮前」のファイルのレベルでは完全に固定できましたが、最終的な xlsx ファイルはまだ固定しきれていません。

それは ZIP ファイルのレイヤーでもタイムスタンプを保持しているからです。

そのタイムスタンプを固定するために、いったん `Workbook#save()` したあとに [`zipfile.Zipfile`](https://docs.python.org/ja/3/library/zipfile.html#zipfile.ZipFile) を使ってファイルを開き直してタイムスタンプを書き換えます（`save()` をいじって保存処理に手を加えようとしたのですが、思ったより該当処理が深かったため、トリッキーなこの形になりました）。

なお ZIP ファイルの形式は MS-DOS 由来なため、タイムスタンプの起点は1980年1月1日です。それ以前の日付は保持することができないため、固定値を UNIX timestamp の起点である1970年1月1日にするとエラーになるので注意してください。

まずファイルを `mode="a"` で開きます（`"w"` だと中身が消えてしまいます）。

```python
# ...
book.save(path)

with ZipFile(path, mode="a") as f:
```
開くと各ファイルのプロパティ情報が [`zipfile.ZipInfo`](https://docs.python.org/ja/3/library/zipfile.html#zipfile.ZipInfo) クラスで保持されているので、この `date_time` 属性を変更します。

```python
     for info in f.infolist():
        info.date_time = timestamp.timetuple()[:6]
```

変更した値を反映する必要があるのですが、注意点として、ZIP のファイルプロパティは2箇所に重複して書き込まれています。齟齬がある場合は後者が尊重されるようですが、ファイルを固定するという意味では両方に反映する必要がありますね。

- ローカルファイルヘッダー……各ファイルエントリーの先頭に書き込まれたプロパティ
- セントラルディレクトリ……ZIP ファイル全体の末尾に書き込まれたプロパティ

参考:

- [ZIP (ファイルフォーマット) - Wikipedia](https://ja.wikipedia.org/wiki/ZIP_(%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%83%88)#%E6%A7%8B%E9%80%A0)

ローカルファイルヘッダーについては、各ファイルエントリーごとに該当箇所まで `seek` して `write` します。

```python
        f.fp.seek(info.header_offset)
        # ファイルの途中だが、タイムスタンプ変更してもヘッダー長変わらないので OK
        f.fp.write(info.FileHeader())
```

セントラルディレクトリについては `ZipFile#_didModify` を `True` に設定しておくことで、自動で反映されるようです（ソース: [Lib/zipfile.py#L1821-L1825](https://github.com/python/cpython/blob/v3.9.1/Lib/zipfile.py#L1821-L1825)）。

```python
    f._didModify = True
```

## おわりに

以上、ファイルの中身が同じならハッシュ値も同じになるように保存する方法を解説しました。

ただし執筆にあたっては Excel や ZIP の仕様には当たっておらず、実装レベルで試行錯誤した結果となります。書き出しに用いたソフトウェアのバージョンや対象ファイルのサイズなどによってはうまく動かない可能性があることをご理解ください。