using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Text.RegularExpressions; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using MLEM.Formatting; using MLEM.Misc; using MLEM.Textures; using MLEM.Ui.Elements; using MLEM.Ui.Style; namespace MLEM.Ui.Parsers { /// /// A class for parsing Markdown strings into a set of MLEM.Ui elements with styling for each individual . /// To parse, use or . To style the parsed output, use before parsing. /// /// /// Note that this parser is rather rudimentary and doesn't deal well with very complex Markdown documents. Missing features are as follows: /// /// Lines that end without a double space are still converted to distinct lines rather than being merged with the next line /// Better list handling, including nested lists /// Horizontal rules /// Tables /// /// public class UiMarkdownParser { private static readonly ElementType[] ElementTypes = EnumHelper.GetValues().ToArray(); /// /// The base path for markdown images, which is prepended to the image link. /// public string ImageBasePath; /// /// An action that is invoked when an image fails to load while parsing. /// This action receives the expected location of the image, as well as the that occured. /// public Action ImageExceptionHandler; /// /// The graphics device that should be used when loading images and other graphics-dependent content. /// public GraphicsDevice GraphicsDevice; /// /// The name of the font used for inline code as well as code blocks. /// This only has an effect if a font with this name is added to the used 's . /// This defaults to "Monospaced" if default styling is applied in . /// public string CodeFont; private readonly Dictionary> elementStyles = new Dictionary>(); /// /// Creates a new UI markdown parser and optionally initializes some default style settings. /// /// Whether default style settings should be applied. public UiMarkdownParser(bool applyDefaultStyling = true) { if (applyDefaultStyling) { this.CodeFont = "Monospaced"; this.Style(ElementType.VerticalSpace, v => v.Size = new Vector2(1, 5)); for (var i = 0; i < 6; i++) { var level = i; this.Style(UiMarkdownParser.ElementTypes[Array.IndexOf(UiMarkdownParser.ElementTypes, ElementType.Header1) + i], p => { p.Alignment = TextAlignment.Center; p.TextScaleMultiplier = 2 - level * 0.15F; }); } } } /// /// Parses the given markdown string into a set of elements (using ) and adds them as children to the givem . /// During this process, the element stylings specified using are also applied. /// /// The markdown to parse. /// The element to add the parsed elements to. /// The , for chaining. public Element ParseInto(string markdown, Element element) { foreach (var (_, e) in this.Parse(markdown)) element.AddChild(e); return element; } /// /// Parses the given markdown string into a set of elements and returns them along with their . /// During this process, the element stylings specified using are also applied. /// /// The markdown to parse. /// The parsed elements. public IEnumerable<(ElementType, Element)> Parse(string markdown) { foreach (var (t, e) in this.ParseUnstyled(markdown)) { if (this.elementStyles.TryGetValue(t, out var style)) style.Invoke(e); yield return (t, e); } } /// /// Specifies an action to be invoked when a new element with the given is parsed in or . /// These actions can be used to modify the style properties of the created elements. /// /// The element types that should be styled. Can be a combined flag. /// The action that styles the elements with the given element type. /// Whether the function should be added to the existing style settings, or replace them. /// The type of elements that the given flags are expected to be. /// This parser, for chaining. public UiMarkdownParser Style(ElementType types, Action style, bool add = false) where T : Element { foreach (var type in UiMarkdownParser.ElementTypes) { if (types.HasFlag(type)) { if (add && this.elementStyles.ContainsKey(type)) { this.elementStyles[type] += Action; } else { this.elementStyles[type] = Action; } } } return this; void Action(Element e) { style.Invoke(e as T ?? throw new ArgumentException($"Expected {typeof(T)} for style action but got {e.GetType()}")); } } private IEnumerable<(ElementType, Element)> ParseUnstyled(string markdown) { var inCodeBlock = false; foreach (var line in markdown.Split('\n')) { // code blocks if (line.Trim().StartsWith("```")) { inCodeBlock = !inCodeBlock; continue; } // code block content if (inCodeBlock) { yield return (ElementType.CodeBlock, new Paragraph(Anchor.AutoLeft, 1, $"{line}")); continue; } // quotes if (line.StartsWith(">")) { yield return (ElementType.Blockquote, new Paragraph(Anchor.AutoLeft, 1, line.Substring(1).Trim())); continue; } // vertical space (empty lines) if (line.Trim().Length <= 0) { yield return (ElementType.VerticalSpace, new VerticalSpace(0)); continue; } // images var imageMatch = Regex.Match(line, @"!\[\]\(([^)]+)\)"); if (imageMatch.Success) { if (this.GraphicsDevice == null) throw new NullReferenceException("A markdown parser requires a GraphicsDevice for parsing images"); TextureRegion image = null; LoadImageAsync(); yield return (ElementType.Image, new Image(Anchor.AutoLeft, new Vector2(1, -1), _ => image) { OnDisposed = e => image?.Texture.Dispose() }); async void LoadImageAsync() { var loc = imageMatch.Groups[1].Value; // only apply the base path for relative files if (this.ImageBasePath != null && !loc.StartsWith("http") && !Path.IsPathRooted(loc)) loc = $"{this.ImageBasePath}/{loc}"; try { Texture2D tex; if (loc.StartsWith("http")) { using (var client = new HttpClient()) { using (var src = await client.GetStreamAsync(loc)) { using (var memory = new MemoryStream()) { // download the full stream before passing it to texture await src.CopyToAsync(memory); tex = Texture2D.FromStream(this.GraphicsDevice, memory); } } } } else { using (var stream = Path.IsPathRooted(loc) ? File.OpenRead(loc) : TitleContainer.OpenStream(loc)) tex = Texture2D.FromStream(this.GraphicsDevice, stream); } image = new TextureRegion(tex); } catch (Exception e) { if (this.ImageExceptionHandler != null) { this.ImageExceptionHandler.Invoke(loc, e); } else { throw new Exception($"Couldn't parse image {loc}, and no ImageExceptionHandler was set", e); } } } continue; } // headers var parsedHeader = false; for (var h = 6; h >= 1; h--) { if (line.StartsWith(new string('#', h))) { var type = UiMarkdownParser.ElementTypes[Array.IndexOf(UiMarkdownParser.ElementTypes, ElementType.Header1) + h - 1]; yield return (type, new Paragraph(Anchor.AutoLeft, 1, line.Substring(h).Trim())); parsedHeader = true; break; } } if (parsedHeader) continue; // parse everything else as a paragraph (with formatting) var par = line; // replace links par = Regex.Replace(par, @"<([^>]+)>", "$1"); par = Regex.Replace(par, @"\[([^\]]+)\]\(([^)]+)\)", "$1"); // replace formatting par = Regex.Replace(par, @"\*\*([^\*]+)\*\*", "$1"); par = Regex.Replace(par, @"__([^_]+)__", "$1"); par = Regex.Replace(par, @"\*([^\*]+)\*", "$1"); par = Regex.Replace(par, @"_([^_]+)_", "$1"); par = Regex.Replace(par, @"~~([^~]+)~~", "$1"); // replace inline code with custom code font par = Regex.Replace(par, @"`([^`]+)`", $"$1"); yield return (ElementType.Paragraph, new Paragraph(Anchor.AutoLeft, 1, par)); } } /// /// A flags enumeration used by that contains the types of elements that can be parsed and returned in or . /// This is a flags enumeration so that can have multiple element types being styled at the same time. /// [Flags] public enum ElementType { /// /// A blockquote. /// This element type is a . /// Blockquote = 1, /// /// A vertical space, which is a gap between multiple markdown paragraphs. /// This element type is a . /// VerticalSpace = 2, /// /// An image. /// This element type is an . /// Image = 4, /// /// A header with header level 1. /// This element type is a . /// Header1 = 8, /// /// A header with header level 2. /// This element type is a . /// Header2 = 16, /// /// A header with header level 3. /// This element type is a . /// Header3 = 32, /// /// A header with header level 4. /// This element type is a . /// Header4 = 64, /// /// A header with header level 5. /// This element type is a . /// Header5 = 128, /// /// A header with header level 6. /// This element type is a . /// Header6 = 256, /// /// A combined flag that contains through . /// This element type is a . /// Headers = ElementType.Header1 | ElementType.Header2 | ElementType.Header3 | ElementType.Header4 | ElementType.Header5 | ElementType.Header6, /// /// A paragraph, which is one line of markdown text. /// This element type is a . /// Paragraph = 512, /// /// A single line of a code block. /// This element type is a . /// CodeBlock = 1024 } } }