この記事では、 JavaScript で文字コードを扱う際に知っておくべき概念である Code Point や Code Unit、サロゲートペア、といったものについて説明していく。
また、具体的にそれらの概念を使ってどのようにコードを書いていくのかについても扱う。
この記事に出てくるコードの動作確認は以下の環境で行った。
- Deno 1.26.0
- TypeScript 4.8.3
Code Point (符号位置)
プログラムで文字を表現する方法は複数あるが、 JavaScript では Unicode という方法を採用している。
Unicode ではあらゆる文字に対して一意の値を割り振ることを目的としており、この値のことを Code Point (符号位置)という。
Code Point は 16 進数の非負整数で、文章中で表記するときは接頭辞としてU+
をつける。
例えばA
という文字の Code Point はU+0041
、あ
はU+3042
、🐶
はU+1f436
として定義されている。
ES2015 で追加されたcodePointAt
メソッドを使うと、任意の文字列リテラルの Code Point を取得できる。
数値リテラルが返ってくるので、 16 進数による表記を得たい場合はtoString
で変換する。
const str = "Aあ🐶"; console.log(str.codePointAt(0)); // 65 console.log(str.codePointAt(0)?.toString(16)); // 41 console.log(str.codePointAt(1)?.toString(16)); //3042 console.log(str.codePointAt(2)?.toString(16)); //1f436
`\u{CodePoint}`
と書くことで Code Point から文字列リテラルを得ることもできる。
console.log(`\u{41}`); // A console.log(`\u{3042}`); // あ console.log(`\u{1f436}`); // 🐶
ES2015 で追加された静的メソッドであるString.fromCodePoint
を使うことでも Code Point から文字列リテラルへの変換を行える。この方法だと Code Point を変数に入れて使うこともできる。
const codePoint = 0x41; console.log(String.fromCodePoint(codePoint)); // A
Code Unit(符号単位)
文字を実際にコンピュータで扱うためには、 Code Point をさらに Code Unit(符号単位)に変換する必要がある。
Code Unit はプログラムにおける文字の内部表現であり、これを元に0
と1
の羅列であるバイト列に変換することで、コンピュータが文字をスムーズに扱えるようになる。
Unicode の Code Point を Code Unit に変換する方法はいくつか定義されているが、 JavaScript では UTF-16 という方法を採用している。
UTF-16 では、 Code Unit を符号なし 16 ビット整数を使って表現する。そのため、 JavaScript の内部においては文字列は、符号なし 16 ビット整数が並んでいるものとして扱われる。
Code Unit も Code Point 同様に 16 進数で表記されることが多い。
符号なし 16 ビット整数の範囲は0000
からFFFF
。
16 ビットは 16 桁の 2 進数なので2 ^ 16 = 65536
であり、FFFF
の 10 進数表記が65535
であるためそうなる。
charCodeAt
メソッドで、任意の文字列リテラルの Code Unit を取得できる。
これもcodePointAt
と同様に数値リテラルが返ってくる。
const str = "Aあ"; console.log(str.charCodeAt(0)); // 65 console.log(str.charCodeAt(0)?.toString(16)); // 41 console.log(str.charCodeAt(1)?.toString(16)); //3042
Code Unit から文字列リテラルに変換する方法も用意されており、`\uCodeUnit`
とString.fromCharCode
がある。
console.log(`\u0041`); // A console.log(`\u3042`); // あ const codeUnit = 0x41; console.log(String.fromCharCode(codeUnit)); // A
サロゲートペア
A
とあ
は Code Point と Code Unit が同じだったが、🐶
は異なる。
そもそもA
やあ
とは異なり Code Unit が 2 つある。
const check = (str: string): void => { const length = str.length; for (let i = 0; i < length; i++) { console.log(i, str.charCodeAt(i).toString(16)); } }; // 0 41 check("A"); // 0 3042 check("あ"); // 0 d83d // 1 dc36 check("🐶");
先程、符号なし 16 ビット整数では65536
個の数を扱えると書いたが、 Unicode が扱う文字の数はそれをゆうに超える。
つまり符号なし 16 ビット整数では、 Unicode が扱う全ての文字を表現することが出来ないのである。
そのため UTF-16 では、 Code Unit をふたつ組み合わせてひとつの文字を表現する方法を導入した。
そのような文字をサロゲートペアと呼ぶ。
🐶
もサロゲートペアである。そのため、 Code Unit がふたつあった。
// U+1f436(🐶)は d83d と dc36 の組み合わせで表現される console.log(`\ud83d\udc36`); // 🐶 console.log(String.fromCharCode(0xd83d, 0xdc36)); // 🐶
一方でA
とあ
はひとつの Code Unit で表現されており、サロゲートペアではない。
このように UTF-16 においては、ひとつの Code Unit で表現する文字と、ふたつの Code Unit で表現する文字が混在している。
UTF-16 による変換ロジック
Code Point から Code Unit への変換は、定義された所定のロジックで行われる。
まず、U+10000
からU+10FFFF
の Code Point がサロゲートペアになり、それ以外の Code Point は Code Point がそのまま Code Unit になる。
サロゲートペアの場合、 2 進数表記の Code Point をゼロパディングして 24 桁にする。
そして以下の表の変換ロジックで、ふたつの 16 ビットのビット列に変換する。
Code Point | UTF-16 | 備考 | |
---|---|---|---|
ロジック | 000uuuuuyyyyyyxxxxxxxxxx |
110110wwwwyyyyyy 110111xxxxxxxxxx |
wwww = uuuuu - 1 |
U+1f436(🐶) | 000000011111010000110110 |
1101100000111101 1101110000110110 |
0000 = 00001 - 1 |
U+1f436(🐶)
の例も合わせて書いておいた。
1f436
をビット列(2 進数)で表現すると11111010000110110
なので、それをゼロパディングした000000011111010000110110
から変換ロジックが始まる。
そして変換を行うと、U+1f436
の Code Unit は1101100000111101
(d83d
)と1101110000110110
(dc36
)の組み合わせになる。
このロジックを TypeScript で雑に実装すると以下のようになる。
const encode = (codePoint: string): [string] | [string, string] => { const decimalCodePoint = parseInt(codePoint, 16); const isSurrogatePair = decimalCodePoint >= 0x10000 && decimalCodePoint <= 0x10ffff; if (!isSurrogatePair) { return [codePoint]; } const scalar = decimalCodePoint.toString(2).padStart(24, "0"); const u = scalar.substring(3, 8); const x1 = scalar.substring(8, 14); const x2 = scalar.substring(scalar.length - 10); const w = (parseInt(u, 2) - 1).toString(2).padStart(4, "0"); return [ parseInt(`110110${w}${x1}`, 2).toString(16), parseInt(`110111${x2}`, 2).toString(16), ]; }; console.log(encode("0041")); // [ "0041" ] console.log(encode("3042")); // [ "3042" ] console.log(encode("1f436")); // [ "d83d", "dc36" ] console.log( String.fromCharCode( ...encode("1f436").map((codeUnit) => parseInt(codeUnit, 16)) ) ); // 🐶
文字列リテラルとバイト列の相互変換
Web API の機能を使うことで、文字列リテラルとバイト列の相互変換を行える。
文字列リテラルからバイト列への変換にはTextEncoder
を使う。
TextEncoder
インスタンスのencode
メソッドは文字列リテラルを受け取り、それを UTF-8 でエンコードしたUint8Array
を返す。
const encoder = new TextEncoder(); console.log(encoder.encode("A")); // Uint8Array(1) [ 65 ] console.log(encoder.encode("あ")); // Uint8Array(3) [ 227, 129, 130 ] console.log(encoder.encode("🐶")); // Uint8Array(4) [ 240, 159, 144, 182 ]
UTF-8 では Code Unit を符号なし 8 ビット整数で表現し、ひとつの文字を 1 ~ 4 つの Code Unit で表現する。
そのため、あ
や🐶
のケースを見れば分かるように、 UTF-16 による表現とは一致しないので注意する。
バイト列から文字列リテラルへの変換はTextDecoder
で行える。
コンストラクタの引数にはエンコーディング形式を渡すことができ、省略した場合はutf-8
になる。
utf-8
を指定したTextDecoder
インスタンスのdecode
メソッドに、 UTF-8 でエンコードされたUint8Array
を渡すと、デコードした文字列リテラルが返ってくる。
const decoder = new TextDecoder("utf-8"); console.log(decoder.decode(new Uint8Array([65]))); // A console.log(decoder.decode(new Uint8Array([227, 129, 130]))); // あ console.log(decoder.decode(new Uint8Array([240, 159, 144, 182]))); // 🐶 console.log( decoder.decode(new Uint8Array([65, 227, 129, 130, 240, 159, 144, 182])) ); // Aあ🐶