0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-05-31

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側にはスペースを入れる。
実際はタグとスペース文字一個が入ってます。スペース文字要らないと思われるでしょうが、これあるといろいろ楽なので。

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

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
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);
        }
    }
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?