SATOXのシテオク日記

~ふもっふ、ふもふも~

サロゲートペアと日本語文字揃え問題

C#(.NET)の関数では基本Unicodeで文字を扱うことになるわけですが、サロゲートペアにも対応しています。……なんて得意げに語りましたが、あまりよく知りませんでした。
サロゲートペア、すなわち、基本16ビットの文字コードなのですが16ビット×2を使って1文字を表すというもの。
JISの第3水準以上の文字などにもがっつりと対応しようと思うと、サロゲートペア問題がにょきっとでてきてじわりと問題になります。
でも、「サロゲートペア」なんていうなんだか怪しげなお話よりも、「C#と日本語文字揃え」単純な観点で問題があるんです。
 

文字幅、簡単に揃えられない問題
等幅ピッチフォントを扱い、タブ文字も当てにならないという前提のDOSコマンドプロンプト画面で、リストを表示したいと思います。
例えば、以下のような感じ。通し番号、任意の文字列、数字、こんな表示。
■は"𠮟"という漢字

String[] list = { "test", "IsSurrogate", "本日test", "■るtest" };
Int32 count = 0;
foreach (String str in list) {
    Console.WriteLine(String.Format("{0,4} {1,-20} {2:0000}", count++, str, str.Length));
}

フォーマットの「-20」は20文字の左揃え指定ですが、きれいに表示されると思いますか?
こうなります。
右側の数字がずれちゃう。

出力
   0 test                 0004
   1 IsSurrogate          0011
   2 本日test               0006
   3 ■るtest              0007

英語圏のヒトはこれだから困ります。
「𠮟」という文字はフォントがない場合「??」と表示されます。
半角1文字、全角2文字という計算が出来れば良いのですが、結果漢字は1文字「本日」と「𠮟る」もString.Lengthの結果が異なるという結果になりました。
「𠮟」はChar×2のサロゲートペアの文字なんです。
 

サロゲート文字の判別
Char.IsSurrogateでサロゲートチェック。
■は"𡋤"という漢字

static void check(String s)
{
    Console.WriteLine(s + " String.Length = " + s.Length);
    foreach (Char c in s) {
        Console.WriteLine(String.Format("\t0x{0} IsSurrogate={1}", Convert.ToString(c, 16), 
			Char.IsSurrogate(c).ToString()));
    }
}

check("A");
check("あ");
check("漢");
check("■"); // Unicode 0x212E4 '■'

出力は以下の通り。

出力
A String.Length = 1
        0x41 IsSurrogate=False
あ String.Length = 1
        0x3042 IsSurrogate=False
漢 String.Length = 1
        0x6f22 IsSurrogate=False
■ String.Length = 2
        0xd844 IsSurrogate=True
        0xdee4 IsSurrogate=True

Char.IsSurrogate関数でサロゲート文字を判断できるというわけです。
Char.IsHighSurrogate関数でHigh側、Char.IsLowSurrogate関数でLow側かどうかを確認できます。
 

文字を揃えるよ
というわけで、サロゲートペアの問題を踏まえて文字を揃えるプログラムです。後述も読んでね。

public static Int32 GetStringWidth(String s)
{
    Int32 count = 0;
    
    foreach (Char c in s) {
        if (Char.IsSurrogate(c) == true) {
            if (Char.IsHighSurrogate(c) == true) {
                continue; // ハイサロゲートは文字幅として無視
            }
        }
        if (c <= 0xff) {
            count += 1; // 半角(エスケープ文字も含む)
            continue;
        }
        count += 2; // 全角
    }

    return count;
}

Int32 count = 0;
foreach (String str in list) {
    String fname = str + new String(' ', 20 - GetStringWidth(str));
    Console.WriteLine(String.Format("{0,4} {1} {2:0000}", count++, fname, GetStringWidth(str)));
}

GetStringWidthという文字幅を返す関数を作り、文字幅を調節するようにしました。
■は"𠮟"という漢字

出力
   0 test                 0004
   1 IsSurrogate          0011
   2 本日test             0008
   3 ■るtest             0008

やった、できた。
オチとしては、以下のようにするとGetStringWidth関数はシンプルになります。

public static Int32 GetStringWidth(String s)
{
    Byte[] bytes = Encoding.GetEncoding("Shift_JIS").GetBytes(s);
    return bytes.Length;
}

そう、シフトJISにすれば、全角は2バイト、それ以外は1バイトなのでそのバイト列の長さが文字幅となるのです。
つまるところ、StringをCharとして扱う場合にはサロゲートペア文字もあるので注意しないといけないよ、というお話でした。