JavaScript に置ける文字コードと「文字数」の数え方

Intro

textarea などに入力された文字数を、 JS で数えたい場合がある。
ここで .length を数えるだけではダメな理由は、文字コードや JS の内部表現の話を理解する必要がある。

多言語や絵文字対応なども踏まえた上で、どう処理するべきなのか。
それ自体は枯れた話題ではあるが、近年 ECMAScript に追加された機能などを交えて解説する。

なお、文字コードの仕組みを詳解すること自体が目的では無いため、 BMP, UCS-2, Endian, 歴史的経緯など、この手の話題につき物な話の一部は省くこととする。

1 文字とは何か

Unicode は全ての文字に ID を振ることを目的としている。

例えば 😭 (loudly crying face) なら 0x1F62D だ。

1 つの文字に 1 つの ID が割り当てられているのだから、文字の数を数える場合は、この ID の数を数えれば良いと考えることができるだろう。おおよその場合はそれで良い。

例えば 𠮷野屋で𩸽頼んで𠮟られる😭 という文字列を、それぞれ ID の配列に変換するとこうなる。

str = "𠮷野屋で𩸽頼んで𠮟られる😭";

[
  0x20BB7, // 𠮷
  0x91CE,  // 野
  0x5C4B,  // 屋
  0x3067,  // で
  0x29E3D, // 𩸽
  0x983C,  // 頼
  0x3093,  // ん
  0x3067,  // で
  0x20B9F, // 𠮟
  0x3089,  // ら
  0x308C,  // れ
  0x308B,  // る
  0x1F62D  // 😭
]

ID が 13 個あるので、この文字列は 13 文字だと考えることができる。

この ID のことを Unicode では Code Point という

文字の伝達

データとして文字を相手に送る際に、この Code Point が利用できる。
例えば 😭 を送るには、 0x1F62D という Code Point が相手に伝われば良いのだ。

では、この値をどうやって送るのか。そこにはいくつかの方式がある。

UTF-32

単純に考えれば、この Code Point をバイナリデータとしてそのまま送れば良いだろう。

Code Point はおおよそ 4byte あれば収まるので 32bit のデータとして送ることができる。

[0x00, 0x01, 0xF6, 0x2D] // 😭
// 0x1F62D を二進数にし、 32 桁になるまで先頭に 0 を追加してから 8 bit づつ区切った配列

受け取った側は、データを 32bit づつ Code Point とみなして文字に置き換えていけば良いし、受け取ったバイト数を 4 で割れば文字の数もわかる。

このように 1 Code Point を 32bit データとして表すという発想が、 UTF-32 と呼ばれる方式の中核である。

UTF-16

UTF-32 なら Code Point がそのまま入ってるので非常にシンプルだが、よく使う文字はそこまで大きな Code Point が振られてないため、ほとんどが 0 になる。

先の文字列では という文字は Code Point が 0x91CE0x3067 なので、 32 bit だと先頭 2byte が 0 になる。

[0x00, 0x00, 0x91, 0xCE] // 野
[0x00, 0x00, 0x30, 0x67] // で

そこで、 Code Point を 32bit ではなく、半分の 16bit で表せば、半分のサイズで送ることができる。

[0x91, 0xCE] // 野
[0x30, 0x67] // で

このように 1 Code Point を 16bit データとして表すという発想が、 UTF-16 と呼ばれる方式の中核である。

ところが、 𠮷 (0x20BB7), 𩸽 (0x29E3D), 𠮟 (0x20B9F), 😭 (0x1F62D) の 4 文字は 2byte では収まらない。
そこで、 UTF-16 では、こうした 2byte では収まらない文字について、倍の 32bit で表す。
この 16bit x2 で表される文字を、 サロゲートペア と呼ぶ。

[0xD8, 0x67, 0xDE, 0x3D] // 𠮷
[0xD8, 0x42, 0xDF, 0xB7] // 𩸽
[0xD8, 0x42, 0xDF, 0x9F] // 𠮟
[0xD8, 0x3D, 0xDE, 0x2D] // 😭

逆を言えば、サロゲートペアになるのは Code Point が大きい、 Unicode に後から追加された文字が多い。
𩸽 は後から追加された文字であり、 𠮷 の、 𠮟𠮟異体字 と呼ばれるものだ。絵文字も最近追加されたため Code Point が大きい。

こうして、サロゲートペアが導入されたことにより、 UTF-16 のデータは可変長、つまり、文字数がバイト列の長さだけではわからなくなってしまったのである。

もし、幸運にも文字列の中にサロゲートペアが 1 つも入っていなければ、バイト列を単純に 2 で割れば文字数が出る。しかし 1 つでもサロゲートペアがあると、単純な割り算では本来よりも多くの文字数があるように見えてしまうのだ。

UTF-8

英数字(a-zA-Z0-9) など、いわゆるアスキー文字と呼ばれるものは、 Code Point の中でも小さい値が割り当てられている。
これら Code Point は 8bit の範囲に収まっているので、 16 bit で表すと無駄が出てくる。

[0x00, 0x61] // a

そこで、 8bit で表せる Code Point は 8bit で、足らないものは 16bit で、さらに足らないものは 24bit で、、と「小さい Code Point はより小さく」表せば、英語のみのテキストなどはさらに小さく表すことができる。

[0x61]                   // a
[0xC2, 0xA9]             // ©
[0xE3, 0x81, 0x82]       // あ
[0xF0, 0xA0, 0xAE, 0xB7] // 𠮷

この 8bit を最小とし、それ以外を必要に応じて 2, 3, 4...byte と可変長で表す発想が UTF-8 と呼ばれる方式の中核だ。

JS の内部表現

さて、 JS で以下の処理を実行した場合、代入した文字列データがメモリ上に保存されるわけだが、このデータは Code Point がそのまま保存されているわけではない。

char = "😭"

JS の内部表現は UTF-16 であるため、メモリに保存された値は絵文字 😭 の Code Point である 0x1F62D ではなく、それを UTF-16 にした [0xD8, 0x3D, 0xDE, 0x2D] だ。

ここで注意したいのは、ここで UTF-16 が選ばれるのは JS の仕様であって、 JS ファイルのエンコーディングとは関係ない点だ。
HTML/CSS/JS ファイルは UTF-8 を使うのがデファクトとなっているが、それによって JS の内部の表現が UTF-8 になったりはしない。

イメージとしては、ブラウザは JS ファイルのレスポンスを受けた際、 Content-Encoding ヘッダなどによってファイルを解釈し、そこから Code Point を割り出す。代入された値が "😭" であることを知ったら、それをメモリ上に UTF-16 で保存する。 JS ファイルが Shift-JIS であっても同じだ。

これを聞くと JS が UTF-16 であれば、その変換オーバーヘッドが無いのでは? と思うかもしれないが、レガシーシステムとの連携などを考えなければ、 UTF-8 以外を使う必要は基本的にないので気にしないで良い。

JS が内部で持つ値は Code Point ではなく UTF-16 の値だ という点を踏まえた上で、 JS のプログラム上で文字列を数える処理について見ていく。

length

length は文字数ではなく、単にこの UTF-16 配列の長さだ。
だから、 1 文字に 16bit が 2 つ必要なサロゲートペアは length が 2 となってしまう。

つまり、内部で保持されているデータはこうなっている。

str = `𠮷野屋で𩸽頼んで𠮟られる😭`;
[
  0xD842, 0xDFB7 // 𠮷
  0x91CE         // 野
  0x5C4B         // 屋
  0x3067         // で
  0xD867, 0xDE3D // 𩸽
  0x983C         // 頼
  0x3093         // ん
  0x3067         // で
  0xD842, 0xDF9F // 𠮟
  0x3089         // ら
  0x308C         // れ
  0x308B         // る
  0xD83D, 0xDE2D // 😭
]

この文字列は 13 文字と考えられるが、 length はこの配列の長さである 17 を返す。

str = `𠮷野屋で𩸽頼んで𠮟られる😭`;
str.length // => 17

これが、文字数を数える処理に length が使えない場合があることの原因だ。
(逆を言えば、 16bit で収まる文字の範囲のみであると 保証 できるならば length を使うこともできなくはない)

そもそも、 Code Point の数を数えたいのに、内部で保持している UTF-16 の配列を操作しているから問題なのだ。
つまり、 JS が内部で保持している UTF-16 の配列を、元の Unicode の Code Point の配列に戻せば良さそうだ。

もちろん、この方法は知られている。
特に、ブラウザがこれをどう行うべきかというアルゴリズムは WHATWG の仕様に書かれているため、これを実装すれば Code Point の配列が手に入る。

WebIDL-1#dfn-obtain-unicode

筆者は、これを実装したライブラリも公開している。

github.com/Jxck/obtain-unicode

Code Point の配列にしてしまえば、文字の数 (=== Code Point の数)を数える処理はそのまま length で行える。

str = `𠮷野屋で𩸽頼んで𠮟られる😭`;
codePoints = obtainUnicode(str);
// [134071, 37326, 23627, 12391, 171581, 38972, 12435, 12391, 134047, 12425, 12428, 12427, 128557]

codePoints.length // => 13

しかし、最近はこうした処理を改善する API がブラウザ自体にあるため、使えるならそれらを使うのが良いだろう。
自前で Code Point 列にするのは、それらで間に合わない場合にとる手段だ。

正規表現

正規表現における . も 1 文字ではなく、 UTF-16 の 16bit データ 1 つを意味する。
したがって、サロゲートペアがあると 1 文字にマッチせず、途中で切れる。

'吉野家'.match(/./) // ["吉"]
'𠮷野家'.match(/./) // ["�"]

'吉野家'.match(/.{3}/) // ["吉野家"]
'𠮷野家'.match(/.{3}/) // ["𠮷野"] 変なところで切れる

そこで、 ES2015 では Unicode Flag というフラグが入った。これで Code Point の単位でマッチさせることができるようになる。

'吉野家'.match(/./u) // ["吉"]
'𠮷野家'.match(/./u) // ["𠮷"]

'吉野家'.match(/.{3}/u) // ["吉野家"]
'𠮷野家'.match(/.{3}/u) // ["𠮷野家"]

split

文字列を文字の配列に分解するのに使われる split('') も、サロゲートペアがあると崩れてしまう。

'叱られる'.split('') // ["叱", "ら", "れ", "る"]
'𠮟られる'.split('') // ["�", "�", "ら", "れ", "る"]

代わりに、先ほどの Unicod フラグを使った正規表現を使うと、正しく文字の配列に分解できる。

'叱られる'.match(/./ug) // ["叱", "ら", "れ", "る"]
'𠮟られる'.match(/./ug) // ["𠮟", "ら", "れ", "る"]

for in / for of

繰り返し処理も注意が必要だ。特に文字列に対する添え字アクセスは、 UTF-16 配列に対するアクセスだとイメージするとわかりやすい。(ちなみに charAt() も同じだ)

'鯖定食'[0] === "鯖"
'鯖定食'.charAt(0) === "鯖"

'𩸽定食'[0] === "�"
'𩸽定食'.charAt(0) === "�"

よって 1 文字ずつ処理をするという処理に for を使う場合は、添え字を基準にすることができない。

const str = '鯖定食'
for (const i in str) console.log(str[i])
// 鯖
// 定
// 食

const str = '𩸽定食'
for (const i in str) console.log(str[i])
// �
// �
// 定
// 食

for (i = 0; i < str.length; i ++) と書いても同じだ。

代わりに ES2015 で追加された for of を使うと、 Unicode の Code Point 単位で繰り返し処理が可能だ。

for (let c of '𩸽定食') console.log(c)
// 𩸽
// 定
// 食

Split Operator

Split Operator を用いた分割も、 Code Point の単位で分割される。

((a, b, c) => console.log(a, b, c))(...'𩸽定食') // 𩸽 定 食

Destructoring

いわゆる分割代入時の分割も Code Point が意識されている。

[a,b,c]='𩸽定食'
a // "𩸽"
b // "定"
c // "食"

Array.of

文字列から、文字の配列に分割するのは、 split operator と Array.of を合わせるのが一番簡単だろう。

Array.of(...'叱られた😭')
[ '叱', 'ら', 'れ', 'た', '😭' ]

charCode/codePoint

charCodeAt() は文字コードを取り、 fromCharCode() はその逆を行う。
𩸽の方は前半のバイトしかないため、元に戻らない。

'鯖定食'.charCodeAt(0) === 0x9BD6
'𩸽定食'.charCodeAt(0) === 0xD867

String.fromCharCode('鯖'.charCodeAt(0)) //"鯖"
String.fromCharCode('𩸽'.charCodeAt(0)) // "�"

これも、 Code Point を取り出すメソッドが定義されている。
これならサロゲートペアもうまく扱える。

'𩸽定食'.codePointAt(0) // 0x9BD6
'鯖定食'.codePointAt(0) // 0x29E3D

String.fromCodePoint('鯖'.codePointAt(0)) // "鯖"
String.fromCodePoint('𩸽'.codePointAt(0)) // "𩸽"

まとめ

文字には Code Point が割り当てられており、「文字数を数える」を「Code Point を数える」とするならば、単に文字列の length や添え字での処理では正確な値が出ない場合がある。

これは、 JavaScript は文字列データを Code Point の配列ではなく UTF-16 の配列として持っているからだ。

JavaScript の API には、この Code Point を意識した操作と、 そうでない操作があるため、それを意識して処理をすれば、正しく文字数を数えられるだろう。

それでも足らない場合は、自分で UTF-16 配列を Code Point 配列に変換してしまうことも可能だ。

おまけ

ここまでは基礎であり、まだまだ厄介な問題はある。

ここまでは、「文字数を数える」という処理を「Code Point の数を数える」処理であると定義した上で話を進めた。

しかし、これでは直感に反する場合が出る。

1 つが 👨‍👩‍👧‍👦 だ。

Array.of(...'👨‍👩‍👧‍👦')
["👨", "‍", "👩", "‍", "👧", "‍", "👦"]

この絵文字は "family with mother father son daughter" という名前の文字で、 4 つの絵文字が合成されてできている。
家族は多様なので別の組み合わせもある。

いずれにせよ、先ほどの方法で分解すると、個々の顔の間に空の文字が見える。

これは、 👨‍👩‍👧‍👦 という絵文字自体が、👨, 👩, 👧, 👦 という 4 つの絵文字とそれを結合する制御文字でできているからである。
この制御文字を ZWJ(ZERO WCode PointTH JOINER) といい、 ZWJ の Code Point は 0x200D だ。
先ほどのように「文字の数を数える == Code Point の数を数える」としてしまえば、これは emoji * 4 + ZWJ * 3 で 7 文字となる。

ところが、おそらく多くの人がこれを 1 文字と捉えるだろう。

つまり 1 文字を カーソルが 1 つ移動する分 と捉えているとなると、 Code Point の数だけでなく、 ZWJ を持つ文字列も 1 文字と捉える必要が出てくる。

ZWJ は絵文字だけではなく、アラビア文字などで良く使われているようだ。
もし ZWJ で結合された文字も 1 文字と数えたい場合は、 Code Point の分割の後にさらなる結合処理が必要になるだろう。

ちなみに Twitter は 👨‍👩‍👧‍👦 を入れると 7 文字減る。つまり、 ZWJ も 1 文字として Code Point を数えていると推測される。