JavaScriptで「見た目の1文字」を数える — Intl.Segmenterで書記素クラスタ単位に分割する
入力欄に「あと何文字」と出すカウンタを作ると、たいてい一度は絵文字で壊れる。str.length は UTF-16 のコード単位を数えているので、絵文字ひとつが2文字や7文字に化ける。かといって [...str] に変えても、家族の絵文字や国旗、濁点付きの分解済みカタカナでずれが残る。
Intl.Segmenter を使うと、この「人が1文字と見なす単位」=書記素クラスタで分割できる。ECMA-402 の標準APIで、モダンブラウザと Node.js で使える。
なにがずれるのか
まず、3つの数え方が同じ文字列に対してどれだけ違うかを見る。
1 | |
length はコード単位、[...s] はコードポイントを数えている。どちらも「見た目の1文字」ではない。
がは「か」と結合用濁点の2コードポイントで、表示は「が」1文字になる🇯🇵は地域指標記号2つの組み合わせで1つの旗になる👨👩👧👦は4人の絵文字を ZWJ(U+200D)で3回つないだもので、コードポイントは7つある
Intl.Segmenter で書記素単位に分ける
granularity: 'grapheme' を指定した Intl.Segmenter は、この結合をまとめて1セグメントとして返す。
1 | |
segment() が返すのはイテラブルなので、[...] で配列にするか for...of で回す。各要素は { segment, index, input } というオブジェクトで、index は元文字列内の開始位置(コード単位)になっている。
Segmenter のインスタンス生成はそれなりにコストがかかるので、モジュールのトップレベルで1つ作って使い回す。ループの中で毎回 new すると無駄に遅くなる。
文字数カウンタに組み込む
入力欄のカウンタは、この関数を1つ挟むだけで直る。
1 | |
1 | |
数えるだけなら配列にせずカウンタを回すほうが、長文でメモリを食わない。
なお <textarea maxlength> はコード単位で数えるので、書記素で数えたい場合はブラウザ側の制限と表示上の数字が食い違う。厳密にそろえるなら maxlength を外して自前で制御することになる。
先頭N文字で切り出す
一覧の要約テキストを「先頭140文字」で切るような処理も、slice のままだと絵文字を真っ二つにして壊れた文字(U+FFFD)が出る。
1 | |
途中で打ち切れるので、長大な文字列でも全体を分割し切る必要がない。
単語と文の分割もできる
granularity には 'word' と 'sentence' も指定できる。日本語は空白で単語が区切られないので、split(' ') のような手当ては効かない。
1 | |
isWordLike は単語らしいセグメントに true が入る。空白や句読点を落とすのに使う。ここで得られるのは形態素解析の結果そのものではなく、Unicode のテキスト分割規則に沿った区切りである。検索キーワードのハイライトや、おおまかな単語数の見積もりには十分に使える。
分割結果はロケールに依存する。第1引数に渡すロケールを変えると区切り方が変わりうるので、対象の言語に合わせて指定する。
対応状況を確認して使う
古い環境を相手にする場合は、存在チェックを入れてコードポイント単位にフォールバックしておく。
1 | |
フォールバック側は完全ではない。それでも length をそのまま使うよりは、絵文字を含むテキストで実態に近い数字になる。
まとめ
str.lengthはコード単位、[...str]はコードポイント。どちらも見た目の文字数ではないIntl.Segmenterのgranularity: 'grapheme'が書記素クラスタ単位を返す- インスタンスは使い回す。数えるだけなら配列化せずイテレータを回す
- 切り出しも書記素単位でやらないと絵文字が割れる
'word'/'sentence'を使えば、空白のない日本語でも区切りが取れる
入力欄のカウンタと要約の切り詰めは、この2箇所を直すだけで絵文字に強くなる。