AIで作った商品ZIPの日本語ファイル名が文字化けした|原因を3通りに分けて直した

unzip -lで見た一覧の先頭行が、���ǂ݂�������.txtでした。本当はお読みください.txtのはずです。
2026 年 10 月 5 日、日本語名のファイルを 3 件入れた販売用 ZIP を、作り方を変えて 3 通り用意しました。1 つは問題なし。残る 2 つは、文字化けする ZIP と、見た目は同じなのに名前の照合に失敗する ZIP です。検査は各 3 件の不備を出し、修復後は 3 通りとも 0 件になりました。
AI にテンプレート一式を作らせても、ZIP にまとめる段階で名前が壊れたら、購入者は README を見つけられません。しかも作り手の環境では、たいてい正常に見えます。
前回完了時と今回完了後
前回までは、ZIP に混ざってはいけないファイルを止める検査と、PDF、EPUB、JSON など中身の形式ごとの検査を続けました。今回は、中身は正しいのに名前だけが壊れるケースです。
| 時点 | 検査できること | 見えていないこと |
|---|---|---|
| 前回完了時 | 同梱してよいファイルか、形式として壊れていないか | 日本語ファイル名が相手の環境で読めるか |
| 今回完了後 | 日本語名の文字コード指定と正規化を検査し、直せる | 購入者の実機での表示。ここは別に確認が要ります |
飛ばせる章: ZIP のファイル名にフラグがあることを知っている場合は、「3通りの ZIP を作る」から読めます。
なぜ名前だけが壊れるのか
ZIP の中のファイル名は、ただの文字列ではありません。どの文字コードで書かれたかを、各ファイルのヘッダーにある 1 ビットで示します。PKWARE の仕様では、ビット 11 が立っていればファイル名は UTF-8 です。立っていなければ、読む側が自分の都合で解釈します。
昔の日本語 Windows 製の ZIP には、ビットなしで CP932 という文字コードの名前を入れたものがあります。UTF-8 として読むと、冒頭の例のように化けます。
もう 1 つ厄介なのが Unicode の正規化です。「だ」は、1 文字の「だ」でも、「た」に濁点を足した 2 文字でも表せます。前者が NFC、後者が NFD です。macOS は NFD で名前を扱う場面があり、見た目は同じでも、README に書いたファイル名と文字列として一致しません。
販売前に ZIP を作る人の手元で、この 2 点を機械的に止めたいと考えました。購入者の環境を全部試すのは難しいので、作る側で検査します。
[AIが日本語名のテンプレートを生成]
│
▼
[ZIPにまとめる]── UTF-8フラグあり? ──いいえ──► 名前が化ける
│
▼
[NFCで保存? ]──いいえ──► READMEの名前と不一致
│
▼
[販売候補ZIP]
用語
| 用語 | この回での意味 |
|---|---|
| ZIP | 複数ファイルを1つにまとめる形式です。各ファイルの名前と中身を別々に持ちます |
| ビット11 | ZIPの各ファイルにある汎用フラグの1つです。立っていれば名前はUTF-8です |
| UTF-8 | 世界中の文字を表せる文字コードです。ZIPの仕様で唯一、ビット11で明示できます |
| CP932 | 日本語Windowsで使われてきた文字コードです。Shift_JISの拡張にあたります |
| 正規化 | 同じ見た目の文字を、1通りの並びにそろえる処理です |
| NFC / NFD | 濁点付きの文字を1文字で持つ形がNFC、本体と濁点に分ける形がNFDです |
| メタデータ | 中身以外の付随情報です。この回ではファイル名を指します |
3通りのZIPを作る
実験用のフォルダを作ります。Python の版と、確認に使う解凍ツールの版は次のとおりです。
mkdir hibanas-zip-name-lab
cd hibanas-zip-name-lab
python3 --version
unzip -v | head -1
Python 3.13.5
UnZip 6.00 of 20 April 2009, by Debian. Original by Info-ZIP.
最初に、CP932 の ZIP を Python で作ろうとして失敗しました。ZipInfoに日本語名を渡すだけで十分だろうと考えたのです。次のスクリプトをfirst_try.pyとして保存し、実行しました。
import zipfile
with zipfile.ZipFile("first-try.zip", "w") as z:
z.writestr(zipfile.ZipInfo("お読みください.txt"), "x")
info = zipfile.ZipFile("first-try.zip").infolist()[0]
print("UTF-8フラグ:", bool(info.flag_bits & 0x800))
python3 first_try.py
UTF-8フラグ: True
Python は、ASCII 以外の名前を見ると自動でビット 11 を立てて UTF-8 で保存します。これでは古い形式の ZIP になりません。実験の前提が崩れました。
そこで、同じバイト数の ASCII の仮名で ZIP を作り、できあがったファイル内の仮名を、CP932 のバイト列へ置き換える方法に変えました。make_zips.pyの全文です。
import unicodedata
import zipfile
NAMES = ["お読みください.txt", "テンプレート/ガイド.md", "素材/パッケージ.txt"]
def write_zip(path, names, encode=None):
placeholders = []
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
for i, name in enumerate(names):
body = ("# " + name + "\n").encode("utf-8")
if encode is None:
zname = name
else:
raw = name.encode(encode)
zname = ("Q%d" % i).ljust(len(raw), "_")
placeholders.append((zname.encode(), raw))
info = zipfile.ZipInfo(zname)
info.compress_type = zipfile.ZIP_DEFLATED
z.writestr(info, body)
if placeholders:
data = open(path, "rb").read()
for ph, raw in placeholders:
data = data.replace(ph, raw)
open(path, "wb").write(data)
write_zip("a-utf8-nfc.zip", [unicodedata.normalize("NFC", n) for n in NAMES])
write_zip("b-cp932-legacy.zip", NAMES, encode="cp932")
write_zip("c-utf8-nfd.zip", [unicodedata.normalize("NFD", n) for n in NAMES])
python3 make_zips.py
ls -1 *.zip
a-utf8-nfc.zip
b-cp932-legacy.zip
c-utf8-nfd.zip
仮名はファイル内にローカルヘッダーと中央ディレクトリで 2 回ずつ入ります。同じバイト数にしてあるので、置き換えても全体の長さはずれません。
3 つの ZIP をunzip -lで見ました。
for z in a-utf8-nfc b-cp932-legacy c-utf8-nfd; do echo "--- $z"; unzip -l $z.zip | sed -n 4,6p; done
--- a-utf8-nfc
28 1980-01-01 00:00 お読みください.txt
34 1980-01-01 00:00 テンプレート/ガイド.md
29 1980-01-01 00:00 素材/パッケージ.txt
--- b-cp932-legacy
28 1980-01-01 00:00 ���ǂ݂�������.txt
34 1980-01-01 00:00 �e���v���[�g/�K�C�h.md
29 1980-01-01 00:00 �f��/�p�b�P�[�W.txt
--- c-utf8-nfd
31 1980-01-01 00:00 お読みください.txt
43 1980-01-01 00:00 テンプレート/ガイド.md
35 1980-01-01 00:00 素材/パッケージ.txt
b は文字化けしました。c は一見すると a と同じです。ただし長さの列が違います。
見た目が同じなのに、一致しない
c の最初の名前を、README に書くつもりのお読みください.txtと照合しました。
python3 - <<'EOF'
import zipfile
want = "お読みください.txt"
for z in ["a-utf8-nfc", "b-cp932-legacy", "c-utf8-nfd"]:
names = zipfile.ZipFile(z + ".zip").namelist()
print(z, "len=%d" % len(names[0]), want in names)
EOF
a-utf8-nfc len=11 True
b-cp932-legacy len=18 False
c-utf8-nfd len=12 False
文字数は a が 11、c が 12 です。「ください」の「だ」が 2 文字に分かれているためです。端末では同じに見えても、プログラムは別の名前として扱います。
3件ずつの不備を検査で止める
検査用のcheck_zip_names.pyを保存しました。ASCII だけの名前は対象外にします。ASCII 以外の名前では、ビット 11 の有無と、NFC かどうかを確かめます。
import sys, unicodedata, zipfile
def check(path):
problems = []
with zipfile.ZipFile(path) as zf:
for info in zf.infolist():
name = info.filename
utf8_flag = bool(info.flag_bits & 0x800)
if name.isascii():
continue
if not utf8_flag:
problems.append(f"UTF-8フラグなし: {name!r}")
elif name != unicodedata.normalize("NFC", name):
problems.append(f"NFCでない名前: {unicodedata.normalize('NFC', name)}")
return problems
bad = 0
for path in sys.argv[1:]:
problems = check(path)
print(f"{path}: {'FAIL' if problems else 'PASS'} ({len(problems)}件)")
for p in problems:
print(" -", p)
bad += bool(problems)
sys.exit(1 if bad else 0)
python3 check_zip_names.py a-utf8-nfc.zip b-cp932-legacy.zip c-utf8-nfd.zip
echo "exit=$?"
a-utf8-nfc.zip: PASS (0件)
b-cp932-legacy.zip: FAIL (3件)
- UTF-8フラグなし: 'é¿ô╟é▌é¡é╛é│éó.txt'
- UTF-8フラグなし: 'âeâôâvâîü[âg/âKâCâh.md'
- UTF-8フラグなし: 'æfì▐/âpâbâPü[âW.txt'
c-utf8-nfd.zip: FAIL (3件)
- NFCでない名前: お読みください.txt
- NFCでない名前: テンプレート/ガイド.md
- NFCでない名前: 素材/パッケージ.txt
exit=1
b の名前がé¿ô╟...のような別の文字に見えるのは、Python がビットなしの名前を CP437 という古い文字コードで読むからです。c の行に出た名前は、直した後の NFC 形です。そのため、画面では問題の名前と同じに見えます。
不備は b と c で 3 件ずつ、合計 6 件でした。
名前を直して、同じ検査を通す
修復は、元の文字コードを指定して読み直し、NFC にそろえて、UTF-8 で書き直す流れです。repair_zip_names.pyを保存しました。
import sys, unicodedata, zipfile
src, dst, enc = sys.argv[1], sys.argv[2], sys.argv[3]
with zipfile.ZipFile(src, metadata_encoding=enc if enc != "utf-8" else None) as zin, \
zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
for info in zin.infolist():
name = unicodedata.normalize("NFC", info.filename)
zi = zipfile.ZipInfo(name, date_time=info.date_time)
zi.compress_type = zipfile.ZIP_DEFLATED
zi.external_attr = info.external_attr
zout.writestr(zi, zin.read(info))
print("wrote", dst)
python3 repair_zip_names.py b-cp932-legacy.zip b-fixed.zip cp932
python3 repair_zip_names.py c-utf8-nfd.zip c-fixed.zip utf-8
python3 check_zip_names.py a-utf8-nfc.zip b-fixed.zip c-fixed.zip
echo "exit=$?"
wrote b-fixed.zip
wrote c-fixed.zip
a-utf8-nfc.zip: PASS (0件)
b-fixed.zip: PASS (0件)
c-fixed.zip: PASS (0件)
exit=0
直した b を、実際に解凍して名前を見ました。
mkdir out-b
unzip -q b-fixed.zip -d out-b
ls -1 out-b
お読みください.txt
テンプレート
素材
3 通りとも、不備は 0 件になりました。
直らなかったこと
この実験で言えるのは、3 つの ZIP のファイル名が、仕様どおりの形になったところまでです。
- 古い ZIP を直すには、元の文字コードを自分で指定します。ここでは CP932 だと分かっていました。分からない ZIP を自動判定して直せるわけではありません。
- NFC にそろえると、ファイル名が変わります。README やリンクの名前も、同じ NFC へそろえる必要があります。
- Windows のエクスプローラーや macOS の Finder での表示は、この環境では試していません。検査が通ることと、購入者の画面で読めることは別です。
unzip -O cp932で文字コードを指定して一覧を見ようとしましたが、この環境の UnZip 6.00 では使い方の表示が出て終了コード 10 になりました。この版では、その確認方法は使えません。- 7-Zip は
-mcp=932を付けると、b-cp932-legacy.zip の名前を日本語で表示しました。付けない場合は化けました。
AI が作った日本語名のファイルを販売する場合、私は ASCII の名前にそろえるのが一番安全だと思っています。名前に日本語を残す価値があるときだけ、この検査を通してから販売候補にします。
よくあるエラー
| 症状 | 原因 | 対処 |
|---|---|---|
解凍すると���の列になる | ビット11なしでCP932の名前が入っています | metadata_encoding="cp932"で読み直し、UTF-8で書き直します |
| 一覧は同じ見た目なのに名前が一致しない | NFDとNFCが混ざっています | 全ファイル名と、README内の名前をNFCにそろえます |
first_try.pyで「古い形式」を作れない | PythonがASCII以外の名前に自動でビット11を立てます | 同じ長さの仮名で作り、バイト列を置き換えます |
unzip -O cp932が使い方を表示して終了する | UnZip 6.00のDebian版はこのオプションを受け付けません | 7-Zipの-mcp=932か、Pythonのmetadata_encodingを使います |
| 修復後にREADMEのリンクが切れる | 名前を正規化したのにリンク側を直していません | READMEの記載名も同じ関数で正規化して照合します |
一次情報
| 資料 | 確認した内容 |
|---|---|
| ZIP File Format Specification (APPNOTE.TXT) | 汎用フラグのビット11が、ファイル名とコメントをUTF-8とする印であること |
| zipfile ― ZIP アーカイブの処理(Python公式) | ZipFileのmetadata_encoding引数で、ZIP内の名前の文字コードを指定できること |
| unicodedata ― Unicode データベース(Python公式) | normalizeでNFCとNFDを相互に変換できること |
| UAX #15 Unicode Normalization Forms | NFCとNFDの定義と、見た目が同じでも並びが違う理由 |


