42
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

AsiaQuestAdvent Calendar 2019

Day 11

僕は、なぜ絵文字の長さが、直感に反するのか理解したい...!!

Last updated at Posted at 2019-12-12

対象者

  • UnicodeやUTF-16について、よくわかってない人 -> ここから
  • "😀".split("")で文字化けする理由がわからない人 -> ここから
  • [..."👨‍👩‍👧"].lengthが5になる理由がわからない人 -> ここから

文字コードについてもう一度

文字コードは以下の二つで構成されています

  • 符号化文字集合: 文字と、その文字の位置を示す一意の番号の集合
  • 文字符号化方式: 文字に振られた番号をバイト表現にエンコードする方法

符号化文字集合

符号化文字集合は、

  • 文字
  • その文字の位置を示す一意の番号

この二つの組み合わせの集合のことを指します。

"A".codePointAt() 
// 65 ← これは10進数。16進数だと41。

例えばASCIIでは 8bit(128通り) でラテン文字や英数字を表現しています。

しかしASCIIには日本語などの非英語圏の文字が収録されていません。
そのため、日本語を収録したShift-JISやアジア圏の文字を収録したEUCといった規格ができました。

ただ、規格が乱立したため、グローバルスタンダードを作ろうということになり
世界中の文字を収録した Unicodeが誕生しました。

Unicodeとサロゲートペア

ASCIIは128個の文字集合なので、8bitで収まりましたが、
Unicodeでは世界中の言語を収録するため、8bit(128通り)では表現できなくなりました。

そこで当初は16bit(65536通り、U+0000~U+FFFF) で全ての文字を表現しようと試みました。

しかし、残念なことに世界中の文字は65536通りで収まりませんでした。
(中華字海という中国で出版された漢字辞典には85568字収録されてるみたいです)

そこで16bitのUnicodeの領域の2048文字のうち

  • 前半の U+D800 ~ U+DBFF
  • 後半の U+DC00 ~ U+DFFF

を2つ組み合わせて使うことで、
1024 * 1024 = 1048576通り(U+010000~U+10FFFF) に表現できる文字が増えました。
この2つで1つの文字を表すことを、サロゲートペア と呼びます。

Memo: U+XXXXは Unicodeスカラ値と呼ばれ、ユニコードのコードポイント(その文字の位置を示す一意の番号)を示します。XXXX の部分は16進数で表現されます

Memo: 16bit(U+0000~U+FFFF)で表現できる文字を基本多言語面(BMP)、残り(U+010000~U+10FFFF)を追加多言語面(SMP)と呼びます

"A".codePointAt() // 10進数 65、スカラ値 U+0041
"".codePointAt() // 12354、U+3042

文字符号化方式

文字符号化方式 とは
8bitで収まらなくなった文字集合をどのようにエンコードするのかを指定しています。
例えば、

  • UTF-8: 英数字は8bitで表現して、それ以外は16,24,32bitで可変的に表現する
  • UTF-16: 基本多言語面で表現できるものもは全て16bitで表現して、
    サロゲードペアが必要なら32bitで表現する

のようなものが有名です

では、という文字をutf-8とutf-16でエンコードして出力してみます

echo -n あ| iconv -t utf-8 | hexdump -C
e3 81 82

echo -n あ| iconv -t utf-16 | hexdump -C
30 42

このように文字符号化方式(エンコード方法)によって、符号化される値が異なることがわかります。

Memo: 日本語だけなら上記を見ればわかるようにutf-8よりutf-16の方がサイズは大きくなります

誤解を招きやすいASCII

utf-8はASCIIと互換性があり、
U+0000~U+007FはASCIIに対応しています。

#utf-8の文字列をasciiにエンコードして出力
echo -n a | iconv -f utf-8 -t ascii | hexdump -C
61

#asciiの文字列をutf-8にエンコードして出力
echo -n a | iconv -f ascii -t utf-8 | hexdump -C
61

ASCIIは8bitで表現されており、サロゲートペアもないため、文字符号化方式(エンコード)する必要がありません。

そのため冒頭の

また文字コードは以下の二つで構成されています

  • 符号化文字集合: 文字と、その文字の位置を示す一意の番号の集合
  • 文字符号化方式: 文字に振られた番号をバイト表現にエンコードする方法

後者の文字符号化方式がない、符号化文字集合だけで表現できる文字コードもあるということになります.

参考

Unicodeについて

es2015で絵文字の長さを取得する

jsのStringはutf-16で表現されています.
またサロゲートペアを表現する方法は、

  • \uXXXX: utf-16のコードポイント使用する
  • \u{X}: utf-32のコードポイントを使用する

などがあります

"\uD83D\uDE00" // 😀
"\u{1F600}" // 😀
"\uD83D\uDE00" === "\u{1F600}" // true

また、String.lengthはUTF-16のコード単位の数を返しています.
ここらへん


"😀".length // 2

この絵文字(😀)はコードポイントが U+1F600 で表されており、サロゲートペアで表現されます.
そのため内部的には16bitが2つで表現されているため、文字列の長さが2になります.

また、splitすると文字化けする原因もサロゲートペアで表現していることに起因します.
String.prototype.split([separator]) のseparatorに ""(empty string) を渡すと、
文字列は個々のコード単位に分割されます.
Note1のところ

そのため、サロゲートペアが分割されてしまい、文字化けが生じてしまいます.


"😀".split("") // ["�", "�"]
JSON.stringify(["\ud83d","\ude00"]) === JSON.stringify("😀".split("")) // true

ではこの文字列の長さを1として取得するにはどうするかというと、
es2015から導入された、

  • Spread Operator
  • Array.from

を使えばある程度は正しく長さを取得できます

const str = "hoge" + "😀"
[...str].length // 5
Array.from(str).length // 5

絵文字を合成する

しかし、絵文字の長さが想定とことなる挙動をする場合もあるので注意が必要です.


// 同じに見える
[..."🕵"].length // 1
[..."🕵️"].length // 2
[..."🕵️‍♂️"].length // 5

// 肌の色が異なる
[..."👋"].length // 1
[..."👋🏻"].length // 2

// すごい多い
[..."👨‍👩‍👧"].length // 5

絵文字が1つなのにlengthが異なっています.
実はこれ


// 同じに見える
[..."\u{1F575}"].length // 1
[..."\u{1F575}\u{FE0F}"].length // 2
[..."\u{1F575}\u{FE0F}\u{200D}\u{2642}\u{FE0F}"].length //5

// 肌の色が異なるだけ
[..."\u{1F44B}"].length // 1
[..."\u{1F44B}\u{1F3FB}"].length // 2

// すごい多い
[..."\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"]

のように内部ではいくつかの文字を組み合わせて表現されています.

このように絵文字は、修飾したり、絵文字同士を組み合わせたりすることができます.
以下では様々な組み合わせを紹介していきます

プレゼンテーション(Emoji Presentation)

特定の絵文字では、
テキスト表現になる U+FE0E(text presentation sequence)と
絵文字表現になる U+FE0F ((emoji presentation selector)があります

特定の絵文字にプレゼンテーションを付与することで、表現を切り替えることができます

↩︎ \u{21A9}\u{FE0E}
↩️ \u{21A9}\u{FE0F}

左側がテキスト表現、右側が絵文字表現です
テキスト表現と絵文字表現の一覧はここで見れます

修飾詞(Emoji Modifiers)

修飾詞にはSkin Toneが入っています
🏻🏿
このSkin Toneを用いて、絵文字の色を変えることができます


"\u{270A}" // ✊
"\u{270A}\u{1F3FB}" // ✊🏻
"\u{270A}\u{1F3FF}" // ✊🏿

トーン絵文字の一覧はここで見れます

シーケンス(Emoji Sequences)

zwj (zero with joiner)

zwj(\u{200d}) は絵文字と絵文字を組み合わせるため使用します.

人間の形をした絵文字に、zwjを使って、性別を追加することができます(human-form + sign)

"\u{1F646}" // 🙆

"\u{2640}" // ♀
"\u{1F646}\u{200D}\u{2640}\u{FE0F}" // 🙆‍♀️

"\u{2642}" // ♂
"\u{1F646}\u{200D}\u{2642}\u{FE0F}" // 🙆‍♂️

human-formの一覧(person-XXXのperson-role以外)
person
person-fantasy
person-activity
person-sport
person-resting

Memo: 文末に \u{FE0F} がなくても、正しく表示されます. 推奨されている挙動はここを確認してみてください

また、職業にも性別を追加することができます(gender + object)

"\u{1F4BB}" // 💻
"\u{1F468}" // 👨
"\u{1F469}" // 👩
"\u{1F468}\u{200D}\u{1F4BB}" //👨‍💻
"\u{1F469}\u{200D}\u{1F4BB}" // 👩‍💻

// 一応性別不詳も用意されていますが、僕の環境では正しく表示できませんでした
"\u{1F9D1}" //🧑
"\u{1F9D1}\u{200D}\u{1F4BB}" //🧑‍💻

roleの一覧
person-role

さらに、人と人を組みわせて、家族の絵文字を作ることもできます

"\u{1F468}" // 👨
"\u{1F469}" // 👩
"\u{1F466}" // 👦
"\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F466}" //👨‍👩‍👦

"\u{1F467}" // 👧
"\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F467}" //👨‍👩‍👧‍👧

zwjを使った絵文字の組み合わせの一覧はここで見れます

キーキャップ(emoji keycap sequence)

数字,#,* の絵文字の後ろに \u{FE0F}\u{20E3} を追加すると1つの絵文字になります


"\u{0023}\u{FE0F}\u{20E3}" // #️⃣
"\u{0036}\u{FE0F}\u{20E3}" // 6️⃣

キーキャップの一覧はここでみれます

国旗は、ISO 3166-1で定義されているコードを、Regional Indicator Symbol Letter と呼ばれる文字を組み合わせて表現します.

"\u{1F1EC}" // 🇬
"\u{1F1E7}" // 🇧
"\u{1F1EC}\u{1F1E7}" //🇬🇧

また、イングランドやフィンランドのような下位区分の場合は

🏴(\u{1F3F4}) + 国コード(GB) + その後に下位区分(ENG) + \u{E007F}

のように指定します.
また 国コード + 下位部分はTag Charactersと呼ばれる文字で表現します.

"\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}" // 🏴󠁧󠁢󠁥󠁮󠁧󠁿

また、zwj を使って、🏳(U+1F3F3)🌈(U+1F308)を組み合わた🏳️‍🌈(Rainbow Flag)もあります

"\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}" //🏳️‍🌈

旗の一覧はここで見れます

参考

qiita -> 絵文字を支える技術の紹介

反復処理プロトコル(Iteration protocols)

では[..."😀"].lengthは 1 になるのに対して、[..."👨‍👩‍👧"].lengthは 5 になる理由を探ってみたいと思います

es2015 から導入された、Array.from[...value]は内部的にはfor-of(反復)の仕組みを使っています.

let arr = [];
for (let str of "😀"){
  arr.push(str)
}
// arr -> ["😀"]
JSON.stringify(arr) === JSON.stringify([..."😀"]) // true

let arr2 = [];
for (let str of "👨‍👩‍👧"){
  arr2.push(str);
}
// arr2 ->["👨", "‍", "👩", "‍", "👧"]
JSON.stringify(arr2) === JSON.stringify([..."👨‍👩‍👧"]) // true

ここから先は es2015 から導入された、オブジェクトを反復するための 反復処理プロトコルについて文字が関係している部分を解説していきます.

反復処理プロトコルとは

  • iterable プロトコル
  • iterator プロトコル

の二つで構成されています。

iterable プロトコル

iterable プロトコルにより、for..of で反復的な処理をするループを作成、カスタマイズできます.

またjavascript オブジェクトは内部的に @@iteratorメソッドを持ちます.
@@iteratorメソッド[Symbol.iterator]で定義する引数なしの関数です.
また、String, Array, TypedArray, Map, Set はすでに定義されています(built-in iterables)

const str = "a"
const $$iterator = str[Symbol.iterator] 
// ƒ [Symbol.iterator]() { [native code] }

const $$strIterator = str[Symbol.iterator]()
// StringIterator {}

iterator プロトコル

この @@iterator関数 は iterator プロトコルに準拠したオブジェクトを返します.
iterator プロトコルは

  • next

という引数なしの関数を返します.
またnext関数は

  • done(boolean): 終了したかどうかのboolean値
  • value: 任意の値. done property が trueの場合は省略

を返します.

const str = "a"
const $$iterator = str[Symbol.iterator]()
$$iterator.next() // {value: "a", done: false}
$$iterator.next() // {value: undefined, done: true}

String.prototype [@@iterator]()

最後にString.prototype [@@iterator]()]についてです.
先ほどのコードにある

const $$strIterator = str[Symbol.iterator]()
// StringIterator {} 

StringIterator の中に答えがあります.
仕様はここ

ここにはStringのbuilt-in iterablesが実行された時の挙動について書かれています.
今回の絵文字のついてはここにある、nextを実行した時が関係しています.

抜粋
10. If first < 0xD800 or first > 0xDBFF or position + 1 = len, let resultString be the String value consisting of the single code unit first.

  1. Else

a, Let second be the numeric value of the code unit at index position + 1 within the String s.

b, If second < 0xDC00 or second > 0xDFFF, let resultString be the String value consisting of the single code unit first.

c, Else, let resultString be the string-concatenation of the code unit first and the code unit second.

つまり、0xD800 <= value[position] <= 0xDBFF かつ 0xDC00 <= value[position + 1] <= 0xDFFF
だった場合は文字列連結をするため、サロゲートペアは1文字、zwjやskinはここに含まれないので1文字としてカウントされてまいます.

// -> 上記のfor-ofのcount数と同じになります

const str = "😀"
const $iterator = str[Symbol.iterator]();
$iterator.next().value // 😀
$iterator.next().value // undefined

const family = "👨‍👩‍👧"
const $$iterator = family[Symbol.iterator]();
$$iterator.next().value //👨
$$iterator.next().value // "" <- zwj
$$iterator.next().value // 👩
$$iterator.next().value // "" <- zwj
$$iterator.next().value // 👧
$$iterator.next().value // undefined

つまり、Array.from[...value]でStringが配列に変換される際にfor-ofが内部で実行されるので、
[..."😀"]["😀"] になり length が 1 になり
[..."👨‍👩‍👧"]["👨", "‍", "👩", "‍", "👧"]になり length が 5 になります

参考

MDN -> 反復処理プロトコル
もっと詳しくiteratorについて知りたい人 -> https://2ality.com/2015/02/es6-iteration.html

まとめ

文字コードから、絵文字の仕様、そしてes2015のイテレーターまで解説しました.
サロゲートペアだけでなく、zwjや、skinを考えると、
絵文字の長さを直感的(grapheme??)に取得するのは相当めんどくさくなります.

フィードバック や レビュー待ってます:muscle:

おーーわりっ!!

42
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?