Unicode 正規化:見た目が同じ二つの文字列が等しくない理由
é は一つのコードポイントにも、基底文字と結合記号にもなります。比較・保存・索引の前に NFC へ揃えないと、重複やログイン失敗が静かに発生します。
見た目が完全に同じ二つの文字列が false になることがあります。ブラウザの不具合ではありません。同じ文字に Unicode 上で複数の正しい符号化があるためです。
- U+00E9(単一のコードポイント)
- U+0065 U+0301(基底文字と結合アクセント)
描画は同じですがバイト列が違い、length も違います(1 対 2)。
四つの正規化形式
| 形式 | 内容 | 主な用途 |
|---|---|---|
| NFC | 最小のコードポイント数に合成 | 保存と比較の既定 |
| NFD | 基底文字と結合記号に分解 | テキスト処理、アクセント除去 |
| NFKC | 互換分解してから合成 | 識別子、検索 |
| NFKD | 互換分解 | 同上、分解状態のまま |
規則は短いです。比較と保存は NFC、アクセントを剥がすなら NFD。
'é'.normalize('NFC').length // 1
'é'.normalize('NFD').length // 2
正規化しないとどうなるか
| 場面 | 症状 |
|---|---|
| ユーザ名の重複判定 | 「同じ」名前が二つ登録できる |
| ログイン検証 | 別の入力方式だとパスワードが通らない |
| 一意インデックス | データベースが別行と見なす |
| 文字列の整列 | 目で見た順と一致しない |
| 検索 | 存在するのに見つからない |
ログインが最も厄介です。スマートフォンの入力は NFC、デスクトップの IME は NFD を出すことがあります。パスワードハッシュはバイト列に敏感なので「パスワードが違います」になります。
どこで揃えるか
境界で一度だけ揃え、その後は信頼します。 入力を受け取る最外周で normalize('NFC') を一度行えば、保存・キャッシュ・比較はすべて正規形になります。
const normalizeKey = (s) => s.normalize('NFC').toLowerCase();
既存データが二つの形で混ざっている場合、いきなり一意インデックスを張ってはいけません。先に全体を正規化してください。でなければ制約の作成時に失敗します。
互換分解は別の話
NFKC はより強力で、① を 1 に、㎏ を kg に変えます。識別子や検索には適しますが、表示に使うとユーザの意図を変えます。
'①'.normalize('NFKC') // '1'
'①'.normalize('NFC') // '①'
パスワードに NFKC を適用してはいけません。 見た目が異なるパスワードを同一にまとめ、鍵空間を狭めます。
同形異義文字は別問題
正規化が解決するのは「同一文字の異なる符号化」です。「異なる文字が同じに見える」は解決しません。キリル文字 U+0430 とラテン文字 U+0061 は多くのフォントで区別できません。これは許可リストや混同検出の領域です。
バイト列の等価は、常に正規化後の等価を意味します。境界で一度やれば後は無料です。

コメント
…