Unicode Normalization: NFC, NFD, NFKC, NFKD DemystifiedUnicode正規化完全ガイド: NFC/NFD/NFKC/NFKD の違い
Why the same-looking text can have different bytes, when each normalization form matters, and how to see the differences visually.同じに見えるテキストがなぜ異なるバイト列になるのか。4つの正規化形式の使い分けを視覚的に解説。
The Problem: "café" Encoded Two Ways
Type café on two different computers and you might get two completely different byte sequences — even though the text looks identical on screen. The letter é can be represented as a single code point U+00E9 (LATIN SMALL LETTER E WITH ACUTE), or as two code points: U+0065 (e) followed by U+0301 (COMBINING ACUTE ACCENT).
This means 'caf\u00E9' === 'cafe\u0301' evaluates to false in JavaScript, even though both strings render identically. File systems, databases, and search engines all face this problem: the same human-readable text can have multiple valid binary representations. A user searching for “café” might miss results stored in the other form.
Unicode normalization exists to solve exactly this problem. It defines deterministic algorithms for converting between equivalent representations, ensuring that text which looks the same actually compares as equal.
Canonical Equivalence: NFC and NFD
The two most common normalization forms handle canonical equivalence — sequences that represent the exact same abstract character:
| Form | Name | Strategy | café |
|---|---|---|---|
| NFC | Canonical Decomposition + Composition | Compose into fewest code points | c a f é (4 CPs) |
| NFD | Canonical Decomposition | Decompose into base + combining marks | c a f e ◌́ (5 CPs) |
NFD breaks every precomposed character into its base character plus combining marks. The é (U+00E9) becomes e (U+0065) + combining acute accent (U+0301). Characters that are already in their most decomposed form remain unchanged. NFD also applies canonical ordering to ensure combining marks appear in a deterministic order.
NFC first performs the full NFD decomposition, then recomposes the result using canonical composition rules. The net effect is that each character is represented by the fewest possible code points. NFC is by far the most common normalization form on the web: the W3C recommends NFC for all web content, and most modern operating systems use NFC by default. The notable exception is macOS, whose HFS+ file system historically stored filenames in a variant of NFD.
// NFC and NFD produce the same visual result
const nfc = "caf\u00E9"; // "café" — 4 code points
const nfd = "cafe\u0301"; // "café" — 5 code points
nfc === nfd; // false!
nfc.normalize("NFC") === nfd.normalize("NFC"); // true
nfc.normalize("NFD") === nfd.normalize("NFD"); // trueCompatibility Equivalence: NFKC and NFKD
Beyond canonical equivalence, Unicode defines compatibility equivalence: characters that are semantically similar but not identical. These are characters that were given separate code points for historical or formatting reasons but are considered “the same” for many practical purposes.
| Original | NFKC/NFKD result | Relationship |
|---|---|---|
| fi (U+FB01) | fi | Ligature → component letters |
| ffl (U+FB04) | ffl | Ligature → component letters |
| ㍻ (U+337B) | 平成 | Square composition → characters |
| ㌔ (U+3314) | キロ | Square composition → characters |
| ㍑ (U+3351) | リットル | Square composition → characters |
| ① (U+2460) | 1 | Enclosed numeral → digit |
| H (U+FF28) | H | Fullwidth → ASCII |
| ℌ (U+210C) | H | Letterlike symbol → letter |
NFKD (Compatibility Decomposition) performs canonical decomposition plus compatibility decomposition, breaking apart ligatures, square compositions, and other compatibility characters. NFKC (Compatibility Decomposition + Composition) does the same decomposition and then recomposes using canonical composition — like NFC applied after NFKD.
Compatibility normalization is lossy: the round-trip ㍻ → 平成 → ㍻ is impossible because NFKC/NFKD destroys the information that the original was a single square composition character. Use compatibility normalization for search and matching, but preserve the original form for display.
The 4-Form Comparison Matrix
The four normalization forms can be organized along two axes: canonical vs. compatibility decomposition, and whether recomposition is applied:
| Canonical only | Canonical + Compatibility | |
|---|---|---|
| Composed | NFC | NFKC |
| Decomposed | NFD | NFKD |
Here is how a single example character transforms under each form:
| Input | 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) |
Notice the last two rows: U+00C5 (LATIN CAPITAL LETTER A WITH RING ABOVE) and U+212B (ANGSTROM SIGN) are canonically equivalent. NFC maps both to U+00C5, NFD maps both to U+0041 U+030A, and the compatibility forms follow the same pattern. This is a case where two distinct code points represent the same abstract character.
Practical Impact: Comparison, Databases, and Security
Normalization issues surface in surprisingly many real-world systems:
- String comparison: Without normalization,
===fails on visually identical strings. Always normalize before comparing user input. - Database uniqueness: A UNIQUE constraint on a username field will allow both
café(NFC) andcafé(NFD) as separate entries. PostgreSQL and SQLite do not normalize by default. - Search and indexing: Elasticsearch and other search engines typically apply NFKC normalization in their analyzers to ensure
findmatchesfind. - Security: Homoglyph attacks can exploit normalization differences. An attacker might register a domain using compatibility characters that normalize to a known brand name. NFKC is used in PRECIS (RFC 8264) for username and password preparation.
- File systems: macOS HFS+ stores filenames in a modified NFD form, while most Linux systems store bytes as-is. Copying files between systems can create duplicates that look identical but differ at the byte level.
// Always normalize before comparing
function safeEquals(a: string, b: string): boolean {
return a.normalize("NFC") === b.normalize("NFC");
}
// For search: use NFKC to catch compatibility variants
function normalizeForSearch(s: string): string {
return s.normalize("NFKC").toLowerCase();
}
normalizeForSearch("find"); // "find"
normalizeForSearch("㍻"); // "平成"When to Use Which Form
Choosing the right normalization form depends on your use case:
| Use case | Recommended form | Why |
|---|---|---|
| Web content / HTML | NFC | W3C recommendation; smallest representation |
| Database storage | NFC | Consistent canonical form; preserves formatting characters |
| String comparison | NFC or NFD | Either works as long as both sides agree |
| Search / indexing | NFKC | Catches ligatures, fullwidth, and other compatibility variants |
| Username / password | NFKC (PRECIS) | RFC 8264 requires NFKC for identifier normalization |
| Display / rendering | Preserve original | Do not normalize display text; you lose formatting intent |
The golden rule: normalize early, normalize consistently. Pick one form for your system and apply it at the boundary where text enters. NFC is the safe default for most applications. Switch to NFKC only when you specifically need to collapse compatibility variants (search, security checks).
Be cautious with NFKC/NFKD in contexts where formatting matters. Converting ㍻ (a single-character representation of the Japanese era name “Heisei”) to 平成 is helpful for search, but the original compact form carries distinct semantic meaning in Japanese typography. Similarly, converting fi to fi is correct for search but may affect typographic rendering in some fonts.
問題: 「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 に変換するのは検索には正しいですが、一部のフォントでのタイポグラフィックレンダリングに影響する可能性があります。