絵文字👨🏻‍🦱は何文字としてカウントする？関連する文字コードの仕様を詳しく調べてみた

## はじめに

この記事は、[NTTコムウェア Advent Calendar 2021](https://qiita.com/advent-calendar/2021/nttcomware) 8日目の記事です。

文字コードは魔境です。そんな魔境の一部である絵文字について調べました。


## この記事に書かれていること

- Unicode の絵文字、特に合字（リガチャ）として表現される絵文字の仕組み
- リガチャと似たような見た目と文字数の異なる文字コードの仕様（サロゲートペア、結合文字列）
- 絵文字を含んだ文字はどうバリデーションすればよいのか


## 問題

以下のような文字列は、何文字としてカウントすればよいでしょうか？（文字コードはUTF-8とします）
また、バリデーションする上ではどのような点に注意すべきでしょうか？

```
カーリヘアの白人男性👨🏻‍🦱
```

※環境によっては絵文字が正しく表示されないかもしれません。画像にすると以下のようになっています（Windows 10 / Firefox を使用）。

![当該文字列の画像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2318198/9fe54153-41ef-3c49-b048-9593f62bddcc.png)


## 実際にカウントしてみる

業務においては「1文字の定義」をまず確認した方が良いと思いますが、ここではひとまずカウントしてみましょう。


### 見た目でカウント

見たままを数えると11文字です。

| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|
| カ | ー | リ | ヘ | ア | の | 白 | 人 | 男 | 性 | 👨🏻‍🦱 |


### プログラムでカウント

各種プログラミング言語で文字数をカウントするプログラムを作って調べてみました。

| プログラミング言語 | カウントに使った関数 | 結果 |
|---|---|---|
| Python 3 | len() | 14 |
| Java 12 | String.length() | 17 |
| PHP 7 | mb_strlen() | 14 |
| Swift 4 | String.count | 11 |
| Swift 4 | String.unicodeScalars.count | 14 |
| Swift 4 | String.characters.count | 11 |

結果はバラバラになりました。次に絵文字 `👨🏻‍🦱` の部分を消してカウントしなおしてみましょう。

| プログラミング言語 | カウントに使った関数 | 結果 |
|---|---|---|
| Python 3 | len() | 10 |
| Java 12 | String.length() | 10 |
| PHP 7 | mb_strlen() | 10 |
| Swift 4 | String.count | 10 |
| Swift 4 | String.unicodeScalars.count | 10 |
| Swift 4 | String.characters.count | 10 |

10文字で一致したことから、絵文字がカウントの差を生んでいることが分かりました。なぜこのような差が生まれるのでしょうか？


## 合字（リガチャ）は複数の文字を見た目上1文字にしている

UTF-8をうまく扱えるプログラミング言語であれば、絵文字は1つ1文字とカウントしてくれます。しかし、中には「見た目上は1文字だが、文字コードとしては複数文字である」という文字があります。その1つが合字（リガチャ）です。

今回の `👨🏻‍🦱` という絵文字も、合字が使われています。1文字ずつに分解すると以下のようになります。

| 文字順 | 絵文字内容 | 絵文字グリフ | Emojipedia リンク |
|---|---|---|---|
| 1 | 男性 | `👨` | [Man Emoji](https://emojipedia.org/man/) |
| 2 | ZWJ | `(表示不可)` | [‍Zero Width Joiner (ZWJ) Emoji](https://emojipedia.org/zero-width-joiner/) |
| 3 | 明るい肌 | `🏻` | [Pale Skin Tone Emoji](https://emojipedia.org/light-skin-tone/) |
| 4 | ZWJ | `(表示不可)` | [‍Zero Width Joiner (ZWJ) Emoji](https://emojipedia.org/zero-width-joiner/) |
| 5 | カーリヘア | `🦱` | [Emoji Component Curly Hair Emoji](https://emojipedia.org/curly-hair/) |

上記のように合字は「Zero Width Joiner（ZWJ）」という制御文字で絵文字同士を結合[^1]しています。数式のように書くと以下のようになります。

```
👨　+ ZWJ +　🏻　+ ZWJ +　🦱　=　👨🏻‍🦱
```

[^1]: Emojipedia で各文字を1文字ずつコピーできます。1文字ずつ適当なテキストエリアにコピペしていくとお手元の環境でも合字を組み立てることができます。

このZWJを使って表現可能な絵文字は、[Emoji ZWJ (Zero Width Joiner) Sequences](https://emojipedia.org/emoji-zwj-sequence/)に一覧があります。

ZWJによる合字の絵文字はUnicodeによって定義されています。この定義のよれば合字によって幅広い絵文字を表現可能としており、将来合字によってさらに多くの絵文字が表現できる可能性があります。例えば、肌の色や性別、髪形だけでなく、以下のような絵文字（[Couple with Heart: Woman, Woman Emoji](https://emojipedia.org/couple-with-heart-woman-woman/)）も存在します。片方を男性に変えたり、肌の色を変えたりもできます。

```
例1：👩 + ZWJ + ❤️ + ZWJ + 👩 = 👩‍❤️‍👩

例2：👩 + ZWJ + ❤️ + ZWJ + 👨 + ZWJ + 🏿 = 👩‍❤️‍👨🏿
```



ここでプログラムでのカウント結果をもう一度確認してみます。結果が11文字となっているものは見た目通りにカウントしていると言えます。一方、ZWJも1文字とカウントするならば、前述の通り `👨🏻‍🦱` は5文字とか数えることもできます。つまり `カーリヘアの白人男性👨🏻‍🦱` を15文字としてカウントすることが予想できます。しかし実際には15文字の結果はなく、14文字または17文字と予想と異なります。何故でしょうか？


## ZWJを使わない絵文字の結合

実はZWJなしでも絵文字が結合される場合があります。（後述の結合文字に似ています。）これが話をややこしくしています。

例えば、肌の色はZWJを挟まずとも他の絵文字と結合することがあります。Unicodeでは「[Modifier Sequences](https://emojipedia.org/emoji-modifier-sequence/)」と呼ばれています。

```
例：👍 + 🏽 = 👍🏽 (ZWJを挟まずとも肌の色が変わる)
```

つまり、絵文字 `👨🏻‍🦱` は以下の2パターンあるということです。

```
(1) 👨　+ ZWJ +　🏻　+ ZWJ +　🦱　=　👨🏻‍🦱  # 5 文字の合字
(2) 👨　      +　🏻　+ ZWJ +　🦱　=　👨🏻‍🦱  # 4 文字の合字
```

前述の問題に使われていた `👨🏻‍🦱` は実は (2) の4文字からなる合字でした。つまり14文字とカウントしたプログラムは正しく合字を分解して1文字ずつ判断したと言えるでしょう。[^2]

[^2]: 今回は文字コード仕様理解のため結果からプログラムの動作を推測していますが、実際使う際は各メソッド・関数の仕様をマニュアルなどで正しく把握して使いましょう。


## 合字とよく似た仕様の文字

### サロゲートペア

初期のUnicodeは1文字2バイトで表現しようとしていましたが、すぐに2バイトでは足りなくなったため拡張されました。しかし元々Unicodeは2バイトで表現できるという前提で作られたUTF-16で不都合が起こります。そこでUnicodeとUTF-16は未使用のコード範囲の文字を2文字分使うことで1文字4バイトで表現する仕様、サロゲートペアを作りました。

サロゲートペアのエンコーディングは少々複雑なので割愛しますが、見た目もUnicode定義も1文字です。しかしUTF-16でサロゲートペアをエンコードすると、文字コード仕様上は2文字とも言える表現になっています。そしてUTF-16では絵文字もサロゲートペアで表されます。

なお今回の問題としているのはUTF-8では、サロゲートペアは存在しません（UTF-8は1文字4バイトでも表現可能のため）。


### 結合文字列

UnicodeにはCombining Character（結合文字）という仕様があります。これは日本語では濁点・半濁点が当てはまります。例えば以下のようなものです。

```
か + (濁点の結合文字) = が
```

このように結合された文字列、結合文字列は見た目上は1文字ですが、文字コードとしては2文字になります。合字と似ていますが、ZWJを必要としません。また、結合文字単独で使うことは想定されていません。

さらにUnicodeでは結合文字列の「が」と結合文字列ではない「が」の両方が存在します。つまり、見た目上は一緒でも結合文字列かどうかで文字コード上は1文字か2文字か変わるのです。これも文字列のカウントをややこしくしています。


## プログラムで文字列をカウントする意味

プログラムでは文字数のバリデーションすることが非常によくあります。バリデーションをする主な理由は、DBのカラム定義などシステム上の上限値を超えないようにするためです。

例えばMySQLのVARCHAR型は、そのカラムの文字コードがutf8mb4である場合[^3]、UTF-8としての1文字を長さの単位として扱えます。つまり `VARCHAR(50)` ならUTF-8で50文字が上限のカラムとなります。バイト単位ではありません。UTF-8での1文字を単位とするので、合字や結合文字は個別の文字に分解した後の文字数を見ます。[^4]

[^3]: カラムの文字コードがutf8mb3（UTF-8の3バイトで表現できる文字しか扱えない）である場合は絵文字（UTF-8で4バイト表現の文字）は扱えません。デフォルトがutf8mb4なので、あまり気にする必要はありませんが。

[^4]: 余談ですが、MySQL 5.6などの標準設定では、カラムではなくインデックスに767バイトの制限があり、思った長さの文字列を入れられないという事があります。インデックスのフォーマットを `Barracuda` に変更することでこの上限は回避することが可能です。MySQL 5.7 以降では標準で `Barracuda` となっています。参考: [MySQLのインデックスサイズに767byteまでしかつかえない問題と対策 - ハマログ](https://blog.e2info.co.jp/2017/04/17/mysql%E3%81%AE%E3%82%A4%E3%83%B3%E3%83%87%E3%83%83%E3%82%AF%E3%82%B9%E3%82%B5%E3%82%A4%E3%82%BA%E3%81%AB767byte%E3%81%BE%E3%81%A7%E3%81%97%E3%81%8B%E3%81%A4%E3%81%8B%E3%81%88%E3%81%AA%E3%81%84/)

もしバイト数の上限値があるなら、文字コード表現でのバイト数を数えればよいです。この場合はプログラミング言語内部の文字表現や組み込み関数の仕様に気を付けてカウントする必要があります。

いずれの場合もGUIでの文字列の見た目と、文字カウント数が異なるパターンが出てきます。ユーザが混乱しないようにUIやメッセージの工夫でなんとかしたいところです。

悪い例として、絵文字の入力を禁止するという方法が考えられます。今回は絵文字にフォーカスしましたが、サロゲートペアや結合文字など他にも厄介な文字表現が沢山あります。安易な考えで入力されたくない文字を禁止にするだけでは予想しない文字が入ってくることがあります。各種文字コードの仕様やDBなどシステムを構成を正しく把握してバリデーションを行いましょう。


## まとめ

- 合字
    - 複数の文字（絵文字）をZWJで結合して1文字に見せている 
    - ZWJ含め、合字を構成する文字1つひとつを1文字とカウントすると見た目上の文字数と異なる。
    - 肌の色はZWJを使わず結合するため、👨🏻‍🦱は4文字、5文字どちらでも表現できてしまう。何文字か知るには文字コード単位で数えてみるしかない。
- サロゲートペア
    - UTF-8では使われない。
    - UTF-16を使う場合、サロゲートペアは見た目上1文字だが、2文字分のバイト数となる。
    - UTF-16で絵文字はサロゲートペアとして表される。
- 結合文字列
    - 前の文字にくっつく文字。
    - 文字としては1文字分。
    - 濁音・半濁音の結合文字も存在するが、混乱のもとになるだけなので使わない方が無難だし普通にしてれば使われない。


## あれ、Javaが17文字なのは何で？？

解説が長くなったので割愛しましたが、Javaは内部的に文字をUTF-16として扱います。サロゲートペア1文字は2とカウントします。つまり、👨🏻‍🦱は以下のように合計7文字としてカウントされました。10+7=17文字です。

| # | 絵文字 | 文字分類 | String.length() |
|---|---|---|---|
| 1 | `👨` | サロゲートペア | 2 |
| 2 | `🏻` | サロゲートペア | 2 |
| 3 | ZWJ | 制御文字 | 1 |
| 4 | `🦱` | サロゲートペア | 2 |


## 参考

- [Unicode - Wikipedia](https://ja.wikipedia.org/wiki/Unicode)
- [Emoji ZWJ Sequences: Three Letters, Many Possibilities](https://blog.emojipedia.org/emoji-zwj-sequences-three-letters-many-possibilities/)
    - ZWJによる絵文字結合の解説
- [ゼロ幅接合子 - Wikipedia](https://ja.wikipedia.org/wiki/%E3%82%BC%E3%83%AD%E5%B9%85%E6%8E%A5%E5%90%88%E5%AD%90)
    - そもそもZWJはアラビア文字などの組版などのために存在した制御文字らしい。へー。
- [Unicode Emoji v14.0](https://www.unicode.org/emoji/charts-14.0/index.html)
    - UnicodeのEmoji仕様が書かれている。
- [合成済み文字 - Wikipedia](https://ja.wikipedia.org/wiki/%E5%90%88%E6%88%90%E6%B8%88%E3%81%BF%E6%96%87%E5%AD%97)
    - 結合文字の別名。
- [Unicodeの特殊な文字 “結合文字列” – ものかの](https://tama-san.com/combining_character_sequence/)
    - Mac OS Xでは濁点・半濁点に結合文字が使われていたことがあった。
- [UTF-8の文字コード表 - 備忘帳 - オレンジ工房](https://orange-factory.com/dnf/utf-8.html)
    - 4バイト文字に絵文字が入っている。
- [Swift3で絵文字を含んだ文字列の長さを取得する方法 - Qiita](https://qiita.com/aqubi/items/032e9a07b856150de46a)
- [String (Java SE 12 & JDK 12 )](https://docs.oracle.com/en/java/javase/12/docs/api/java.base/java/lang/String.html)