Загрузка данных


using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;

/// <summary>
/// Отображает базовую Markdown-разметку в TextMeshProUGUI
/// и обрабатывает клики по ссылкам.
/// </summary>
public sealed class TmpMarkdownText : MonoBehaviour, IPointerClickHandler
{
    [SerializeField]
    private TMP_Text targetText;

    [SerializeField]
    private Color linkColor = new Color(0.25f, 0.65f, 1f, 1f);

    [Tooltip("Открывать http/https-ссылки через Application.OpenURL.")]
    [SerializeField]
    private bool openLinksExternally = true;

    private readonly Dictionary<string, string> linkTargets =
        new Dictionary<string, string>();

    /// <summary>
    /// Позволяет приложению дополнительно обработать нажатие на ссылку.
    /// </summary>
    public event Action<string> LinkClicked;

    private void Awake()
    {
        if (targetText == null)
            targetText = GetComponent<TMP_Text>();

        if (targetText == null)
        {
            Debug.LogError(
                "TmpMarkdownText: компонент TMP_Text не найден.",
                this);

            enabled = false;
            return;
        }

        targetText.richText = true;
        targetText.raycastTarget = true;
    }

    /// <summary>
    /// Передавайте сюда уже извлечённое из JSON Markdown-поле.
    /// </summary>
    public void SetMarkdown(string markdown)
    {
        if (targetText == null)
            return;

        linkTargets.Clear();

        string colorHex = ColorUtility.ToHtmlStringRGB(linkColor);

        targetText.text = MarkdownToTmpConverter.Convert(
            markdown ?? string.Empty,
            linkTargets,
            colorHex);

        // Немедленно обновляет textInfo, включая linkInfo.
        targetText.ForceMeshUpdate();
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        if (targetText == null)
            return;

        Camera eventCamera = eventData.pressEventCamera;

        // Для Screen Space Overlay камера должна быть null.
        // Для остальных режимов используем камеру Canvas.
        if (eventCamera == null)
        {
            Canvas canvas = targetText.GetComponentInParent<Canvas>();

            if (canvas != null &&
                canvas.renderMode != RenderMode.ScreenSpaceOverlay)
            {
                eventCamera = canvas.worldCamera;
            }
        }

        int linkIndex = TMP_TextUtilities.FindIntersectingLink(
            targetText,
            eventData.position,
            eventCamera);

        if (linkIndex < 0)
            return;

        TMP_LinkInfo linkInfo = targetText.textInfo.linkInfo[linkIndex];
        string linkId = linkInfo.GetLinkID();

        string url;

        if (!linkTargets.TryGetValue(linkId, out url))
            return;

        LinkClicked?.Invoke(url);

        if (!openLinksExternally)
            return;

        OpenSafeUrl(url);
    }

    private static void OpenSafeUrl(string url)
    {
        Uri uri;

        if (!Uri.TryCreate(url, UriKind.Absolute, out uri))
        {
            Debug.LogWarning("Некорректная ссылка: " + url);
            return;
        }

        // Не открываем file://, javascript: и произвольные схемы.
        bool isAllowed =
            uri.Scheme == Uri.UriSchemeHttp ||
            uri.Scheme == Uri.UriSchemeHttps;

        if (!isAllowed)
        {
            Debug.LogWarning(
                "Заблокирована ссылка с недопустимой схемой: " + url);

            return;
        }

        Application.OpenURL(uri.AbsoluteUri);
    }
}

internal static class MarkdownToTmpConverter
{
    // Используются временные символы из Private Use Area.
    private const string TokenStart = "\uE000";
    private const string TokenEnd = "\uE001";

    private const string LiteralLessThan = "\uE010";
    private const string LiteralGreaterThan = "\uE011";

    private static readonly Regex MarkdownLinkRegex = new Regex(
        @"(?<!!)\[(?<label>[^\]\r\n]+)\]\((?<url>https?://[^)\s]+)\)",
        RegexOptions.Compiled |
        RegexOptions.IgnoreCase |
        RegexOptions.CultureInvariant);

    private static readonly Regex RawUrlRegex = new Regex(
        @"(?<![""'>=])(https?://[^\s<>\[\]]+)",
        RegexOptions.Compiled |
        RegexOptions.IgnoreCase |
        RegexOptions.CultureInvariant);

    private static readonly Regex InlineCodeRegex = new Regex(
        @"`([^`\r\n]+)`",
        RegexOptions.Compiled |
        RegexOptions.CultureInvariant);

    private static readonly Regex BoldItalicRegex = new Regex(
        @"\*\*\*(.+?)\*\*\*",
        RegexOptions.Compiled);

    private static readonly Regex BoldAsteriskRegex = new Regex(
        @"\*\*(.+?)\*\*",
        RegexOptions.Compiled);

    private static readonly Regex BoldUnderscoreRegex = new Regex(
        @"__(.+?)__",
        RegexOptions.Compiled);

    private static readonly Regex StrikeRegex = new Regex(
        @"~~(.+?)~~",
        RegexOptions.Compiled);

    private static readonly Regex ItalicAsteriskRegex = new Regex(
        @"(?<!\*)\*([^*\r\n]+)\*(?!\*)",
        RegexOptions.Compiled);

    private static readonly Regex ItalicUnderscoreRegex = new Regex(
        @"(?<![\w_])_([^_\r\n]+)_(?![\w_])",
        RegexOptions.Compiled);

    private static readonly Regex HeadingRegex = new Regex(
        @"^(#{1,6})\s+(.+)$",
        RegexOptions.Compiled);

    private static readonly Regex BulletRegex = new Regex(
        @"^(\s*)[-+*]\s+(.+)$",
        RegexOptions.Compiled);

    private static readonly Regex QuoteRegex = new Regex(
        @"^(\s*)>\s?(.*)$",
        RegexOptions.Compiled);

    public static string Convert(
        string markdown,
        IDictionary<string, string> linkTargets,
        string linkColorHex)
    {
        string source = markdown
            .Replace("\r\n", "\n")
            .Replace('\r', '\n');

        /*
         * Защищаем обычные символы < и >, чтобы входящий текст
         * не мог случайно сформировать TMP-тег.
         */
        source = source
            .Replace("<", LiteralLessThan)
            .Replace(">", LiteralGreaterThan);

        List<string> tokens = new List<string>();
        int linkCounter = 0;

        // Защищаем inline-код от дальнейшей Markdown-обработки.
        source = InlineCodeRegex.Replace(source, delegate(Match match)
        {
            string code =
                "<color=#D8D8D8>" +
                "<mark=#00000055>" +
                match.Groups[1].Value +
                "</mark>" +
                "</color>";

            return StoreToken(tokens, code);
        });

        // Markdown-ссылки: [подпись](https://example.com)
        source = MarkdownLinkRegex.Replace(source, delegate(Match match)
        {
            string label = ApplyInlineStyles(
                match.Groups["label"].Value);

            string url = match.Groups["url"].Value;
            string linkId = "markdown-link-" + linkCounter++;

            linkTargets[linkId] = url;

            string tmpLink =
                "<link=\"" + linkId + "\">" +
                "<color=#" + linkColorHex + ">" +
                "<u>" + label + "</u>" +
                "</color>" +
                "</link>";

            return StoreToken(tokens, tmpLink);
        });

        // Обычные ссылки без Markdown-разметки.
        source = RawUrlRegex.Replace(source, delegate(Match match)
        {
            string url = match.Value;
            string punctuation = ExtractTrailingPunctuation(ref url);

            string linkId = "raw-link-" + linkCounter++;
            linkTargets[linkId] = url;

            string tmpLink =
                "<link=\"" + linkId + "\">" +
                "<color=#" + linkColorHex + ">" +
                "<u>" + url + "</u>" +
                "</color>" +
                "</link>";

            return StoreToken(tokens, tmpLink) + punctuation;
        });

        string[] lines = source.Split('\n');

        for (int i = 0; i < lines.Length; i++)
            lines[i] = ConvertLine(lines[i]);

        string result = string.Join("\n", lines);

        // Возвращаем защищённые ссылки и inline-код.
        for (int i = 0; i < tokens.Count; i++)
        {
            result = result.Replace(
                CreateToken(i),
                tokens[i]);
        }

        // Выводим исходные < и > как обычные символы.
        result = result
            .Replace(
                LiteralLessThan,
                "<noparse><</noparse>")
            .Replace(
                LiteralGreaterThan,
                "<noparse>></noparse>");

        return result;
    }

    private static string ConvertLine(string line)
    {
        Match heading = HeadingRegex.Match(line);

        if (heading.Success)
        {
            int level = heading.Groups[1].Value.Length;

            int sizePercent;

            switch (level)
            {
                case 1:
                    sizePercent = 150;
                    break;
                case 2:
                    sizePercent = 135;
                    break;
                case 3:
                    sizePercent = 120;
                    break;
                case 4:
                    sizePercent = 110;
                    break;
                default:
                    sizePercent = 100;
                    break;
            }

            return
                "<size=" + sizePercent + "%>" +
                "<b>" +
                ApplyInlineStyles(heading.Groups[2].Value) +
                "</b>" +
                "</size>";
        }

        Match bullet = BulletRegex.Match(line);

        if (bullet.Success)
        {
            return
                bullet.Groups[1].Value +
                "• " +
                ApplyInlineStyles(bullet.Groups[2].Value);
        }

        Match quote = QuoteRegex.Match(line);

        if (quote.Success)
        {
            return
                quote.Groups[1].Value +
                "<color=#A0A0A0>│ " +
                ApplyInlineStyles(quote.Groups[2].Value) +
                "</color>";
        }

        return ApplyInlineStyles(line);
    }

    private static string ApplyInlineStyles(string text)
    {
        text = BoldItalicRegex.Replace(
            text,
            "<b><i>$1</i></b>");

        text = BoldAsteriskRegex.Replace(
            text,
            "<b>$1</b>");

        text = BoldUnderscoreRegex.Replace(
            text,
            "<b>$1</b>");

        text = StrikeRegex.Replace(
            text,
            "<s>$1</s>");

        text = ItalicAsteriskRegex.Replace(
            text,
            "<i>$1</i>");

        text = ItalicUnderscoreRegex.Replace(
            text,
            "<i>$1</i>");

        return text;
    }

    private static string StoreToken(
        IList<string> tokens,
        string value)
    {
        int index = tokens.Count;
        tokens.Add(value);

        return CreateToken(index);
    }

    private static string CreateToken(int index)
    {
        return TokenStart + index + TokenEnd;
    }

    private static string ExtractTrailingPunctuation(ref string url)
    {
        string punctuation = string.Empty;

        while (url.Length > 0)
        {
            char last = url[url.Length - 1];

            if (last != '.' &&
                last != ',' &&
                last != ';' &&
                last != ':' &&
                last != '!' &&
                last != '?')
            {
                break;
            }

            punctuation = last + punctuation;
            url = url.Substring(0, url.Length - 1);
        }

        return punctuation;
    }
}