本文へ移動
myowchick

家族の絵文字は11文字 — JavaScriptの文字数、4つの数え方

👨‍👩‍👧‍👦を数えると1になる。7にもなる。11にもなるし、25という答えもある。 どれも間違っていない。数えているものが違うだけ。

文字数カウンターを作ったとき、答えを1つに決めるのをやめて全部並べることにした。 「文字数」という言葉が指すものが1つではない以上、1つの数だけ返すツールはどこかで嘘をつくことになるので。

数え方を並べる

代表的な文字列を4つの数え方で数えるとこうなる。ここの数字は全部この記事を書きながらnodeで実際に出したもの。

文字列書記素クラスタコードポイントString#lengthUTF-8バイト
a1111
あ1113
が(U+304C)1113
が(U+304B U+3099)1226
😀1124
👍🏽1248
🇯🇵1248
👨‍👩‍👧‍👦171125

数え方はそれぞれこう書く。

const s = '👨‍👩‍👧‍👦'

s.length                                    // 11  UTF-16 コードユニット
Array.from(s).length                        // 7   コードポイント
new TextEncoder().encode(s).length          // 25  UTF-8 バイト
[...new Intl.Segmenter('ja', { granularity: 'grapheme' }).segment(s)].length  // 1

String#lengthは文字数ではなくUTF-16のコードユニット数を返す。 JavaScriptの文字列はUTF-16のコードユニットの並びとして定義されていて、lengthはその要素数をそのまま返しているだけ。 BMP外の文字はサロゲートペアで表されるので2つ分になる。だから😀のlengthは2。

Array.fromは文字列のイテレータを使うので、サロゲートペアを1つのコードポイントに戻して数える。 スプレッド構文とfor...ofも同じ。逆にcharAtと[i]はコードユニット単位なので、絵文字の途中を掴む。

1つの見た目が複数のコードポイントでできている

コードポイント数と見た目の文字数がずれるのは、複数のコードポイントが結合して1つの字を作るケースがあるから。 上の表の後半は全部それ。

  • がはか(U+304B)と結合濁点(U+3099)に分けても書ける。NFDで正規化するとこの形になるので、macOSのファイル名から拾った文字列がこうなっていることがある
  • 🇯🇵は地域指示子(U+1F1EF、U+1F1F5)の2つ。JとPの記号が並ぶと国旗になる
  • 👍🏽は👍と肌の色の修飾子(U+1F3FD)の2つ
  • 👨‍👩‍👧‍👦は👨👩👧👦の4つをゼロ幅接合子(U+200D)3つで繋いだ7つ

この「見た目の1文字」の単位が書記素クラスタで、Intl.Segmenterが切ってくれる。 文字数カウンターの内訳表示は、これで分解したうえで各コードポイントを並べている。 👨‍👩‍👧‍👦を貼るとゼロ幅接合子が3つ挟まっているのが見える。

どの数え方が効くかは、数えた先が決める

数える理由はたいてい「上限に収まるか」なので、上限を決めている側の数え方に合わせるしかない。

場面効く数え方
MySQLのVARCHAR(n)(utf8mb4)コードポイント
バイト上限(Cookie、HTTPヘッダ、ファイルサイズ)UTF-8バイト
<input maxlength>UTF-16コードユニット1
Xのポスト独自の重み付き長さ
カーソル移動、1回のバックスペース、「あと何字」の表示書記素クラスタ

下の2つは実際に動かして確かめた。

MySQLのVARCHAR(3)に入るもの

MySQL 8.4.11をdockerで立てて、VARCHAR(3)の列に何が入るか試した。

CREATE TABLE v3 (a VARCHAR(3)) DEFAULT CHARSET=utf8mb4;

INSERT INTO v3 VALUES ('あああ');   -- OK
INSERT INTO v3 VALUES ('👨‍👩‍👧‍👦');    -- ERROR 1406 Data too long

あああは9バイトあるが3文字なので通る。 👨‍👩‍👧‍👦は見た目1文字で25バイトだが、MySQLの数え方では7文字なのでVARCHAR(3)に入らない。 CHAR_LENGTHはコードポイントを返し、LENGTHはバイトを返す。

+-----------+-------+-------+
| a         | chars | bytes |
+-----------+-------+-------+
| あああ    |     3 |     9 |
| 😀        |     1 |     4 |
+-----------+-------+-------+

NFDのがも2文字扱いになるので、VARCHAR(3)には1つしか入らない。 か+結合濁点を3つ並べると6文字で弾かれる。

ついでに、列の文字セットがutf8mb3だと絵文字はそもそも入らない。

ERROR 1366 (HY000): Incorrect string value: '\xF0\x9F\x98\x80' for column 'a' at row 1

古いテーブルで絵文字が落ちるやつはこれ。バイト数でもコードポイント数でもなく、文字セットの話。

Xの280は文字数ではない

Xのポスト上限は英数で280、日本語で140とよく言われるが、これは2つの上限があるわけじゃない。 twitter-textが公開している数え方を実際に動かすと、上限は常に280で、文字ごとに重みが違うだけだと分かる。

入力lengthコードポイント重み付き長さ
a×280280280280
あ×140140140280
あ×141141141282(超過)
😀212
👨‍👩‍👧‍👦1172
https:// から始まる120字のURL12012023

設定を覗くとdefaultWeight: 200、scale: 100で、重み100(つまり1文字分)になるのはU+0000からU+10FFと、いくつかの約物の範囲だけ。 それ以外は全部2文字分として数える。だから日本語は140で頭打ちになる。

面白いのは絵文字の扱いで、👨‍👩‍👧‍👦は11コードユニット・7コードポイントあるのに重み付き長さは2。 1つの絵文字としてまとめて数えてから、絵文字1つ分の重みを掛けている。 URLは長さに関係なく23固定。

もう1つ、NFDのがも重み付き長さは2になる。数える前にNFCへ正規化しているから。

tt.parseTweet('が'.normalize('NFD')).weightedLength  // 2(length は 2)
tt.parseTweet('が'.normalize('NFC')).weightedLength  // 2(length は 1)

自前で「あと何文字」を出すつもりなら、正規化まで含めて真似しないと数がずれる。

切り詰めるときにlengthで切ると壊れる

数えるより厄介なのが切るほう。sliceはコードユニット単位なので、サロゲートペアの真ん中で切れる。

const s = '👨‍👩‍👧‍👦です'

s.slice(0, 3)                         // '👨‍'      サロゲートペアは無事だが家族が分解した
Array.from(s).slice(0, 3).join('')    // '👨‍👩'    末尾がゼロ幅接合子で終わっている

コードポイント単位に直しても、結合列の途中で切れることは変わらない。 見た目の文字で切りたいなら書記素クラスタで切る。

const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' })

function truncate(input, max) {
  const out = []
  for (const { segment } of segmenter.segment(input)) {
    if (out.length >= max) break
    out.push(segment)
  }
  return out.join('')
}

truncate('👨‍👩‍👧‍👦です', 2)  // '👨‍👩‍👧‍👦で'

改行と空白も数え方で割れる

長さ以外も同じ問題を持っている。まず改行。

'a\r\nb'.match(/[\r\n]/g).length        // 2
'a\r\nb'.match(/\r\n|\r|\n/g).length    // 1

CRLFは改行1つなので、[\r\n]で数えると倍になる。 CR・LF・CRLFが混ざった'a\r\nb\nc\rd'だと、正しく3、雑に数えると4。 \r\nを先に書いた交替にしておけば全部1つずつ数える。

空白のほうはもっと気付きにくい。JavaScriptの\sが何を空白と見なすかがそこそこ広い。

コードポイント\sにマッチUTF-8バイト
U+0020 スペースする1
U+00A0 ノーブレークスペースする2
U+3000 全角スペースする3
U+FEFF BOMする3
U+200B ゼロ幅スペースしない3

ゼロ幅スペースは名前に空白と入っているのに\sにマッチしない。 見えないし空白でもないので、「空白を除いた文字数」に黙って1文字として残る。 コピペで混入したときに数が合わない原因はだいたいこれ。

ついでに単語数も、空白区切りで数える限り日本語では機能しない。

'hello world'.trim().split(/\s+/u).length   // 2
'吾輩は猫である'.trim().split(/\s+/u).length  // 1

これは仕様として割り切っている。日本語の単語数が要るなら形態素解析の話になって、文字数カウンターの仕事ではなくなるので。

書記素で数えるのはそこそこ重い

Intl.Segmenterは正しいが速くはない。5400コードユニット(4400コードポイント)の文字列を200回ずつ数えて平均を取った。

数え方1回あたり
String#length0.000ms
Array.from0.035ms
TextEncoder#encode0.037ms
Intl.Segmenter0.544ms

15倍くらい違う。打鍵のたびに数え直すツールだと、ここは効いてくる。 文字数カウンターが内訳を先頭200書記素までで打ち切っているのはこれが理由。

もう1つ、Intl.SegmenterとTextEncoderのインスタンスは毎回作らずモジュールスコープで使い回している。 短い入力だと構築コストのほうが支配的になるので。

対象使い回し毎回new
Intl.Segmenter(6文字)9.3μs21.0μs
TextEncoder(6文字)0.7μs2.7μs

2倍以上。1打鍵あたり数十マイクロ秒の話なので体感には出ないが、無料で消せる差なら消しておく。

まとめ

  • String#lengthはUTF-16コードユニット数。文字数だと思って使うと絵文字で壊れる
  • コードポイント数はArray.from。DBのVARCHAR(n)はだいたいこれ
  • UTF-8バイト数はTextEncoder。バイト上限がある場所はこれ
  • 見た目の文字数はIntl.Segmenter。切り詰めもこれでやる
  • どれが正解かは、数えた数を渡す先が決める

自分の入力で確かめたければ文字数カウンターに貼ってみてほしい。 コードポイントの内訳まで出るので、どこにゼロ幅接合子が挟まっているかも見える。

Footnotes

  1. HTML仕様のmaxlengthはcode-unit lengthで判定される。つまりString#lengthと同じ数え方になる。 ↩

← 一覧へ戻る