背景
自分のツールサイトに、文字列を1文字ずつコードポイントに分解して見せるページがあります。
https://hashitosystem.com/tools/codepoint/
入力欄の上に「文字数」を出しているのですが、ところが、家族の絵文字 👨👩👧 を1つ貼ると「文字数:5」と出ます。
見た目は1文字なので、5はさすがに変だな…と思って、JavaScript で「文字数」を数える方法を3つ並べてみました。
3つの数え方
前提です。
JavaScript の文字列は UTF-16 という形式で持っていて、length はその16ビット単位の数を返す。
コードポイントは Unicode が1文字ごとに振った番号(U+0041 など)で、U+FFFF を超える文字は UTF-16 では2単位(サロゲートペア)になる。
Array.from(str) や for...of は、文字列をコードポイント単位で分けます。
そして「見た目の1文字」は書記素クラスタと呼ばれ、複数のコードポイントがまとまって1文字に見えるものを指す。
Unicode の UAX #29 という文書で、その区切り方が決められています。
https://www.unicode.org/reports/tr29/
Node.js 24 で数えた結果
下記のスクリプトで、いくつかの文字列を3通りに数えました。
Intl.Segmenter は、文字列を書記素・単語・文の単位に区切る標準の API です。
const seg = new Intl.Segmenter('ja', { granularity: 'grapheme' });
for (const s of ['👨👩👧', '🇯🇵', '👍🏽', '𠮷野家', 'が', 'が', '1️⃣']) {
console.log(JSON.stringify(s), s.length, Array.from(s).length, [...seg.segment(s)].length);
}
結果が下記です(左から length・コードポイント数・書記素の数)。
"👨👩👧" 8 5 1
"🇯🇵" 4 2 1
"👍🏽" 4 2 1
"𠮷野家" 4 3 3
"が" 1 1 1
"が" 2 2 1
"1️⃣" 3 3 1
(6行目は画面上「が」と表示されて区別がつかないので、ここだけ ゙ と書き直しています。)
👨👩👧 は、男性・女性・女の子の3つの絵文字を ZWJ(U+200D、ゼロ幅接合子という「つなげて表示して」という印)で結んだ5コードポイントだ。
🇯🇵 は J と P を表す2つの地域指示記号、👍🏽 は親指と肌の色の修飾子の2つでできています。
6行目の「が」は、か(U+304B)に結合用の濁点(U+3099)を付けたもので、1行目の合成済みの「が」(U+304C)と見た目は同じです。
𠮷 はサロゲートペアなので length だけが1つ多くなる。
私のツールは Array.from で数えていたので、👨👩👧 が5になった、ということでした。
length のまま切ると壊れる
数えるだけならまだいいのですが、困るのは「先頭から N 文字で切って … を付ける」処理です。
下記は、同じ文字列を3つの方法で6文字に切った結果です。
const s = 'うちの家族👨👩👧と🇯🇵';
const seg = new Intl.Segmenter('ja', { granularity: 'grapheme' });
function truncate(text, max) {
const parts = Array.from(seg.segment(text), (x) => x.segment);
return parts.length <= max ? text : parts.slice(0, max).join('') + '…';
}
console.log(s.slice(0, 6) + '…'); // length 単位
console.log(Array.from(s).slice(0, 6).join('') + '…'); // コードポイント単位
console.log(truncate(s, 6)); // 書記素単位
Node.js 24 での出力が下記です。
うちの家族�…
うちの家族👨…
うちの家族👨👩👧…
slice は 👨 のサロゲートペアの前半だけを残すので、JSON.stringify で見ると末尾が \ud83d という片割れになっていて、画面では � に化けます。
Array.from は文字化けはしないものの、家族が男性1人になります(笑)。
Intl.Segmenter で切ったものだけが、見た目どおり「家族の絵文字まで」で止まりました。
なぜ全部 Segmenter にしないのか
じゃあ常に書記素で数えればいいかというと、用途によります。
データベースの列の長さや、API の文字数上限が「コードポイント数」や「UTF-8 のバイト数」で決まっていることは多い。
その上限チェックを書記素で数えると、見た目は1文字でも保存先では18バイトある、というずれが起きます(👨👩👧 は new TextEncoder().encode() で測ると UTF-8 で18バイトでした)。
なので、画面に出す文字数や表示用の切り詰めは書記素、保存先の制限チェックはその保存先が数える単位、と使い分けるのが筋な気がします。
私のツールはコードポイントを1行ずつ見せるのが目的なので、表はそのままにして、「文字数」の横に書記素の数も並べるのがよさそうです。
どれで数えるか
length は UTF-16 の単位数、Array.from はコードポイント数で、どちらも見た目の1文字ではない。
人が見る文字数を数えたり、文字列を途中で切ったりするときは、Intl.Segmenter の granularity: 'grapheme' を使ってください。
本記事はAI補助で執筆した、個人開発の紹介記事です。