Unicode正規化完全ガイド: NFC/NFD/NFKC/NFKD の違い
同じに見えるテキストがなぜ異なるバイト列になるのか。4つの正規化形式の使い分けを視覚的に解説。
問題: 「café」の2つのエンコード方法
2台の異なるコンピュータで café と入力すると、画面上は同じに見えるにもかかわらず、まったく異なるバイト列が生成されることがあります。文字 é は単一のコードポイント U+00E9(LATIN SMALL LETTER E WITH ACUTE)としても、2つのコードポイント U+0065(e)+ U+0301(COMBINING ACUTE ACCENT)としても表現できます。
つまり JavaScript では 'caf\u00E9' === 'cafe\u0301' が false になります。両方の文字列がまったく同じに見えるにもかかわらずです。ファイルシステム、データベース、検索エンジンはすべてこの問題に直面します:同じ人間可読テキストが複数の有効なバイナリ表現を持ちうるのです。「café」を検索したユーザーが、別の形式で保存された結果を見逃す可能性があります。
Unicode正規化はまさにこの問題を解決するために存在します。等価な表現間の変換を決定論的なアルゴリズムで定義し、同じに見えるテキストが実際に等しいと比較されることを保証します。
正準等価性: NFC と NFD
最も一般的な2つの正規化形式は正準等価性(canonical equivalence)を扱います。これはまったく同じ抽象文字を表すシーケンスのことです:
| 形式 | 名称 | 戦略 | café |
|---|---|---|---|
| NFC | 正準分解 + 正準合成 | 最少コードポイントに合成 | c a f é(4 CP) |
| NFD | 正準分解 | 基底文字 + 結合文字に分解 | c a f e ◌́(5 CP) |
NFD はすべての合成済み文字を基底文字と結合マークに分解します。é(U+00E9)は e(U+0065)+ 結合アキュートアクセント(U+0301)になります。既に最も分解された形式にある文字はそのままです。NFDは正準順序付け(canonical ordering)も適用し、結合マークが決定論的な順序で出現することを保証します。
NFC はまず完全なNFD分解を行い、次に正準合成規則で再合成します。結果として各文字が最少のコードポイントで表現されます。NFCはウェブで最も一般的な正規化形式です:W3CはすべてのWebコンテンツにNFCを推奨し、ほとんどの最新OSはデフォルトでNFCを使用します。注目すべき例外はmacOSで、HFS+ファイルシステムは歴史的にNFDの変種でファイル名を保存していました。
// NFC と NFD は同じ視覚的結果を生成する
const nfc = "caf\u00E9"; // "café" — 4コードポイント
const nfd = "cafe\u0301"; // "café" — 5コードポイント
nfc === nfd; // false!
nfc.normalize("NFC") === nfd.normalize("NFC"); // true
nfc.normalize("NFD") === nfd.normalize("NFD"); // true互換等価性: NFKC と NFKD
正準等価性を超えて、Unicodeは互換等価性(compatibility equivalence)を定義します。意味的に類似しているが同一ではない文字のことです。歴史的またはフォーマット上の理由で個別のコードポイントが割り当てられたものの、多くの実用的な目的では「同じ」と見なされる文字です。
| 元の文字 | NFKC/NFKD の結果 | 関係 |
|---|---|---|
| fi (U+FB01) | fi | 合字 → 構成文字 |
| ffl (U+FB04) | ffl | 合字 → 構成文字 |
| ㍻ (U+337B) | 平成 | 組文字 → 文字列 |
| ㌔ (U+3314) | キロ | 組文字 → 文字列 |
| ㍑ (U+3351) | リットル | 組文字 → 文字列 |
| ① (U+2460) | 1 | 囲み数字 → 数字 |
| H (U+FF28) | H | 全角 → ASCII |
| ℌ (U+210C) | H | 記号文字 → 文字 |
NFKD(互換分解)は正準分解に加えて互換分解を実行し、合字、組文字、その他の互換文字を分解します。NFKC(互換分解 + 正準合成)は同じ分解を行った後、正準合成で再合成します。NFC を NFKD の後に適用したようなものです。
互換正規化は不可逆です:㍻ → 平成 → ㍻ のラウンドトリップは不可能です。元が単一の組文字だったという情報が NFKC/NFKD で失われるからです。互換正規化は検索やマッチングに使い、表示には元の形式を保持してください。
4つの正規化形式の比較マトリクス
4つの正規化形式は、正準分解 vs 互換分解、および再合成の有無という2つの軸で整理できます:
| 正準のみ | 正準 + 互換 | |
|---|---|---|
| 合成 | NFC | NFKC |
| 分解 | NFD | NFKD |
各形式で文字がどのように変換されるかの具体例:
| 入力 | NFC | NFD | NFKC | NFKD |
|---|---|---|---|---|
| é (U+00E9) | é (U+00E9) | e◌́ (U+0065 U+0301) | é (U+00E9) | e◌́ (U+0065 U+0301) |
| fi (U+FB01) | fi (U+FB01) | fi (U+FB01) | fi (U+0066 U+0069) | fi (U+0066 U+0069) |
| ㍻ (U+337B) | ㍻ (U+337B) | ㍻ (U+337B) | 平成 | 平成 |
| Å (U+00C5) | Å (U+00C5) | A◌̊ (U+0041 U+030A) | Å (U+00C5) | A◌̊ (U+0041 U+030A) |
| Å (U+212B) | Å (U+00C5) | A◌̊ (U+0041 U+030A) | Å (U+00C5) | A◌̊ (U+0041 U+030A) |
最後の2行に注目してください:U+00C5(LATIN CAPITAL LETTER A WITH RING ABOVE)と U+212B(ANGSTROM SIGN)は正準等価です。NFC は両方を U+00C5 にマッピングし、NFD は両方を U+0041 U+030A にマッピングし、互換形式も同様のパターンに従います。2つの異なるコードポイントが同じ抽象文字を表すケースです。
実用的な影響: 文字列比較、データベース、セキュリティ
正規化の問題は驚くほど多くの実世界のシステムで表面化します:
- 文字列比較: 正規化なしでは、見た目が同じ文字列でも
===が失敗します。ユーザー入力の比較前には必ず正規化してください。 - データベースの一意性: ユーザー名フィールドの UNIQUE 制約は、
café(NFC)とcafé(NFD)を別のエントリとして許可してしまいます。PostgreSQL と SQLite はデフォルトで正規化を行いません。 - 検索とインデックス: Elasticsearch などの検索エンジンは通常、アナライザで NFKC 正規化を適用し、
findがfindにマッチすることを保証します。 - セキュリティ: ホモグリフ攻撃は正規化の差異を悪用できます。攻撃者が既知のブランド名に正規化される互換文字でドメインを登録する可能性があります。NFKC は PRECIS(RFC 8264)でユーザー名とパスワードの準備に使用されます。
- ファイルシステム: macOS の HFS+ はファイル名を修正NFD形式で保存しますが、ほとんどの Linux システムはバイト列をそのまま保存します。システム間でファイルをコピーすると、見た目は同じだがバイトレベルで異なる重複が作られることがあります。
// 比較前に必ず正規化する
function safeEquals(a: string, b: string): boolean {
return a.normalize("NFC") === b.normalize("NFC");
}
// 検索用: NFKC で互換バリアントを統一
function normalizeForSearch(s: string): string {
return s.normalize("NFKC").toLowerCase();
}
normalizeForSearch("find"); // "find"
normalizeForSearch("㍻"); // "平成"どの正規化形式をいつ使うか
正しい正規化形式の選択はユースケースによって異なります:
| ユースケース | 推奨形式 | 理由 |
|---|---|---|
| Webコンテンツ / HTML | NFC | W3C推奨。最小のコードポイント表現 |
| データベース格納 | NFC | 一貫した正準形式。書式文字を保持 |
| 文字列比較 | NFC または NFD | 双方が同じ形式であればどちらでも可 |
| 検索 / インデックス | NFKC | 合字、全角文字などの互換バリアントを統一 |
| ユーザー名 / パスワード | NFKC (PRECIS) | RFC 8264 が識別子の正規化に NFKC を要求 |
| 表示 / レンダリング | 元の形式を保持 | 表示テキストは正規化しない。書式の意図が失われる |
黄金律は:早期に正規化し、一貫して正規化する。システムで1つの形式を選び、テキストが入力される境界で適用してください。ほとんどのアプリケーションでは NFC が安全なデフォルトです。互換バリアントを統合する必要がある場合(検索、セキュリティチェック)にのみ NFKC に切り替えてください。
フォーマットが重要な文脈では NFKC/NFKD に注意が必要です。㍻(日本の元号「平成」の1文字表現)を 平成 に変換するのは検索には有用ですが、元のコンパクトな形式は日本語の組版で固有の意味を持ちます。同様に fi を fi に変換するのは検索には正しいですが、一部のフォントでのタイポグラフィックレンダリングに影響する可能性があります。