以前から、「文字数を数える」「1文字とはなにか」といった興味深い問題の記事をいくつか目にします。
Intl.SegmenterがChromiumとSafariで採用されて以降、これを使って文字数を数えたり、反転させる記事を見かけます。たしかに、Intl.Segmenterを使うことで、絵文字や結合文字を適切に扱える場面は多々存在します。
上記の記事を引用しつつ確認してみます。実は以下の記述がとても重要です。
本稿のタイトルは「文字数を正確にカウントするには?」としましたが、以上の事情により「正確に」の部分は要件定義によるところが出てくる部分です。なので、ここで紹介する方法はすべてのアプリケーションに適用できるものではない点はご留意ください。
今回の記事では「なんとなくIntl.Segmenterで書記素で数える」を防ぐために、注意点をまとめます。
用語整理
まず、「1文字」「文字の長さ」といったものには複数種類の概念があります。
用語 | 日本語 | 意味 |
---|---|---|
byte | バイト | メモリやストレージ等に記録する際のサイズ。同じ文字でも、エンコーディング等によってバイト数は変わってくる。 |
Code Point | コードポイント / 符号点 / 符号位置 | ユニコードでの符号位置を表す数字。U+006Aのように記述する。制御文字等にも1つのコードポイントがある |
Code Unit | コードユニット | 各エンコード方式での1単位。たとえば、𠮷(U+20BB7)は1コードポイントだが、UTF-16LEでは42D8 B7DFの2コードユニットで表される。(サロゲートペア) |
grapheme | 書記素 | 人間が「1文字」と捉える最小単位。👨🦰は赤髪の男性の絵文字だが、男性+結合子+髪色セレクタで「U+1F468 U+200D U+1F9B0」と3コードポイントで表現される。 |
1書記素は何バイトか
上記の記事で取り上げられている中では🏴(ウェールズの旗、Windowsでは黒い旗ですが…)の24バイト、7コードポイントが最大ですが、もっとバイト数が多いものがあります。さて、何バイトあれば1書記素を表せるでしょうか。バイト数のカウントはJavaScriptでは以下のようにできるかもしれません。
function countGrapheme(string) {
const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
return [...segmenter.segment(string)].length;
}
const a = "[1書記素]";
console.log(countGrapheme(a));
console.log(a.length * 2); // length * 2でバイト数
.
.
.
.
.
.
.
↓答えは下
.
.
.
.
.
.
.
答え
function countGrapheme(string) {
const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
return [...segmenter.segment(string)].length;
}
const a = "A" + "\u036A".repeat(10000000);
console.log(countGrapheme(a)); // => 1
console.log(a.length * 2); // => 20000002
答えは、無限バイト。実際にはJavaScriptの制限や帯域、保存領域の問題もありますが、Unicodeの仕様上は無限です(多分)。
U+036AはPͪのような結合文字(ダイアクリティカルマーク)で、前の文字の上にhという文字を合成します。日本語ではひらがなと濁点が別のコードポイントになっているのを想像するのがわかりやすいでしょう。このダイアクリティカルマークの数には制限がありません。(多分。Unicodeに詳しい方、制限があれば教えてください。)制限があるとしても、現在のChromeではちゃんと1文字にカウントしてくれます。
ということで、「入力可能な文字数(書記素)は10文字」という要件があった場合、「入力可能なバイト長は無限バイト」という意味です。保存領域や帯域は高々有限なので、破綻した仕様と言えます。
横幅計算
文字の横幅を計算するために書記素の数を数えようと考えるかもしれません。例えば、ひらがな5文字分ぐらいの枠に名前を表示したい、といった状況です。しかし、文字の横幅は書記素によって決まるものではありません。たとえば、U+A9C5(꧅)は1文字でU+00969(i)の5文字分ぐらいの大きさがあります。また、フォントや環境によってもかなり変わってきます。
ひらがな5文字の枠では、U+A9C5は2文字がせいぜいというのが見てわかります。ひらがな5文字分の横幅しか確保されていなければ、当然枠からはみ出します。U+A9C5が最大というわけでもないので、もしかすると縦横500pxある文字が収録されたフォントもあるかもしれません。
現実的な用途
書記素での分割を否定するような記事を書きましたが、適切な検討をすれば十分に有用です。
「名前は10文字」
「我々のゲームは日本をターゲットにしている。名前はひらがな10文字程度が入ればよいだろう。書記素で数えて、10文字までとしよう。しかし、複雑な絵文字や、横幅が大きい特殊な文字ははみ出してしまう…。」
→「これを入力するのは、ユーザーもわかってやっていることでクレームにはつながらないだろう。横幅が長すぎる場合、はみだすか...で省略する。横のボタンが押せなくなったり、大事なメッセージが読めなくなると言ったUI上の問題が発生しないように設計しよう。」
→「仕様:キャラクター名は10書記素かつ、UTF-8で50バイトまで入力可能。UIに応じて、操作に影響が無いように枠をはみ出すか、...で省略する。」
まとめ
「書記素」はコードユニットやコードポイント数と比べて極めて複雑な概念です。あなたが「書記素で文字カウント」をしようとしているなら、今一度立ち止まって本当にそうする必要があるのかを検討すべきです。
「10書記素」かつ「100バイト以内(25コードポイント以内)」といった2種類の制限を課すことも検討してください。
文字数の横幅を考えたいなら、「実際に描画して測定」「不可能なので諦めて妥協。はみ出るか、省略表示するか。」のどちらかが現実解かと思います。
参考
C++標準化委員会、ついに文字とは何かを理解する: char8_t - Qiita
こちらの記事自身と、さらにその参考文献もおすすめです。(孫引きですいません…)