自力でTextMeshProのテキスト内にスプライトを描画

2023/05/31 : 初稿
Unity : 2021.3.15f1

# やりたいこと

Unity・TextMeshProUGUI文字列中にTMP_SpriteAssetにしていないスプライトを埋め込みたい。

例えば、Addressablesでロードした不特定多数のアイコンを、TMP_SpriteAssetに登録することなくテキストに埋め込みたいとか、そういった状況を想定。

TextMeshPro自体が対応してない・・・ですよね・・・？してるのかな？

# 実現方法

自力でTextMeshProをカスタマイズするのは心が折れるので、昔のUnityEngine.UI.Text時代のやり方で。
文字列中のスプライト表示したい場所に`<img=hogehoge>`というタグを埋め込むとする。

スクリプト側で実行時にTextMeshProUGUIに渡すべき文字列を解析し、上記タグを発見したら`hogehoge`に該当するスプライトを表示するUnityEngine.UI.ImageをTextMeshProUGUIの子供として配置する。

UnityEngine.UI.Image表示エリア分、TextMeshProUGUI側にはスペースを入れる。
実際は<space>タグとスペース文字一個が入ってます。スペース文字要らないと思われるでしょうが、これあるといろいろ楽なので。

※全てのTextMeshProの設定に対応できてるわけではありません。
※エスケープシーケンスや一文字ずつ表示などいろいろ対応していません。
もっと賢いやり方あるんだろうなあ。

```cs:TextMeshProImageDrawer.cs
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
using System.Text;
using TMPro;

namespace Utils
{
    // 抽象化したクラス
    public abstract class TextMeshProImageDrawerBase<IMAGE, INSTANCE, USER>
    {
        // ImageData
        public struct ImageData
        {
            // 元の位置
            public int Index;

            // タグを抜いた文字列とその中での位置
            public int TagStartPos;
            public int TagLen;
            public int NameStartPos;
            public int NameLen;
            public USER User;

            // 表示すべきもの
            public IMAGE Image;
            public Vector2 EmSize; // 1で一文字分の横幅
            public Vector3 EmOffset;
            public INSTANCE Instance;
        }
        public List<ImageData> Datas { get; private set; } = new List<ImageData>();

        // builder
        static StringBuilder Builder = new StringBuilder();

        // tag
        protected virtual string BeginTag { get { return "img="; } }

        // space
        protected virtual string SpaceBegin { get { return "<nobr><space="; } }
        protected virtual string SpaceEnd { get { return "em> </nobr>"; } }

        // テキスト設定
        public void Set(TMP_Text target, string text)
        {
            // null check
            if (target == null) {
                return;
            }

            // TextMeshProのタグ解析結果取得
#if false // AutoSizeの時うまくいかない
            var textInfo = target.GetTextInfo(text);
#else
            target.SetText(text);
            target.ForceMeshUpdate();
            var textInfo = target.textInfo;
#endif

            // イメージタグ解析
            Analyze(target, textInfo);

            // 元の文字列
            Builder.Clear();
            Builder.Append(text);

            // イメージ構築
            Build(target);
        }

        // テキスト設定
        public void Set(TMP_Text target, StringBuilder builder)
        {
            // null check
            if (target == null) {
                return;
            }

            // TextMeshProのタグ解析結果取得
            target.SetText(builder);
            target.ForceMeshUpdate();
            var textInfo = target.textInfo;

            // イメージタグ解析
            Analyze(target, textInfo);

            // 元の文字列
            Builder.Clear();
            Builder.Append(builder);

            // イメージ構築
            Build(target);
        }

        // タグ解析
        protected void Analyze(TMP_Text target, TMP_TextInfo textInfo)
        {
            // clear
            foreach (var data in Datas) {
                DestroyImage(data);
            }
            Datas.Clear();

            // <img=xxx>を検索
            for (int i = 0; i < textInfo.characterCount; i++) {
                TMP_CharacterInfo charaInfo = textInfo.characterInfo[i];

                // '<'を検索
                if (charaInfo.character != '<') {
                    continue;
                }

                //　そのあとが"img="か？
                bool match = true;
                for (int j = 0; j < BeginTag.Length; j++) {
                    // 最後を越えてしまうので違うし、これ以上は存在しない
                    if (i + 1 + j >= textInfo.characterCount) {
                        return;
                    }

                    // 順番？
                    var currentCharaInfo = textInfo.characterInfo[i + 1 + j];
                    if (currentCharaInfo.index != charaInfo.index + 1 + j) { // 間に別のタグが挟まったので違う
                        match = false;
                        break;
                    }

                    // タグ？
                    if (currentCharaInfo.character != BeginTag[j]) {
                        match = false;
                        break;
                    }
                }
                if (!match) {
                    continue;
                }

                // その後の'>'を検索
                int nameStartPos = i + BeginTag.Length + 1;
                int TagClosePos = -1; // '>'の位置
                var currentPos = nameStartPos;
                while (true) {
                    // 越えてない？
                    if (currentPos >= textInfo.characterCount) {
                        break;
                    }

                    // 調査位置のデータ
                    var currentCharaInfo = textInfo.characterInfo[currentPos];

                    // // そうなる前に別のタグが挟まってないか？
                    if (currentCharaInfo.index != charaInfo.index + currentPos - i) {
                        break;
                    }

                    // '>'になるまで続ける
                    if (currentCharaInfo.character != '>') {
                        currentPos++;
                        continue;
                    }

                    // あった。
                    TagClosePos = currentPos;
                    break;
                }
                if (TagClosePos < 0) {
                    continue;
                }

                // そこの状況を取得
                var (image, emSize, emOffset, user) = GetImageInfo(textInfo, nameStartPos, TagClosePos - nameStartPos);
                ImageData data = default;
                data.Index = charaInfo.index;
                data.TagStartPos = i;
                data.TagLen = TagClosePos - i + 1;
                data.NameStartPos = nameStartPos;
                data.NameLen = TagClosePos - nameStartPos;
                data.Image = image;
                data.EmSize = emSize;
                data.EmOffset = emOffset;
                data.User = user;
                Datas.Add(data);
            }
        }

        // IsImageNameMatch
        protected bool IsImageNameMatch(TMP_TextInfo textInfo, int nameStartPos, int nameLen, string targetName)
        {
            // null/length check
            if (targetName == null || textInfo == null || nameLen != targetName.Length) {
                return false;
            }

            // index check
            if (nameStartPos < 0 || nameStartPos + nameLen >= textInfo.characterCount) {
                return false;
            }

            // 一致判定
            for (int i = 0; i < nameLen; i++) {
                if (textInfo.characterInfo[nameStartPos + i].character != targetName[i]) {
                    return false;
                }
            }

            // 全部一致しました
            return true;
        }

        // BuildName
        protected bool CreateImageName(StringBuilder dst, TMP_TextInfo textInfo, int nameStartPos, int nameLen)
        {
            // null check
            if (textInfo == null || dst == null) {
                return false;
            }

            // index check
            if (nameStartPos < 0 || nameStartPos + nameLen >= textInfo.characterCount) {
                return false;
            }

            // Builderへ登録
            dst.Clear();
            for (int i = 0; i < nameLen; i++) {
                dst.Append(textInfo.characterInfo[nameStartPos + i].character);
            }

            // OK
            return true;
        }

        // GetImageInfo
        protected abstract (IMAGE image, Vector2 emSize, Vector3 emOffset, USER user) GetImageInfo(TMP_TextInfo textInfo, int nameStartPos, int nameLen);

        // Builderに元の文字列を入れておくこと。Analyzeした結果を元に文字列変換
        void Build(TMP_Text target)
        {
            //<tex xxx>を全てサイズに合わせたスペースタブに変換し、
            // 同じ場所にImageを設定
            for (int i = Datas.Count - 1; i >= 0; i--) {
                // "<tex xxx>"を"<nobr><space=*em> </nobr>"に置換
                var data = Datas[i];
                var addedLen = Replace(data);

                // 取り除いた文字数分だけ、後ろの位置をずらす
                for (int j = i + 1; j < Datas.Count; j++) {
                    var tmp = Datas[j];
                    tmp.Index += addedLen;
                    tmp.TagStartPos += addedLen;
                    tmp.NameStartPos += addedLen;
                    Datas[j] = tmp;
                }
            }

            // 最終設定
            target.SetText(Builder);
            target.ForceMeshUpdate();

            // スプライト部分にイメージを配置
            var textInfo = target.textInfo;
            for (int i = 0; i < Datas.Count; i++) {
                var data = Datas[i];
                data = AddImage(target, textInfo, data);
                Datas[i] = data;
            }

            // 後始末
            Builder.Clear();
        }

        // Replace
        int Replace(ImageData data)
        {
            // texタグを取り除く
            var len = Builder.Length;
            Builder.Remove(data.Index, data.TagLen);
            var removedLen = len - Builder.Length;

            // <nobr><space=xem> </nobr>を追加
            Builder.Insert(data.Index, SpaceEnd);
            Builder.Insert(data.Index, data.EmSize.x);
            Builder.Insert(data.Index, SpaceBegin);

            // 表示する文字としてはスペース一個分増えた
            return -removedLen + 1;
        }

        // AddImage
        protected virtual ImageData AddImage(TMP_Text target, TMP_TextInfo textInfo, ImageData data)
        {
            // 置き換えたスペース文字の頂点座標を取得
            var charInfo = textInfo.characterInfo[data.TagStartPos];
            var posLB = charInfo.bottomLeft;
            var posRT = charInfo.topRight;

            // 左右
            posLB.x = charInfo.origin;
            posRT.x = charInfo.xAdvance;

            // 上下
            posLB.y = charInfo.descender;
            posRT.y = charInfo.ascender;

            // emサイズ
            var emSize = charInfo.pointSize;

            // その左端から<space>分左が画像エリアの左端
            var min = posLB + Vector3.left * emSize * data.EmSize.x;

            // 右端はそのまま右端
            var max = posRT;

            // image設置
            data.Instance = InstantiateImage(target, textInfo, data, (min + max) * 0.5f + data.EmOffset, data.EmSize * emSize);

            // OK
            return data;
        }

        // 設置
        protected abstract INSTANCE InstantiateImage(TMP_Text target, TMP_TextInfo textInfo, ImageData data, Vector3 center, Vector2 size);
        protected abstract void DestroyImage(ImageData data);
    }

    // UGUI/Image
    public abstract class TextMeshProImageDrawerUGUIBase<IMAGE, INSTANCE, USER> : TextMeshProImageDrawerBase<IMAGE, INSTANCE, USER> where INSTANCE : Graphic
    {
        // 設置
        protected override INSTANCE InstantiateImage(TMP_Text target, TMP_TextInfo textInfo, ImageData data, Vector3 center, Vector2 size)
        {
            // Imageを追加してスプライトを適用
            var image = InstantiateImage(target, textInfo, data, center, size, target.transform);
            if (image == null) {
                return null;
            }

            // サイズ設定
            SetRectTransform(image.rectTransform, center, size);

            // 生成したものを返す
            return image;
        }

        // サイズ設定
        protected void SetRectTransform(RectTransform target, Vector3 center, Vector2 size)
        {
            if (target == null) {
                return;
            }
            target.SetLocalPositionAndRotation(center, Quaternion.identity);
            target.localScale = Vector3.one;
            target.sizeDelta = size;
        }

        // Instantiate
        protected abstract INSTANCE InstantiateImage(TMP_Text target, TMP_TextInfo textInfo, ImageData data, Vector3 center, Vector2 size, Transform parent);

        // Destroy
        protected override void DestroyImage(ImageData data)
        {
            if (data.Instance == null) {
                return;
            }
            UnityEngine.Object.Destroy(data.Instance.gameObject);
        }
    }

    // UGUI/Image
    public abstract class TextMeshProImageDrawerUGUI<USER> : TextMeshProImageDrawerUGUIBase<Sprite, Image, USER>
    {
        // Instantiate
        protected override Image InstantiateImage(TMP_Text target, TMP_TextInfo textInfo, ImageData data, Vector3 center, Vector2 size, Transform parent)
        {
            // Imageを追加してスプライトを適用
            var go = new GameObject("img");
            go.transform.SetParent(parent);
            var image = go.AddComponent<Image>();
            if (data.Image != null) {
                image.sprite = data.Image;
            }
            return image;
        }
    }

    // UGUI/RawImage
    public abstract class TextMeshProImageDrawerUGUIRaw<USER> : TextMeshProImageDrawerUGUIBase<Texture2D, RawImage, USER>
    {
        protected override RawImage InstantiateImage(TMP_Text target, TMP_TextInfo textInfo, ImageData data, Vector3 center, Vector2 size, Transform parent)
        {
            // RawImageを追加してテクスチャを適用
            var go = new GameObject("img");
            go.transform.SetParent(parent);
            var image = go.AddComponent<RawImage>();
            if (data.Image != null) {
                image.texture = data.Image;
            }
            return image;
        }
    }
}
```

使い方のサンプルとしてはこんな感じ。
下記のファイルを上のファイルと一緒にプロジェクトに配置して、
どこかのTextMeshProUGUIのついたGameObjectにAddComponent<TextMeshProImageDrawerSample>()すれば動くはず。
```cs:TextMeshProImageDrawerSample.cs
using UnityEngine;
using TMPro;
using System;
using System.Collections.Generic;

namespace Utils
{
    // サンプル
    public class TextMeshProImageDrawerUGUIRawSample : TextMeshProImageDrawerUGUIRaw<int>
    {
        // テクスチャリスト
        [Serializable]
        public struct TexData
        {
            public string Name;
            public Texture2D Tex;
            public float Scale; // defaultは1
            public Vector3 EmOffset; // defaultはゼロ

            // コンストラクタ
            public TexData(string name, Texture2D tex)
            {
                this = default;
                Name = name;
                Tex = tex;
                Scale = 1;
            }

            // コンストラクタ
            public TexData(string name, Texture2D tex, float scale)
            {
                this = default;
                Name = name;
                Tex = tex;
                Scale = scale;
            }

            // コンストラクタ
            public TexData(string name, Texture2D tex, float scale, Vector3 emOffset)
            {
                this = default;
                Name = name;
                Tex = tex;
                Scale = scale;
                EmOffset = emOffset;
            }
        }
        List<TexData> TexDatas;

        // Set
        public void Set(TextMeshProUGUI target, string text, List<TexData> spriteDatas)
        {
            TexDatas = spriteDatas;
            Set(target, text);
            TexDatas = null;
        }

        // GetImageInfo
        protected override (Texture2D image, Vector2 emSize, Vector3 emOffset, int user) GetImageInfo(TMP_TextInfo textInfo, int nameStartPos, int nameLen)
        {
            // コード整理
            if (TexDatas == null) {
                return default;
            }

            // 名前が一致するものを検索
            foreach (var texData in TexDatas) {
                // 一致判定
                if (!IsImageNameMatch(textInfo, nameStartPos, nameLen, texData.Name)) {
                    continue;
                }

                // 画像
                var tex = texData.Tex;

                // 画像なし
                if (tex == null) {
                    return default;
                }

                // サイズに合わせる
                var w = tex.width;
                var h = tex.height;
                if (w <= 0 || h <= 0) {
                    return default;
                }
                return (tex, new Vector2((float)w / h, 1) * texData.Scale, texData.EmOffset, default);
            }

            // なかった
            return default;
        }
    }

    // Mono
    public class TextMeshProImageDrawerSample : MonoBehaviour
    {
        // inspector
        [SerializeField] string SetOnAwake = "あいうえ<color=red>お<img=gray>か</color>きくけこ<img=white>さ";
        [SerializeField] List<TextMeshProImageDrawerUGUIRawSample.TexData> TexDatas;

        // 適当
        List<TextMeshProImageDrawerUGUIRawSample.TexData> SampleTexDatas;

        // Target
        TextMeshProUGUI Target;

        // core
        TextMeshProImageDrawerUGUIRawSample Core = new TextMeshProImageDrawerUGUIRawSample();

        // Awake
        void Awake()
        {
            // get target
            Target = GetComponent<TextMeshProUGUI>();

            // sample
            SampleTexDatas = new List<TextMeshProImageDrawerUGUIRawSample.TexData>()
            {
                new TextMeshProImageDrawerUGUIRawSample.TexData("gray", Texture2D.grayTexture),
                new TextMeshProImageDrawerUGUIRawSample.TexData("white", Texture2D.whiteTexture),
            };
            if (TexDatas != null)
            {
                SampleTexDatas.AddRange(TexDatas);
            }

            // 自動設定
            if (!string.IsNullOrEmpty(SetOnAwake))
            {
                SetText(SetOnAwake);
            }
        }

        // テキスト設定
        public void SetText(string text)
        {
            Core.Set(Target, text, SampleTexDatas);
        }
    }
}
```