1. トップ
  2. 新着ニュース
  3. IT
  4. IT総合

ユニコードで文字数を数える方法

ASCII.jp / 2024年9月22日 10時0分

ユニコードでは複数の絵文字を結合させて 別の絵文字を表現することもある

 Unicodeが一般的になって、日本語を含めて、さまざまな言語の文字を自由に使えるようになったが、「文字」を取り出す、あるいは数えるのが面倒になったのも確かだ。というのも、1つの文字が必ずしも1つのコードポイントで表現されるとは限らないからだ。

 たとえば絵文字では、複数の絵文字をゼロ幅接合子(Zero Width Joiner:ZWJ、U+200D)で結合することで、別の絵文字を表現することがある。たとえば、「🐦 鳥(bird)」(U+1F426)と「🔥 火」(U+1F525)をゼロ幅接合子でつなげたものは、「🐦‍🔥フェニックス」(Unicode Emoji 15.1で定義)の絵文字になる。

 コードだと「U+1F426」「U+200D」「U+1F525」なのだが、表示上は1つの文字に見える。なお、こうした組み合わせは、ユニコード仕様書で定められ、誰かが勝手に作っているわけではない。

 漢字の場合には、異字体がある。こちらはコードポイントの後ろに「異字体セレクタ」が付く。簡単に言えば、ユニコードは32bitのコードポイントを使うが、人間が認識する文字である「書記素クラスタ」(grapheme cluster)は、複数のコードポイントから構成されることがある。文字列から、書記素クラスタを認識して境界を決定する処理を「テキスト・セグメンテーション」という。

 これは人間が見たときに「1文字」に見えるようなコードポイントのつながりを認識して、切れ目を見つけるのが「テキスト・セグメンテーション」である。ユニコードの処理では、分割された1文字(人の目に見える1文字)を「書記素」(grapheme)と表現することがある。

 テキスト・セグメンテーションに関しては、ユニコードでは「Unicode Standard Annex #29 (UAX#29) Unicode Text Segmentation」(https://unicode.org/reports/tr29/)に定義がある。

 PowerShellなどで、文字列を正しく分割するには、.NETの「StringInfoクラス(System.Globalization)」(https://learn.microsoft.com/ja-jp/dotnet/api/system.globalization.stringinfo?view=net-8.0)を使うのが簡単だ。

 まずは、文字(書記素)の先頭位置を求める。PowerShellでは、内部は、リトルエンディアンのUTF16(これをWindowsではUnicodeと呼ぶ)でエンコードされている。このため、一部のコードポイントは、サロゲートペアを使って16ビット文字コード2つで表現されている。

 StringInfoクラスの「ParseCombiningCharacters」メソッド(https://learn.microsoft.com/ja-jp/dotnet/api/system.globalization.stringinfo.parsecombiningcharacters?view=net-8.0#system-globalization-stringinfo-parsecombiningcharacters(system-string))は、文字列を受け取って、その書記素の先頭部分の位置を返すものだ。これを使うことで、文字の「境界」を得られる。

 具体的には、PowerShellのコマンドラインで以下のようにする。

[System.Globalization.StringInfo]::ParseCombiningCharacters(<文字列>)

「`u{845B}`u{E0100}-`u{845B}」は、ユニコードのコードポイントによる文字定義で、具体的には、「葛󠄀-葛」という文字列になる。UTF16では、5つのワードから構成されるが、ParseCombiningCharactersメソッドを使うと、文字の切れ目は、0文字目、3文字目、4文字目となることがわかる

 このメソッドは、書記素の先頭の位置を返す。たとえば、「葛󠄀-葛」という文字列は、コードポイントとしてみると、以下の図のようになっている。

「葛󠄀-葛」という文字列は、人間には3つの文字に見えるが、ユニコードコードポイントとしては4つある。さらに異字体セレクタは、UTF16ではサロゲートペアとして表現されるため、16bitワードとしては5つになる。ParseCombiningCharactersは、書記素の先頭部分として0番目、3番目、4番目を返す。ここが文字列として扱う場合の文字の切れ目となる

 この文字列に対して、ParseCombiningCharactersを実行すると、「0、3、4」という位置が返る。これは、文字の先頭が0文字目、3文字目、4文字目にあるということだ。入力が面倒そうだが、正直にキーを打つ必要はなく、先頭部分は、「“[stringinfo”+Tabキー」で、後半は「“]::p”+Tabキー」で開きカッコまで補完できる。

 なお、いわゆる文字数(書記素クラスタの数)は、ParseCombiningCharactersがいくつ整数を出力しているかを数えるだけでいいので、

([System.Globalization.StringInfo]::ParseCombiningCharacters($x)).Length

で求めることができる(コマンドラインならMeasure-Objectコマンドを使うこともできる)。

文字を取り出す

 この文字先頭位置から「書記素クラスタ」(文字)を取り出すには、GetNextTextElementメソッド(https://learn.microsoft.com/ja-jp/dotnet/api/system.globalization.stringinfo.getnexttextelement?view=net-7.0)を使う。このメソッドは、コードポイントが複数あっても、1つの書記素クラスタ分を文字列として出力してくれる。

 具体的には、ParseCombiningCharactersの出力(文字の先頭位置)を、2つ目の引数に入れて文字(書記素クラスタ。セグメント)を取り出す。ParseCombiningCharactersの出力する位置を変数に入れおく。

$p=[System.Globalization.StringInfo]::ParseCombiningCharacters($x)

 次にこれを使って、文字を順次取り出す。

$p | foreach-object { [System.Globalization.StringInfo]::GetNextTextElement($x,$_)

とすればよい。

 ついでに16進数でダンプさせるなら、

$p | %{ $t=[System.Globalization.StringInfo]::GetNextTextElement($myKatu,$_); "$_ $t`t$(([int[]][char[]]$t)|%{$_.ToString('X4')})" }

などとする。

「葛󠄀-葛」という文字列を16進数で表現すると、「葛󠄀」は、ベースとなる文字のコードポイントである「0x845B」に異字体セレクタがついたものになる。ただし、異字体セレクタは、UTF16では、Dから始まるサロゲートペアになるため、先頭の「葛󠄀」は、文字列中では、3つの16 bitワードで表現される

 サンプルとしてもう少し複雑な文字列を使ってみる。

さらに複雑な文字列を$xに定義する。先頭は家族4人の絵文字でそれぞれに肌の色を指定してある。その次に異字体セレクタのついた文字、さらにサロゲートペアになる文字、最後の通常漢字を半角ハイフンでつなげてある。書記素クラスタは7つ。先頭から3文字(書記素クラスタ3つ)を得たければ、範囲指定演算子とGetNextElementメソッドを使って先頭3つの書記素クラスタを取り出して-join演算子で繋ぐ。文字列後半を取り出したい場合にも同様に、後半部分を取り出して結合すればよい

 これは、家族4人の絵文字と異字体セレクタ付きの文字、サロゲートペアになる文字、それ以外の漢字の4つをハイフンでつなげたもので、$xに代入してある。まずは、$pに文字列内の先頭位置を格納しておく。このとき$p.lengthが書記素クラスタの数(文字数)となる。

$p=[System.Globalization.StringInfo]::ParseCombiningCharacters($x) $p.length $p

 たとえば、先頭から3文字目(葛󠄀)までを取り出したいなら、

($p[0..2] | %{[System.Globalization.StringInfo]::GetNextTextElement($x,$_)}) -join ""

とする。前半の$p[0..2]は、3つ目の書記素クラスタの開始位置までの開始位置を取り出すもの(配列のインデックスなので0から始まることに注意)。後半の部分は、GetNextTextElementを使って、書記素に対応する文字列を取り出し、全体を-joinで結合して1つの文字列としている。

 同様に後半4文字目から最後までを取り出したいなら。

($p[3..999] | %{[System.Globalization.StringInfo]::GetNextTextElement($x,$_)}) -join ""

とする。「[3..999]」は、書記素開始位置配列の4文字目から最後までを取り出すもの。範囲演算子では、対象配列の最大インデックスが分からないとき、それよりも大きなインデックス値を指定(ここでは999)を指定しておけば、エラーにならず、最大インデックスを指定したのと同等になる。もちろん「$p[3..($p.Length-1)]」などのように正しく計算してもいいが、最大インデックスを超えない大きな数を使うほうが簡単だ。

 このようにすることで、文字列の前半部分(Left関数)、後半部分(Right関数)のようにユニコード文字列を正しい位置で分割することができる。

 ユニコードになって、さまざまな文字を扱えるようになった反面、単純な文字列処理が不可能になり、今回のように、StringInfoクラスなどを使ってテキスト・セグメンテーションをして、その上で、分割などの処理をする必要がある。

 なお、ユニコードの正しいテキスト・セグメンテーション(前述のUAX#29)には、.NET 5.0以降の対応なので、処理には、PowerShell(pwsh.exe)のほうを利用する。

この記事に関連するニュース

トピックスRSS

ランキング

記事ミッション中・・・

10秒滞在

記事にリアクションする

記事ミッション中・・・

10秒滞在

記事にリアクションする

デイリー: 参加する
ウィークリー: 参加する
マンスリー: 参加する
10秒滞在

記事にリアクションする

次の記事を探す

エラーが発生しました

ページを再読み込みして
ください