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

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つにまとめる形式です。各ファイルの名前と中身を別々に持ちます
ビット11ZIPの各ファイルにある汎用フラグの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 FormsNFCとNFDの定義と、見た目が同じでも並びが違う理由
この記事をシェア