🌊

pyminizip で日本語ファイル名を含めて ZIP ファイルを作成したときは注意しよう

2023/05/11に公開

pyminizip · PyPI で日本語ファイル名を含めて ZIP を作成した場合 且つ その ZIP ファイルを Python 3.11 未満の ZipFile クラスで参照すると、ファイル名が文字化けします。それ以外の場合は問題ありません。まとめると以下のとおり:

Python 3.11 未満でZIP参照 Python 3.11 以上でZIP参照
pyminizipでZIP作成 問題あり 問題なし
それ以外でZIP作成 問題なし 問題なし

事例

テキトーに日本語のファイル名を作成して

% echo 'pyminizipで生成した' > pyminizipで生成した.txt

pyminizip で ZIP ファイルを作成して

import pyminizip

pyminizip.compress('pyminizipで生成した.txt', None, 'zip2.zip', None, 0)

Python 3.11 未満 (ここでは Python 3.9) でその ZIP ファイルを参照すると、 ZIP ファイルに含まれている日本語のファイル名が文字化けします。

import zipfile

with zipfile.ZipFile('zip2.zip', mode='r') as z:
    print(z.filelist[0].filename)
% python3.9 zip0.py 
pyminizipで生成した.txt

ZIP ファイルを作成するとき pyminizip ではなく例えば zip コマンドで作成した場合は問題ありません。

zip コマンドで ZIP ファイルを作成して

% echo 'zipコマンドで生成した' > zipコマンドで生成した.txt
% zip zip1.zip zipコマンドで生成した.txt

読んでみます。

import zipfile

with zipfile.ZipFile('zip1.zip', mode='r') as z:
    print(z.filelist[0].filename)

文字化けせず読めました。

% python3.9 zip0.py 
zipコマンドで生成した.txt

回避策

Python 3.11 以上で metadata_encoding を指定しましょう。

Python 3.11 から metadata_encoding が追加されました。これを使いましょう。

zipfile --- ZIP アーカイブの処理 — Python 3.11.3 ドキュメント

バージョン 3.11 で変更: Added support for specifying member name encoding for reading metadata in the zipfile's directory and file headers.

こんな感じで使います。

import zipfile

with zipfile.ZipFile('zip1.zip', mode='r', metadata_encoding='utf-8') as z:
    print(z.filelist[0].filename)

with zipfile.ZipFile('zip2.zip', mode='r', metadata_encoding='utf-8') as z:
    print(z.filelist[0].filename)

ここで ZIP ファイルは 2 つ登場しますが、各々以下の内容です。

  • zip1.zip : zip コマンドで作成した ZIP ファイル
  • zip2.zip : pyminizip で作成した ZIP ファイル

上記コードを Python 3.11 で実行します。ちゃんと日本語のファイル名も読めました。

% python3.11 zip0.py 
zipコマンドで生成した.txt
pyminizipで生成した.txt

汎用目的のビットフラグ

ざっくりとした説明ですが、ZIP ファイルフォーマットは以下のようになっています。( ZIP ファイルフォーマット - Wikipedia より )

このうちの「セントラルディレクトリ」に注目。セントラルディレクトリの内容は以下のとおり。( ZIP ファイルフォーマット - Wikipedia より )

オフセット サイズ 内容[8]
0 4 セントラルディレクトリエントリのシグネチャ = 0x504B0102(PK\001\002)
4 2 作成されたバージョン
6 2 展開に必要なバージョン (最小バージョン)
8 2 汎用目的のビットフラグ
10 2 圧縮メソッド
12 2 ファイルの最終変更時間
14 2 ファイルの最終変更日付
16 4 CRC-32
20 4 圧縮サイズ
24 4 非圧縮サイズ
28 2 ファイル名の長さ (n)
30 2 拡張フィールドの長さ (m)
32 2 ファイルコメントの長さ (k)
34 2 ファイルが開始するディスク番号
36 2 内部ファイル属性
38 4 外部ファイル属性
42 4 ローカルファイルヘッダの相対オフセット
46 n ファイル名
46+n m 拡張フィールド
46+n+m k ファイルコメント

注目するのはこのうちの「セントラルディレクトリ」の「汎用目的のビットフラグ」です。pyminizip ではこの汎用目的のビットフラグの扱いに問題があります。

汎用目的のビットフラグはローカルヘッダとセントラルディレクトリの 2 箇所に存在しますが、 ZipFile クラスで扱っているのはセントラルディレクトリのほうです。ローカルヘッダとセントラルディレクトリの両方に同じ情報が書かれているのは冗長性のためのらしいです (ローカルヘッダが冗長)。

ZIP ファイルフォーマット - Wikipedia より

また冗長性の確保のため、各エントリも、エントリ情報をローカルファイルヘッダとして保持している。zipファイルが追加されることもあるため、ファイルの最後にあるセントラルディレクトリに載っているファイルだけが、正しいファイルである。

zip ファイルフォーマットの仕様は https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT に書いてあり、ここの 4.4.4 general purpose bit flag: (2 bytes) が汎用目的のビットフラグの説明です。

この Bit 11 にファイル名のエンコードについてのビットがあります。つまり、ファイル名が UTF-8 の場合は Bit 11 を立てないといけません。

Bit 11: Language encoding flag (EFS).  If this bit is set,
        the filename and comment fields for this file
        MUST be encoded using UTF-8. (see APPENDIX D)

ZipFile クラスはコンストラクタ内でセントラルディレクトリの汎用目的のビットフラグをチェックして filename が UTF-8 かどうかを決めています。

https://github.com/python/cpython/blob/v3.9.16/Lib/zipfile.py#L1325

  filename = fp.read(centdir[_CD_FILENAME_LENGTH])
  flags = centdir[5]
  if flags & 0x800:
      # UTF-8 file names extension
      filename = filename.decode('utf-8')
  else:
      # Historical ZIP filename encoding
      filename = filename.decode('cp437')
  • pyminizip で作成された ZIP ファイルは汎用目的のビットフラグのビット 11 が立っていない
  • zip コマンドで作成された ZIP ファイルは汎用目的のビットフラグのビット 11 が立っている

これが問題です。

なので、 pyminizip で作成された ZIP ファイルを Python 3.11 未満の ZipFile クラスで扱うと文字化けが発生します。( ZIP ファイルに日本語ファイル名が含まれないならば問題ない )

実際に ZIP ファイルのフォーマットを確認してみましょう。ZIP ファイルのフォーマットを確認するには zipdetail コマンドが便利です。

たとえば上記 zip コマンドで作成した ZIP ファイルの場合は以下のとおり。アドレス 0x0083 にある CENTRAL HEADER #1General Purpose Flag (汎用目的のビットフラグ) の Bit 11 が立っています。( アドレス 0x0083 の値が 0x0800 であり、これは 2 進数で 0000 1000 0000 0000 なのでビット 11 が立っていることが分かる )

% zipdetails -v zip1.zip

0000 0004 50 4B 03 04 LOCAL HEADER #1       04034B50
0004 0001 0A          Extract Zip Spec      0A '1.0'
0005 0001 00          Extract OS            00 'MS-DOS'
0006 0002 00 08       General Purpose Flag  0800
                      [Bit 11]              1 'Language Encoding'
0008 0002 00 00       Compression Method    0000 'Stored'
000A 0004 AF 73 AB 56 Last Mod Time         56AB73AF 'Thu May 11 23:29:30 2023'
000E 0004 8E 88 73 8C CRC                   8C73888E
0012 0004 1F 00 00 00 Compressed Length     0000001F
0016 0004 1F 00 00 00 Uncompressed Length   0000001F
001A 0002 22 00       Filename Length       0022
001C 0002 1C 00       Extra Length          001C
001E 0022 7A 69 70 E3 Filename              'zipコマンドで生成した.txt'
          82 B3 E3 83
          9E E3 83 B3
          E3 83 89 E3
          81 A7 E7 94
          9F E6 88 90
          E3 81 97 E3
          81 9F 2E 74
          78 74
0040 0002 55 54       Extra ID #0001        5455 'UT: Extended Timestamp'
0042 0002 09 00         Length              0009
0044 0001 03            Flags               '03 mod access'
0045 0004 39 7D 5C 64   Mod Time            645C7D39 'Thu May 11 14:29:29 2023'
0049 0004 52 7D 5C 64   Access Time         645C7D52 'Thu May 11 14:29:54 2023'
004D 0002 75 78       Extra ID #0002        7875 'ux: Unix Extra Type 3'
004F 0002 0B 00         Length              000B
0051 0001 01            Version             01
0052 0001 04            UID Size            04
0053 0004 E8 03 00 00   UID                 000003E8
0057 0001 04            GID Size            04
0058 0004 E8 03 00 00   GID                 000003E8
005C 001F 7A 69 70 E3 PAYLOAD               zip............................
          82 B3 E3 83
          9E E3 83 B3
          E3 83 89 E3
          81 A7 E7 94
          9F E6 88 90
          E3 81 97 E3
          81 9F 0A

007B 0004 50 4B 01 02 CENTRAL HEADER #1     02014B50
007F 0001 1E          Created Zip Spec      1E '3.0'
0080 0001 03          Created OS            03 'Unix'
0081 0001 0A          Extract Zip Spec      0A '1.0'
0082 0001 00          Extract OS            00 'MS-DOS'
0083 0002 00 08       General Purpose Flag  0800
                      [Bit 11]              1 'Language Encoding'    ★ これ
0085 0002 00 00       Compression Method    0000 'Stored'
0087 0004 AF 73 AB 56 Last Mod Time         56AB73AF 'Thu May 11 23:29:30 2023'
008B 0004 8E 88 73 8C CRC                   8C73888E
008F 0004 1F 00 00 00 Compressed Length     0000001F
0093 0004 1F 00 00 00 Uncompressed Length   0000001F
0097 0002 22 00       Filename Length       0022
0099 0002 18 00       Extra Length          0018
009B 0002 00 00       Comment Length        0000
009D 0002 00 00       Disk Start            0000
009F 0002 01 00       Int File Attributes   0001
                      [Bit 0]               1 Text Data
00A1 0004 00 00 A4 81 Ext File Attributes   81A40000
00A5 0004 00 00 00 00 Local Header Offset   00000000
00A9 0022 7A 69 70 E3 Filename              'zipコマンドで生成した.txt'
          82 B3 E3 83
          9E E3 83 B3
          E3 83 89 E3
          81 A7 E7 94
          9F E6 88 90
          E3 81 97 E3
          81 9F 2E 74
          78 74
00CB 0002 55 54       Extra ID #0001        5455 'UT: Extended Timestamp'
00CD 0002 05 00         Length              0005
00CF 0001 03            Flags               '03 mod access'
00D0 0004 39 7D 5C 64   Mod Time            645C7D39 'Thu May 11 14:29:29 2023'
00D4 0002 75 78       Extra ID #0002        7875 'ux: Unix Extra Type 3'
00D6 0002 0B 00         Length              000B
00D8 0001 01            Version             01
00D9 0001 04            UID Size            04
00DA 0004 E8 03 00 00   UID                 000003E8
00DE 0001 04            GID Size            04
00DF 0004 E8 03 00 00   GID                 000003E8

00E3 0004 50 4B 05 06 END CENTRAL HEADER    06054B50
00E7 0002 00 00       Number of this disk   0000
00E9 0002 00 00       Central Dir Disk no   0000
00EB 0002 01 00       Entries in this disk  0001
00ED 0002 01 00       Total Entries         0001
00EF 0004 68 00 00 00 Size of Central Dir   00000068
00F3 0004 7B 00 00 00 Offset to Central Dir 0000007B
00F7 0002 00 00       Comment Length        0000
Done

pyminizip で作成した ZIP ファイルに対しての zipdetails の出力は以下のとおり。アドレス 0x005ECENTRAL HEADER #1General Purpose Flag は Bit 11 が立っていません。

% zipdetails -v zip2.zip 

0000 0004 50 4B 03 04 LOCAL HEADER #1       04034B50
0004 0001 14          Extract Zip Spec      14 '2.0'
0005 0001 00          Extract OS            00 'MS-DOS'
0006 0002 00 00       General Purpose Flag  0000
                      [Bits 1-2]            0 'Normal Compression'
0008 0002 08 00       Compression Method    0008 'Deflated'
000A 0004 27 74 AB 56 Last Mod Time         56AB7427 'Thu May 11 23:33:14 2023'
000E 0004 5B 61 24 3F CRC                   3F24615B
0012 0004 1C 00 00 00 Compressed Length     0000001C
0016 0004 19 00 00 00 Uncompressed Length   00000019
001A 0002 1C 00       Filename Length       001C
001C 0002 00 00       Extra Length          0000
001E 001C 70 79 6D 69 Filename              'pyminizipで生成した.txt'
          6E 69 7A 69
          70 E3 81 A7
          E7 94 9F E6
          88 90 E3 81
          97 E3 81 9F
          2E 74 78 74
003A 001C 2B A8 CC CD PAYLOAD               +.......,x.......:&<n...q>..
          CC CB AC CA
          2C 78 DC B8
          FC F9 94 F9
          CF 3A 26 3C
          6E 9C FE B8
          71 3E 17 00

0056 0004 50 4B 01 02 CENTRAL HEADER #1     02014B50
005A 0001 00          Created Zip Spec      00 '0.0'
005B 0001 00          Created OS            00 'MS-DOS'
005C 0001 14          Extract Zip Spec      14 '2.0'
005D 0001 00          Extract OS            00 'MS-DOS'
005E 0002 00 00       General Purpose Flag  0000                     ★ これ
                      [Bits 1-2]            0 'Normal Compression'
0060 0002 08 00       Compression Method    0008 'Deflated'
0062 0004 27 74 AB 56 Last Mod Time         56AB7427 'Thu May 11 23:33:14 2023'
0066 0004 5B 61 24 3F CRC                   3F24615B
006A 0004 1C 00 00 00 Compressed Length     0000001C
006E 0004 19 00 00 00 Uncompressed Length   00000019
0072 0002 1C 00       Filename Length       001C
0074 0002 00 00       Extra Length          0000
0076 0002 00 00       Comment Length        0000
0078 0002 00 00       Disk Start            0000
007A 0002 01 00       Int File Attributes   0001
                      [Bit 0]               1 Text Data
007C 0004 00 00 00 00 Ext File Attributes   00000000
0080 0004 00 00 00 00 Local Header Offset   00000000
0084 001C 70 79 6D 69 Filename              'pyminizipで生成した.txt'
          6E 69 7A 69
          70 E3 81 A7
          E7 94 9F E6
          88 90 E3 81
          97 E3 81 9F
          2E 74 78 74

00A0 0004 50 4B 05 06 END CENTRAL HEADER    06054B50
00A4 0002 00 00       Number of this disk   0000
00A6 0002 00 00       Central Dir Disk no   0000
00A8 0002 01 00       Entries in this disk  0001
00AA 0002 01 00       Total Entries         0001
00AC 0004 4A 00 00 00 Size of Central Dir   0000004A
00B0 0004 56 00 00 00 Offset to Central Dir 00000056
00B4 0002 00 00       Comment Length        0000
Done

Python 3.11 未満の ZipFile でも文字化けを回避したい

ZipFile クラスで扱う前に汎用目的のビットフラグのビット 11 を立ててしまえばよいです。

上記 zipdetail の出力より、 CENTRAL HEADERGeneral Purpose Flag0x005E なので、アドレスを決め打ちして以下のように書いてしまいます。

import zipfile
import io
import struct

with io.open('zip2.zip', 'r+b') as fp:
    fp.seek(0x005E, 0)
    central_flag = struct.unpack('<H', fp.read(2))
    fp.seek(0x005E, 0)
    fp.write(struct.pack('<H', central_flag[0] | 0x800))

with zipfile.ZipFile('zip2.zip', mode='r') as z:
    print(z.filelist[0].filename)

文字化けせず読めました。

% python3.9 zip1.py
pyminizipで生成した.txt

しかし、汎用目的のビットフラグのアドレスは ZIP ファイルごとに異なります (セントラルディレクトリは ZIP ファイルの末尾に位置するので、ZIP ファイルに含めるファイルのサイズが異なればセントラルディレクトリの位置も変化する)。 アドレス決め打ちはよろしくないのでなんとか頑張ってみます。

ここではセントラルディレクトリエントリのシグネチャ 0x504B0102 を利用してみます。 0x504B0102 を目印にしてセントラルディレクトリのアドレスを特定し、シグネチャのアドレスからのオフセットを汎用目的のビットフラグのアドレスとします。こんな感じ。

import zipfile
import io
import struct

# The "central directory" structure, magic number
ZIP_CENTRAL_DIRECTORY_SIGNATURE = b'\x50\x4B\x01\x02'

with io.open('zip2.zip', 'r+b') as fp:
    data = fp.read()
    centdir_addr = data.find(ZIP_CENTRAL_DIRECTORY_SIGNATURE)
    centdir_flag_addr = centdir_addr + 8
    fp.seek(centdir_flag_addr, 0)
    centdir_flag = struct.unpack('<H', fp.read(2))
    # read するとファイルポインタが進むので元の位置に戻す
    fp.seek(centdir_flag_addr, 0)
    fp.write(struct.pack('<H', centdir_flag[0] | 0x800))

with zipfile.ZipFile('zip2.zip', mode='r') as z:
    print(z.filelist[0].filename)

文字化けせず読めました。

% python3.9 zip2.py 
pyminizipで生成した.txt

ただ、セントラルディレクトリは ZIP ファイルに含まれるファイルの個数ぶんだけ存在します。ここではZIP ファイルに含まれるファイルの個数 1 つのみしか考慮してないので、 2 つ以上のファイルが含まれている場合も考慮すると、 「ZIPセントラルディレクトリの終端レコード」の「セントラルディレクトリレコードの合計数」を利用してセントラルディレクトリすべてを処理しないといけません。

Discussion