created_at
updated_at
tags
toc

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

Intro

textarea などに入力された文字数を、JS で数えたい場合がある。

ここで .length を数えるだけではダメな理由は、文字コードや JS の内部表現の話を理解する必要がある。

多言語や絵文字対応なども踏まえた上で、どう処理するべきなのか。

それ自体は枯れた話題ではあるが、近年 ECMAScript に追加された機能などを交えて解説する。

なお、文字コードの仕組みを詳解すること自体が目的では無いため、BOM, 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 bit になるまで先頭に 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 の配列が手に入る。

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

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 列にするのは、それらで間に合わない場合にとる手段だ。

charCode/codePoint

charCodeAt() は文字コードを取り、fromCharCode() はその逆を行う。

𩸽 の方は前半のバイトしかないため、元に戻らない。

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

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

一方、codePointAt()fromCodePoint() は、その名の通り Code Point に対応している。

これならサロゲートペアもうまく扱う事ができる。

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

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

正規表現

正規表現における . も 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("") // ["�", "�", "ら", "れ", "る"]

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

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

String Iterator

繰り返し処理も注意が必要だ。特に文字列に対する添え字アクセスは、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 ++) と書いても同じだ。

しかし String は Iterator に対応した Iterable Object であり、その処理は Code Point をベースとしている。

(正確に言うと、String はネイティブに実装している Symbol.Iterator の処理が Code Point ベースで反復処理するようになっている)

つまり Iterator を扱う API を用いれば、自然と Code Point を意識した処理が可能だ。

for of

例えば ES2015 で追加された for of は Iterator に対応しているため、Code Point 単位の繰り返し処理が可能だ。

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

Spread Operator

Spread Operator を用いた分割も Iterator で行われる。

[..."𩸽定食"]  // 𩸽 定 食
Array.of(..."𩸽定食")

Destructuring

分割代入時の分割も Iterator で行われる。

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

Array.from

Array.from は Iterator をもとに配列を作る。

Array.from("叱られた😭")
[ "叱", "ら", "れ", "た", "😭" ]

Outro

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

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

JavaScript で Code Point を意識した処理をしたい場合は、以下が使えるだろう。

  • codePointAt()fromCodePoint()
  • 正規表現の Unicode フラグを用いた処理
  • String Iterator を利用した API

おまけ

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

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

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

異体字セレクタ

の異体字として 葛󠄀 があり、カツシカ区は前者を、カサイ市は後者を使うらしい。

ところがこの二つは 𠮟𠮟 のように別の文字コードを振るわけではなく、どちらも同じ文字であり、書き方のバリエーションが違うという扱いになっている。

そこで、基本となる文字コードを定義し、そこに対してバリエーションがあるものはその番号を組み合わせるという考え方が 異体字セレクタ だ。

の文字コードは 0x845B であり、異体字の 1 番目である 葛󠄀 には、後ろに 0xE0101 をつける。

str = "葛飾区"
[
  0x845B // 葛
  0x98FE // 飾
  0x533A // 区
]


str = "葛󠄀城市"
[
  0x845B  // 葛
  0xE0100 // (異体字セレクタ)
  0x57CE  // 城
  0x5E02  // 市
]

これは、もし異体字セレクタで選択したフォントが入ってなかった場合、セレクタ抜きのフォントを選ぶというフォールバックが可能となる。

この異体字セレクタは、絵文字でも利用される。

例えば 👍 という絵文字は 👍🏻 👍🏼 👍🏽 👍🏾 👍🏿 のように肌の色を変えることができる。

これは、元となる絵文字に対して、Emoji Modifier Sequence という変更を加えるための異体字セレクタを組みわせている。

肌の色を変える skin tone は 5 種類定義されており、これを直後に置くことで表示上肌の色を変えられるのである。

Array.from("👍🏻👍🏼👍🏽👍🏾👍🏿")
[
  "👍", "🏻", // "0x1F44D", "0x1F3FB"
  "👍", "🏼", // "0x1F44D", "0x1F3FC"
  "👍", "🏽", // "0x1F44D", "0x1F3FD"
  "👍", "🏾", // "0x1F44D", "0x1F3FE"
  "👍", "🏿"  // "0x1F44D", "0x1F3FF"
]

これも、元となる文字に対して、異体字セレクタを付与する方式である。

ともあれ、セレクタ自体に Code Point が割り当てられているため、文字数を数える場合には考える必要がある。

異体字セレクタ自体も、漢字などに使われる IVS 、絵文字などに使われる SVS があるため、そのあたりを踏まえて処理することになるだろう。

結合文字

よく例に上がるのが パ だ。

Array.from("パ") // 0x30d1
["パ"]
Array.from("パ") // 0x30cf, 0x309a
["ハ", "゚"]

このように、見た目上どちらも 1 文字だが、前者は 1 つの CodePoint 、後者は「ハ」と「半濁点」の 2 つの CodePoint から成り立っている。

ウムラウトやマクロンのような記号でも同じことがおこる。以下の文字は 3 つの方法で表すことができる。

Array.from("ǖ") // 0x1d6
["ǖ"]
Array.from("ǖ") // 0xfc, 0x304
["ü", "̄"]
Array.from("ǖ") // 0x75, 0x308, 0x304
["u", "̈", "̄"]

濁点やウムラウトのような、前にある文字(基底文字)に結合される図形文字を結合文字という。

結合して表せる文字そのものに CodePoint が割り当てられているために、複数の表現方法が可能になる。

CodePoint の数が変わるため、文字数も表現方法によって結果がブレてしまう。

そこで、Unicode ではこれらを「なるべく単一の CodePoint で表す」か「なるべく結合で表す」のどちらかに変換する方法が知られている。

これが 正規化 と呼ばれるものであり、前者を NFC 後者を NFD という。

NFC (Normalization Form Canonical Composition)
なるべく単一の CodePoint で表す
NFD (Normalization Form Canonical Decomposition)
なるべく結合で表す

JavaScript では、String.prototype.normalize() を用いるとこれを変換することが可能だ。

Array.from("パ".normalize("NFC"))
["パ"]
Array.from("パ".normalize("NFD"))
["ハ", "゚" ]

NFC, NFD は単純に結合かどうかを変更するが、この派生として NFKC, NFKD もある。

K は Compatibility のことであり、C だと Composition と被るのであえて K で表している。これは「互換等価」であるものへの変換も同時に行うオプションだ。

「互換等価」とは、要するに「意味的に同じ」ということであり、例を見るとわかりやすいだろう。

"A1".normalize("NFKC")
// "A1" (全角英数を半角に)
"ア".normalize("NFKC")
// "ア" (半角カナを全角に)
"①".normalize("NFKC")
// "1" (囲み文字を数字に)
"㌶".normalize("NFKC")
// "ヘクタール" (互換用文字をカタカナに)

特に全角半角文字の正規化は、入力文字列のクレンジングなどにも使うことができる。

CodePoint によって文字数を数える観点からは、NFC または文脈によっては NFKC によって最小の CodePoint を数える方が一般的だろう。

しかし、注意点もあり、例えば (ローマ数字 3)は III (I が三つ)になったりなどが、入力者によって意図しない変換である場合もあるため、安易な変換には注意が必要だ。

ZWJ

UPDATE: macOS/iOS 17.4.1 での family 絵文字変更

2024 年 3 月の macOS/iOS 17.4.1 から family の絵文字が性別などを明示しないものに変更になったため、以下の内容がわかりにくくなってしまった。

参考までに、変更前の "family with mother father son daughter" の絵文字のデザインを以下に貼っておく。

family with mother father son daughter

他にも 👨‍👩‍👧‍👦 も合字を利用している。

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

この絵文字は "family with mother father son daughter" という名前の文字で、4 つの絵文字が合成されてできている。

家族は多様なので別の組み合わせもある。

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

これは、👨‍👩‍👧‍👦 という絵文字自体が、👨, 👩, 👧, 👦 という 4 つの絵文字とそれを結合する制御文字でできているからである。

この制御文字を ZWJ(ZERO WIDTH JOINER) といい、ZWJ の Code Point は 0x200D だ。

先ほどのように「文字の数を数える == Code Point の数を数える」としてしまえば、👍🏻 は 2 文字で 👨‍👩‍👧‍👦 は 7 文字ということになる。しかし、おそらく多くの人がこれらを 1 文字と捉えるだろう。

Unicode Text Segmentation

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

この カーソルが 1 つ移動する分 を Grapheme と言い、Code Point の列の中から、書記素クラスタの区切りを判別する方法は Unicode の中に定義されている。

この処理はそれなりに難しいものであるため、取り入れる際はライブラリを利用する方が良いだろう。

(どのライブラリが枯れているかは知らないため、ここでは紹介しないが、実装自体は探せばいくつかある。)

なお、JavaScript に関しては、TC39 にこれを標準で入れるというプロポーサルが上がっており、執筆時は Stage 3 である。

これを用いると以下のように実現できる。

const segmenter = new Intl.Segmenter("ja-JP", { granularity: "grapheme" })
const segments = [...segmenter.segment("👪")]
// [{ "segment": "👪", "index": 0, "input": "👪" }]

DEMO

動作する DEMO を以下に用意した。