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 は多くのフォントで区別できません。これは許可リストや混同検出の領域です。

バイト列の等価は、常に正規化後の等価を意味します。境界で一度やれば後は無料です。

← 記事一覧に戻る

コメント

…