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
}
}
}