using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Xml.Linq; using UnityEditor; using UnityEngine; using UnityEngine.UIElements; namespace Unity.Tutorials.Core.Editor { /// /// Creates UIToolkit elements from a rich text. /// public static class RichTextParser { /// /// Tries to parse text to XDocument word by word - outputs the longest successful string before failing /// /// Content that contains a markdown error /// static string ParseUntilError(string faultyContent) { string longestString = ""; string previousLongestString = ""; string[] lines = faultyContent.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); foreach (string line in lines) { string[] words = line.Split(new[] { " " }, StringSplitOptions.None); foreach (string word in words) { longestString += word + " "; try { XDocument.Parse("" + longestString + ""); } catch { continue; } previousLongestString = longestString; } longestString += "\r\n"; } return previousLongestString; } /// /// Preprocess rich text - add space around tags. /// /// Text with tags /// Text with space around tags static string PreProcessRichText(string inputText) { string processed = inputText; processed = processed.Replace("", " "); processed = processed.Replace("", " "); processed = processed.Replace("", " "); processed = processed.Replace("", " "); processed = processed.Replace("", " "); processed = processed.Replace("", " "); processed = processed.Replace("", " "); processed = processed.Replace("", " "); return processed; } /// /// Helper function to detect if the string contains any characters in /// the Unicode range reserved for Chinese, Japanese and Korean characters. /// /// String to check for CJK letters. /// True if it contains Chinese, Japanese or Korean characters. static bool NeedSymbolWrapping(string textLine) { // Unicode range for CJK letters. // Range chosen from StackOverflow: https://stackoverflow.com/a/42411925 // Validated from sources: // https://www.unicode.org/faq/han_cjk.html // https://en.wikipedia.org/wiki/CJK_Unified_Ideographs_(Unicode_block) return textLine.Any(c => (uint)c >= 0x4E00 && (uint)c <= 0x2FA1F); } /// /// Adds a new wrapping word Label to the target visualElement. Type can be BoldLabel, ItalicLabel or Label /// /// /// The text inside the word label. /// Redundant storage, mostly used for automated testing. /// Parent container for the word Label. static Label AddLabel(string textToAdd, List elementList, VisualElement targetContainer) where T : Label { Type labelType = typeof(T); Func generator = null; if (labelType == typeof(ItalicLabel)) { generator = () => new ItalicLabel(textToAdd); } else if (labelType == typeof(BoldLabel)) { generator = () => new BoldLabel(textToAdd); } else if (labelType == typeof(TextLabel)) { generator = () => new TextLabel(textToAdd); } else if (labelType == typeof(WhiteSpaceLabel)) { generator = () => new WhiteSpaceLabel(textToAdd); } if (generator == null) { Debug.LogError("Error: Unsupported Label type used. Use TextLabel, BoldLabel or ItalicLabel."); return null; } return TrackAndAddGeneratedElement(generator, targetContainer, elementList) as Label; } static VisualElement GenerateParseErrorLabel(string errorText) { var label = new ParseErrorLabel() { text = Localization.Tr(LocalizationKeys.k_TutorialLabelParseError), tooltip = Localization.Tr(LocalizationKeys.k_TutorialLabelParseErrorTooltip) }; label.RegisterCallback((e) => Debug.LogError(errorText)); return label; } static VisualElement GenerateLinkLabel(string text, string url) { var label = new HyperlinkLabel { text = text, tooltip = url }; EventCallback linkCallback = null; string actualPath = ""; // Link points to a relative directory if (url[0] == '.') { if (url.Length == 1) //current folder { actualPath = Path.GetFullPath(url); } else if (url.StartsWith("./")) { if (url.Length == 2) //current folder { actualPath = Path.GetFullPath(url); } else //Folder within the current one, or parent folder { actualPath = Path.GetFullPath(url.Remove(0, 2)); } } else //local asset, assume the "." is there only because the user doesn't know about the "./" notation { actualPath = Path.GetFullPath(url.Remove(0, 1)); } linkCallback = (evt, path) => { EditorUtility.OpenWithDefaultApp(path); }; } else { actualPath = url; linkCallback = (evt, path) => { TutorialEditorUtils.OpenUrl(path); }; } label.RegisterCallback(linkCallback, actualPath); return label; } /// /// Transforms HTML tags to word element labels with different styles to enable rich text. /// /// /// /// The following need to set for the container's style: /// flex-direction: row; /// flex-wrap: wrap; /// /// List of VisualElements made from the parsed text. public static List RichTextToVisualElements(string htmlText, VisualElement targetContainer) { targetContainer.Clear(); var generatedElements = new List(); // start streaming text per word to elements while retaining current style for each word block string[] lines = htmlText.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); RenderLines(lines, targetContainer, generatedElements); return generatedElements; } static void RenderLines(string[] lines, VisualElement targetContainer, List elements) { bool boldOn = false; // sets this on sets off bool italicOn = false; // bool forceWordWrap = false; bool linkOn = false; string linkURL = ""; string styleClass = ""; bool addStyle = false; bool firstLine = true; bool lastLineHadText = false; foreach (string line in lines) { // Check if the line begins with whitespace and turn that into corresponding Label string initialWhiteSpaces = ""; foreach (char singleCharacter in line) { if (singleCharacter == ' ' || singleCharacter == '\t') { initialWhiteSpaces += singleCharacter; } else { break; } } string processedLine = PreProcessRichText(line); // Separate the line into words string[] words = processedLine.Split(new[] { " ", "\t" }, StringSplitOptions.RemoveEmptyEntries); if (!lastLineHadText) { if (!firstLine) { elements.Add(AddParagraphToElement(targetContainer)); } if (initialWhiteSpaces.Length > 0) { AddLabel(initialWhiteSpaces, elements, targetContainer); } } if (!firstLine && lastLineHadText) { elements.Add(AddLinebreakToElement(targetContainer)); lastLineHadText = false; if (initialWhiteSpaces.Length > 0) { AddLabel(initialWhiteSpaces, elements, targetContainer); } } RenderWords(words, ref lastLineHadText, ref boldOn, ref italicOn, ref forceWordWrap, ref linkOn, ref addStyle, ref linkURL, ref styleClass, elements, targetContainer); firstLine = false; } } static void RenderWords(string[] words, ref bool lastLineHadText, ref bool boldOn, ref bool italicOn, ref bool forceWordWrap, ref bool linkOn, ref bool addStyle, ref string linkURL, ref string styleClass, List elements, VisualElement targetContainer) { foreach (string word in words) { // Wrap every character instead of word in case of Chinese and Japanese // Note: override with Force word wrapping here if (word == "" || word == " " || word == " ") { continue; } lastLineHadText = true; string strippedWord = word; bool removeBold = false; bool removeItalic = false; bool addParagraph = false; bool removeLink = false; bool removeWordWrap = false; bool removeStyle = false; strippedWord = strippedWord.Trim(); if (strippedWord.Contains("")) { strippedWord = strippedWord.Replace("", ""); boldOn = true; } if (strippedWord.Contains("")) { strippedWord = strippedWord.Replace("", ""); italicOn = true; } if (strippedWord.Contains("")) { strippedWord = strippedWord.Replace("", ""); forceWordWrap = true; } if (strippedWord.Contains("", ""); } if (addStyle && strippedWord.Contains("class=")) { strippedWord = strippedWord.Replace("class=", ""); int styleFrom = strippedWord.IndexOf("\"", StringComparison.Ordinal) + 1; int styleTo = strippedWord.LastIndexOf("\"", StringComparison.Ordinal); styleClass = strippedWord.Substring(styleFrom, styleTo - styleFrom); strippedWord = strippedWord.Substring(styleTo + 2, (strippedWord.Length - 2) - styleTo); strippedWord.Replace("\">", ""); } if (strippedWord.Contains("&")) { strippedWord = WebUtility.HtmlDecode(strippedWord); } if (strippedWord.Contains("")) { strippedWord = strippedWord.Replace("", ""); removeLink = true; } if (strippedWord.Contains("")) { strippedWord = strippedWord.Replace("", ""); removeStyle = true; } if (strippedWord.Contains("
")) { strippedWord = strippedWord.Replace("
", ""); addParagraph = true; } if (strippedWord.Contains("
")) { strippedWord = strippedWord.Replace("
", ""); removeBold = true; } if (strippedWord.Contains("")) { strippedWord = strippedWord.Replace("", ""); removeItalic = true; } if (strippedWord.Contains("")) { strippedWord = strippedWord.Replace("", ""); removeWordWrap = true; } if (boldOn && strippedWord != "") { if (wrapCharacters) { foreach (char character in strippedWord) { AddLabel(character.ToString(), elements, targetContainer); } } else { AddLabel(strippedWord, elements, targetContainer); } } else if (italicOn && strippedWord != "") { if (wrapCharacters) { foreach (char character in strippedWord) { AddLabel(character.ToString(), elements, targetContainer); } } else { AddLabel(strippedWord, elements, targetContainer); } } else if (addParagraph) { elements.Add(AddParagraphToElement(targetContainer)); } else if (linkOn && !string.IsNullOrEmpty(linkURL)) { string url = linkURL; TrackAndAddGeneratedElement(() => GenerateLinkLabel(strippedWord, url), targetContainer, elements); } else { if (strippedWord != "") { if (wrapCharacters) { foreach (char character in strippedWord) { Label newLabel = AddLabel(character.ToString(), elements, targetContainer); if (addStyle && !string.IsNullOrEmpty(styleClass)) { newLabel.AddToClassList(styleClass); } } } else { Label newLabel = AddLabel(strippedWord, elements, targetContainer); if (addStyle && !string.IsNullOrEmpty(styleClass)) { newLabel.AddToClassList(styleClass); } } } } if (removeBold) { boldOn = false; } if (removeItalic) { italicOn = false; } if (removeLink) { linkOn = false; linkURL = ""; } if (removeStyle) { addStyle = false; } if (removeWordWrap) { forceWordWrap = false; } } } static VisualElement TrackAndAddGeneratedElement(Func generator, VisualElement targetContainer, List trackedElements) { VisualElement newElement = generator.Invoke(); targetContainer.Add(newElement); trackedElements.Add(newElement); return newElement; } static VisualElement AddLinebreakToElement(VisualElement elementTo) { Label wordLabel = new Label(" "); wordLabel.style.flexDirection = FlexDirection.Row; wordLabel.style.flexGrow = 1f; wordLabel.style.width = 3000f; wordLabel.style.height = 0f; elementTo.Add(wordLabel); return wordLabel; } static VisualElement AddParagraphToElement(VisualElement elementTo) { Label wordLabel = new Label(" "); wordLabel.style.flexDirection = FlexDirection.Row; wordLabel.style.flexGrow = 1f; wordLabel.style.width = 3000f; elementTo.Add(wordLabel); return wordLabel; } // Dummy classes so that we can customize the styles from a USS file. /// /// Label for the red parser error displayed where the parsing fails /// public class ParseErrorLabel : Label { } /// /// Text label for links /// public class HyperlinkLabel : Label { } /// /// Text label for text that wraps per word /// public class TextLabel : Label { /// /// Constructs with text. /// /// public TextLabel(string text) : base(text) { } } /// /// Text label for white space used to indent lines /// public class WhiteSpaceLabel : Label { /// /// Constructs with text. /// /// public WhiteSpaceLabel(string text) : base(text) { } } /// /// Text label with bold style /// public class BoldLabel : Label { /// /// Constructs with text. /// /// public BoldLabel(string text) : base(text) { } } /// /// Text label with italic style /// public class ItalicLabel : Label { /// /// Constructs with text. /// /// public ItalicLabel(string text) : base(text) { } } } }