0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

家族の絵文字1つが length では8、Array.from では5。見た目の1文字は Intl.Segmenter で数える

0
Posted at

背景

自分のツールサイトに、文字列を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補助で執筆した、個人開発の紹介記事です。

0
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?