/dev/null

脳みそのL1キャッシュ

Unicode の等価性と正規化について

はじめに

Unicode には見た目が同じで表現方法が複数ある文字があります。例えば、「で」ひとつを取っても以下のように 2 つの表現方法があります。

$ python
Python 3.8.2 (default, Apr 14 2020, 13:29:18)
[Clang 11.0.3 (clang-1103.0.32.29)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> "\u3067"
'で'
>>> "\u3066\u3099"
'で'

1 つ目は単一のコードポイントによって表現される「で」で、2 つ目は「て」+「゛」の 2 つのコードポイントを使った結合文字によって表現される「で」です。

このように、同じ文字でも複数の表現方法があるのですが、これでは文字列比較が面倒なので、統一的な表現に変換したい場面もあるでしょう。 その処理が Unicode 正規化です。

そして、Unicode 正規化をする上ではどういう文字や文字の並びは同じでどういう文字や文字の並びが違うのか、また、それをどのように判断するのかということが大事になります。 この判断に必要なのが等価性というものです。

最近、仕事でここらへんの知識がないせいではまったことがあったので勉強しました。

等価性

Unicode では 2 種類の等価性を定義しています。つまり、文字や文字の並び同士が同じであるかどうかを判断する方法が 2 種類あります。

正準等価

Unicode® Standard Annex #15 では正準等価を以下のように定義しています。

Canonical equivalence is a fundamental equivalency between characters or sequences of characters which represent the same abstract character, and which when correctly displayed should always have the same visual appearance and behavior.

「正準等価は同じ抽象文字を表現するような文字や文字の並びに関する等価性で、正準等価な文字や文字の並びは正しく表示されるときに同じ見た目、同じ機能を有するべきである」と…

わからん!と思っていましたが、Unicode® Standard Annex #15 にわかりやすい表がありました。

f:id:d2v:20201228000850p:plain

つまり、最終的に画面に表示される見た目が同じで、同じ意味を持つような文字や文字の並びは同じですよということです。

先程例に挙げた「で」に関しても、「で(U+3067)」と「て(U+3066)+゛(U+3099)」は異なる文字の並びですが、最終的には「で」として表示されるし、意味的にも「で」なので、これらは正準等価と言えます。

互換等価

互換等価は正準等価よりも制限がゆるい等価です。正準等価なものは互換等価ですが、逆は必ずしも成り立ちません。

Unicode® Standard Annex #15 では互換等価を以下のように定義しています。

Compatibility equivalence is a weaker type of equivalence between characters or sequences of characters which represent the same abstract character (or sequence of abstract characters), but which may have distinct visual appearances or behaviors.

正準等価と同様に「同じ抽象文字を表現する」という条件は残ったままですが、見た目や機能は異なってもいいということになっています。

ここでもわかりやすい表があったので引用してみます。

f:id:d2v:20201228002455p:plain

なんとなくわかりますね。同じ抽象文字を表現しているけど、フォントが違ったり、文字の幅が違ったりしています。こういう文字や文字の並びは正準等価ではありませんが、互換等価ではあります。

正規化

2 つの Unicode 列が等しいかどうか判断できるようにするために、統一的な表現に変換する処理が Unicode 正規化です。正規化は 4 種類あります。これらの正規化がどのような動作をするのか、以下の Python コードで実験してみることにします。

from unicodedata import normalize

def dump(form, string):
    normalized = normalize(form, string)
    for c in normalized:
        print("U+{:04x}".format(ord(c)), end=" ")
    print()

NFD: Normalization Form Canonical Decomposition

NFD の場合、文字は正準等価に分解されます。例えば、以下のように「で」は「て」+「゛」に変換されます。

# で

dump("NFD", "\u3066\u3099") # て + ゛
# => U+3066 U+3099

dump("NFD", "\u3067") # で
# => U+3066 U+3099

NFC: Normalization Form Canonical Composition

NFC の場合、文字は正準等価に分解され、再結合されます。つまり、NFD とは逆で最終的には結合文字になります。

# で

dump("NFC", "\u3066\u3099") # て + ゛
# => U+3067

dump("NFC", "\u3067") # で
# => U+3067

また、NFC は分解して再結合するので、分解前と並びが異なることもあります。

# q̣̇

dump("NFC", "\u0071\u0307\u0323") # q + ◌̣ + ◌̇
# => U+0071 U+0323 U+0307

dump("NFC", "\u0071\u0323\u0307") # q + ◌̇ + ◌̣
# => U+0071 U+0323 U+0307

NFKD: Normalization Form Compatibility Decomposition

NFKD の場合、文字は互換等価に分解されます。ここでは、「ガ」と「ガ」を NFKD で正規化した結果について見てみます。

dump("NFKD", "ガ")
# => U+30ab U+3099

dump("NFKD", "ガ")
# => U+30ab U+3099

この U+30ab U+3099 という列は「カ(U+30ab)」と「゛(U+3099)」を表しています。「ガ」と「ガ」は互換等価なので、正規化後はどちらかの表現(全角 or 半角)に統一されることは予想していましたが、結果は全角側に統一されましたね。

NFKC: Normalization Form Compatibility Composition

NFKD の場合、文字は互換等価性に分解され、再結合されます。「ガ」と「ガ」の例で見てみると

dump("NFKD", "ガ")
# => U+30ac

dump("NFKD", "ガ")
# => U+30ac

この U+30ac という列は「ガ(U+30ac)」を表しています。NFKD でも全角側に統一されましたね。

おわりに

実は仕事で DB 内のファイル名と S3 上のファイル名がそれぞれ別の正規化(たぶん、NFD と NFC)によって違う文字の並びに変換されていて、DB 内のファイル名を使って S3 上のファイルを引っ張ってこれないという事態に遭遇しました。Unicode の等価性と正規化に関する知識があればすぐに気付けたかもしれませんが、残念ながら不勉強だったので、ここらへんの知識がなく結構な時間を溶かしてしまいました。

こういう文字コードの知識って今まで必要に迫られないと勉強してこなかったのですが、今回みたいに知らないと無限に嵌りそうな予感がするので、いつか体系的に勉強したいものですね(文字コード周りのいい本ってあるのかしら)。

参考サイト

unicode.org

ja.wikipedia.org

ja.wikipedia.org