家族の絵文字は11文字 — JavaScriptの文字数、4つの数え方
👨👩👧👦を数えると1になる。7にもなる。11にもなるし、25という答えもある。 どれも間違っていない。数えているものが違うだけ。
文字数カウンターを作ったとき、答えを1つに決めるのをやめて全部並べることにした。 「文字数」という言葉が指すものが1つではない以上、1つの数だけ返すツールはどこかで嘘をつくことになるので。
数え方を並べる
代表的な文字列を4つの数え方で数えるとこうなる。ここの数字は全部この記事を書きながらnodeで実際に出したもの。
| 文字列 | 書記素クラスタ | コードポイント | String#length | UTF-8バイト |
|---|---|---|---|---|
a | 1 | 1 | 1 | 1 |
あ | 1 | 1 | 1 | 3 |
が(U+304C) | 1 | 1 | 1 | 3 |
が(U+304B U+3099) | 1 | 2 | 2 | 6 |
| 😀 | 1 | 1 | 2 | 4 |
| 👍🏽 | 1 | 2 | 4 | 8 |
| 🇯🇵 | 1 | 2 | 4 | 8 |
| 👨👩👧👦 | 1 | 7 | 11 | 25 |
数え方はそれぞれこう書く。
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×280 | 280 | 280 | 280 |
あ×140 | 140 | 140 | 280 |
あ×141 | 141 | 141 | 282(超過) |
| 😀 | 2 | 1 | 2 |
| 👨👩👧👦 | 11 | 7 | 2 |
https:// から始まる120字のURL | 120 | 120 | 23 |
設定を覗くと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#length | 0.000ms |
Array.from | 0.035ms |
TextEncoder#encode | 0.037ms |
Intl.Segmenter | 0.544ms |
15倍くらい違う。打鍵のたびに数え直すツールだと、ここは効いてくる。 文字数カウンターが内訳を先頭200書記素までで打ち切っているのはこれが理由。
もう1つ、Intl.SegmenterとTextEncoderのインスタンスは毎回作らずモジュールスコープで使い回している。
短い入力だと構築コストのほうが支配的になるので。
| 対象 | 使い回し | 毎回new |
|---|---|---|
Intl.Segmenter(6文字) | 9.3μs | 21.0μs |
TextEncoder(6文字) | 0.7μs | 2.7μs |
2倍以上。1打鍵あたり数十マイクロ秒の話なので体感には出ないが、無料で消せる差なら消しておく。
まとめ
String#lengthはUTF-16コードユニット数。文字数だと思って使うと絵文字で壊れる- コードポイント数は
Array.from。DBのVARCHAR(n)はだいたいこれ - UTF-8バイト数は
TextEncoder。バイト上限がある場所はこれ - 見た目の文字数は
Intl.Segmenter。切り詰めもこれでやる - どれが正解かは、数えた数を渡す先が決める
自分の入力で確かめたければ文字数カウンターに貼ってみてほしい。 コードポイントの内訳まで出るので、どこにゼロ幅接合子が挟まっているかも見える。
Footnotes
-
HTML仕様の
maxlengthはcode-unit lengthで判定される。つまりString#lengthと同じ数え方になる。 ↩