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 ガギ なら見つかります。

流れ図。Foundation を使う Mac アプリが NFD のファイル名を作り、APFS はそのまま NFD で保存する。そこから3つの道でバグになる。NFC で入力すると find・grep・Python の glob が見つけられない。zip と ditto は UTF-8 フラグなしで保存するので、他の環境は CP437・CP932・CP949 で読む。precomposeunicode を切った git はインデックスに NFD を記録する。3つとも同じ対策「境界で NFC に正規化」に行き着く。
Mac で NFD のファイル名が生まれる場所と、壊れる場所(macOS 26.6 で実測)。APFS はどちらの形でもファイルを見つけるが、文字列で比べるツール、UTF-8 フラグのない zip、git のリポジトリが NFD のバイト列を外へ運ぶ。

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 入力を NFC に正規化 12.1 ms、is_normalized 判定 12.2 ms、str.lower() 22.0 ms、NFKC+casefold 30.5 ms、NFD 入力を NFC に正規化 225.2 ms。
ランダムな12文字の名前20万件の処理時間。5回の最小値、Python 3.9.6、Apple Silicon Mac(M1 Pro)、シード42。すでに NFC の文字列の正規化は lower() と同程度で、実際に重いのは NFD の入力だけ。

すでに 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 の仕様と一般的な報告に基づきます。

練習問題

  1. 取引先の CSV の「ガイド」と自社 DB の「ガイド」が一致しない。どの行が NFC でないかを Python の1行で探してください。
  2. 会員 ID を TARO でも taro でも受け付けたいが、表示は登録したとおりにしたい。何を保存し、何にユニークインデックスを張りますか。

解答

  1. [n for n in names if not unicodedata.is_normalized("NFC", n)](Python 3.8 以降)。見つけたら unicodedata.normalize("NFC", n) で元データを直します。
  2. 表示用に 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 で作るのが確実です。

  • #Unicode
  • #正規化
  • #NFC
  • #NFD
  • #濁点分離
  • #zip
  • #git
  • #中級

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑