zip の中のファイル名
zip ファイルを展開(解凍)すると、書庫内の各データは元のファイル名で展開されます。すなわちファイル名情報も zip ファイル内に格納されています。
zip ファイル内に格納されている各ファイル名のエンコーディングは、現バージョンの zip の仕様だと UTF-8 フラグの有無を指定することができるようですが、 UTF-8 以外のエンコーディングを具体的に指定することができません。
歴史的に、従来より日本語ロケールの Windows で zip ファイルを作成すると(ツールにもよりますが)、ファイル名情報は Shift_JIS (CP932) で書き込まれます。最近の Linux や Mac は UTF-8 がほとんどです。
異なる OS で作成された zip ファイルを展開するときに、 UTF-8 フラグが付いていれば(最近の展開ツールであれば)問題なく展開することが可能ですが Shift_JIS (CP932) が使われている場合、 UTF-8 でないことはわかっても、具体的なエンコーディング情報が不明なためファイル名の文字化けが起きることがあります。
このように異 OS 間で zip ファイルをやりとりする際の問題はよく知られていますが、これとは別の理由で Python3 の ZipFile ライブラリでも正しくファイル名を認識できず文字化けが発生することがあります。
Python3 の ZipFile ライブラリ
ZipFile ライブラリでは、 UTF-8 フラグがあれば UTF-8 として、そうでなければ CP437 として、バイト列を文字列に変換する仕様となっています。
このため CP932 でファイル名情報が保管された zip ファイルを、 ZipFile.extractall()
などを使って展開しようとするとファイル名が文字化けされた状態で展開されてしまいます。
対処するためには ZipInfo.filename
を CP437 としてバイト列に戻したのち、正しいエンコーディングで文字列に戻し、それを ZipFile.extract(ZipInfo)
とします。
import zipfile
f = r'/file/to/path'
with zipfile.ZipFile(f) as z:
for info in z.infolist():
info.filename = info.filename.encode('cp437').decode('cp932')
z.extract(info)
上記は、本来のエンコーディングを CP932 と決め打ちで処理していますが、実際はそうとは限らないので、エンコーディング判定とか例外処理が必要です。
が…… 駄目っ……!
前述のコードを使って展開する処理を Mac で実行する場合は問題ないのですが、 Windows で展開しようとする場合は、ファイル名によってはエラーが発生します。
実は ZipInfo.filename
は os.sep
を /
に置換された状態のファイル名となっています。つまり Windows の場合 \
が /
に置換されています。
CP437 は1バイト文字のエンコーディングで、 ASCII 印字文字( \x20
- \x7f
)においては ASCII 互換ですので、(本来マルチバイト文字の一部であっても)この置換処理が行われてしまいます。その結果、バイト列に戻した際 b'\x90\x2f'
といったパターンが出現してしまいます。
Shift_JIS (CP932) では2バイト目の \x2f
は絶対に使用しないようになっているため、このようなバイト列を CP932 として再び文字列に変換しようとしたときにデコードエラーが発生してしまいます。
この問題が起きるのは、2バイト目が \x5c
の文字。
そう。いわゆる「ダメ文字」です。(ダメな理由はこれとは別でしたが)
いやー。 Shift_JIS (CP932) のダメ文字とかここ10年くらい存在をすっかり忘れてました。しかも \x2f
に置換したことで Shift_JIS (CP932) として不正なバイト列となるという変化球で来るとは。
解決法
置換処理が行われる前の( CP437 としてデコードされた)ファイル名情報は ZipInfo.orig_filename
に格納されていますので、こっちを使うことで解決することができます。
import os
import zipfile
f = r'/file/to/path'
with zipfile.ZipFile(f) as z:
for info in z.infolist():
info.filename = info.orig_filename.encode('cp437').decode('cp932')
if os.sep != "/" and os.sep in info.filename:
info.filename = info.filename.replace(os.sep, "/")
z.extract(info)