refactor: use fork of MdXaml for Markdown

This commit is contained in:
Anna 2021-02-07 00:17:12 -05:00
parent 787bf93dbd
commit bfa603d55a
Signed by: anna
GPG Key ID: 0B391D8F06FCD9E0
8 changed files with 53 additions and 450 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "MdXaml"]
path = MdXaml
url = https://github.com/ascclemens/MdXaml

1
MdXaml Submodule

@ -0,0 +1 @@
Subproject commit e2b314e3ab37d7ba492d65653538f5e622380da1

View File

@ -22,6 +22,12 @@
SourceInitialized="MainWindow_OnSourceInitialized"
Closing="MainWindow_OnClosing"
d:DataContext="{d:DesignInstance local:MainWindow}">
<local:XivChatWindow.CommandBindings>
<CommandBinding Command="local:MainWindow.OpenLink"
Executed="OpenLink_Execute"
CanExecute="OpenLink_CanExecute" />
</local:XivChatWindow.CommandBindings>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />

View File

@ -2,6 +2,8 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Net;
using System.Runtime.CompilerServices;
using System.Text;
using System.Windows;
@ -123,6 +125,34 @@ namespace XIVChat_Desktop {
this.App.Connection?.ChangeChannel(param);
}
public static readonly RoutedUICommand OpenLink = new(
"OpenLink",
"OpenLink",
typeof(MainWindow)
);
private void OpenLink_CanExecute(object sender, CanExecuteRoutedEventArgs e) {
e.CanExecute = true;
}
private void OpenLink_Execute(object sender, ExecutedRoutedEventArgs e) {
if (!(e.Parameter is string param)) {
return;
}
try {
var uri = new Uri(param);
if (uri.Scheme == "http" || uri.Scheme == "https") {
Process.Start(new ProcessStartInfo {
FileName = uri.ToString(),
UseShellExecute = true,
});
}
} catch (Exception ex) {
Console.WriteLine(ex.ToString());
}
}
#endregion
public App App => (App) Application.Current;

View File

@ -1,448 +0,0 @@
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;
}
}
}

View File

@ -6,12 +6,16 @@ using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using MdXaml;
using XIVChatCommon.Message;
using XIVChatCommon.Message.Server;
namespace XIVChat_Desktop {
public class MessageFormatter {
public static class MessageFormatter {
private static readonly BitmapFrame FontIcon = BitmapFrame.Create(new Uri("pack://application:,,,/Resources/fonticon_ps4.tex.png"));
private static readonly Markdown Markdown = new() {
HyperlinkCommand = MainWindow.OpenLink,
};
public static IEnumerable<Inline> ChunksToTextBlock(ServerMessage message, double lineHeight, bool processMarkdown, bool showTimestamp) {
var elements = new List<Inline>();
@ -37,7 +41,7 @@ namespace XIVChat_Desktop {
var style = textChunk.Italic ? FontStyles.Italic : FontStyles.Normal;
if (processMarkdown) {
var inlines = Markdown.MarkdownToInlines(textChunk.Content);
var inlines = Markdown.RunSpanGamut(textChunk.Content);
foreach (var inline in inlines) {
inline.Foreground = brush;

View File

@ -70,6 +70,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MdXaml\MdXaml\MdXaml.csproj" />
<ProjectReference Include="..\XIVChatCommon\XIVChatCommon.csproj" />
</ItemGroup>

View File

@ -13,6 +13,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XIVChat Desktop", "XIVChat
EndProject
Project("{930C7802-8A8C-48F9-8165-68863BCCD9DD}") = "XIVChat Desktop Installer", "XIVChat Desktop Installer\XIVChat Desktop Installer.wixproj", "{6017ABE2-E82F-476D-B3DA-18B634E092F2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MdXaml", "MdXaml\MdXaml\MdXaml.csproj", "{084B2C7D-8C0B-4774-A6E1-D804EE1A6D24}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -33,6 +35,10 @@ Global
{D2773EAE-6B9E-4017-9681-585AAB5A99F4}.Release|Any CPU.Build.0 = Release|Any CPU
{6017ABE2-E82F-476D-B3DA-18B634E092F2}.Debug|Any CPU.ActiveCfg = Debug|x64
{6017ABE2-E82F-476D-B3DA-18B634E092F2}.Release|Any CPU.ActiveCfg = Release|x64
{084B2C7D-8C0B-4774-A6E1-D804EE1A6D24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{084B2C7D-8C0B-4774-A6E1-D804EE1A6D24}.Debug|Any CPU.Build.0 = Debug|Any CPU
{084B2C7D-8C0B-4774-A6E1-D804EE1A6D24}.Release|Any CPU.ActiveCfg = Release|Any CPU
{084B2C7D-8C0B-4774-A6E1-D804EE1A6D24}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE