なんか、昔作ったGraphemeSplitterC++方面のUnicodeがらみのブログから参照されてたので、ちょっと補足。

UNICODE TEXT SEGMENTATION

「書記素って何?」って話は詳しくは昔書いた記事でも見てもらうとして。 とりあえず、「人間が見て1文字と思うようなもの」を指して書記素(grapheme)といいます。複数の Unicode コードポイントが結合しまくるので、可変長。

いつも例に出すのが家族絵文字(👩🏻‍👦🏼👨🏽‍👦🏾‍👦🏿👩🏼‍👨🏽‍👦🏼‍👧🏽👩🏻‍👩🏿‍👧🏼‍👧🏾とか)ですが、1書記素で11コードポイント、UTF-8で41バイトになったりします。

で、問題は、書記素の機械的な判定方法。 コンピューター上でもちゃんと書記素単位で処理してくれないと、人間の感覚からすると「backspace/delete を押すたびに文字が変わる」みたいな変な感じになります。

Unicode 標準としては、「あくまで参考。もっといいアルゴリズムにしてもらってもいいけど」という但し書き付きですが、以下のようなドキュメントがあります。

書記素の区切り(grapheme cluster boundary)だけじゃなくて、単語区切り(word boundary)や文区切り(sentence boundary)についても言及。

基本的には、「このカテゴリーのコードポイントの後ろにこのカテゴリーが来たら繋げろ(あるは、そこで区切れ)」というルールが示されていて、そのルール自体は割と単純です。カテゴリーさえわかっていれば。 自分が書いた実装でも、コメント・空行を除けば16行。

コードポイントのカテゴリー

真の問題はカテゴリー判定。

Unicode では、コードポイント1つ1つにいろいろな属性が定義されています。 例えば、C# でGetUnicodeCategoryで取れるやつは「general category」というやつで、 「UnicodeData.txt」(;区切り)の3列目に定義があります。

UnicodeData.txt の中身を見れば何がきついかわかっていただけると思います。 こいつ、(Version 11 時点で)32292行もあります。 何らかの計算式があるとかではなく、愚直にテーブル。 そりゃまあ、それしかやりようがないのはわかりますが…

UnicodeData.txt 1個でもでかいのに、さらに追加で別の定義ファイルを参照せざるを得ない処理なんかもあったりします。 書記素区切りはその1つで、GraphemeBreakProperty.txt内のデータが必要になったり。

この問題は別に絵文字とか書記素分割だけのものでもなくて、 例えば ToLower/ToUpperの実装とかでも問題になります。

テーブルの引き方(自分の実装)

GetUnicodeCategoryで取れるカテゴリーだけで判別できるんだったら楽なんですけどね。 自分が書いたコードが3千行近くなった理由は、GraphemeBreakProperty.txt で定義されたカテゴリーが必要だったからです。

当たり前ですけど、こんなのコード生成で作ってます。

実行速度とか生成されるDLLサイズとかを比較するために数パターンのコード生成をやってみていて、全部 switch case に展開したやつ(約2万行)とかもあったりします(コンパイルするだけで1分くらいかかります。)。結局、まあ、二分探索でやるパターンを採用したのが上記の3千行近いコード。

テーブルの引き方(.NET Core の実装)

.NET 標準のGetUnicodeCategory とか GetNumericValueとかも、 やっぱりテーブルを引く実装になっています。 テーブルのデータは以下のコード中にあり。

13万文字以上もあるものを愚直にテーブル化するわけにもいかないので、11:5:4ビットに区切った3段テーブルになっています。 (同じカテゴリーが連続していることが多いので、こういう分け方をするとデータ量が減る。 それでも23KBほどのサイズ。)

もちろんこいつもコード生成。 UnicodeData.txt からこのテーブルを生成するコードも coreclr 内にあります。

バージョン

テーブル実装のなお悪いところは、バージョンが変わるとテーブル自体を作り直すしかないところでして。

例えば先ほどの CharUnicodeInfoData.cs ですが、Unicode 11 にアップデートした時のプルリクエストがこちら:

まあ、「Files changed」で差分を見てみてください。結構な分量。

しかも、Unicode、ほとんどの場合は「追加」なんですが、 たまーに破壊的変更もやるんですよね。 Unicode 標準に追従すると、そのフレームワークも破壊的変更を起こすことが。

JavaC#やられてますが、Unicode のカテゴリー変更のあおりを受けています。

そうなると、指定したバージョンの Unicode 文字カテゴリーを取れる API も欲しいところなんですが… 1バージョン辺り23KBとかのサイズになるわけで、それを10以上あるバージョンすべてで持つのも結構な負担です。

char と CharUnicodeInfo

ちなみに、char.GetUnicodeCategoryCharUnicodeInfo.GetUnicodeCategory で結果が違うという邪悪なおまけつき。

どうも、昔からある char の方の実装は Unicode 4.0 がベース、 CharUnicodeInfo は Unicode 5.0 がベース(最近 11.0 ベースに更新)だそうです。

char の方を「破壊的変更になるし変えれない」とか中途半端にやった結果こうなったとか。 しかも、完全に Unicode 4.0 のままなんじゃなくて、 Latin-1 の文字だけ 4.0 の時のカテゴリーのままで、残りは更新されていそうという

International Components for Unicode

そんな感じで、Unicode のカテゴリー判定は結構つらい作業です。 なので、OS に ICU が入ってることを期待して、それを参照するのがいいのかも… (自前で ICU のバイナリを同梱しようとすると20MBを超えます。)

書記素、単語、文、行の区切りの実装もあります。

ちなみに、Windows 10 には標準で ICU が組み込まれてるそうです。