12
\$\begingroup\$

I wanted to display a markdown document in WPF sensibly without relying on an HTML rendering system. I wrote a control based on the RichTextBox, and used Microsoft's markdown parser. What do you think?

XAML:

<RichTextBox x:Class="MarkdownViewer.MarkdownBox"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:MarkdownViewer"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800"
             x:Name="Content">
</RichTextBox>

C# backend for the XAML file:

public partial class MarkdownBox : RichTextBox
{
    public static readonly DependencyProperty TextProperty =
        DependencyProperty.Register(nameof(Text), typeof(string), typeof(MarkdownBox), new UIPropertyMetadata(default(string), PropertyChangedCallback));

    private static void PropertyChangedCallback(DependencyObject source, DependencyPropertyChangedEventArgs args)
    {
        if (source is MarkdownBox control)
        {
            var newValue = (string)args.NewValue;
            switch (args.Property.Name)
            {
                case nameof(Text):
                    control.Text = newValue;
                    break;
            }
        }
    }

    public string Text
    {
        get => (string)GetValue(TextProperty);
        set
        {
            var old = GetValue(TextProperty);
            SetValue(TextProperty, value);
            OnPropertyChanged(new DependencyPropertyChangedEventArgs(TextProperty, old, value));

            SetTextboxContent();
        }
    }

    public MarkdownBox()
    {
        InitializeComponent();
    }

    private void Hlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
    {
        Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri));
        e.Handled = true;
    }

    private void SetTextboxContent()
    {
        Content.Document.Blocks.Clear();

        var doc = new MarkdownDocument();
        doc.Parse(Text ?? string.Empty);

        Content.Document.Blocks.AddRange(GetBlocks(doc.Blocks));
    }

    private IEnumerable<Block> GetBlocks(IList<MarkdownBlock> blocks)
    {
        foreach (var block in blocks)
        {
            switch (block)
            {
                case HeaderBlock header:
                    yield return GetHeaderBlock(header);
                    break;
                case ParagraphBlock paragraph:
                    yield return GetParagraphBlock(paragraph);
                    break;
                case ListBlock list:
                    yield return GetListBlock(list);
                    break;
                case CodeBlock code:
                    yield return GetCodeBlock(code);
                    break;
                case QuoteBlock quote:
                    yield return GetQuoteBlock(quote);
                    break;
                case HorizontalRuleBlock rule:
                    yield return GetRuleBlock(rule);
                    break;
                case TableBlock table:
                    yield return GetTableBlock(table);
                    break;
                default:
                    throw new NotImplementedException();
            }
        }
    }

    private Block GetHeaderBlock(HeaderBlock header)
    {
        var headerLevels = new Dictionary<int, double>
        {
            [1] = 28,
            [2] = 21,
            [3] = 16.3833,
            [4] = 14,
            [5] = 11.6167,
            [6] = 9.38333,
        };

        var content = header.Inlines.Select(GetInline);
        var span = new Span();
        span.Inlines.AddRange(content);

        var labelElement = new Label
        {
            Content = span,
            FontSize = headerLevels[header.HeaderLevel]
        };
        var blockElement = new BlockUIContainer(labelElement);
        return blockElement;
    }

    private Block GetParagraphBlock(ParagraphBlock paragraph)
    {
        var paragraphElement = new Paragraph();
        paragraphElement.Inlines.AddRange(paragraph.Inlines.Select(GetInline));
        return paragraphElement;
    }

    private Block GetListBlock(ListBlock list)
    {
        var listElement = new List
        {
            MarkerStyle = list.Style == ListStyle.Bulleted ? TextMarkerStyle.Disc : TextMarkerStyle.Decimal
        };
        foreach (var item in list.Items)
        {
            var listItemElement = new ListItem();
            listItemElement.Blocks.AddRange(GetBlocks(item.Blocks));
            listElement.ListItems.Add(listItemElement);
        }

        return listElement;
    }

    private Block GetCodeBlock(CodeBlock code)
    {
        var typeConverter = new HighlightingDefinitionTypeConverter();
        var avalon = new TextEditor
        {
            Text = code.Text,
            SyntaxHighlighting = (IHighlightingDefinition)typeConverter.ConvertFrom("C#"),
            FontFamily = new FontFamily("Consolas"),
            FontSize = 12,
            Padding = new Thickness(10),
            BorderBrush = Brushes.LightGray,
            BorderThickness = new Thickness(1),
            HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
            VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
            IsReadOnly = true,
            ShowLineNumbers = true,
            MaxHeight = 250
        };

        return new BlockUIContainer(avalon);
    }

    private Block GetQuoteBlock(QuoteBlock quote)
    {
        var sectionElement = new Section
        {
            Background = new SolidColorBrush(Color.FromRgb(0xFF, 0xF8, 0xDC)),
            BorderBrush = new SolidColorBrush(Color.FromRgb(0xff, 0xeb, 0x8e)),
            BorderThickness = new Thickness(2, 0, 0, 0),
            Padding = new Thickness(5)
        };
        var quoteBlocks = GetBlocks(quote.Blocks).ToList();
        for (var i = 0; i < quoteBlocks.Count; i++)
        {
            var item = quoteBlocks[i];
            item.Padding = new Thickness(5, 0, 5, 0);
            item.Margin = new Thickness(0);
            sectionElement.Blocks.Add(item);
        }

        return sectionElement;
    }

    private Block GetRuleBlock(HorizontalRuleBlock rule)
    {
        var line = new Line
        {
            Stretch = Stretch.Fill,
            Stroke = Brushes.DarkGray,
            X2 = 1
        };
        return new Paragraph(new InlineUIContainer(line));
    }

    private Block GetTableBlock(TableBlock table)
    {
        var alignments = new Dictionary<ColumnAlignment, TextAlignment>
        {
            [ColumnAlignment.Center] = TextAlignment.Center,
            [ColumnAlignment.Left] = TextAlignment.Left,
            [ColumnAlignment.Right] = TextAlignment.Right,
            [ColumnAlignment.Unspecified] = TextAlignment.Justify
        };

        var tableElement = new Table
        {
            BorderThickness = new Thickness(0, 0, 1, 1),
            BorderBrush = new SolidColorBrush(Color.FromRgb(0xdf, 0xe2, 0xe5)),
            CellSpacing = 0
        };
        var tableRowGroup = new TableRowGroup();
        for (int rowIndex = 0; rowIndex < table.Rows.Count; rowIndex++)
        {
            var row = table.Rows[rowIndex];
            var tableRow = new TableRow();

            if (rowIndex % 2 == 0 && rowIndex != 0)
            {
                tableRow.Background = new SolidColorBrush(Color.FromRgb(0xf6, 0xf8, 0xfa));
            }

            for (int cellIndex = 0; cellIndex < row.Cells.Count; cellIndex++)
            {
                var cell = row.Cells[cellIndex];

                var cellContent = new Paragraph();
                cellContent.Inlines.AddRange(cell.Inlines.Select(GetInline));

                var tableCell = new TableCell
                {
                    TextAlignment = alignments[table.ColumnDefinitions[cellIndex].Alignment],
                    BorderBrush = new SolidColorBrush(Color.FromRgb(0xdf, 0xe2, 0xe5)),
                    BorderThickness = new Thickness(1, 1, 0, 0),
                    Padding = new Thickness(13, 6, 13, 6)
                };
                tableCell.Blocks.Add(cellContent);

                if (rowIndex == 0)
                {
                    tableCell.FontWeight = FontWeights.Bold;
                }

                tableRow.Cells.Add(tableCell);
            }

            tableRowGroup.Rows.Add(tableRow);
        }
        tableElement.RowGroups.Add(tableRowGroup);

        return tableElement;
    }

    private Inline GetInline(MarkdownInline element)
    {
        switch (element)
        {
            case BoldTextInline bold:
                return GetBoldInline(bold);
            case TextRunInline text:
                return GetTextRunInline(text);
            case ItalicTextInline italic:
                return GetItalicInline(italic);
            case StrikethroughTextInline strikethrough:
                return GetStrikethroughInline(strikethrough);
            case CodeInline code:
                return GetCodeInline(code);
            case MarkdownLinkInline markdownLink:
                return GetMarkdownLinkInline(markdownLink);
            case HyperlinkInline hyperlink:
                return GetHyperlinkInline(hyperlink);
            case ImageInline image:
                return GetImageInline(image);
            case SubscriptTextInline subscript:
                return GetSubscriptInline(subscript);
            case SuperscriptTextInline superscript:
                return GetSuperscriptInline(superscript);
            default:
                throw new NotImplementedException();
        }
    }

    private Inline GetBoldInline(BoldTextInline bold)
    {
        var boldElement = new Bold();
        foreach (var inline in bold.Inlines)
        {
            boldElement.Inlines.Add(GetInline(inline));
        }
        return boldElement;
    }

    private static Inline GetTextRunInline(TextRunInline text)
    {
        return new Run(text.ToString());
    }

    private Inline GetItalicInline(ItalicTextInline italic)
    {
        var italicElement = new Italic();
        foreach (var inline in italic.Inlines)
        {
            italicElement.Inlines.Add(GetInline(inline));
        }
        return italicElement;
    }

    private Inline GetStrikethroughInline(StrikethroughTextInline strikethrough)
    {
        var strikethroughElement = new Span();
        strikethroughElement.TextDecorations.Add(TextDecorations.Strikethrough);
        foreach (var inline in strikethrough.Inlines)
        {
            strikethroughElement.Inlines.Add(GetInline(inline));
        }
        return strikethroughElement;
    }

    private static Inline GetCodeInline(CodeInline code)
    {
        return new Run(code.Text)
        {
            Background = new SolidColorBrush(Color.FromRgb(0xef, 0xf0, 0xf1))
        };
    }

    private Inline GetMarkdownLinkInline(MarkdownLinkInline markdownLink)
    {
        var markdownLinkElement = new Hyperlink();
        markdownLinkElement.Inlines.AddRange(markdownLink.Inlines.Select(GetInline));
        markdownLinkElement.NavigateUri = new Uri(markdownLink.Url);
        markdownLinkElement.ToolTip = markdownLink.Tooltip;
        markdownLinkElement.RequestNavigate += Hlink_RequestNavigate;
        return markdownLinkElement;
    }

    private Inline GetHyperlinkInline(HyperlinkInline hyperlink)
    {
        var hyperlinkElement = new Hyperlink();
        hyperlinkElement.Inlines.Add(hyperlink.Text);
        hyperlinkElement.NavigateUri = new Uri(hyperlink.Url);
        hyperlinkElement.RequestNavigate += Hlink_RequestNavigate;
        return hyperlinkElement;
    }

    private static Inline GetImageInline(ImageInline image)
    {
        var uri = new Uri(image.RenderUrl);
        var bitmap = new BitmapImage(uri);
        var imageElement = new Image
        {
            Source = bitmap,
            Height = image.ImageHeight == 0 ? double.NaN : image.ImageHeight,
            Width = image.ImageWidth == 0 ? double.NaN : image.ImageWidth,
            ToolTip = image.Tooltip
        };
        return new InlineUIContainer(imageElement);
    }

    private Inline GetSubscriptInline(SubscriptTextInline subscript)
    {
        var subscriptElement = new Span();
        subscriptElement.Typography.Variants = FontVariants.Subscript;
        foreach (var inline in subscript.Inlines)
        {
            subscriptElement.Inlines.Add(GetInline(inline));
        }
        return subscriptElement;
    }

    private Inline GetSuperscriptInline(SuperscriptTextInline superscript)
    {
        var superscriptElement = new Span();
        superscriptElement.Typography.Variants = FontVariants.Superscript;
        foreach (var inline in superscript.Inlines)
        {
            superscriptElement.Inlines.Add(GetInline(inline));
        }
        return superscriptElement;
    }
}
\$\endgroup\$
4
  • \$\begingroup\$ I think everything below GetBlocks rendering each block could be extracted into another component and implemented nicely with the visitor-pattern which you virtually already did. I'm curious about the markdown parser by microsoft... may I please have a link? ;-] \$\endgroup\$
    – t3chb0t
    Dec 29, 2018 at 18:15
  • 2
    \$\begingroup\$ It's nuget.org/packages/Microsoft.Toolkit.Parsers. \$\endgroup\$
    – user34073
    Dec 29, 2018 at 18:33
  • 1
    \$\begingroup\$ Love the idea of a rich-text editor for Markdown (being that there really aren't any I can find for WPF). What about making this a WPF Custom Control ( instead of a code-behind User Control). Then you can package it as a nuget package, and its re-themeable, etc. \$\endgroup\$ Mar 22, 2019 at 19:21
  • \$\begingroup\$ I did actually package it as a nuget package since I posted this! nuget.org/packages/MarkdownViewer. Do note that there are some UI quirks in this release. Codeblocks don't scroll correctly, unfortunately. I'd had that working at one point (I have a GIF to prove it), but I made some tweaks and broke it before I committed, so I don't know how to fix it. I've not really been maintaining the project, but it's OSS (Unlicense) at GitHub: github.com/Hosch250/MarkdownViewer \$\endgroup\$
    – user34073
    Mar 22, 2019 at 20:47

1 Answer 1

10
\$\begingroup\$
<RichTextBox x:Class="MarkdownViewer.MarkdownBox"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:MarkdownViewer"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800"
             x:Name="Content">
</RichTextBox>

Why? In the interests of KISS, why not delete this entire file, and remove partial and the constructor from the declaration of the class?


    private static void PropertyChangedCallback(DependencyObject source, DependencyPropertyChangedEventArgs args)
    {
        if (source is MarkdownBox control)
        {
            var newValue = (string)args.NewValue;
            switch (args.Property.Name)
            {
                case nameof(Text):
                    control.Text = newValue;
                    break;
            }
        }
    }

Either this should be deleted or it should be commented to explain why it does anything useful, because it seems to say

control.Text = control.Text

and be either a no-op or an infinite recursion.


        set
        {
            var old = GetValue(TextProperty);
            SetValue(TextProperty, value);
            OnPropertyChanged(new DependencyPropertyChangedEventArgs(TextProperty, old, value));

            SetTextboxContent();
        }

I think I see it now, and I think what this whole section should say is

    public static readonly DependencyProperty TextProperty =
        DependencyProperty.Register(nameof(Text), typeof(string), typeof(MarkdownBox), new UIPropertyMetadata(default(string), TextPropertyChanged));

    private static void TextPropertyChanged(DependencyObject source, DependencyPropertyChangedEventArgs args)
    {
        // Assert that (source is MarkdownBox) && (args.Property.Name == nameof(Text)) if you want,
        // but IMO that's overkill. You've only registered the callback on one property.
        (source as MarkdownBox).SetTextboxContent();
    }

    public string Text
    {
        get => (string)GetValue(TextProperty);
        set => SetValue(TextProperty, value);
    }

And it seems to me that SetTextboxContent should be renamed UpdateTextboxContent.


    private void Hlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
    {
        Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri));
        e.Handled = true;
    }

Since you're packaging this as a library, it should expose the event rather than handling it.


    private IEnumerable<Block> GetBlocks(IList<MarkdownBlock> blocks)
    {
        foreach (var block in blocks)
        {
            switch (block)
            {
                case HeaderBlock header:
                    yield return GetHeaderBlock(header);
                    break;
                case ParagraphBlock paragraph:
                    yield return GetParagraphBlock(paragraph);
                    break;
                ...

Why IList instead of IEnumerable?

Also, I would be tempted to pull out GetBlock, which can be rather more compact:

    private Block GetBlock(MarkdownBlock block)
    {
        switch (block)
        {
            case HeaderBlock header:
                return GetHeaderBlock(header);
            case ParagraphBlock paragraph:
                return GetParagraphBlock(paragraph);
            ...

and reduce GetBlocks to

    private IEnumerable<Block> GetBlocks(IEnumerable<MarkdownBlock> blocks) => blocks.Select(GetBlock);

    private Block GetHeaderBlock(HeaderBlock header)
    {
        var headerLevels = new Dictionary<int, double>
        {
            [1] = 28,
            [2] = 21,
            [3] = 16.3833,
            [4] = 14,
            [5] = 11.6167,
            [6] = 9.38333,
        };

        var content = header.Inlines.Select(GetInline);
        var span = new Span();
        span.Inlines.AddRange(content);

        var labelElement = new Label
        {
            Content = span,
            FontSize = headerLevels[header.HeaderLevel]
        };
        var blockElement = new BlockUIContainer(labelElement);
        return blockElement;
    }

WPF, like HTML/CSS, allows separation of structure from style, but this actively works against that. At the very least the labels could use Tag so that I can write a style which uses triggers to override the hard-coded sizes, but since (again) this is published as a library I would look at defining separate subclasses for each of the header levels (or perhaps a single Header : Paragraph with a property HeaderLevel that can be used in a trigger) so that they can be styled directly. And the default values should be pulled out into Themes/Generic.xaml.

Also, why BlockUIContainer(Label) instead of Paragraph?


    private Block GetCodeBlock(CodeBlock code)
    {
        var typeConverter = new HighlightingDefinitionTypeConverter();
        var avalon = new TextEditor
        {
            Text = code.Text,
            SyntaxHighlighting = (IHighlightingDefinition)typeConverter.ConvertFrom("C#"),
            FontFamily = new FontFamily("Consolas"),
            FontSize = 12,
            Padding = new Thickness(10),
            BorderBrush = Brushes.LightGray,
            BorderThickness = new Thickness(1),
            HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
            VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
            IsReadOnly = true,
            ShowLineNumbers = true,
            MaxHeight = 250
        };

        return new BlockUIContainer(avalon);
    }

Similar comments apply: the default style should be in XAML, and IMO a library shouldn't assume that code will be C#. Leave SyntaxHighlighting blank and document how to use highlighting for end users who want it.


    private Inline GetInline(MarkdownInline element)

I would re-order the code a bit to put this next to GetBlock as "high level" methods, and probably use a #region for the lower level methods for blocks and another for the inlines.


    private static Inline GetCodeInline(CodeInline code)
    {
        return new Run(code.Text)
        {
            Background = new SolidColorBrush(Color.FromRgb(0xef, 0xf0, 0xf1))
        };
    }

I'm surprised that this doesn't use the same monospaced font as the code block.


To finish, let me say that I love the concept. As it stands I wouldn't use the library, but if the issues I've raised (particularly about stylability) are addressed then it's possible I'll use it at some point in the future.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.