sources/Google.Solutions.Mvvm/Controls/MarkdownViewer.cs (332 lines of code) (raw):

// // Copyright 2023 Google LLC // // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. // using Google.Solutions.Mvvm.Format; using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.Drawing; using System.IO; using System.Windows.Forms; namespace Google.Solutions.Mvvm.Controls { /// <summary> /// Control that can render a limited subset of Markdown. /// </summary> public partial class MarkdownViewer : UserControl { private string markdown = string.Empty; private uint textPadding = 0; public MarkdownViewer() { InitializeComponent(); // // The RTF box always tries to show a caret. Try to suppress // this by catching focus events and explicitly hiding the caret. // this.richTextBox.HideCaret(); this.richTextBox.LinkClicked += (_, args) => OnLinkClicked(args); this.richTextBox.GotFocus += (_, __) => this.richTextBox.HideCaret(); this.richTextBox.Enter += (_, __) => this.richTextBox.HideCaret(); this.richTextBox.MouseDown += (_, __) => this.richTextBox.HideCaret(); // // When the RTF box is resized or moved, it tends to loose its padding. // this.richTextBox.Layout += (_, __) => this.richTextBox.SetPadding((int)this.textPadding); } //--------------------------------------------------------------------- // Events. //--------------------------------------------------------------------- public LinkClickedEventHandler? LinkClicked; protected void OnLinkClicked(LinkClickedEventArgs args) { this.LinkClicked?.Invoke(this, args); } //--------------------------------------------------------------------- // Properties. //--------------------------------------------------------------------- public ColorStyles Colors { get; } = new ColorStyles(); public FontStyles Fonts { get; } = new FontStyles(); public ParagraphStyles Paragraphs { get; } = new ParagraphStyles(); /// <summary> /// The intermediate RTF. /// </summary> internal string? Rtf { get; private set; } /// <summary> /// Gets or sets the Markdown text to bew rendered. /// </summary> public string Markdown { get => this.markdown; set { if (value == null) { throw new ArgumentNullException(nameof(this.Markdown)); } var document = MarkdownDocument.Parse(value); using (var buffer = new StringWriter()) using (var writer = new RtfWriter(buffer)) using (var visitor = new NodeVisitor( this.Paragraphs, this.Fonts, this.Colors, writer)) { visitor.Visit(document.Root); this.Rtf = buffer.ToString(); this.richTextBox.Rtf = this.Rtf; this.markdown = value; } } } [Category("Appearance")] public uint TextPadding { get => this.textPadding; set { this.richTextBox.SetPadding((int)value); this.textPadding = value; } } //--------------------------------------------------------------------- // Markdown to RTF conversion. //--------------------------------------------------------------------- public class ColorStyles { public Color BackColor { get; set; } = Color.White; public Color TextForeColor { get; set; } = Color.DarkSlateGray; public Color LinkForeColor { get; set; } = Color.DarkBlue; public Color CodeBackColor { get; set; } = Color.LightGray; internal uint BackgroundIndex = 0; internal uint TextIndex = 1; internal uint LinkIndex = 2; internal uint CodeIndex = 3; internal Color[] GetTable() { return new[] { this.BackColor, this.TextForeColor, this.LinkForeColor, this.CodeBackColor }; } } public class FontStyles { public uint FontSizeHeading1 { get; set; } = 16; public uint FontSizeHeading2 { get; set; } = 14; public uint FontSizeHeading3 { get; set; } = 13; public uint FontSizeHeading4 { get; set; } = 12; public uint FontSizeHeading5 { get; set; } = 11; public uint FontSizeHeading6 { get; set; } = 10; public uint FontSize { get; set; } = 10; public FontFamily Text { get; set; } = FontFamily.GenericSansSerif; public FontFamily Code { get; set; } = FontFamily.GenericMonospace; public FontFamily Symbols { get; set; } = new FontFamily("Symbol"); internal uint TextIndex = 0; internal uint CodeIndex = 1; internal uint SymbolsIndex = 2; internal FontFamily[] GetTable() { return new[] { this.Text, this.Code, this.Symbols }; } } public class ParagraphStyles { /// <summary> /// Space before paragraph, in twips. /// </summary> public uint SpaceBeforeParagraph { get; set; } = 100; /// <summary> /// Space after paragraph, in twips. /// </summary> public uint SpaceAfterParagraph { get; set; } = 100; /// <summary> /// Space before paragraph, in twips. /// </summary> public uint SpaceBeforeListItem { get; set; } = 50; /// <summary> /// Space after paragraph, in twips. /// </summary> public uint SpaceAfterListItem { get; set; } = 50; } /// <summary> /// Visitor class. /// </summary> private class NodeVisitor : IDisposable { private const int FirstLineIndent = -270; private const int BlockIndent = 360; protected readonly RtfWriter writer; private readonly ParagraphStyles layoutTable; private readonly FontStyles fontTable; private readonly ColorStyles colorTable; private readonly uint indentationLevel; private bool inParagraph = false; private int nextListItemNumber = 1; private uint FontSizeForHeading(MarkdownDocument.HeadingNode heading) { Debug.Assert(heading.Level >= 1); var fontSizes = new uint[] { this.fontTable.FontSizeHeading1, this.fontTable.FontSizeHeading2, this.fontTable.FontSizeHeading3, this.fontTable.FontSizeHeading4, this.fontTable.FontSizeHeading5, this.fontTable.FontSizeHeading6, }; return fontSizes[Math.Min(heading.Level, fontSizes.Length) - 1]; } public NodeVisitor( ParagraphStyles layoutTable, FontStyles fontTable, ColorStyles colorTable, RtfWriter writer, uint indentationLevel = 0) { this.layoutTable = layoutTable; this.fontTable = fontTable; this.colorTable = colorTable; this.writer = writer; this.indentationLevel = indentationLevel; } //----------------------------------------------------------------- // Paragraph management. //----------------------------------------------------------------- private void EndParagraph() { if (this.inParagraph) { this.inParagraph = false; this.writer.EndParagraph(); } } private void StartParagraph( uint fontSize, uint fontColorIndex, uint spaceBefore, uint spaceAfter) { EndParagraph(); this.inParagraph = true; this.writer.StartParagraph(); this.writer.SetSpaceBefore(spaceBefore); this.writer.SetSpaceAfter(spaceAfter); this.writer.SetFontSize(fontSize); this.writer.SetFontColor(fontColorIndex); } private void StartParagraph() { StartParagraph( this.fontTable.FontSize, this.colorTable.TextIndex, this.layoutTable.SpaceBeforeParagraph, this.layoutTable.SpaceAfterParagraph); } private void ContinueParagraph() { if (!this.inParagraph) { StartParagraph(); } } public virtual void Dispose() { EndParagraph(); } //----------------------------------------------------------------- // Node visitor. //----------------------------------------------------------------- private void Visit(IEnumerable<MarkdownDocument.Node> nodes) { foreach (var node in nodes) { Visit(node); } } public void Visit(MarkdownDocument.Node node) { if (node is MarkdownDocument.HeadingNode heading) { StartParagraph( FontSizeForHeading(heading), this.colorTable.TextIndex, this.layoutTable.SpaceBeforeParagraph, this.layoutTable.SpaceAfterParagraph); this.writer.SetBold(true); this.writer.Text(heading.Text); this.writer.SetBold(false); EndParagraph(); } else if (node is MarkdownDocument.TextNode text) { ContinueParagraph(); this.writer.Text(text.Text); } else if (node is MarkdownDocument.LinkNode link) { ContinueParagraph(); this.writer.StartHyperlink(link.Href); this.writer.SetUnderline(true); this.writer.SetFontColor(this.colorTable.LinkIndex); Visit(link.Children); this.writer.SetFontColor(); this.writer.SetUnderline(false); this.writer.EndHyperlink(); } else if (node is MarkdownDocument.EmphasisNode emph) { ContinueParagraph(); if (emph.IsCode) { this.writer.SetHighlightColor(this.colorTable.CodeIndex); this.writer.SetFont(this.fontTable.CodeIndex); this.writer.Text(emph.Text); this.writer.SetFont(); this.writer.SetHighlightColor(); } else if (emph.IsStrong) { this.writer.SetBold(true); this.writer.Text(emph.Text); this.writer.SetBold(false); } else { this.writer.SetItalic(true); this.writer.Text(emph.Text); this.writer.SetItalic(false); } } else if (node is MarkdownDocument.ParagraphBreak) { EndParagraph(); } else if (node is MarkdownDocument.UnorderedListItemNode ul) { using (var block = new NodeVisitor( this.layoutTable, this.fontTable, this.colorTable, this.writer, this.indentationLevel + 1)) { block.StartParagraph( this.fontTable.FontSize, this.colorTable.TextIndex, this.layoutTable.SpaceBeforeListItem, this.layoutTable.SpaceAfterListItem); this.writer.UnorderedListItem( FirstLineIndent, (int)block.indentationLevel * BlockIndent, this.fontTable.SymbolsIndex); block.Visit(ul.Children); block.EndParagraph(); } } else if (node is MarkdownDocument.OrderedListItemNode ol) { using (var block = new NodeVisitor( this.layoutTable, this.fontTable, this.colorTable, this.writer, this.indentationLevel + 1)) { block.StartParagraph( this.fontTable.FontSize, this.colorTable.TextIndex, this.layoutTable.SpaceBeforeListItem, this.layoutTable.SpaceAfterListItem); this.writer.OrderedListItem( FirstLineIndent, (int)block.indentationLevel * BlockIndent, block.nextListItemNumber++); block.Visit(ol.Children); block.EndParagraph(); } } else if (node is MarkdownDocument.DocumentNode) { this.writer.StartDocument(); this.writer.FontTable(this.fontTable.GetTable()); this.writer.ColorTable(this.colorTable.GetTable()); Visit(node.Children); // // Add an empty paragraph to prevent "hanging" list items. // StartParagraph(); EndParagraph(); this.writer.EndDocument(); } else { if (!(node is MarkdownDocument.OrderedListItemNode)) { this.nextListItemNumber = 1; // Reset } Visit(node.Children); } } } } }