見えない文字がキー検索とdiffを壊す — 検出と正規化の方法
画面上はまったく同じに見える2つの文字列が、完全一致の比較に失敗する。翻訳者が打ち直した翻訳キーが、コード側で参照しているキーと一致しなくなる。2つのバージョンのファイルをdiffすると、見た目には何も変わっていない行に変更があると表示される。この3つに共通する原因は多くの場合同じで、画面上ははっきり見えないのに、コンピューターには実在する別の文字として扱われる「見えない文字」です。
関係する文字の種類
これらはどれも文字化けのような「データ破損」ではなく、それぞれ定義通りに正しく機能している正当なUnicode文字です。問題は、その一部が画面上は何も表示しない、あるいは別の文字と見た目が区別できないため、画面でテキストを目視確認する人間には見えない一方で、バイト単位や文字単位で比較するコードにとってははっきり別物として扱われる点です。
- ゼロ幅スペース(U+200B)や、その他のゼロ幅文字(ゼロ幅結合子U+200D、ゼロ幅非結合子U+200C) — 見た目には文字通り何も表示されないが、文字列上には確実に存在する文字。
- ノーブレークスペース(U+00A0) — ほとんどのフォントで通常の半角スペースと見た目が同じだが、別の文字であり、通常のスペースを想定した検索や比較には一致しない。
- ファイルの先頭以外の途中に紛れ込んだUTF-8やUTF-16のBOM — それぞれ独自のBOMを持つ複数のファイルを結合した際によく発生し、ツールによって何も表示されなかったり、余計な箱として表示されたりする。
- 左から右への方向マーク(U+200E)や右から左への方向マーク(U+200F)などの方向制御文字、より強い埋め込み・上書き制御文字 — 双方向テキストで意図的に使われるものだが、それらを使う言語のソーステキストから翻訳先の言語に意図せず引き継がれ、見えないまま残ることがある。
- 全角スペース(U+3000)と半角スペース(U+0020) — どちらも空白として表示されるが、幅の異なる別の文字であり、日本語テキストでは見た目の位置を揃える目的で全角スペースが使われることが多い。
なぜ完全一致比較・キー検索・diffが壊れるのか
翻訳キーの検索、diffツール、重複文字列のチェック、2回の解析実行を比較するリグレッション検出 — これらの仕組みはどれも、テキストが表示上どう見えるかではなく、文字単位・バイト単位で文字列を比較することで動いています。キー検索は、2つの文字列が表示上同じに見えるかどうかは気にせず、文字の並びが完全に一致するかどうかだけを見ています。
だからこそ目視デバッグでは特に混乱します。2つの文字列を並べて見ている人には、まったく違いが見えません。その違い自体が「描画されない種類の違い」だからです。これを見つける唯一の方法は、表示されたテキストではなく、実際の文字コードやバイト値そのものを確認することです。
// この2つは画面上まったく同じに見えるが、異なる文字列
const key1 = "menu.start"; // 通常の文字のみ
const key2 = "menu.start"; // 末尾にゼロ幅スペース — 見えないが、
// キー検索やMapにとってkey1とkey2は
// 別物として扱われる検出方法
見た目上まったく分からないことがこの問題の本質なので、検出は視覚レベルではなく文字コードを直接確認するレベルで行う必要があります。
- 対象となる特定のコードポイントを、ソースファイル全体に対して直接検索する(正規表現やコードポイント検索)。ゼロ幅スペース、ノーブレークスペース、ファイル途中のBOM、方向制御文字は、明示的にチェックする価値のある短い既知のリストです。
- 同一であるべき2つの文字列や、一方が他方のコピーであるべき箇所で、文字列の長さを比較する。見た目に違いがないのに長さが違えば、強い手がかりになります。
- 検索や比較が思いがけず失敗し、2つの値が見た目には同じに見える場合、その文字列の生のコードポイントをログ出力・表示して確認する。多くのテキストエディタやコンソールは、要求すれば文字列をコードポイントやエスケープシーケンスの並びとして表示できます。
再発を防ぐ正規化ポリシー
有効な対策は、個別のインスタンスを1つずつ潰すことではなく、テキストがパイプラインに入るすべての地点(インポート、翻訳の受け取り、手入力)で一貫した正規化ステップを適用し、見えない文字がそもそも静かに蓄積しないようにすることです。
- インポート時にゼロ幅文字やファイル途中のBOMを除去する。特定の文字列が正当な理由でそれを必要とする場合は例外とし(まれで、あればコメントを残す価値がある)。
- 空白文字の種類は偶然ではなく意図的に正規化する。ノーブレークスペースや全角スペースを特定の文脈で許可するか(見た目の整形目的では許可することが多い)を決め、それ以外の箇所では除去または変換する。
- Unicode正規化(多くのローカライズパイプラインではNFCが一般的な選択)をインポート時に一貫して適用する。これにより、Unicode上は複数の同値な表現を持ちうる文字が常に同じ形で保存されるようになり、「Unicode的には等価だがバイト単位では一致しない」という、根っこは同じ別種の問題も同時に塞げます。
- キーや重複文字列の比較は、生の入力ではなく正規化後のテキストに対して行う。そうすれば、翻訳者やツールが偶然混入させた見えない文字が、これまで動いていたキー検索を静かに壊すことがなくなります。