Unicode正規化入門:NFC・NFD・NFKCと濁点分離・zip文字化け
見た目が同じ「ガ」が一致しない原因を実測。NFC/NFD/NFKC/NFKDの違い、半角カナ・①㈱・波ダッシュの扱い、APFSの実際の挙動、Macで作ったzipの濁点分離と文字化け、gitの設定まで直し方付きで解説。
シリーズ:文字コード講座 3/6(中級)。 文字はバイト列だということ(講座1・2)は分かったうえで、「見た目は同じなのに一致しない」バグに当たった人向けの回です。Unicode の4つの正規化形式を実際に動かし、macOS が本当に何を保存しているかを測り、そこから起きる比較・zip・git のバグを直します。
Mac で作った資料を Windows の取引先に zip で送ったら「ガイド.pdf」が「カ゛イト゛.pdf」のように濁点が分かれて届いた、find . -name 'ガイド*' が何も返さないのに ls には出ている、半角カナで登録された「カブシキガイシャ」が全角の検索で引っかからない、という経験があれば、この記事はその原因を扱います。どれも根は同じで、Unicode では同じ見た目の文字を異なるコードポイントの並びで書けるのに、ほとんどのコードはコードポイント単位で比べているからです。以下はすべて Mac 上で実行し、出力はターミナルからそのまま写しました。Windows の挙動はドキュメントに基づく説明で、「ここでは未実行」と明記します。
前回:講座2:文字化けの仕組みと直し方。全体像はプログラミング学習ロードマップへ。
検証環境: macOS 26.6.2(APFS)、Apple Silicon Mac(M1 Pro)、Python 3.9.6(unicodedata 13.0.0)、PHP 8.5.6+intl(ICU 78.3)、JavaScriptCore の jsc、Swift 6.4.2、Apple 同梱の Info-ZIP Zip 3.0、ditto、git 2.54.0(Apple Git-157)、sqlite3 3.51.0。パスは ~/demo に短縮しています。
「ガ」には2つの書き方がある
コードポイントは Unicode が各文字に振った番号で、U+30AC のように書きます。結合文字は直前の文字にくっつくコードポイントで、濁点なら U+3099 COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK です。Unicode では「ガ」を1つのコードポイントでも、「カ」+結合用濁点でも書けます。両方の形と UTF-8 のバイト列を表示してみます。
# cps.py - NFC と NFD のコードポイントと UTF-8 バイト列
import unicodedata as ud, sys
for s in sys.argv[1:]:
for f in ("NFC", "NFD"):
t = ud.normalize(f, s)
print(f, s, " ".join(f"U+{ord(c):04X}" for c in t), t.encode().hex(" "))
~/demo % python3 cps.py ガ é
NFC ガ U+30AC e3 82 ac
NFD ガ U+30AB U+3099 e3 82 ab e3 82 99
NFC é U+00E9 c3 a9
NFD é U+0065 U+0301 65 cc 81
NFC(正規化形式C、合成)は合成済みの1文字があればそれを使い、NFD(分解)はベースの文字と結合文字に分けます。どちらも正しい Unicode で、画面上は同じに見えます。それなのに普通のコードはこう失敗します。
>>> a = "ガギ"; b = "ガギ" # ガギ(NFC と NFD)
>>> a == b, len(a), len(b)
(False, 2, 4)
>>> b[:1]
'カ'
PHP(intl の Normalizer)や JavaScript(jsc)でも同じで、正規化してから比べると true になります。
~/demo % php norm.php # $nfd === $nfc → 正規化後
bool(false)
bool(true)
~/demo % jsc n.js # a === b, 長さ → normalize("NFC") 後
false 4 5
true
4つの形式:NFC・NFD・NFKC・NFKD
残りの2つの K は「互換(compatibility)」の意味で、NFKC・NFKD は Unicode が「見た目の違い」とみなす文字も置き換えます。半角カナ、全角英数、丸数字、合字などです。検索には便利ですが、保存には破壊的です。実行結果をまとめます。
| 入力 | NFC(長さ) | NFD(長さ) | NFKC(長さ) | 変わったこと |
|---|---|---|---|---|
ガギ |
ガギ (2) | ガギ (4) | ガギ (2) | NFD で濁点が分離 |
ガギ(半角カナ) |
そのまま (4) | そのまま (4) | ガギ (2) | NFKC で全角になり濁点も合成 |
ABC123(全角) |
そのまま (6) | そのまま (6) | ABC123 (6) | NFKC で ASCII に |
①㈱(機種依存文字) |
そのまま (2) | そのまま (2) | 1(株) (4) | NFKC で丸や囲みが消える |
〜~(U+301C, U+FF5E) |
そのまま (2) | そのまま (2) | 〜~ (2) | U+FF5E だけが ~ に |
café |
café (4) | café (5) | café (4) | NFD でアクセントが分離 |
한글 |
한글 (2) | 한글 (6) | 한글 (2) | NFD で字母に分解 |
日本語の現場で特に効くのは次の3点です。
- NFC は「半角カナを全角に」してくれません。 半角カナ・全角英数・丸数字を変えるのは K の付く形式だけです。
- NFKC は不可逆です。 ①は1に、㈱は (株) になり、元には戻せません。ユーザーが入力したとおりに保存・表示する文字列に NFKC をかけてはいけません。
- 波ダッシュ問題は正規化では解決しません。 CP932 由来の「~」(U+FF5E 全角チルダ)は NFKC で半角の
~になりますが、JIS 由来の「〜」(U+301C 波ダッシュ)は NFKC でもそのままです。2つを同一視したいなら、正規化とは別に置換表が要ります(講座4で CSV と一緒に扱います)。
もう一つの落とし穴は、単独で入力された濁点「゛」(U+309B、幅を持つ濁点)です。これは結合用の U+3099 ではないので、NFC は カ゛ を2文字のまま残します。NFKC は カ、スペース、U+3099 に変えるだけで、ガ にはなりません。古い入力システムから来たデータには専用の置換が必要です。
PHP では strlen がバイト数なので、正規化すると数字が変わります。
~/demo % php norm.php
ガギ strlen=12 NFD strlen=12 mb_strlen=4 NFKC=ガギ
①㈱ strlen=6 NFD strlen=6 mb_strlen=2 NFKC=1(株)
~ strlen=3 NFD strlen=3 mb_strlen=1 NFKC=~
APFS は実際に何を保存しているか
昔の HFS+ はファイル名を分解形に変換して保存していたため、「Mac は NFD で保存する」という説明が今でも多く見られます。そこで macOS 26.6 の APFS を実測しました。NFC の名前でファイルを作り、NFD の名前で存在確認と2つ目の作成を試します。
# apfs.py - APFS は NFC と NFD の名前をどう扱うか
import os, unicodedata as ud
os.makedirs("t", exist_ok=True)
for word in ["ガギ", "café", "한글", "año"]:
nfc, nfd = ud.normalize("NFC", word), ud.normalize("NFD", word)
open(os.path.join("t", nfc + ".txt"), "w").write("nfc")
print(word, "exists via NFD name:", os.path.exists(os.path.join("t", nfd + ".txt")))
try:
with open(os.path.join("t", nfd + ".txt"), "x") as f:
f.write("nfd")
except FileExistsError as e:
print(" FileExistsError:", e.strerror)
for name in sorted(os.listdir("t")):
print(repr(name), name.encode().hex(" "))
ガギ exists via NFD name: True
FileExistsError: File exists
...
'ガギ.txt' e3 82 ac e3 82 ae 2e 74 78 74
つまりこの APFS は正規化を区別しない(どちらの形でも見つかり、両方は作れない)うえに、正規化を保存する(作ったときのバイト列をそのまま返す)ことが分かりました。NFD の名前で作ると、listdir は NFD のバイト列(e3 82 ab e3 82 99 …)を返しました。
では今の Mac で NFD の名前を作るのは誰か。Finder などほとんどの Mac アプリが使う Foundation に、Swift でファイルを作らせてみました。
FileManager.default.createFile(atPath: "sw/\(w).txt", contents: Data("x".utf8))
ガギ.txt e3 82 ab e3 82 99 e3 82 ad e3 82 99 2e 74 78 74 8
Foundation は NFD で渡し、APFS はそれを保持しました。NFC の名前で開くこと(cat)はできますが、自分で文字列を比べるツールは見つけられません。
~/demo % find d -name 'ガギ*' # NFC で入力:何も出ない
~/demo % ls d | grep ガギ; echo $?
1
~/demo % python3 -c 'import glob; print(glob.glob("d/ガ*"))'
[]
一方、zsh 5.9 の d/한* のようなグロブは一致しました。シェルによって結果が変わるので「たまに動かない」バグに見えます。macOS の iconv には NFD を NFC に直す UTF-8-MAC があり、ls d | iconv -f UTF-8-MAC -t UTF-8 | grep ガギ なら見つかります。
Mac で作った zip の濁点分離と文字化け
zip は名前をバイト列で保存し、汎用フラグのビット11で「このバイト列は UTF-8」と示します。フラグがなければ、ZIP の仕様上は IBM のコードページ 437 として読むことになっています。Foundation で作ったフォルダを macOS の2つのツールで圧縮し、中身を調べました。
# zipinfo.py(要点)- 生の名前と UTF-8 フラグ(ビット11)
for i in zipfile.ZipFile(path).infolist():
utf8 = bool(i.flag_bits & 0x800)
raw = i.filename.encode("utf-8" if utf8 else "cp437")
print(int(utf8), "NFC" if ud.is_normalized("NFC", raw.decode()) else "NFD", repr(i.filename))
== zip -r zip_nfd.zip sw
flag800=0 NFD extra=['0x5455', '0x7875'] python_sees='sw/ガギ.txt'
== ditto -c -k --sequesterRsrc sw ditto_nfd.zip
flag800=0 NFD extra=['0x5855'] python_sees='ガギ.txt'
どちらもフラグを立てず、NFD のバイト列をそのまま入れていました。仕様どおり CP437 で読んだ Python は意味不明な名前を出しています。ditto --sequesterRsrc はファイルごとに __MACOSX/._ガギ.txt も追加しました。Windows(ここでは未実行)では、フラグのない名前はシステムの OEM コードページで読まれることが多く、日本語版なら CP932 です。同じ NFD のバイト列を CP932 で読むと 繧ォ繧吶く繧… になり、NFC でも 繧ャ繧ョ です。UTF-8 と正しく推測された場合でも名前は NFD のままなので、フォントによっては「カ゛キ゛」のように濁点が分かれて表示されます。これがいわゆる濁点分離問題です。
対策は、名前を NFC にそろえてフラグを立てた zip を作ることです。Python の zipfile は ASCII 以外の名前に自動でビット11を立てます。
# safezip.py - NFC の名前・UTF-8 フラグ付きでフォルダを zip にする
import os, sys, zipfile, unicodedata as ud
src, dst = sys.argv[1], sys.argv[2]
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as z:
for root, dirs, files in os.walk(src):
for f in sorted(files):
if f == ".DS_Store":
continue
path = os.path.join(root, f)
z.write(path, ud.normalize("NFC", os.path.relpath(path, src)))
== safe.zip
flag800=1 NFC extra=[] python_sees='ガギ.txt'
受け取った Mac の zip は、CP437 でエンコードし直して元のバイト列に戻し、UTF-8 でデコードしてから NFC にすれば直ります(今回の実行では 'sw/πé½πéÖπé¡πéÖ.txt' -> sw/ガギ.txt)。
git と core.precomposeunicode
macOS の git にはまさにこの問題のための設定があります。今回の git init では core.precomposeunicode=true が自動で設定され、NFD のファイル名も NFC(ed 95 9c …)でインデックスに記録されました。false にすると NFD のまま記録されます。問題は、その NFD のリポジトリを既定の設定で clone したときです。
~/demo/c_nfd % git -c core.quotepath=false status --short
?? 한글.txt
追跡済みの NFD のファイルが、未追跡の NFC のファイルとして隣に表示されます。ここで何も考えずに git add -A すると、Linux のメンバーには同じ名前が2つ見えます。1回だけ名前を変えるコミットで直します。
~/demo/c_nfd % git -c core.precomposeunicode=false rm -q --cached -- "$(git -c core.precomposeunicode=false ls-files -z | tr -d '\0')"
~/demo/c_nfd % git add -A && git -c core.quotepath=false status --short
R 한글.txt -> 한글.txt
コミット後はインデックスが NFC になり、status もきれいになりました。
よくある間違い:正規化せずに比べる
間違い。 ユーザー名が Web フォーム(NFC)、iPhone のキーボード(たいてい NFC)、Mac のファイル名から作った CSV(NFD)から入り、UNIQUE 制約付きの列に入る。
実際に起きること。 NFD を sqlite3 に保存し、NFC で検索しました。
café: rows=0
한글: rows=0
ガギ: rows=0
año: rows=0
inserted NFC café as a second, 'unique' row: 2
何も見つからず、「ユニーク」なはずの列に同じ見た目の行が2つ入りました。
直し方。 文字列がシステムに入る境界(フォーム、ファイル名、CSV 取り込み、API)で一度だけ正規化し、NFC で保存します。あいまい一致には別の検索キー列を使います。
import unicodedata as ud
def store(s): return ud.normalize("NFC", s)
def search_key(s): return ud.normalize("NFKC", s).casefold()
print(search_key("CAFÉ") == search_key("café")) # True
表示にはユーザーが入力した NFC の文字列を使い、NFKC+casefold() は照合にだけ使います。
正規化のコストを測る
すべての文字列に normalize をかけて大丈夫か。ランダムな12文字の名前20万件で計りました(シード42、5回の最小値、Apple Silicon Mac(M1 Pro)、Python 3.9.6)。
すでに NFC の文字列を正規化するのは str.lower() と同程度でした。CPython はまず高速チェックをして、変更不要ならそのまま返すからです。実際に重いのは NFD の入力だけで、それでも1件あたり約1.1µsでした。境界での正規化は安く、省いたときの代償のほうが高くつきます。
どの形式を使うか:判断表
| 場面 | 形式 | 理由 |
|---|---|---|
| ユーザー入力の保存、DB のキー、自分で作るファイル名 | NFC | 最も短く、多くの環境やキーボードが出す形 |
| 2つの文字列の等価比較 | 両方を NFC | 同じ文字なら同じバイト列になる |
| 検索、ログイン ID、重複検出 | NFKC+casefold() を別キーに |
半角カナ・全角英数・大文字小文字がそろう |
| アクセントを外す(año → ano) | NFD にして結合文字を捨てる | アクセントが別のコードポイントになる |
| 入力どおりの表示・往復 | NFC、NFKC は使わない | NFKC は①・㈱を失う |
| Windows ユーザー向けの zip | NFC の名前+UTF-8 フラグ | 今回の Mac のツールはどちらもしない |
正直な限界とバージョンの注意
- Python 3.9 の
unicodedataは Unicode 13.0.0、ここの PHP intl は ICU 78.3・Unicode 17.0 でした。既存の文字の NFC/NFD の結果は Unicode の方針でバージョン間で変わりませんが、新しい文字は新しいデータでないと正しく正規化されません。 unicodedata.is_normalizedは Python 3.8 で追加されました。- APFS の挙動は macOS 26.6.2 の起動ボリュームでのみ測りました。初期の APFS では正規化の扱いが変わった経緯があり、exFAT の USB メモリや SMB 共有では違う可能性があります(未確認)。
- Windows、エクスプローラーの zip 機能、NTFS は実行していません。説明は ZIP の仕様と一般的な報告に基づきます。
練習問題
- 取引先の CSV の「ガイド」と自社 DB の「ガイド」が一致しない。どの行が NFC でないかを Python の1行で探してください。
- 会員 ID を
TAROでもtaroでも受け付けたいが、表示は登録したとおりにしたい。何を保存し、何にユニークインデックスを張りますか。
解答
[n for n in names if not unicodedata.is_normalized("NFC", n)](Python 3.8 以降)。見つけたらunicodedata.normalize("NFC", n)で元データを直します。- 表示用に
normalize("NFC", 入力)を保存し、別の列にnormalize("NFKC", 入力).casefold()を入れてUNIQUEインデックスを張ります。どちらの入力もキーはtaroになります。
前回: 講座2:文字化けの仕組みと直し方。次回: 講座4:CSV の文字コード・BOM・Excel。同じ文字列が表計算ソフトに出会います。関連:.htaccess のリライトと日本語 URL、Python の型と文字列、PHP の文字列。
よくある質問
Macから来たファイル名で「ガ」が「カ゛」のように分かれるのはなぜ?
「ガ」には1文字の U+30AC(NFC)と、「カ」U+30AB+結合用濁点 U+3099(NFD)の2通りの書き方があります。Foundation を使う Mac アプリは NFD で名前を作り、APFS はそのまま保存しました。受け取った側が結合できないと濁点が分かれて見えます。境界で NFC に正規化すれば直ります。
半角カナや①を普通の文字にそろえたい。NFC で足りる?
足りません。NFC は半角カナ・全角英数・丸数字を一切変えません。ガ→ガ、ABC→ABC、①→1 にするのは NFKC です。ただし①や㈱が元に戻せなくなるので、検索用キーにだけ使い、表示用の文字列には使いません。
Macで作ったzipのファイル名がWindowsで文字化けするのはなぜ?
macOS 26.6 の zip コマンドも ditto も、名前を UTF-8 のバイトのまま、UTF-8 フラグ(ビット11)を立てずに保存しました。フラグがないと読む側はレガシーなコードページで解釈し、Python は CP437 で「πé½πéÖ」のように読みました。NFC にそろえてフラグも立てる Python の zipfile で作るのが確実です。