using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Windows; using System.Windows.Documents; namespace XIVChat_Desktop { public static class Markdown { private readonly static char[] Delimiters = { '*', '_', '~', }; public static IEnumerable MarkdownToInlines(string input) { var nodes = ParseNodes(input); return NodesToInlines(nodes); } private static ContainerNode ParseNodes(string input) { var root = new ContainerNode(); DelimiterNode? run = null; char? last = null; var escaping = false; var segment = new StringBuilder(); void AddTextRun() { if (segment.Length == 0) { return; } root.AddChild(new TextNode(root, segment.ToString())); segment.Clear(); } void ProcessExistingRun() { // determine the flank run.DetermineFlank(); // if flank is still unknown, this is an invalid run var isUnknown = run.flank == DelimiterNode.Flank.Unknown; // also if strikethrough does not contain two delimiters, this is an invalid run var notLongEnough = run.character == '~' && run.length < 2; if (isUnknown || notLongEnough) { // return these characters to the string for (var i = 0; i < run.length; i++) { segment.Append(run.character); } // remove the run goto RemoveRun; } // at this point, we're going to add the run, so also add the text if necessary AddTextRun(); // add the run to the list root.AddChild(run); RemoveRun: // remove the run from working area run = null; } var chars = input.ToCharArray(); foreach (var c in chars) { var append = true; if (c == '\\' && !escaping) { escaping = true; append = false; } // these characters can form delimiter runs if (Delimiters.Contains(c) && !escaping) { // don't add this character to the text segment append = false; if (run != null && run.character != c) { run.following = c; ProcessExistingRun(); } // create a run if necessary run ??= new DelimiterNode(root, c) { preceding = last, length = 0, originalLength = 0, }; // increase the run's length run.length += 1; run.originalLength += 1; // skip to the end goto Finish; } // the last character was a delimiter but this character is not // we know that this character is not a delimiter because of the goto if (run != null && last != null && Delimiters.Contains((char)last)) { // set the following character to this character run.following = c; ProcessExistingRun(); } Finish: last = c; if (!append) { continue; } if (escaping) { escaping = false; if (!c.IsAsciiPunctuation()) { segment.Append('\\'); } } segment.Append(c); } // re-add backslashes as final character if (escaping) { segment.Append('\\'); } // if we ended on a run, process it if (run != null) { ProcessExistingRun(); } AddTextRun(); return root; } private static IEnumerable NodesToInlines(MarkdownNodeWithChildren root) { var openersBottom = new Dictionary?>>(); for (var i = 0; i < 3; i++) { openersBottom[i] = new Dictionary?>(); foreach (var c in Delimiters) { openersBottom[i][c] = null; } } var delims = new LinkedList(); var node = root.Children.First; while (node != null) { if (node.Value is DelimiterNode dRun) { delims.AddLast(dRun); } node = node.Next; } // no emphasis to process, so just return all nodes as-is if (delims.Count == 0) { return root.Children.Select(child => child.ToInline()); } // move forward looking for closers var closer = delims.First; while (closer != null) { if (!closer.Value.CanClose) { closer = closer.Next; continue; } // found first emphasis closer. now look back for first matching opener var opener = closer.Previous; var openerFound = false; var bottom = openersBottom[closer.Value.originalLength % 3][closer.Value.character]; while (opener != null && opener != bottom) { var oddMatch = (closer.Value.CanOpen || opener.Value.CanClose) && closer.Value.originalLength % 3 != 0 && (opener.Value.originalLength + closer.Value.originalLength) % 3 == 0; if (opener.Value.character == closer.Value.character && opener.Value.CanOpen && !oddMatch) { openerFound = true; break; } opener = opener.Previous; } var oldCloser = closer; // emphasis and strikethrough // https://spec.commonmark.org/0.29/#emphasis-and-strong-emphasis // https://github.github.com/gfm/#strikethrough-extension- if (Delimiters.Contains(closer.Value.character)) { if (!openerFound) { closer = closer.Next; } else { if (opener == null) { throw new InvalidOperationException(); } MarkdownNodeWithChildren emph; var isStrike = closer.Value.character == '~'; if (isStrike) { opener.Value.length -= 2; closer.Value.length -= 2; emph = new StrikethroughNode(root); } else { var useDelims = closer.Value.length >= 2 && opener.Value.length >= 2 ? 2 : 1; opener.Value.length -= useDelims; closer.Value.length -= useDelims; emph = useDelims == 1 ? (MarkdownNodeWithChildren)new EmphasisNode(root) : new StrongNode(root); } var runsOpener = root.Children.Find(opener.Value); var runsCloser = root.Children.Find(closer.Value); var tmp = runsOpener!.Next; while (tmp != null && tmp != runsCloser) { emph.AddChild(tmp.Value); var oldTmp = tmp; tmp = tmp.Next; root.Children.Remove(oldTmp); if (oldTmp.Value is DelimiterNode dNode) { delims.Remove(dNode); } } root.Children.AddAfter(runsOpener, emph); if (opener.Value.length == 0) { delims.Remove(opener); root.Children.Remove(runsOpener); } if (closer.Value.length == 0) { closer = closer.Next; delims.Remove(oldCloser); root.Children.Remove(runsCloser!); } } } if (openerFound) { continue; } openersBottom[oldCloser.Value.originalLength % 3][oldCloser.Value.character] = oldCloser.Previous; if (!oldCloser.Value.CanOpen) { delims.Remove(oldCloser); } } return root.Children.Select(child => child.ToInline()); } } public abstract class MarkdownNode { public MarkdownNode? Parent { get; set; } protected MarkdownNode(MarkdownNode? parent = null) { this.Parent = parent; } public abstract Inline ToInline(); } public class TextNode : MarkdownNode { public string Text { get; set; } public TextNode(MarkdownNode parent, string text) : base(parent) { this.Text = text; } public override Inline ToInline() => new Run(this.Text); } public class DelimiterNode : MarkdownNode { public enum Flank { Unknown, Left, Right, Both, } public readonly char character; public char? preceding; public char? following; public int originalLength; public int length; public Flank flank = Flank.Unknown; public bool CanOpen => this.CalcCanOpen(); public bool CanClose => this.CalcCanClose(); public DelimiterNode(MarkdownNode parent, char character) : base(parent) { this.character = character; } public override Inline ToInline() { var text = new StringBuilder(this.length); for (var i = 0; i < this.length; i++) { text.Append(this.character); } return new Run(text.ToString()); } private bool CalcCanOpen() { switch (this.character) { case '*' when (this.flank == Flank.Left || this.flank == Flank.Both): case '_' when (this.flank == Flank.Left || (this.flank == Flank.Both && this.preceding?.IsAsciiPunctuation() == true)): case '~' when (this.length >= 2 && (this.flank == Flank.Left || this.flank == Flank.Both)): return true; default: return false; } } private bool CalcCanClose() { switch (this.character) { case '*' when (this.flank == Flank.Right || this.flank == Flank.Both): case '_' when (this.flank == Flank.Right || (this.flank == Flank.Both && this.following?.IsAsciiPunctuation() == true)): case '~' when (this.length >= 2 && (this.flank == Flank.Right || this.flank == Flank.Both)): return true; default: return false; } } public void DetermineFlank() { var followedByWhitespace = this.following?.IsWhitespace() ?? true; var followedByPunctuation = this.following?.IsAsciiPunctuation() ?? false; var precededByWhitespace = this.preceding?.IsWhitespace() ?? true; var precededByPunctuation = this.preceding?.IsAsciiPunctuation() ?? false; var isLeft = false; var isRight = false; // A left-flanking delimiter run is a delimiter run that is (1) not followed by Unicode whitespace, and // either (2a) not followed by a punctuation character, or (2b) followed by a punctuation character and // preceded by Unicode whitespace or a punctuation character. For purposes of this definition, the beginning // and the end of the line count as Unicode whitespace. if (!followedByWhitespace && (!followedByPunctuation || (followedByPunctuation && (precededByWhitespace || precededByPunctuation)))) { isLeft = true; } // A right-flanking delimiter run is a delimiter run that is (1) not preceded by Unicode whitespace, and // either (2a) not preceded by a punctuation character, or (2b) preceded by a punctuation character and // followed by Unicode whitespace or a punctuation character. For purposes of this definition, the beginning // and the end of the line count as Unicode whitespace. if (!precededByWhitespace && (!precededByPunctuation || (precededByPunctuation && (followedByWhitespace || followedByPunctuation)))) { isRight = true; } if (isLeft && isRight) { this.flank = Flank.Both; } else if (isLeft) { this.flank = Flank.Left; } else if (isRight) { this.flank = Flank.Right; } } } public class EmphasisNode : MarkdownNodeWithChildren { public EmphasisNode(MarkdownNode parent) : base(parent) { } public override Inline ToInline() { var span = new Span { FontStyle = FontStyles.Italic, }; span.Inlines.AddRange(this.Children.Select(child => child.ToInline())); return span; } } public class StrongNode : MarkdownNodeWithChildren { public StrongNode(MarkdownNode parent) : base(parent) { } public override Inline ToInline() { var span = new Span { FontWeight = FontWeights.Bold, }; span.Inlines.AddRange(this.Children.Select(child => child.ToInline())); return span; } } public class StrikethroughNode : MarkdownNodeWithChildren { public StrikethroughNode(MarkdownNode parent) : base(parent) { } public override Inline ToInline() { var span = new Span(); span.TextDecorations.Add(TextDecorations.Strikethrough); span.Inlines.AddRange(this.Children.Select(child => child.ToInline())); return span; } } public abstract class MarkdownNodeWithChildren : MarkdownNode { public LinkedList Children { get; } = new LinkedList(); protected MarkdownNodeWithChildren(MarkdownNode? parent) : base(parent) { } public void AddChild(MarkdownNode node) { node.Parent = this; this.Children.AddLast(node); } /// /// Replace this node with its children in its parent's children. /// public void PopUp() { if (!(this.Parent is MarkdownNodeWithChildren parent)) { return; } var us = parent.Children.Find(this); if (us == null) { return; } foreach (var child in this.Children) { child.Parent = parent; parent.Children.AddBefore(us, child); parent.Children.Remove(us); } } } public class ContainerNode : MarkdownNodeWithChildren { public ContainerNode(MarkdownNode? parent = null) : base(parent) { } public override Inline ToInline() { var span = new Span(); span.Inlines.AddRange(this.Children.Select(child => child.ToInline())); return span; } } }