Spannable芸〜Unicode絵文字の表記長〜


ハロー、ノハナ社の @hiroyuki-seto です
Android Advent Calendar 2018の13日目です。
みなさん大好き1 絵文字の話です。

導入

今日は父親の誕生日なので、家族の話をしましょう。

👪 のような表記の絵文字があったとします。
この絵文字は String.length で何文字になるでしょうか?
答えは 28 などの可能性があります。

なぜこうなるのか

👪 (Family)はUnicodeで U+1F46A で定義されています。
これはサロゲート文字と呼ばれ、U+D83DU+DC6A という2つの文字を合わせてできているのでString.lengthの値は2になります。

また、Unicodeには複数の絵文字を組み合わせて1つの絵文字にする機能が定義されています。
Familyは👨(Man, U+1F468)とZWJ(U+200D)と👩(Woman, U+1F469)とZWJ(U+200D)と👦(Boy, U+1F466)を組み合わせても表現でき、この場合のString.lengthの値は8になります。

表記長を数えたい

弊社ではノハナというフォトブックサービスをやっています。
フォトブックの各ページでは写真にコメントをつけることができ、絵文字を入力することもできます。
コメントは32文字まで入力できるのですが、この「32文字」は String.length の値ではなく、当然目に見える文字数、表記長です。

頑張って数える

サロゲート文字についてはCharacter.isHighSurrogate(Char)Char.isHighSurrogate()Character.isSurrogatePair(Char, Char)などを使うと判定することができます。
Character.codePointCountを使うと、ハイサロゲートとロウサロゲートを合わせて1文字とカウントしてくれます。

複数の絵文字を組み合わせる件ですが、UnicodeではRecommended Emoji ZWJ Sequencesというものを公開しています。
これを完全に再現する実装をすれば対応できます。
たかだか777個しかないので、全部実装できますね!
いや、やりたくないですね..

BreakIterator

BreakIteratorというものがあります。文字と文字の境界を判定してくれるクラスです。

こんな感じにすると、大半の絵文字はちゃんとカウントしてくれます。

fun CharSequence.getDisplayLength(): Int {
    val instance = BreakIterator.getCharacterInstance()
    instance.setText(text.text.toString())
    instance.first()

    var displayLength = 0
    while (instance.next() != BreakIterator.DONE) {
        displayLength++
    }
    return displayLength
}

しかし、💑 (Couple With Heart)を U+1F469U+200DU+2764U+FE0FU+200DU+1F468 で表記した場合に 2 が返ってきてしまいます。
これは色々と問題になります。

EmojiCompatとSpannable

本題です。
EmojiCompatを使うと、比較的簡単に解決することができます。

EmojiCompatでは、文字列にEmojiSpanを設定することによって絵文字の表示を実現しています。
また、EmojiCompatはRecommended Emoji ZWJ Sequencesの内容を実装しており、複数の絵文字を結合した場合でも適切に表示することができます。
詳しい仕組みについては、takahiromさんのDroidKaigiの資料を参考にしてください。

👪の例で言うと、U+1F46AU+1F468U+200DU+1F469U+200DU+1F466という文字列に1つのEmojiSpanを設定しています。
つまりEmojiSpanがかかっている文字列を1文字とカウントすれば絵文字の表記長を数えることができます。

コードにするとこんな感じです。

fun CharSequence.getDisplayLength(): Int {
    val codePointCount = Character.codePointCount(this, 0, length)
    val text = EmojiCompat.get().process(SpannableString(this))
    if (text !is Spannable) {
        //文字長が0の場合など
        return codePointCount
    }

    val spans = text.getSpans(0, text.length, EmojiSpan::class.java)
    if (spans.isEmpty()) {
        //絵文字が含まれていない場合
        return codePointCount
    }

    val string = this.toString()
    var displayLength = 0
    var i = 0
    while (i < text.length) {
        val span = spans.firstOrNull { i == text.getSpanStart(it) }
        if (span == null) {
            //charCountの分だけを1文字とみなす
            val codePoint = string.codePointAt(i)
            val count = Character.charCount(codePoint)
            i += count
        } else {
            //EmojiSpanのかかっているindexまでを1文字とみなす
            i = text.getSpanEnd(span)
        }
        displayLength++
    }

    return displayLength
}

対応できないこと

EmojiCompatが対応していない絵文字

EmojiCompatがSpanを設定できないので、当然文字が数えられません。
この記事を書いた時点では、Emoji 11には対応できていません。
Emoji11の機能である「髪の色を変更」した場合は正しく表記長をカウントできないと思います。

おわりに

お分かりいただけたとおり、Spannableは非常に便利なクラスです。
ただし中毒性があり、黒魔術を生み出す可能性があるので用法用量を守って正しく使いましょう。

DroidKaigi2019では弊社の @tacke_jp がUnicode絵文字についてお話しするのでご期待ください。

おまけ

Swift4では

//👪
"\u{1F46A}".count //1が返ってくる
//複数の絵文字を組み合わせた👪
"\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F466}".count //1が返ってくる


  1. (要出典)