System.Char.GetEastAsianWidthKind()


UnicodeのEastAsianWidthが何か話題になっているらしい。 http://tech.albert2005.co.jp/blog/2014/04/21/mco-eaw/

というのをコレで知った。 https://twitter.com/ishisaka/status/458165828165578753

.NETに無い? じゃあ実装してみましょう。

EastAsianWidthの、特にAmbiguousの挙動がこわいこわいって言われているみたいだけど、文字の全角・半角の判別処理を実装するのは、ちっとも難しくない。やることは2つだけだ。

  • 全てのUnicode文字について、EastAsianWidthの値のリストを構築する。
  • 文字(char ch)と対象言語が東アジアかどうか(bool isEastAsian)から、文字種に基づいて全角か半角かをboolで返す

EastAsianWidthの値がわかっていれば、全角・半角を判断するのは簡単だ:

    public static bool IsFullWidth (int c, bool isEastAsian)
    {
        switch (GetKind (c)) {
        case EastAsianWidthKind.Ambiguous:
            return isEastAsian;
        case EastAsianWidthKind.Full:
        case EastAsianWidthKind.Wide:
            return true;
        default:
            return false;
        }
    }

文脈によっては、isEastAsianはCultureInfoから判別してもいい(CultureInfoにそんな情報は無いので、外側から力技で判別するしかないと思うけど)。知る限りでは、.NETでEastAsianWidthがAPIレベルで獲得できるのはWindows Storeアプリのテキストレンダリングに使うフォントの情報くらいだ。 http://msdn.microsoft.com/en-us/library/windows/apps/windows.ui.xaml.documents.typography.eastasianwidth.aspx

UnicodeにおけるEastAsianWidthの値は、Unicode Character Database (UCD)の中に、これをリストアップしたファイルが存在するので、これをパースしてリストとして格納すればいいだけだ。UCDのデータ形式は単純なものだ。 http://www.unicode.org/Public/UCD/latest/ucd/EastAsianWidth.txt

とりあえず、EastAsianWidthを判別するクラス、このテキストの内容をいちいち解析したくはないので、いったんEmbeddedResourceのかたちにして、EastAsianWidthを判別させてくれるEastAsianWidthクラスと一緒のdllに格納できるようにしよう。そのためには、いったんこのファイルをパースして、生データとして書き出すことにする。

EastAsianWidth.txtの記法は、次のようなものだ。

000D;N # <control>
F0000..FFFFD;A # <Plane 15 Private Use, First>..<Plane 15 Private Use, Last>

内容は全て「文字または文字範囲;EastAsianWidth種別 #コメント」の形式になっている。範囲指定がある部分は、別途範囲指定のリストを作って、それ以外の1文字指定の部分は単純に配列に格納しよう。

というわけで、パーサおよびデータジェネレータのコードは、こんな感じだ:

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;

public class Driver
{
public static void Main (string [] args)
{
    new Driver ().Run (args);
}

struct RangeMap<T>
{
    public int Start;
    public int End;
    public T Value;
}

List<RangeMap<char>> ranges = new List<RangeMap<char>> ();
char [] eaw = new char [0x10FFFF];

// list of optimizible mappings.
RangeMap<char> [] optmap = {
    new RangeMap<char> () {Start = 0x2F800, End = 0x2FA1D, Value = 'W'},
    new RangeMap<char> () {Start = 0x4DC0, End = 0x4DFF, Value = 'N'},
    new RangeMap<char> () {Start = 0xD7B0, End = 0xD7C6, Value = 'N'},
    new RangeMap<char> () {Start = 0xD7CB, End = 0xD7FB, Value = 'N'},
    new RangeMap<char> () {Start = 0xE0001, End = 0xE0001, Value = 'N'},
    new RangeMap<char> () {Start = 0xE0020, End = 0xE007F, Value = 'N'},
    new RangeMap<char> () {Start = 0xE0100, End = 0xE01EF, Value = 'A'},
    };

public void Run (string [] args)
{
    string text = null;
    string output = "EastAsianWidth.dat";
    string mapoutput = "EastAsianWidth.opt";
    string source = args.Length == 0 && File.Exists ("EastAsianWidth.txt") ? "EastAsianWidth.txt" : args.FirstOrDefault ();
    if (source != null)
        text = File.ReadAllText (source);
    else {
        string univer = args.Length > 0 ? args [0] : "UCD/latest";

        string url = string.Format ("http://www.unicode.org/Public/{0}/ucd/EastAsianWidth.txt", univer);

        text = new WebClient ().DownloadString (url);
    }

    var lines = text.Split ('\n');
    Func<string,string> first = s => s.Substring (0, s.IndexOf ('.'));
    Func<string,string> last = s => s.Substring (s.LastIndexOf ('.') + 1);
    Func<string,int> parse = s => int.Parse (s, NumberStyles.HexNumber);
    foreach (var p in lines
            .Select (x => x.Split ('#'))
            .Select (arr => arr [0].Trim ())
            .Where (x => x.Contains (';'))
            .Select (x => x.Split (';'))) {
        if (p [0].Contains ('.'))
            ranges.Add (new RangeMap<char> () {Start = parse (first (p [0])), End = parse (last (p [0])), Value = p [1].Last ()});
        else
            eaw [parse (p [0])] = p [1].Last ();
    }
    // Apply mapping optimizer - for predefined optimizible list, fill '\0' (only if the mapping was correct)
    foreach (var m in optmap) {
        bool invalid = false;
        for (int i = m.Start; i <= m.End; i++)
            if (eaw [i] != m.Value) {
                Console.Error.WriteLine ("Invalid optimization, at {0:X06}", i);
                invalid = true;
            }
        if (!invalid)
            for (int i = m.Start; i <= m.End; i++)
                eaw [i] = '\0';
    }
    // calculate max index. After this, mapping is not generated.
    int maxIndex = 0;
    for (int i = eaw.Length - 1;;i--) {
        if (eaw [i] != '\0') {
            Console.Error.WriteLine ("max EAW index: {0:X06}", i);
            maxIndex = i;
            break;
        }
    }
    using (var fs = File.CreateText (output)) {
        for (int i = 0; i <= maxIndex; i++)
            fs.Write (eaw [i]);
    }
    using (var fs = File.CreateText (mapoutput)) {
        string allRanges = string.Join (";", ranges.Concat (optmap)
            .OrderBy (m => m.Start)
            .Select (m => string.Format ("{0:X06}-{1:X06}={2}", m.Start, m.End, m.Value))
            );
        fs.WriteLine (allRanges);
    }
}
}

実のところ、もうひとつ最適化を施してあって、一部の文字範囲(optmapフィールドの範囲)については、手動で範囲指定を追加してある。これは現状ではほぼ意味がないけど、後で配列の格納方法を最適化した時に有効になるだろう。

とりあえず、これでデータは生成できた。後は、EastAsianWidthを扱うクラスの中で、これをEmbeddedResourceから取り出して、使えるデータに戻すだけだ。

    class Map
    {
        public int Start;
        public int End;
        public byte Value;
    }

    static readonly byte [] eaw;
    static readonly Map [] map;

    static EastAsianWidth ()
    {
        var stream = typeof (EastAsianWidth).Assembly.GetManifestResourceStream ("EastAsianWidth.dat");
        eaw = new byte [stream.Length];
        stream.Read (eaw, 0, eaw.Length);
        stream = typeof (EastAsianWidth).Assembly.GetManifestResourceStream ("EastAsianWidth.opt");
        Func<string,int> parse = s => int.Parse (s, NumberStyles.HexNumber);
        string line = new StreamReader (stream).ReadToEnd ().Trim ();
        map = line
            .Split (';')
            .Select (l => l.Split ('='))
            .Select (arr => new {Range = arr [0].Split ('-'), Value = arr [1]})
            .Select (m => new Map () {Start = parse (m.Range [0]), End = parse (m.Range [1]), Value = (byte) m.Value [0]})
            .ToArray ();
    }

いったんデータを復元したら、EastAsianWidth種別を取得するのはとても簡単だ。範囲指定のリストは考慮する必要がある。

    static byte GetValue (int c)
    {
        foreach (var m in map)
            if (m.Start <= c && c <= m.End)
                return m.Value;
        return eaw [c];
    }

    public static EastAsianWidthKind GetKind (char c)
    {
        return GetKind ((int) c);
    }

    public static EastAsianWidthKind GetKind (int c)
    {
        var ret = GetValue (c);
        switch (ret) {
        case (byte) 'F':
            return EastAsianWidthKind.Full;
        case (byte) 'H':
            return EastAsianWidthKind.Half;
        case (byte) 'W':
            return EastAsianWidthKind.Wide;
        case (byte) 'a':
            return EastAsianWidthKind.Narrow;
        case (byte) 'A':
            return EastAsianWidthKind.Ambiguous;
        case (byte) 'N':
            return EastAsianWidthKind.Neutral;
        }
        throw new InvalidOperationException ();
    }

この実装は以下のアーカイブにまとめておいた。 https://dl.dropboxusercontent.com/u/493047/tmp/nunicode.tar.gz

2016.03.23追記: ファイルが消えてしまっていたので、githubにrepoを作った: https://github.com/atsushieno/nunicode

そんなに真剣に最適化していないので、メモリ上に展開される配列はそれなりの大きさになっている。真面目に最適化しようと思ったら、monoでUnicodeサポートを実装するために作成した、空白部分をスキップするインデクサのクラスを活用できるだろう。 https://github.com/mono/mono/blob/master/mcs/class/corlib/Mono.Globalization.Unicode/CodePointIndexer.cs

ちなみに、monoでmscorlibの実装をしていた時は、GetManifestResourceStreamInternal()という裏技を使って、メモリアドレスにダイレクトアクセスして、無駄なmanifest resource streamの生成を省くことができたけど、このコードはそうはいかないので、その部分は最適化出来ないと思って諦めた。

ともあれ、Unicode Character Databaseの値を素直に返すようなAPIを実装するのは、ちっとも難しくないので、みんな積極的にトライしてみても良いと思う。

ちなみにpostしてから書き忘れていたことに気付いたけど、タイトルにあるようなメソッドは作っていない。でもSystem.Charにextension methodを生やすだけだ。これだけならサルでも出来るよね?

March 23, 2014
754 words


Categories

Tags

Author

Backlog