サイト内を検索

UTF8の絵文字の数をカウントしてみよう

はじめに

こんにちは、ココネでクライアントエンジニアをしているOです。
UIが絡む開発をしていると文字数カウントしながら文字入力させるケースというのはよくあると思いますが、絵文字が絡むと面倒なことが多いです。
例えば👍🏾だったりだったり🇯🇵だったり、ぱっと見はただの一文字のものが、突然2文字,3文字でカウントされたりしてしまいます。
ということで今回はUTF8での特殊な絵文字処理について書いていこうと思います。
最近はC++を主体としているプロジェクトに参加しているのでサンプルコードはC++で書いていこうと思います。

複雑な絵文字の例

最初に例を見ていきましょう。
少し複雑な絵文字はいくつかの文字の組み合わせになっています。
👍🏾という文字はというと
「👍 +🟫U+1F3FE」

    • • 👍 (U+1F44D) – Thumbs Up: F0 9F 91 8D

 

    • 🟫(U+1F3FE) – Medium-Dark Skin Tone: F0 9F 8F BE

と、「サムズアップの絵文字」+「色を表すコード」となっています。

 

という文字がどういう構造しているかというと
「👨 + ZWJ + 👩 + ZWJ + 👦 + ZWJ + 👦」 (ZWJについては後程出てきます)

    • • 👨 (U+1F468) – Man: F0 9F 91 A8

 

    • • ZWJ (U+200D): E2 80 8D

 

    • • 👩 (U+1F469) – Woman: F0 9F 91 A9

 

    • • ZWJ (U+200D): E2 80 8D

 

    • • 👦 (U+1F466) – Boy: F0 9F 91 A6

 

    • • ZWJ (U+200D): E2 80 8D

 

    • 👦 (U+1F466) – Boy: F0 9F 91 A6

バイトコードにすると「F0 9F 91 A8 E2 80 8D F0 9F 91 A9 E2 80 8D F0 9F 91 A6 E2 80 8D F0 9F 91 A6」
となっています。ユーザ目線で見るとただの一文字なのに、25バイトも実は使っているのです。
 
🇯🇵 という文字がどういう構造しているかというと
「U+1F1EF(J) + U+1F1F5(P)」
となっています。(J)(P)と記載されていますが、国を表すJPです。国旗はシンプルにA〜Zの26個に割り当てられた、地域用のコードを組み合わせて表現しています。
つまり🇺🇸(アメリカ合衆国US)は 「U+1F1FA (U) + U+1F1F8 (S) 」となります。
 
という感じで色々な組み合わせで表現されているのが分かります。
 

UTF8とは?

そもそもUTF8とは何でしょう?ChatGPTに聞いてみました。
 
「Unicode文字をエンコードするための可変長エンコーディングの一つです。Unicodeは、世界中のほとんどの言語で使われる文字やシンボルを一つの文字コード体系で表現するために開発されました。UTF-8は、そのUnicode文字をコンピュータで効率的に扱うための方法として広く使われています。1から4バイトの長さで構成されています」
 
とのことで、UTF8は1バイトから4バイトで構成されています。
 

要素/用語

UTF8を扱う上で色々と用語が出てくるので見てきましょう
 
1.Unicodeコードポイント (Code Point)
Unicodeで定義された文字や記号に対応する一意の番号(整数値)です。例えば、「A」のコードポイントはU+0041です。絵文字の場合、例えば「😊」のコードポイントはU+1F60Aです。
UTF8をプログラムで扱う上でまずはこのコードポイントに変換することが非常に重要です。本記事の中ではコードポイントと記載します。
 
2.書記素クラスタ (Grapheme Cluster)
ユーザーが一つの文字と認識するものですが、実際には複数のコードポイントから構成されていることがあります。例えば、肌色修飾子が付いた絵文字や、ゼロ幅接合子 (ZWJ) で結合された絵文字などです。先ほどの例でいうと「👍🏾」で1書記素クラスタ、コードポイントとしては2つ(👍と 🟫(U+1F3FE))になります。
 
3.ゼロ幅接合子 (ZWJ: Zero Width Joiner)
コードポイント (U+200D) で、これを使用することで、複数の絵文字を結合して一つの複合絵文字を形成することができます。
先ほどの例ですと、「」は複数の人物絵文字がZWJで結合されていました。
 
4.バリエーションセレクタ (Variation Selector)
特定のUnicode文字に異なる外観を適用するためのコードポイント。例えば、「U+FE0F」(VS16)は、絵文字スタイル(カラーの絵文字)を指定するために使用されます。例えば、「(U+270C)は通常の記号ですが、U+FE0Fを付けることで「✌️」(絵文字スタイル)になります。標準バリエーションセレクタと異体字セレクタがありますが、今回扱うのは主に標準バリエーションセレクタになります。
 
5.修飾子 (Modifier)
絵文字に追加情報を提供するためのコードポイント。例えば、肌の色を変更する修飾子 (U+1F3FB – U+1F3FF) があります。
これらは、基本的な絵文字に続けて適用されます。
先ほどの👍🏾で使用されていた 🟫がこれに該当します。
 
6.地域指標シンボル (Regional Indicator Symbols)
国旗や地域を表す絵文字を形成するために使用されるコードポイントで範囲はU+1F1E6 から U+1F1FFとなっています。AがU+1F1E6 、BがU+1F1E7と各アルファベット分あり、2つの文字がペアになって一つの国旗を形成します。例えば、「🇯🇵」は日本(JP)の国旗を表し、これはU+1F1EF(J)とU+1F1F5(P) を組み合わせて作られています。

 

文字数カウントのおおまかな流れ

1.UTF8のバイトコードをコードポイントへ変換
例えば日本語の「あ」ですと、
UTF8バイトコードが「0xE3 0x81 0x82」の3バイトを、
Unicodeコードポイント 「U+3042」へ変換します。
 
2.書記素クラスタのカウント
色々なパターンに合わせてコードポイントをカウントして、一つにまとめます。
例えば、コードポイント「👨 + ZWJ + 👩 + ZWJ + 👦 + ZWJ + 👦」をまとめて1書記素クラスタで「」になりまして、これを1文字とカウントします。
 

UTF8のバイトコードをコードポイントへ変換

UTF8は1〜4バイトで構成されていると前述しましたが、
1バイト文字〜4バイト文字の範囲とコードポイントへの変換処理は以下になります。
 
1. 1バイト文字 (ASCII)

    • •範囲: 0x00 〜 0x7F

 

    • •処理: 1バイトでそのままコードポイントとして解釈します。

 

    •例: 文字「A」は 0x41 で、そのまま U+0041 になります。

 
2. 2バイト文字

    • •範囲:

 

    • •最初のバイト: 0xC2 〜 0xDF

 

    • •次のバイト: 0x80 〜 0xBF(下位バイトは常にこの範囲内)

 

    • •処理:最初のバイトから 0xC0 を引いた後、次のバイトから 0x80 を引いたものを結合してコードポイントを形成します。

 

    •例: 0xC3 0xA9 は「é」で、U+00E9 になります。

 
3. 3バイト文字

    • •範囲:

 

    • •最初のバイト: 0xE0 〜 0xEF

 

    • •次のバイト以降: 0x80 〜 0xBF

 

    • •処理:最初のバイトから 0xE0 を引き、次の2バイトから 0x80 を引いたものを結合してコードポイントを形成します。

 

    •例: 0xE2 0x82 0xAC は「€」で、U+20AC になります。

 
4. 4バイト文字

    • •範囲:

 

    • •最初のバイト: 0xF0 〜 0xF7

 

    • •次のバイト以降: 0x80 〜 0xBF

 

    • •処理:最初のバイトから 0xF0 を引き、次の3バイトから 0x80 を引いたものを結合してコードポイントを形成します。

 

    •例: 0xF0 0x9F 0x98 0x8A は「😊」で、U+1F60A になります。

 

書記素クラスタのカウント-複数のコードポイントからなる絵文字のパターン

どのようなケースがあるか見ていきましょう。
1. ZWJを使って文字を結合するパターン
ex. = 👨 + ZWJ + 👩 + ZWJ + 👦 + ZWJ + 👦
 
2.バリエーションセレクタを使って結合するパターン
ex.✌ = 「U+270C」 + 「U+FE0F」
 
3.修飾子を使って結合するパターン
ex.👍🏾 = 👍と 「U+1F3FE」
 
4.地域指標シンボルをつかって結合するパターン
ex.🇯🇵 = 「U+1F1EF」+「U+1F1F5」
 
他にもありますが、ほぼこの辺になります。
ざっくり分けると「ZWJで結合するパターン」と「特殊なコードを組み合わせるパターン」になります。
それぞれが1書記素クラスタという単位にまとめられます。
 

サンプルコード

C++で文字数カウントするためのコードを書いてみましょう。
文字をコードポイントに変換し、書記素クラスタ単位で数をカウントすれば文字の数がでます。
サンプルのためエラー処理など省いています。
 

1.コードポイントへの変換


    std::vector utf8ToCodePoints(const std::string& text){
        std::vector codePoints;
        size_t i = 0;
        while (i < text.length()) {
            unsigned char byte = text[i];
            uint32_t codePoint = 0;
            size_t charLen = 0;
            if ((byte & 0x80) == 0x00) {
                // 1バイト文字(ASCII)
                codePoint = byte;
                charLen = 1;
            } else if ((byte & 0xE0) == 0xC0) {
                // 2バイト文字
                codePoint = byte & 0x1F;
                charLen = 2;
            } else if ((byte & 0xF0) == 0xE0) {
                // 3バイト文字
                codePoint = byte & 0x0F;
                charLen = 3;
            } else if ((byte & 0xF8) == 0xF0) {
                // 4バイト文字
                codePoint = byte & 0x07;
                charLen = 4;
            } else {
                // 不正なバイト
                break;
            }
            for (size_t j = 1; j < charLen; ++j) {
                codePoint = (codePoint << 6) | (text[i + j] & 0x3F);
            }
            codePoints.push_back(codePoint);
            i += charLen;
        }
        return codePoints;
    }
  

 

2.文字数カウント


    static const uint32_t ZWJ = 0x200D;
    static const uint32_t VARIATION_SELECTOR_START = 0xFE00;
    static const uint32_t VARIATION_SELECTOR_END = 0xFE0F;
    static const uint32_t EMOJI_MODIFIER_START = 0x1F3FB;
    static const uint32_t EMOJI_MODIFIER_END = 0x1F3FF;
    static const uint32_t REGIONAL_INDICATOR_START = 0x1F1E6;
    static const uint32_t REGIONAL_INDICATOR_END = 0x1F1FF;

    int utf8StringCount(const std::string& text){
        std::vector codePoints = utf8ToCodePoints(text);
        size_t count = 0;
        size_t i = 0;
        while (i < codePoints.size()) {
            ++count;  // 書記素クラスターをカウント

            while (i + 1 < codePoints.size()) { uint32_t currentCp = codePoints[i]; uint32_t nextCp = codePoints[i + 1]; // ZWJ(Zero Width Joiner)を使用した複合グラフェムクラスターの処理 if (nextCp == ZWJ) { i += 2; // ZWJと次のコードポイントをまとめて処理 } else if ((nextCp >= VARIATION_SELECTOR_START && nextCp <= VARIATION_SELECTOR_END) || (nextCp >= EMOJI_MODIFIER_START && nextCp <= EMOJI_MODIFIER_END)) { // 修飾子やバリエーションセレクタが続く限り同じ書記素クラスターとみなす ++i; } else if (currentCp >= REGIONAL_INDICATOR_START && currentCp <= 0x1F1FF && nextCp >= REGIONAL_INDICATOR_START && nextCp <= 0x1F1FF) {
                    // 2つの地域指標シンボルを1つの書記素クラスターとして処理
                    ++i;
                    break; // 他の国旗とつながってしまう可能性があるのでbreakする
               } else {
                    break;
                }
            }

            ++i;
        }
        return (int)count;
    }
  

文字入力処理していると仕様上、max文字数を超えたら文字を切るといったこともあると思います。そのような処理を書いてみましょう。
 

3.コードポイントからstringに戻す処理


    std::string codePointsToUtf8(const std::vector& codePoints){
        std::string text;
        for (uint32_t codePoint : codePoints) {
            if (codePoint <= 0x7F) {
                // 1バイト文字
                text += static_cast(codePoint);
            } else if (codePoint <= 0x7FF) {
                // 2バイト文字
                text += static_cast(0xC0 | ((codePoint >> 6) & 0x1F));
                text += static_cast(0x80 | (codePoint & 0x3F));
            } else if (codePoint <= 0xFFFF) {
                // 3バイト文字
                text += static_cast(0xE0 | ((codePoint >> 12) & 0x0F));
                text += static_cast(0x80 | ((codePoint >> 6) & 0x3F));
                text += static_cast(0x80 | (codePoint & 0x3F));
            } else if (codePoint <= 0x10FFFF) {
                // 4バイト文字
                text += static_cast(0xF0 | ((codePoint >> 18) & 0x07));
                text += static_cast(0x80 | ((codePoint >> 12) & 0x3F));
                text += static_cast(0x80 | ((codePoint >> 6) & 0x3F));
                text += static_cast(0x80 | (codePoint & 0x3F));
            }
        }
        return text;
    }
  

 

4.stringを指定されたmax文字数にまとめる処理


    std::string truncateUTF8String(const std::string& text, int maxLength){
        std::vector codePoints = utf8ToCodePoints(text);
        std::vector result;
        size_t count = 0;
        size_t i = 0;
        while (i < codePoints.size() && count < maxLength) {
            result.push_back(codePoints[i]);
            while (i + 1 < codePoints.size()) { uint32_t currentCp = codePoints[i]; uint32_t nextCp = codePoints[i + 1]; // ZWJ(Zero Width Joiner)を使用した書記素クラスターの処理 if (nextCp == ZWJ) { i += 2; // ZWJと次のコードポイントをまとめて処理 result.push_back(codePoints[i - 1]); // ZWJ result.push_back(codePoints[i]); // 結合された次の絵文字 } else if ((nextCp >= VARIATION_SELECTOR_START && nextCp <= VARIATION_SELECTOR_END) || (nextCp >= EMOJI_MODIFIER_START && nextCp <= EMOJI_MODIFIER_END)) { // 修飾子やバリエーションセレクタが続く限り同じ書記素クラスターとみなす ++i; result.push_back(nextCp); } else if (currentCp >= REGIONAL_INDICATOR_START && currentCp <= REGIONAL_INDICATOR_END && nextCp >= REGIONAL_INDICATOR_START && nextCp <= REGIONAL_INDICATOR_END) {
                    // 2つのリージョナルインジケータシンボルを1つの書記素クラスターとして処理
                    ++i;
                    result.push_back(nextCp);
                    break;
                } else {
                    break;
                }
            }
            ++count;
            ++i;
        }
        return codePointsToUtf8(result);
    }
  

 

最後に

今回UTF8の絵文字処理について書いてみました。
実際試していると色々なケースが出てくるなと感じました。
また、今回は見た目の上で一文字のものを一文字として扱いましたが、場合によってはたかが10文字に見える文字が数百バイトになっていたということもあります。
その辺も考慮に入れて仕様や設計を考えていく必要があると思いますのでご注意ください。
今回は勉強も兼ねて実装コードを書いてみましたが、ちゃんとしたライブラリを使える環境でしたら使用した方がいいと思います。
 
ここまで読んでいただきありがとうございます!

Tag

Category

Tag