TextMesh Proで絵文字を出す

このエントリは【カヤック】面白法人グループ Advent Calendar 2023の16日目の記事です。

はじめに

こんにちは。中山と申します。

UnityのTextMesh Proで絵文字を出す方法と、実装時に躓いた部分について紹介します。

TextMesh Pro標準の機能で絵文字を出す方法

Sprite AssetのSprite Character Tableという項目で画像とコードポイントを紐づけておくと、単体のコードポイントで表される絵文字を表示できるようになります。

✋(U+270B)のような16bitに収まるコードポイントの絵文字はもちろん、🙏(U+1F64F)のようなサロゲートペアで表現される絵文字も表示してくれます。

こんな感じでSprite Character Tableでコードポイント(Unicode: 0x1F64Fの部分)と画像を紐づけると、絵文字を表示できるようになる

TextMesh Pro標準の機能で出せない絵文字

TextMesh Pro標準の機能ではコードポイントと画像を1対1で対応付けることしかできないので、複数のコードポイントで表現される絵文字を表示することはできません。

例えば、以下のような絵文字は複数のコードポイントで表現されるので表示できません。

  • Skin Toneを変更する文字が付いている絵文字(例: 🙏🏽(U+1F64F U+1F3FD))
  • Zero Width Joiner(ゼロ幅接合子 U+200D)を使った絵文字(例: ❤️‍🔥(U+2764 U+FE0F U+200D U+1F525))
  • Regional Indicatorを2つ並べて表現される絵文字(例: 🇯🇵(U+1F1EF U+1F1F5))

例としてU+1F64F U+1F3FDを入力してみると、このように絵文字とSkin Toneを変更する文字が別々の文字として表示されてしまうことが確認できます。

U+1F64FとU+1F3FDが別々の文字になってしまう

TextMesh Pro標準の機能で出せない絵文字を出す方法

spriteタグを使うと文章に画像を埋め込むことができるので、文字列から複数のコードポイントで表される絵文字を探してspriteタグに置換してやれば絵文字を表示できるようになると考えられます。

すでにそういう事ができる仕組みを作ってくれている人がいるので、その仕組みに乗っかるのが楽です。

準備

forum.unity.com

手順はこちらに載っている通りで、改めて書くと以下のようになります。

  1. パッケージマネージャーでcom.kyub.emojisearchを追加する
  2. https://github.com/iamcal/emoji-dataにあるemoji.jsonと「sheet_」で始まるpngをプロジェクトに入れて、メニューの Window/TextMeshPro/Sprite Emoji Importer を選ぶと出てくるウィンドウでSprite Assetを作る
  3. GameObjectにTMP_EmojiTextUGUIというコンポーネントを付けて、インスペクタのSprite Assetに作ったSprite Assetを設定する

うまくいっていると、このように絵文字を表示できるようになります。絵文字とSkin Toneを変更するための文字がくっついて1つの文字として表示されていることが確認できます。

追加で必要だったこと

上記の手順で概ね正しく表示できるようになりますが、いくつか問題や追加で対応しないといけないことがあったので対処法と一緒に列挙しておきます。

違う絵文字になってしまうものがある

例えば黒旗(U+1F3F4)を入力すると海賊旗が表示されてしまうというように、入力したものと違う表示になってしまう絵文字があります。

U+1F3F4は黒旗なのに、海賊旗(U+1F3F4 U+200D U+2620 U+FE0F)が表示される

これは、Sprite Character TableのIndexが黒旗より海賊旗の方が小さいことと、TMP_EmojiTextUGUIは黒旗(U+1F3F4)のような単体のコードポイントで表される絵文字についてはspriteタグへの置換を行わないのが原因です。

なので、Sprite Assetを作る前に単体のコードポイントのIndexが小さくなるようにJSONのデータをソートしてやると正しく表示されるようになります。具体的には以下のような処理を実行するとうまくいきます。

using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

public class EmojiJsonUtility
{
    public string Process(string emojiJson)
    {
        // 「-」を含まない絵文字のIndexが小さくなるようにする
        var sorted = JArray.Parse(emojiJson)
            .OrderBy(x => x.Value<string>("unified")!.Contains('-')).ToList();

        return JsonConvert.SerializeObject(sorted);
    }
}

1つの絵文字として表示されないものがある

虹色の旗🏳️‍🌈(U+1F3F3 U+FE0F U+200D U+1F308)を入力すると使用したキーボードによって白旗と虹の2つの絵文字にわかれて表示されてしまう場合があるというように、絵文字が2つ以上の別々の文字として表示されてしまうことがあります*1

これは、🏳️‍🌈を入力しようとしたときにU+1F3F3 U+FE0F U+200D U+1F308からU+FE0Fを除いたU+1F3F3 U+200D U+1F308というコードポイントが入力されることと、U+FE0Fを除いたデータがSprite Character Tableに存在しないことが原因です。

U+1F3F3 U+200D U+1F308という並びは考慮されていない

なので、U+FE0Fを含むデータからU+FE0Fを削除したデータを生成してやる必要があります。具体的には以下のような処理をしてやるとうまくいくはずです。

using System.Linq;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

public class EmojiJsonUtility
{
    /// <summary>
    /// Variation Selector-16 (U+FE0F)にマッチする正規表現
    /// </summary>
    static readonly Regex _vs16Regex = new("-FE0F(?![0-9A-F])", RegexOptions.IgnoreCase);

    public string Process(string emojiJson)
    {
        // 「-」を含む絵文字のIndexが小さくなるようにする
        var sorted = JArray.Parse(emojiJson)
            .OrderBy(x => x.Value<string>("unified")!.Contains('-'))
            .ThenBy(x => x.Value<string>("unified")!.Length).ToList();

        var replaced = sorted.Where(x =>
        {
            var unified = x.Value<string>("unified")!;
            return unified.Contains("-200D-") && _vs16Regex.IsMatch(unified);
        }).Select(x =>
        {
            // U+FE0Fを含むデータを複製して、U+FE0Fを削除する
            var cloned = x.DeepClone();
            cloned["unified"] = _vs16Regex.Replace(x.Value<string>("unified")!, "");
            cloned["image"] = _vs16Regex.Replace(x.Value<string>("image")!, "");
            return cloned;
        });

        return JsonConvert.SerializeObject(sorted.Concat(replaced));
    }
}

U+FE0Fが入力されると正しい表示にならない

iOS標準のキーボードで⚽や⚾などの絵文字を入力するとU+FE0Fという文字が一緒に入力されますが、これをそのまま表示しようとするとU+FE0Fが□で表示されてしまいます。

U+26BE U+FE0Fを入力するとU+FE0Fが□になる

対処法はいくつかあるかもしれませんが、一つのやり方として TMP_EmojiTextUGUI.PreprocessText をオーバーライドして文字列からU+FE0Fを削除してしまうという方法があります*2。以下のようなコンポーネントを用意するとうまくいくはずです。

using System.Text.RegularExpressions;
using Kyub.EmojiSearch.UI;

public class PlayerInputText : TMP_EmojiTextUGUI
{
    /// <summary>
    /// Variation Selector
    /// </summary>
    static readonly Regex _variationSelectorRegex =
        new(@"[\uFE00-\uFE0F]|(?:\uDB40[\uDD00-\uDDEF])");

    public override bool PreprocessText(string text, out string parsedString, bool forceApply)
    {
        var result = base.PreprocessText(text, out parsedString, forceApply);
        parsedString = _variationSelectorRegex.Replace(parsedString, "");
        return result;
    }
}

リッチテキストをオフにできない

ユーザーが入力したテキストを表示する箇所ではリッチテキストをオフにする場合が多いかと思いますが、TMP_EmojiTextUGUIはspriteタグを使って絵文字を表示するのでオフにできません。

TMP_EmojiTextUGUI.PreprocessText をオーバーライドして、 <<noparse><</noparse> に置換してから絵文字の処理をするようにしてやると、リッチテキストを使えないようにしつつ絵文字の表示をできるようになります。具体的には以下のようなコンポーネントを用意すれば良いです。

using System.Text.RegularExpressions;
using Kyub.EmojiSearch.UI;

public class PlayerInputText : TMP_EmojiTextUGUI
{
    static readonly Regex _variationSelectorRegex =
        new(@"[\uFE00-\uFE0F]|(?:\uDB40[\uDD00-\uDDEF])");

    public override bool PreprocessText(string text, out string parsedString, bool forceApply)
    {
        var result = base.PreprocessText(
            forceApply || m_isRichText
                ? text.Replace("<", "<noparse><</noparse>") : text,
            out parsedString, forceApply);
        parsedString = _variationSelectorRegex.Replace(parsedString, "");
        return result;
    }
}

まとめ

絵文字の仕様は結構ややこしいので不十分なケースはあるかもしれませんが、TextMesh Proで絵文字を表示する方法について紹介しました。

*1:Androidだと「Andoroidキーボード(AOSP)」というキーボードを使うと確認できると思います

*2:https://light11.hatenadiary.com/entry/2021/12/28/114503
Character Spacingが0以外の値にならないのであれば、こちらで紹介されている方法を使うと追加の実装なしで対応可能です