Загрузка данных
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;
}
}