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 ();
		}

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

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を生やすだけだ。これだけならサルでも出来るよね?