理系学生日記

おまえはいつまで学生気分なのか

Unicodeの正規化、UCDに記載された分解結果について

Unicodeの正規化については以下で書きました。今日はその補足です。

なぜUnicodeの正規化が必要になるのか

端的にいうと、Unicodeの中に同じ意味を持つ文字がある、あるいは同じ意味を持つ文字列が構成できてしまうからではないでしょうか。 僕たちに馴染みの深いほげという文字列も、Unicodeでは以下のように表現できてしまいます。

前提として、「ほげ」という文字列をUnicodeで表記しようとするとき、U+307B(ほ)とU+3052(げ)の2つのコードポイント列での表現が可能です。 また、別解として、U+307B(ほ)とU+3051(け)、U+3099(濁点)の組み合わせでも表現できます。

>>> print("\u307B\u3052")
ほげ
>>> print("\u307B\u3051\u3099")
ほげ

では「同じ」とはなんなのか

ここで「同じ」、つまり、等価性をどう定義するのかという問題が出てきます。

Unicode標準では、文字の「等しさ」について2つの基準を定めています。 Canonical Equivalence(正準等価性)とCompatibility Equivalence(互換等価性)です。

正準等価、互換等価の定義はUnicode® Standard Annex #15 UNICODE NORMALIZATION FORMS の1.1 Canonical and Compatibility Equivalenceにあります。

The Unicode Standard defines two formal types of equivalence between characters: canonical equivalence and compatibility equivalence. 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. Figure 1 illustrates this type of equivalence with examples of several subtypes.

以下、ざっくりとChatGPTにまとめてもらいました。

特徴 Canonical Equivalence (正準等価性) Compatibility Equivalence (互換等価性)
定義 異なるバイトシーケンスが同じ文字として認識される。 異なるバイトシーケンスが同じような文字として認識される。ただし、表示スタイルや書体などの視覚的な差異がある場合がある。
目的 文字の基本的な形を維持しながら、異なるエンコード方法を同一視する。 異なるスタイルや書体の文字を、本質的に同じ文字として扱う。
キリル文字の「A」とラテン文字の「A」は見た目が同じだが、異なるコードポイントを持つ。正準等価性では、これらは別の文字として扱われる。 「fi」(合字)と「fi」(2つの別々の文字)は、視覚的に似ているが、異なるコードポイントを持つ。互換等価性により、これらは等価とみなされる。
使用場面 検索、ソート、テキスト処理での一貫性を保つため。 異なるスタイルや書体の文字を統一的に扱うため、特に文書処理や表示の際に重要。
重要性 同じ文字が異なる方法でエンコードされることを許容し、それらを等価とみなす。 視覚的に似ているが異なるコードポイントを持つ文字を等価とみなすことで、柔軟なテキスト処理を可能にする。

正準等価性は見た目では区別できない本質的な意味の同一性を表現している一方、互換等価性は見た目が違っていても同一とみなす、緩い同一性を表現しているようです。

合成と分解

先ほどの「ほげ」の例では、「げ」がU+3052(げ)、および、U+3051(け)、U+3099(濁点)の組み合わせの2パターンで表現されました。つまり、「合成」した結果を正規化の結果とするのか、「分解」した結果を結果とするのかの2つの考え方があるということです。

先ほどの等価性と組み合わせて、全部で4パターンの正規化パターンが存在します。

Form 説明
Normalization Form D (NFD) 正準等価によって分解する正規化方法
Normalization Form C (NFC) 正準等価によって分解された後、正準同意等価によって合成する正規化方法
Normalization Form KD (NFKD) 互換等価によって分解する正規化方法
Normalization Form KC (NFKC) 互換等価によって分解された後、正準等価によって合成する正規化方法

では具体的に見てみましょう

抽象論に終始してもなかなか理解できないので、基本的な文字列をそれぞれ正規化してみましょう。 末尾にJavaでのソースも示します。

末尾が「D」(分解)の正規化では、もともと1つのコードポイントだった文字が、複数のコードポイントに分解されたりしていることがわかります。fiやTEL.などは、正準等価だと1コードポイントのままですが、互換等価だと複数コードポイントに分解され、その表現も大きく変わっていますね。

文字列 Unicode NFD NFD Unicode NFC NFC Unicode NFKD NFKD Unicode NFKC NFKC Unicode
u3042 u3042 u3042 u3042 u3042
u304c が u304b u3099 u304c が u304b u3099 u304c
u3077 ぷ u3075 u309a u3077 ぷ u3075 u309a u3077
u30f4 ヴ u30a6 u3099 u30f4 ヴ u30a6 u3099 u30f4
Á u00c1 u0041 u0301 Á u00c1 u0041 u0301 Á u00c1
u2460 u2460 u2460 1 u0031 1 u0031
ufb01 ufb01 ufb01 fi u0066 u0069 fi u0066 u0069
u2121 u2121 u2121 TEL u0054 u0045 u004c TEL u0054 u0045 u004c
ハンカク uff8a uff9d uff76 uff78 ハンカク uff8a uff9d uff76 uff78 ハンカク uff8a uff9d uff76 uff78 ハンカク u30cf u30f3 u30ab u30af ハンカク u30cf u30f3 u30ab u30af
ハンカク u30cf u30f3 u30ab u30af ハンカク u30cf u30f3 u30ab u30af ハンカク u30cf u30f3 u30ab u30af ハンカク u30cf u30f3 u30ab u30af ハンカク u30cf u30f3 u30ab u30af
u337f u337f u337f 株式会社 u682a u5f0f u4f1a u793e 株式会社 u682a u5f0f u4f1a u793e
❤️ u2764 ufe0f ❤️ u2764 ufe0f ❤️ u2764 ufe0f ❤️ u2764 ufe0f ❤️ u2764 ufe0f
ufa19 u795e u795e u795e u795e

分解結果はどう定義されているのか

正準等価での正規化は、多くの場合「合字」を分解するか、結合するかによって行われているため、ある程度その正規化結果は想像しやすいです1。一方で互換等価の正規化は、その等価性が「緩い」こともあり、なかなか予想しづらいものになっています。この定義はどこで行われるのでしょうか。

およその答えはUNICODE CHARACTER DATABASE (UCD)にあります。Unicode® Standard Annex #44 UNICODE CHARACTER DATABASEがその解説。

UCDはUnicodeの文字に関するデータベースです。Unicodeの文字に関する情報が、コードポイントごとに記載されています。その中には、互換等価性に関する情報も含まれています。

たとえば「TEL」の例であれば、U+2121のコードポイントに対して、以下のような情報が記載されています。

2121;TELEPHONE SIGN;So;0;ON;<compat> 0054 0045 004C;;;;N;T E L SYMBOL;;;;

UCDは;区切りのCSV形式で記載されています。 <compat>というフィールドがあり、これが互換等価性を表しています。このフィールドには、互換等価性を持つコードポイントが列挙されています。この例では、0054 0045 004Cがそれにあたります。

同様に「Á」という文字についても、0041 0301が示されています。

00C1;LATIN CAPITAL LETTER A WITH ACUTE;Lu;0;L;0041 0301;;;;N;LATIN CAPITAL LETTER A ACUTE;;;00E1;

分解結果は5つ目のフィールドで表現されており、<hoge>というようなタグがある場合は互換等価性による分解結果、ない場合は正準等価性による分解結果を表現しています。 この辺りの解説は5.7.3 Character Decomposition Mappingをご参照ください。

https://f.hatena.ne.jp/kiririmode/20231217175131

正規化実装ソース

package com.kiririmode;

import java.text.Normalizer;

public class Main {
    // NFDでの正規化を行い、正規化後の文字列を返す
    public static String NFD(String str) {
        return Normalizer.normalize(str, Normalizer.Form.NFD);
    }

    // NFCでの正規化を行い、正規化後の文字列を返す
    public static String NFC(String str) {
        return Normalizer.normalize(str, Normalizer.Form.NFC);
    }

    // NFKDでの正規化を行い、正規化後の文字列を返す
    public static String NFKD(String str) {
        return Normalizer.normalize(str, Normalizer.Form.NFKD);
    }

    // NFKCでの正規化を行い、正規化後の文字列を返す
    public static String NFKC(String str) {
        return Normalizer.normalize(str, Normalizer.Form.NFKC);
    }

    // 文字列のUnicodeのコード値を取得する
    public static String getUnicode(String str) {
        StringBuilder unicode = new StringBuilder();
        for (int i = 0; i < str.length(); i++) {
            unicode.append(String.format("u%04x ", (int) str.charAt(i)));
        }
        return unicode.toString();
    }

    // 引数で与えられた文字列のリストに対して、NFD、NFC、NFKD、NFKCでの正規化後の文字列とそのUnicodeのコード値をMarkdownの表形式で出力する
    public static void printNormalized(String[] strs) {
        System.out.println("|文字列|Unicode|NFD|NFD Unicode|NFC|NFC Unicode|NFKD|NFKD Unicode|NFKC|NFKC Unicode|");
        System.out.println("|---|---|---|---|---|---|---|---|---|---|");
        for (String str : strs) {
            System.out.println("|" + str + "|" + getUnicode(str) + "|"
                    + NFD(str) + "|" + getUnicode(NFD(str)) + "|"
                    + NFC(str) + "|" + getUnicode(NFC(str)) + "|"
                    + NFKD(str) + "|" + getUnicode(NFKD(str)) + "|"
                    + NFKC(str) + "|" + getUnicode(NFKC(str)) + "|");
        }
    }

    public static void main(String[] args) {
        String[] strs = { "あ", "が", "ぷ", "ヴ", "Á", "①", "fi", "℡", "ハンカク", "ハンカク", "㍿", "❤️", "神", };
        printNormalized(strs);
    }
}

  1. 実際には、正準等価であってもその分解が想像しづらいものになるケースはあります。