XIVChat/XIVChat Desktop/Markdown.cs

449 lines
16 KiB
C#

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<Inline> 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<Inline> NodesToInlines(MarkdownNodeWithChildren root) {
var openersBottom = new Dictionary<int, Dictionary<char, LinkedListNode<DelimiterNode>?>>();
for (var i = 0; i < 3; i++) {
openersBottom[i] = new Dictionary<char, LinkedListNode<DelimiterNode>?>();
foreach (var c in Delimiters) {
openersBottom[i][c] = null;
}
}
var delims = new LinkedList<DelimiterNode>();
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<MarkdownNode> Children { get; } = new LinkedList<MarkdownNode>();
protected MarkdownNodeWithChildren(MarkdownNode? parent) : base(parent) { }
public void AddChild(MarkdownNode node) {
node.Parent = this;
this.Children.AddLast(node);
}
/// <summary>
/// Replace this node with its children in its parent's children.
/// </summary>
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;
}
}
}