zenet_logo

-株式会社ゼネット技術ブログ-

【JavaScript】いまどき(2023年1月)の文字数カウント方法

こんにちは、システム事業部の松永です。

突然ですが、みなさんはJavaScriptで文字数をカウントした経験はありますか?
私は先日、とあるプロジェクトで文字数のカウントを行う機会があり、何も考えずに .length を使って以下のようにカウントしてました。

"ゼネットテックブログ".length // => 10

ところがこの書き方だとうまくいかない文字というのも存在します。
(詳しくは以下のサイトに分かりやすくまとめられています)

JavaScript における文字コードと「文字数」の数え方 | blog.jxck.io

上記サイトを参考にさせていただくと、以下の文字は(人間の感覚から見て)うまくカウントされません。*1

"𩸽".length   // => 2 (サロゲートペア)
"葛󠄀".length   // => 3 (異体字セレクタ)
"パ".length   // => 2 (結合文字)

これはどうしたものかと上記のサイトを読み込んでいると、以下の記述が目に留まりました。

1 文字を カーソルが * 1 つ移動する分 * と捉えているとなると、 Code Point の数を数えるだけではなく、合字も 1 文字と捉える必要が出てくる。

この カーソルが * 1 つ移動する分 * を書記素と言い、 Code Point の列の中から、書記素の区切りを判別する方法は Unicode の中に定義されている。

ふむふむ、書記素 という単位でカウントができればよさそうですね。

さらに最後には

なお、 JavaScript に関しては、 TC39 にこれを標準で入れるというプロポーサルが上がっており、執筆時は Stage 3 である。

tc39/proposal-intl-segmenter: Unicode text segmentation for ECMAScript

とあり、この Intl.Segmenter というのを使えばやりたいことが実現できそうです!

Intl.Segmenter とは

MDNの Intl.Segmenter ページ では以下のように説明されています。

Intl.Segmenter オブジェクトは、ロケールに応じたテキストのセグメンテーションを可能にし、文字列から意味のある項目(書記素、単語、文)を取得することができます。

前述の参考サイトの通り、これを使って文字列を書記素で分割し分割後の長さを測ることで、人間の感覚から見た文字数のカウントができそうです。
なお同サイトではプロポーサルが Stage 3 であるとありましたが、2022年6月にリリースされたECMAScript 2022で正式に取り込まれています。*2

ということで主要なモダンブラウザでは使い放題! と思いきや執筆時点ではFirefoxのみ対応していないようです。*3
ちなみにサーバサイドJSである Node.js では 16.0.0 から対応しているようです。

文字数をカウントする

MDNのサイトを参考に文字列をカウントしてみます。

const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
const segments = segmenter.segment("ゼネットテックブログ");
Array.from(segments).length; // => 10

簡単に各行の説明をします。

1行目では Intl.Segmenter オブジェクトを作成しています。
第1引数には言語、第2引数のオブジェクトの granularity では分割の単位を指定します。
ここでは「日本語」を「書記素」単位で分割するように指定しています。

2行目は "ゼネットテックブログ" という文字列を1行目の指定に沿って分割しています。
ここで呼び出している segment() メソッドはイテレータ( Intl.Segments オブジェクト)を返すため、Array.from() によって配列に変換することが可能です。
3行目では配列に変換後、その長さをカウントしています。

なお、分割の後に配列に変換されたもの( Array.from(segments) の中身)は以下のようになっています。

[
  {segment: 'ゼ', index: 0, input: 'ゼネットテックブログ'}
  {segment: 'ネ', index: 1, input: 'ゼネットテックブログ'}
  {segment: 'ッ', index: 2, input: 'ゼネットテックブログ'}
  {segment: 'ト', index: 3, input: 'ゼネットテックブログ'}
  {segment: 'テ', index: 4, input: 'ゼネットテックブログ'}
  {segment: 'ッ', index: 5, input: 'ゼネットテックブログ'}
  {segment: 'ク', index: 6, input: 'ゼネットテックブログ'}
  {segment: 'ブ', index: 7, input: 'ゼネットテックブログ'}
  {segment: 'ロ', index: 8, input: 'ゼネットテックブログ'}
  {segment: 'グ', index: 9, input: 'ゼネットテックブログ'}
]

それではStringの .length では1とカウントされなかった文字がどうなるのか見てみましょう。

const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
Array.from(segmenter.segment("𩸽")).length;   // => 1
Array.from(segmenter.segment("葛󠄀")).length;   // => 1
Array.from(segmenter.segment("パ")).length;   // => 1

おお、きちんと人間の感覚から見た文字数と同じ1という出力になっていますね!

単語や文に分割する

前述の Intl.Segmenter の説明で、書記素の他に単語や文でも分割できるとお伝えいたしました。
ここでは有名な小説の冒頭 *4 を、単語や分に分割してみます。*5

吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。

まずは簡単そうな「文」で分割してみましょう。
文で分割するには、第2引数の granularity"sentence" を指定します。

const segmenter = new Intl.Segmenter("ja", { granularity: "sentence" });
const str = "吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。"

// 分割された文字列のみを取得するために `map()` を使用
Array.from(segmenter.segment(str)).map(sentence => sentence.segment);
["吾輩は猫である。", "名前はまだ無い。", "どこで生れたかとんと見当がつかぬ。", "何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。"]

おー、ちゃんと分割できてそうですね!

続いては単語で( granularity には "word" を指定)。

const segmenter = new Intl.Segmenter("ja", { granularity: "word" });
const str = "吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。"
Array.from(segmenter.segment(str)).map(word => word.segment);
["吾輩", "は", "猫", "で", "ある", "。", "名前", "は", "まだ", "無い", "。", "どこ", "で", "生れ", "たか", "とんと", "見当", "が", "つ", "か", "ぬ", "。", "何でも", "薄暗い", "じめじめ", "した", "所", "で", "ニャーニャー", "泣", "い", "て", "いた事", "だけ", "は", "記憶", "し", "て", "いる", "。"]

きちんと分割できてそうにも見えますが、ちらほら気になる部分もありますね。
(生れ/たか, つ/か/ぬ, 泣/い/て/いた事 など)
この辺は実装(側で保持する辞書)にも依存するようですが、まだまだ発展途上という感じのようです。*6 *7

まとめ

ここまで Intl.Segmenter を使って、人間の感覚から見て違和感のないように文字をカウントする方法を見てきました。
前述の通り、このAPIを使うことで書記素単位での文字列分割が可能となります。 これまで悩みの種だったUnicodeのコードポイントの闇 *8 を意識することなく、簡潔な文字列操作が行えるようになりましたのでぜひ皆様も使ってみてください!

余談

Intl.Segmenter を使うと 👨‍👩‍👧‍👦 のようなゼロ幅接合子(ZWJ)を用いた文字も1文字としてカウントすることが可能です。

// `.length` を使用
"👨‍👩‍👧‍👦".length // => 11

// `Intl.Segmenter` を使用
const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
Array.from(segmenter.segment("👨‍👩‍👧‍👦")).length; // => 1

𩸽葛󠄀 と同様にこの文字も本文内で用いるつもりだったのですが、執筆時現在コードブロックへのシンタックスハイライトが当たると以下のように表示されてしまいます。*9 *10

👨<200d>👩<200d>👧<200d>👦

調べていないので確かなことは言えませんが、シンタックスハイライトを当てる際の文字列分割処理で、きれいに書記素単位での分割ができていないのかもしれませんね。

*1:参考にさせていただいたサイトの他、 MDNの String length ページ にもある通り、Stringの .length はUTF-16の配列の長さを返すメソッドなので当然の結果なのですが...

*2:https://402.ecma-international.org/9.0/#segmenter-objects

*3:Bugzilla@Mozillaにissueがある ようですが、最近ではあまり更新されていないようです

*4:MDNの Intl.Segmenter の説明ページにも使われていました

*5:余談ですが日本語はいわゆる「分かち書き」をしない言語なので、字句解析が難しいと言われています

*6:使っている辞書を個別に設定したいというissueもあがっているようです。https://github.com/tc39/proposal-intl-segmenter/issues/133

*7:「食べる」と「食べた」を分割してみたところ、前者が ["食べる"] となったのに対し、後者は ["食", "べた"] と2つに分割されてしまいました

*8:ASCII圏から出ない言語には分からない苦しみってありますよね...

*9:上記のソースコードはあえてシンタックスハイライトを当てていません

*10:<200d> がZWJです