文字列内の絵文字を見た目通りにカウントしたい


文字列中の絵文字の数を見た目通りに正確にカウントするにはかなり障害が多いというよくある話。

Ruby を使って正確なカウントの手段を検討してみる。

正直、絵文字の世界は複雑怪奇なので考慮漏れやもっと良い方法があるかもしれない。

よって、最後には今回の検討の結果としてコードを記載するが、本記事はこれの動作を別段保証するものではないことはご留意いただきたい。

# 絵文字の特定
まず、そもそも単純に与えられた文字列の内のどれが絵文字なのかが判別できなければならない。

## Unicode プロパティ
そこで、今回は Unicode プロパティによる文字クラス指定を利用してみる。

[Ruby リファレンスマニュアル - 正規表現 - Unicode プロパティによる文字クラス指定](https://docs.ruby-lang.org/ja/latest/doc/spec=2fregexp.html#charclass_prop)
[鬼雲 Doc - Character Property](https://github.com/k-takata/Onigmo/blob/master/doc/RE.ja#L85)

Ruby の正規表現エンジン Onigmo では Unicode プロパティを文字クラスとして指定でき、以下のように記述するとすべての漢字にマッチする正規表現文字クラスとなります。

```ruby
regexp = Regexp.new(/\p{Han}/)

'你好'.scan(regexp) do |match|
  puts match
end

# 你
# 好
```

(Han はプロパティというよりは [script](https://www.unicode.org/Public/UNIDATA/Scripts.txt) だが利用可能)

プロパティとして利用可能な項目は onigmo のリポジトリに記載がある
https://github.com/k-takata/Onigmo/blob/master/doc/UnicodeProps.txt

### Emoji

まずは、Emoji というそのものズバリなプロパティがあるのでこれを使ってみる。


```ruby
regexp = Regexp.new(/\p{Emoji}/)

p '😀😁😂1231️⃣2️⃣3️⃣'.scan(regexp).length

# 9
# ただの数字もカウントされる
```

ただの数字も絵文字としてカウントされてしまった。

[unicode.org の emoji-data.txt](https://www.unicode.org/Public/16.0.0/ucd/emoji/emoji-data.txt) によると Emoji プロパティ には数字や矢印などの一部記号などが含まれている。

また、[Unicode® Technical Standard #51 Unicode Emoji](https://www.unicode.org/reports/tr51/
) では 1.4.1 Emoji Characters の項で
> ED-3. emoji character — A character that has the Emoji property.
>
> emoji_character := \p{Emoji}
>
> These characters are recommended for use as emoji.

とされている。

recommended とは難儀な表現だが、このプロパティを指定されたコードポイントの表す文字は絵文字としての表現が可能だと認識している。

### Emoji Presentation

ところで、emoji-data.txt で Emoji のすぐ下にあるのが Emoji Presentation プロパティである。
では 1.4.2 Emoji Presentation に記述があり、これが YES のものはデフォルトで絵文字として表示されるとされている。

つまり、`Emoji = YES` のもののうち、`Emoji Presentation = YES` なものは通常絵文字として表示されていると考えてよいだろう。

ちなみに、数字は `Emoji = YES` かつ `Emoji Presentation = NO` が指定されている。
https://util.unicode.org/UnicodeJsps/character.jsp?a=1&B1=Show


ではこちらのプロパティを条件にしてみると。

```ruby
regexp = Regexp.new(/\p{Emoji_Presentation}/)

p '😀😁😂1231️⃣2️⃣3️⃣'.scan(regexp).length

# 3
# 数字の絵文字がカウントされない
```

大方の予想通りであるとは思うが、今度は絵文字として表示されている数字が取れなくなった。


## VARIATION SELECTOR-16

ここで、`1`と`1️⃣`のコードポイントを見比べてみる。

```ruby
p '1'.codepoints.map{|cp| sprintf("U+%04X", cp) }
p '1️⃣'.codepoints.map{|cp| sprintf("U+%04X", cp) }

["U+0031"]
["U+0031", "U+FE0F", "U+20E3"]
```

`U+20E3` は Combining Enclosing Keycap というやつなのだが、今回は話に絡んでこないのでおいておく。
肝心なのは `U+FE0F` である。

これは VARIATION SELECTOR-16 というものであり、Unicode で絵文字が定義されている文字を絵文字として表示する制御文字である。

(もう1つ、絵文字に関連する VARIATION SELECTOR として 15 があるが、これは今回触れていない。)

すなわち、`Emoji = YES` で直後に VARIATION SELECTOR-16 が続くものは基本的に我々の目には絵文字として見えていると考えることにした。

## Zero Width Joiner

絵文字のカウントとなると外せないのがこの ゼロ幅接合子(`U+200D`) である。

これは簡単に言うと複数の絵文字を合成して1つの絵文字とするための制御文字といえるだろう。

```ruby
emoji = '👨‍👩‍👧'
p emoji.length
# 5

p emoji.codepoints.map{|cp| sprintf("U+%04X", cp) }
# ["U+1F468", "U+200D", "U+1F469", "U+200D", "U+1F467"]
```

このように、見かけには1文字にしか見えない 👨‍👩‍👧 は3つの絵文字を2つのゼロ幅接合子(`U+200D`)で接合したものである。

だからってこれを3文字とカウントしたくない。
1文字にしか見えないのだから。

## \X (Extended Grapheme cluster)
Ruby の正規表現エンジンには `Extended Grapheme cluster` に対するマッチを行うメタ文字列の用意があるのでこれを利用して、見た目通りの単位で文字列を分解する。

他の環境では Combining Character Sequence (結合文字列/結合文字シーケンス) や Grapheme cluster などで再現してほしい。

```ruby
text = '家族👨‍👩‍👧の絵文字'

p text.scan(/\X/)
# ["家", "族", "👨‍👩‍👧", "の", "絵", "文", "字"]
```

# 絵文字カウント

ここまでの要素をまとめて、絵文字の見た目通りのカウントを試みるのが以下の内容である。

まず、Extended Grapheme cluster で分解し、一塊ずつ次の条件で絵文字か確認
- `Emoji = YES` であり
  - `Emoji Presentation = YES` である
  - または、VARIATION SELECTOR-16(`U+FE0F`)が含まれている

```ruby
text = "次のような、ゼロ幅接合子で結合された絵文字 👨‍👩‍👧 は1文字とカウント
123 のようなただの数字は絵文字として扱われないが、1️⃣2️⃣3️⃣は絵文字として扱われる
🇯🇵 のような、REGIONAL INDICATOR のサロゲートペアで表現される国旗絵文字なども1文字と判定され、
👍🏾 の様に、👍 に EMOJI MODIFIER が付けられ変化した絵文字も1文字とカウントされる。
"
# 合計 7 文字となってほしい

# 絵文字のカウント
emoji_count = text.scan(/\X/).count do |cluster|
  # 絵文字プロパティが true かつ、以下の条件を満たす場合のみカウント
  cluster.match?(/\p{Emoji}/) && (
    cluster.match?(/\p{Emoji_Presentation}/) || # デフォルトで絵文字として表示されるものがいる
    cluster.match?(/\u{FE0F}/) # 異体字セレクタが付与されている
  )
end

puts "絵文字の数: #{emoji_count}"
# 絵文字の数: 7
```

厳密に見るなら、VARIATION SELECTOR-16(`U+FE0F`) はちゃんと絵文字化可能な文字に連続しているか、などを条件とした方がよさそうだが、普通のユーザーがそこまで異常な文字列を送ってくることもあまりないだろうと思い少し手を抜いた。

# 参考
- [Ruby リファレンスマニュアル - 正規表現](https://docs.ruby-lang.org/ja/latest/doc/spec=2fregexp.html)
- [Onigmo/doc/RE.ja](https://github.com/k-takata/Onigmo/blob/master/doc/RE.ja)
- [Onigmo/doc/UnicodeProps.txt](https://github.com/k-takata/Onigmo/blob/master/doc/UnicodeProps.txt)
- [Unicode® Technical Standard #51 Unicode Emoji](https://www.unicode.org/reports/tr51/)
- [Unicode 絵文字にまつわるあれこれ (絵文字の標準とプログラム上でのハンドリング)](https://qiita.com/_sobataro/items/47989ee4b573e0c2adfc)
- [[Ruby] 👨‍👩‍👧‍👦 を 1 文字とみなす方法](https://qiita.com/QUANON/items/2539feed89cee2290664)