pyminizip で日本語ファイル名を含めて ZIP ファイルを作成したときは注意しよう
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ファイルが追加されることもあるため、ファイルの最後にあるセントラルディレクトリに載っているファイルだけが、正しいファイルである。
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 かどうかを決めています。
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 #1
の General 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 の出力は以下のとおり。アドレス 0x005E
の CENTRAL HEADER #1
の General 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 HEADER
の General Purpose Flag
は 0x005E
なので、アドレスを決め打ちして以下のように書いてしまいます。
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