文字列の正規化をおこなうString.prototype.normalize() 見た目と内部が異なる文字列の正体と対処
見た目が同じでも、Unicode上では異なるコードポイント列で表現され、比較が一致しない文字列があります。NFC・NFD・NFKC・NFKDという4つの正規化形式の違いを整理し、String.prototype.normalize()を使った解決方法を日本語の具体例とともに紹介します。
- カテゴリー
- JavaScript/TypeScript >
- ECMAScript
発行
はじめに
文字列を扱っていると、ときどき不思議な状況に遭遇します。
- PDFからコピーした文字列のデータが何かおかしい
- 外部から取り込んだデータと手元の文字列を比較したら、なぜか一致しない
- 同じ文字列のようだけど
.lengthの値が異なる
こうした経験がある人は少なくないのではないでしょうか。
これらの原因の1つとして、同じ文字でも内部の表現方法が複数あるというUnicodeの仕様があります。
まずは理屈から入らず、実際に起きる現象をコードで見てみましょう。「が」という1文字を例にします。
見た目は同じでも一致しない例
const a = "が"; // U+304C(1つのコードポイント)
const b = "\u304B\u3099"; // U+304B + U+3099(「か」+ 結合濁点「◌゙」)
console.log(a === b); // false
console.log(a.length); // 1
console.log(b.length); // 2
コードの表記について
この記事では、結合文字を含む文字列を"\u304B\u3099"のようなUnicodeエスケープで表記します。実際の文字をそのまま書くと、結合していない文字(このコード例では変数aの「が」)との違いが画面上でわからなくなるためです。
aとbは、どちらも画面には同じ「が」として表示されますが、別物です。
aは「が」という1つのコードポイントで構成されているのに対し、bは「か」というコードポイントと、濁点を表す結合文字の2つで構成されています。
そのためa === bはfalseになり、.lengthの値も異なります。見た目が同じだからといって、同じ文字列表現とは限らないのです。
こうした表現の違いを吸収するために用意されているのが、String.prototype.normalize()です。
normalizeで一致させる
a.normalize() === b.normalize() // true
normalize()を通すことで、aとbは同じ文字列として比較できるようになります。
この記事では、normalize()が何をしているのかを理解し、どう使うのかを日本語の具体例を中心に紹介します。
どのようなケースで遭遇するのか
「そんな特殊な文字列を作ることなんてない」と思うかもしれません。確かに、自分で文字列を組み立てる場合には意識せずに済むことが多いでしょう。
問題になりやすいのは、生成元を自分で制御できない文字列を扱うときです。たとえば次のようなケースが挙げられます。
- PDFなどの文書からコピー&ペーストした文字列
- ユーザーがフォームに入力した文字列
- CSVなどからインポートしたデータ
- 外部APIや別システムから受け取ったデータ
- 異なる環境(ファイルシステムなど)を経由してきたファイル名
中でもPDFはわかりやすい例です。
PDFでは、画面に表示されている文字とコピーによって得られるUnicode文字列が、必ずしも単純に対応しているとは限りません。PDFの作成方法やフォントの埋め込み方によっては、コピーした結果に結合文字が含まれることがあります。もちろん「PDFなら必ずこうなる」というわけではありませんが、よく遭遇するパターンです。
こうした理由から、自分で生成方法を制御できない文字列を扱う際には、Unicode正規化を意識する必要があります。
String.prototype.normalize()の基本
normalize()のAPIそのものはシンプルです。
normalizeの基本形
str.normalize()
str.normalize("NFC")
str.normalize("NFD")
str.normalize("NFKC")
str.normalize("NFKD")
- 引数を省略した場合のデフォルトは
"NFC" - 元の文字列を変更するのではなく、正規化した新しい文字列を返す
- 指定できる正規化形式はNFC・NFD・NFKC・NFKDの4種類
参考リンク
Unicode仕様の用語だけで説明するとわかりにくいので、ここからは日本語の具体例を使いながら見てみましょう。
まずNFCとNFDの関係を見たあとで、NFKCとNFKDの違いを確認します。
NFCとNFD:合成された形と分解された形を使い分ける
NFCとNFDでは、同じ文字を「合成された形」に揃えるか、「分解された形」に揃えるかが違います。日本語では、濁点や半濁点がついた文字がわかりやすい例になります。
冒頭のbのように結合濁点で表現された「が」を、NFDで正規化してみます。
NFDの例
"が".normalize("NFD")
// "\u304B\u3099"("か" + "◌゙")
合成済みの「が」をNFDにかけると、「か」と結合濁点の2つのコードポイントに分解されます。
逆に、分解されている「か」+結合濁点をNFCで正規化すると、合成された「が」に揃います。
NFCの例
"\u304B\u3099".normalize("NFC")
// "が"(1つの文字)
同じ関係は、西欧系言語の文字でも見られます。アクセント記号付きのéは、eと結合アクセント記号( ◌́ )に分解できます。日本語の「が」と「か+結合濁点」の関係を理解していれば、同じ話だとわかるはずです。
ここまでを整理すると次のようになります。
- NFC:可能な文字を合成した形へ揃える
- NFD:文字を分解した形へ揃える
NFKCとNFKD:互換文字まで正規化する
前節で見たように、NFC/NFDは合成と分解の違いだけを扱います。
しかしそれとは別に、互換性のために用意された、見た目や意味が似た別のコードポイントまで正規化するのがNFKCとNFKDです。
日本語環境では身近な例がたくさんあります。わかりやすいのが全角の英数字です。
全角英数字をNFKCで正規化
"ABC123".normalize("NFKC")
// "ABC123"
半角カナも同様に変換されます。
半角カナをNFKCで正規化
"カタカナ".normalize("NFKC")
// "カタカナ"
半角カナに濁点が付いたケースでも同様です。
半角カナ+濁点をNFKCで正規化
"ガ".normalize("NFKC")
// "ガ"
半角カナのカと半角の濁点゙という2文字が、全角の濁点付きカナ「ガ」という1文字にまとまります。単純に1文字ずつ置き換えているわけではないことがわかります。
NFKCでは結合文字(濁点など)は1つの文字に合成されます。
NFKDの場合でも同じように、半角カナ濁点がカナ濁点になるといった互換正規化がおこなわれますが、NFKCとは違って濁点は結合濁点として分解された状態となります。
NFKCとNFKDでは、こうしたUnicodeの互換正規化によって、見た目の異なる表現が1つの形に揃えられます。単なる全角半角変換の機能ではなく、Unicodeの仕様として定義された変換だという点を押さえておきましょう。
スペース文字もNFKCとNFKDで変わることがある
normalize()は空白を整理するための機能ではありません。ただし、NFKCとNFKDでは、互換文字として扱われる一部のスペースも正規化の対象になります。
日本語環境では、全角スペースがわかりやすい例です。
全角スペースの正規化
" ".normalize("NFC") === " "
// false
" ".normalize("NFKC") === " "
// true
全角スペースU+3000は、NFCによる正規化では半角スペースと一致しませんが、NFKCとNFKDの場合は半角スペースU+0020と一致するように正規化されます。
ほかのスペースの例として、見た目では区別しにくいノーブレークスペースがあります。これは「見た目は普通のスペースだけど改行されないスペース」のことですが、これをHTMLで扱う場合、実体参照 としてよく知られています。
ノーブレークスペースの正規化
const space = " ";
const nbsp = "\u00A0"; // NO-BREAK SPACE
space === nbsp
// false
space === nbsp.normalize("NFKC")
// true
ノーブレークスペースは通常のスペースとは異なるコードポイントですが、コピー&ペーストされた文字列に紛れ込んでいても見た目からは気づきにくいものです。
NFKCとNFKDではこれも通常のスペースに変換されます。ですので、Webページや外部から取得した文字列を正規化する際には、NFKCとNFKDでノーブレークスペースが変換されることを意識する必要があるでしょう。これらで正規化すると、元のスペースの情報が失われます。
スペースが全部変換されるわけではない
normalize()という名前から、「文字列をいい感じに掃除してくれる機能」だと誤解してしまいそうになりますが、そうではありません。
先ほど見たように、NFKCとNFKDによって一部のスペースが変換されるのは、あくまでUnicodeの互換正規化の結果です。不可視文字を削除するための機能ではありません。
たとえば、ゼロ幅スペースU+200BをNFKCで正規化しても、そのまま残ります。
ZERO WIDTH SPACEはそのまま残る
"\u200B".normalize("NFKC")
// "\u200B"
見た目に現れない文字を取り除きたいのであれば、normalize()ではなく置換などの文字列処理を使いましょう。
NFKCとNFKDによる合字の分解
互換正規化で姿が変わるのは、スペースだけではありません。
NFKCとNFKDによる正規化は合字(リガチャ)を分解します。
たとえばfi(U+FB01)は1つのコードポイントですが、NFKCやNFKDで正規化するとfとiに分解されます。
合字の分解の対象は、この例で言うと、通常のfとiが続いたときに閲覧環境が処理して合字として表示するものではなく、テキストデータそのものにfiのコードポイントが使われている場合です。
日本語で遭遇する例としては㈱のような組文字が(株)に分解されるといった場合です。
また、Ⅲのようなローマ数字も対象で、IIIに分解されます。
このように、互換文字の違いを維持したい場合は、NFKCやNFKDではなくNFCによる正規化を検討するとよいでしょう。
4つの正規化形式の整理と使い分け
ここまでで、同じ文字を「合成された形」に揃えるNFC、「分解された形」に揃えるNFDに加え、「互換文字の正規化」となるNFKCとNFKDを見てきました。NFKCとNFKDは、全角英数字、半角カナ、全角スペースといったような、見た目の異なる表現も合わせて統一する形式というわけです。
このNFC・NFD・NFKC・NFKDの関係を表にまとめます。
| 形式 | 基本的な方向 | 互換文字の正規化 |
|---|---|---|
| NFC | 合成 | しない |
| NFD | 分解 | しない |
| NFKC | 互換分解してから合成 | する |
| NFKD | 互換分解 | する |
名前の読み方も、次のように覚えると理解しやすくなります。
- C:Composition(合成)
- D:Decomposition(分解)
- K:Compatibility(互換性)
Kが付く形式(NFKCとNFKD)では、互換文字までが正規化の対象になる、と覚えておくとよいでしょう。
NFKCとNFKDでは、まず互換分解がおこなわれます。これによって、fiがfとiに分解されたり、半角カナと半角濁点が全角カナと結合濁点に変換されたりします。
NFKCでは、その後さらに結合可能な文字を合成します。そのため、たとえば半角カナのガは、互換分解されたあとに「カ」と結合濁点が合成され、最終的にガになります。
一方、㈱を互換分解した結果の(株)や、Ⅲを互換分解した結果のIIIには、それ以上合成できる文字の組み合わせがありません。そのため、NFKCでも互換分解後の(株)やIIIのままになります。
以上が4つの形式の中身です。ここからは、実際にどれを選べばよいかを場面ごとに見ていきます。
NFCの利用場面
NFCは、Unicode上の異なる表現を統一したいが、全角半角のような文字の互換上の違いは残しておきたい場合に使います。
引数を省略したnormalize()もNFCと同じ結果になります。
NFCで正規化する
const normalized = input.normalize();
フォーム入力や外部APIなど、生成方法が不明な文字列を比較する場合に有効です。
また、重複チェックやMapやSetのキーなど、文字列の同一性が重要になる処理でも利用できます。
NFKCの利用場面
全角英数字、半角カナ、全角スペースといった違いも吸収して比較したい場合に検討できます。特に検索や、外部から入力された文字列の照合で有用です。
NFKCで正規化する
const normalized = input.normalize("NFKC");
検索での利用
検索対象の文字列と検索クエリを、同じ形式に正規化してから比較します。
検索前に正規化する
const query = input.normalize("NFKC");
const target = text.normalize("NFKC");
target.includes(query);
たとえば、検索対象または検索クエリのどちらかでは入力規則が統一されているとしても、もう一方はそうではないという場合が考えられます。
そういった状況でも、両方をNFKCで正規化することで差を吸収してから検索できます。
全角スペースの扱いに注意
なお、先ほど説明したとおり、NFKCによる正規化には全角スペースも対象となります。
日本語の文書において全角スペースはしっかりと意味があり、勝手に半角スペースに変換されると書式が乱れたと判断される可能性が高い文字です。
NFKCで正規化した後は、もともと全角スペースだったのか半角スペースだったのか区別できません。
正規化した文字列を保存する際などには注意が必要です。
全角スペースを残すには?
normalize("NFKC")を利用しつつ、全角スペースを保持したい場合は、次のような方法が考えられます。
- 全角スペースを一時的に何らかの文字に置き換えて、正規化後に全角スペースに戻す
- 文字列全体を全角スペースで
split()し、それぞれを正規化後に全角スペースでjoin()する
NFKCと表記揺れ解消を混同しない
NFKCは便利ですが、日本語の表記揺れを解消してくれる機能ではありません。あくまでUnicodeの仕様として定義された互換正規化を行っているだけです。
たとえば、次のような違いはnormalize()の対象外です。
- ひらがな ↔ カタカナ
- 大文字 ↔ 小文字
- 漢数字 ↔ アラビア数字
- アプリケーション独自の異体字や表記揺れ
- 不可視文字の削除
- 余分な空白の削除や連続する空白の統一
これらを吸収したい場合は、normalize()とは別に、アプリケーション側で変換処理を用意する必要があります。
NFDとNFKDの利用場面
NFCとNFKCが「表現を1つに揃える」ために使われるのに対し、NFDとNFKDは分解された状態そのものを使いたい場合に使われます。
代表的なのが、欧文のアクセント記号を取り除きたい場面です。
アクセント記号を取り除く例
function removeDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
removeDiacritics("café"); // "cafe"
removeDiacritics("naïve"); // "naive"
NFDで正規化すると、アクセント付きの文字は「基底文字+結合文字(アクセント記号)」に分解されます。この結合文字はU+0300〜U+036Fの範囲に収まることが多いため、正規表現で取り除けば、アクセントなしの文字列が得られます。
検索やURLスラッグの生成など、「アクセントの有無を無視して同一視したい」場面で使われる手法です。
U+0300〜U+036F
これらの文字は「Combining Diacritical Marks(合成可能なダイアクリティカルマーク)」という、Unicodeブロックの範囲です。
なお、この話は日本語の濁点と半濁点にも構造としては当てはまりますが(「が」→「か」+結合濁点)、濁点を取り除くと別の文字(別の意味)になってしまうため、同じような用途で使うことはまずありません。
NFDやNFKDが実際に活躍するのは、基本的に欧文の場面だと考えておくとよいでしょう。
なお、先に述べたとおり、NFKDは合字を分解してしまいますので、どういう結果を得たいかという目的に応じて使い分ける必要があります。
まとめ
見た目が同じ文字でも、Unicode上では異なるコードポイント列で表現される場合があります。特に、外部から取得した文字列では、自分が想定していない正規化形式に遭遇する可能性があります。
String.prototype.normalize()を使えば、こうしたUnicode正規化形式を揃えられます。
- NFCとNFDは、合成された形にするか分解された形にするかの違い
- NFKCとNFKDでは、それに加えて互換文字までを正規化する
- 日本語では、濁点と半濁点、半角カナ、全角英数字、全角スペースなどがわかりやすい例になる
- ノーブレークスペースのように、NFKCによって通常のスペースに変換される文字もある
特に、外部から受け取った文字列を扱う場面では、normalize()の存在を思い出してみてください。