diff --git a/AGENTS.md b/AGENTS.md index ff855f896..b760f1257 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,4 +39,4 @@ On Linux (or with `SHINYPDF_DEVCONTAINER=true`) the Linux native assets are adde ## Current focus -Open issues: template designer app (#12). Markdown follow-ups not yet built: tables, images, task lists. +Open issues: template designer app (#12). Markdown (#33): math and Mermaid have no built-in renderer, only the `CodeBlockRenderers` hook; raw HTML is shown as text. diff --git a/docs/how-to/render-markdown.md b/docs/how-to/render-markdown.md index 1a6b46e75..91d7c46da 100644 --- a/docs/how-to/render-markdown.md +++ b/docs/how-to/render-markdown.md @@ -44,6 +44,10 @@ container.Markdown(markdown, options => options.BlockquoteBorderColor = Colors.Grey.Lighten1; options.BlockquoteTextColor = Colors.Grey.Darken2; options.HorizontalRuleColor = Colors.Grey.Lighten1; + options.TableBorderColor = Colors.Grey.Lighten1; + options.TableHeaderBackgroundColor = Colors.Grey.Lighten4; + options.TableCellPadding = 4; + options.CheckboxColor = Colors.Grey.Darken2; // task list checkboxes }); ``` @@ -62,6 +66,94 @@ options.HeadingStyle = level => TextStyle.Default .FontColor(Colors.Blue.Darken3); ``` +## Tables, task lists and alerts + +GitHub-style pipe tables, task lists and alerts are rendered without extra setup: + +```markdown +| Package | Version | Downloads | +|:---------|:-------:|----------:| +| ShinyPDF | 2.1 | 12,400 | + +- [x] done +- [ ] open + +> [!NOTE] +> Alerts support NOTE, TIP, IMPORTANT, WARNING and CAUTION. +``` + +![Tables, task lists, alerts, images and highlighted code](../img/example-markdown-extended.png) + +- Table header rows are bold on a background and repeat on every page the table spans. Column alignment (`:--`, `:-:`, `--:`) is applied to the cells. +- Task list items show a checkbox instead of the bullet. +- Alerts get a colored border and title. Change the title (for example to translate it) or the color per kind: + +```csharp +options.AlertStyles["NOTE"] = new MarkdownAlertStyle("Hinweis", Colors.Blue.Darken2); +``` + +## Images + +Markdown often comes from users or other systems, so the `Markdown()` element never reads files or the network on its own. Images in `data:` URIs (`![logo](data:image/png;base64,...)`) are rendered directly. For everything else, provide an `ImageResolver` that returns the image bytes, or `null` to show the alternative text: + +```csharp +var imageFolder = Path.GetFullPath("content/images") + Path.DirectorySeparatorChar; + +options.ImageResolver = url => +{ + var path = Path.GetFullPath(Path.Combine(imageFolder, url)); + + // only serve files inside the image folder + return path.StartsWith(imageFolder) && File.Exists(path) ? File.ReadAllBytes(path) : null; +}; +``` + +Images are shown at their natural size (one pixel per point) and scaled down to the available width and to `options.MaxImageHeight` (500 points by default; keep it below the page content height). An image inside a link (`[![logo](logo.png)](https://...)`) is clickable. Images larger than `options.MaxImagePixels` (40 megapixels by default) are not decoded and show their alternative text, so a small compressed file cannot claim gigabytes of memory. PNG, JPEG, WebP, GIF and BMP are supported; SVG is not. Images that cannot be decoded fall back to their alternative text. + +## Syntax highlighting + +Fenced code blocks with a language name are highlighted. Built-in languages and their names: + +| Language | Names after the fence | +|---|---| +| C# | `csharp`, `cs`, `c#` | +| JavaScript | `javascript`, `js`, `jsx`, `mjs`, `cjs` | +| TypeScript | `typescript`, `ts`, `tsx` | +| JSON | `json`, `jsonc` | +| XML / HTML | `xml`, `html`, `xhtml`, `svg`, `xaml`, `csproj`, `razor` | +| CSS | `css`, `scss`, `less` | +| SQL | `sql`, `tsql`, `mysql`, `postgresql`, `psql` | +| Python | `python`, `py` | +| Shell | `bash`, `sh`, `shell`, `zsh`, `console` | +| PowerShell | `powershell`, `pwsh`, `ps1`, `ps` | +| YAML | `yaml`, `yml` | + +Add a language with regular expressions. Rules are tried in the order they are added, so put comments and strings before keywords. A language added later replaces a built-in one with the same name: + +```csharp +options.CodeLanguages.Add(new SyntaxLanguage("kotlin", "kt") + .Rule(SyntaxTokenKind.Comment, @"//.*") + .Rule(SyntaxTokenKind.String, "\"[^\"\\n]*\"") + .Keywords("fun", "val", "var", "class", "if", "else", "return") + .Types("Int", "String", "Boolean")); +``` + +The built-in languages are also available as templates in `SyntaxLanguages`: `SyntaxLanguages.CSharp()` returns a new instance you can extend with more rules. Change colors with `options.SyntaxColors[SyntaxTokenKind.Keyword] = "#0000FF"`. To turn highlighting off, call `options.CodeLanguages.Clear()`. Highlighting one code block may take at most `options.SyntaxHighlightingTimeout` (500 ms by default); after that, the rest of the block is shown without colors. This protects against slow custom patterns and hostile input. + +## Diagrams and formulas + +Mermaid diagrams and LaTeX formulas have no built-in renderer. Register a renderer for a code block language and draw anything you like, for example an image produced by your own Mermaid or LaTeX tooling: + +```csharp +options.CodeBlockRenderers["mermaid"] = (container, code) => + container.Image(MyDiagramService.RenderPng(code)); + +options.CodeBlockRenderers["math"] = (container, code) => + container.AlignCenter().Image(MyFormulaService.RenderPng(code)); +``` + +The renderer replaces the code box for that language entirely. + ## Supported Markdown | Markdown | Result | @@ -70,16 +162,21 @@ options.HeadingStyle = level => TextStyle.Default | Paragraphs, hard line breaks | Text blocks | | `**bold**`, `*italic*`, `~~strikethrough~~` | Matching text styles, nesting combines them | | `` `inline code` `` | Code font with background | -| Fenced and indented code blocks | Code font on a background box, whitespace kept | +| Fenced and indented code blocks | Code font on a background box, whitespace kept, highlighted by language | | `[text](url)`, ``, `` | Clickable hyperlinks | +| `![alt](url)` | Image from a `data:` URI or `ImageResolver`, otherwise the alternative text | | `-`, `*`, `+` and `1.` lists | Bullets or numbers, nested lists indented | +| `- [ ]` and `- [x]` task lists | Empty or checked checkboxes | +| Pipe tables | Bordered table, bold repeating header, column alignment | | `>` blockquotes | Left border with muted text | +| `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]` | Alert with colored border and title | | `---` | Horizontal line | +| `©`, `€`, ... | Decoded HTML entities | -Not supported yet: +Not supported: -- Images: replaced by their alternative text. -- Tables and task lists: shown as plain text. - Raw HTML: shown as plain text, never interpreted. +- Math and Mermaid: no built-in rendering, use `CodeBlockRenderers` (see above). Inline `$...$` math is shown as text. +- SVG images. -Parsing follows CommonMark and is done by [Markdig](https://github.com/xoofx/markdig). +Parsing follows CommonMark with GitHub extensions and is done by [Markdig](https://github.com/xoofx/markdig). diff --git a/docs/img/example-markdown-extended.png b/docs/img/example-markdown-extended.png new file mode 100644 index 000000000..0b4264ebd Binary files /dev/null and b/docs/img/example-markdown-extended.png differ diff --git a/src/ShinyPDF.Examples/MarkdownExamples.cs b/src/ShinyPDF.Examples/MarkdownExamples.cs index 451d40440..9b76500ea 100644 --- a/src/ShinyPDF.Examples/MarkdownExamples.cs +++ b/src/ShinyPDF.Examples/MarkdownExamples.cs @@ -1,3 +1,4 @@ +using System.IO; using NUnit.Framework; using ShinyPDF.Examples.Engine; using ShinyPDF.Fluent; @@ -39,6 +40,54 @@ public class MarkdownExamples ``` """; + private const string ExtendedFeatures = """ + ## Tables, tasks and alerts + + | Package | Version | Downloads | + |:--------|:-------:|----------:| + | ShinyPDF | 2.1 | 12,400 | + | ShinyPDF.Markdown | 2.1 | 1,250 | + + - [x] Tables with column alignment + - [x] Task lists + - [ ] Math formulas + + > [!NOTE] + > Alerts use the GitHub syntax and colors. + + > [!WARNING] + > Images are only loaded through `ImageResolver` or `data:` URIs. + + ![ShinyPDF logo](logo.png) + + ```csharp + // syntax highlighting for common languages + public static int Add(int a, int b) => a + b; + var text = $"Sum: {Add(1, 2)}"; + ``` + + ```json + { "name": "ShinyPDF", "version": 2.1, "stable": true } + ``` + + ```sql + SELECT Name, COUNT(*) FROM Packages WHERE Version = '2.1' -- latest + ``` + """; + + [Test] + public void MarkdownExtendedFeatures() + { + RenderingTest + .Create() + .PageSize(PageSizes.A4) + .ProduceImages() + .Render(container => container.Padding(40).Markdown(ExtendedFeatures, options => + { + options.ImageResolver = path => path == "logo.png" ? File.ReadAllBytes(path) : null; + })); + } + [Test] public void Markdown() { diff --git a/src/ShinyPDF.Markdown/MarkdownAlertStyle.cs b/src/ShinyPDF.Markdown/MarkdownAlertStyle.cs new file mode 100644 index 000000000..4b89e7ae9 --- /dev/null +++ b/src/ShinyPDF.Markdown/MarkdownAlertStyle.cs @@ -0,0 +1,5 @@ +namespace ShinyPDF.Markdown +{ + /// Title and accent color of an alert block such as > [!NOTE]. + public record MarkdownAlertStyle(string Title, string Color); +} diff --git a/src/ShinyPDF.Markdown/MarkdownExtensions.cs b/src/ShinyPDF.Markdown/MarkdownExtensions.cs index b33307bcb..472195314 100644 --- a/src/ShinyPDF.Markdown/MarkdownExtensions.cs +++ b/src/ShinyPDF.Markdown/MarkdownExtensions.cs @@ -7,10 +7,11 @@ namespace ShinyPDF.Fluent public static class MarkdownExtensions { /// - /// Renders Markdown (CommonMark plus ~~strikethrough~~) as PDF content. - /// Supported: headings, paragraphs, bold, italic, strikethrough, inline code, code blocks, - /// links, bullet and numbered lists, blockquotes and horizontal rules. - /// Raw HTML is shown as plain text; images are replaced by their alternative text. + /// Renders Markdown (CommonMark plus GitHub extensions) as PDF content. + /// Supported: headings, paragraphs, bold, italic, strikethrough, inline code, highlighted code blocks, + /// links, images, bullet, numbered and task lists, tables, blockquotes, alerts and horizontal rules. + /// Raw HTML is shown as plain text. Images are loaded from data: URIs or through + /// ; otherwise their alternative text is shown. /// public static void Markdown(this IContainer container, string markdown, Action? configure = null) { diff --git a/src/ShinyPDF.Markdown/MarkdownOptions.cs b/src/ShinyPDF.Markdown/MarkdownOptions.cs index a99a99bf4..6eea3e362 100644 --- a/src/ShinyPDF.Markdown/MarkdownOptions.cs +++ b/src/ShinyPDF.Markdown/MarkdownOptions.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using ShinyPDF.Helpers; using ShinyPDF.Infrastructure; @@ -30,10 +32,80 @@ public class MarkdownOptions public string HorizontalRuleColor { get; set; } = Colors.Grey.Lighten1; + public string TableBorderColor { get; set; } = Colors.Grey.Lighten1; + + public string TableHeaderBackgroundColor { get; set; } = Colors.Grey.Lighten4; + + /// Space between a table cell border and its content. + public float TableCellPadding { get; set; } = 4; + + /// + /// Largest height of an image; taller images are scaled down proportionally. + /// Keep it below the page content height, otherwise the image cannot be placed on any page. + /// + public float MaxImageHeight { get; set; } = 500; + + /// + /// Largest image (width x height in pixels) that is decoded. Larger images show their alternative text, + /// so a small compressed image cannot claim gigabytes of memory. Default: 40 megapixels. + /// + public long MaxImagePixels { get; set; } = 40_000_000; + + /// Border and fill color of task list checkboxes. + public string CheckboxColor { get; set; } = Colors.Grey.Darken2; + /// /// Overrides the style of a heading. Receives the heading level (1-6). /// When not set, headings are bold and scaled from . /// public Func? HeadingStyle { get; set; } + + /// + /// Loads the bytes of an image from its Markdown URL (e.g. from disk, embedded resources or HTTP). + /// Return null to show the alternative text instead. data: URIs are decoded without a resolver. + /// When not set, only data: URIs are rendered, so Markdown never reads files or the network on its own. + /// + public Func? ImageResolver { get; set; } + + /// + /// Languages used to highlight fenced code blocks, matched by the name after the opening fence. + /// Starts with ; add your own or clear the list to disable highlighting. + /// When several languages share a name, the one added last wins. + /// + public List CodeLanguages { get; } = SyntaxLanguages.All().ToList(); + + /// + /// Time budget for highlighting one code block. When it is exceeded (slow custom patterns or hostile input), + /// the rest of the block is shown without highlighting. + /// + public TimeSpan SyntaxHighlightingTimeout { get; set; } = TimeSpan.FromMilliseconds(500); + + /// Colors of highlighted code. Kinds without a color use the regular text color. + public Dictionary SyntaxColors { get; } = new() + { + [SyntaxTokenKind.Keyword] = "#CF222E", + [SyntaxTokenKind.Type] = "#8250DF", + [SyntaxTokenKind.String] = "#0A3069", + [SyntaxTokenKind.Number] = "#0550AE", + [SyntaxTokenKind.Comment] = "#6E7781", + [SyntaxTokenKind.Tag] = "#116329", + [SyntaxTokenKind.Attribute] = "#0550AE" + }; + + /// + /// Custom renderers for fenced code blocks by language name, e.g. "mermaid" or "math". + /// The renderer receives the container and the code; it replaces the code box entirely. + /// + public Dictionary> CodeBlockRenderers { get; } = new(StringComparer.OrdinalIgnoreCase); + + /// Title and color of alert blocks (> [!NOTE], ...) by alert kind. + public Dictionary AlertStyles { get; } = new(StringComparer.OrdinalIgnoreCase) + { + ["NOTE"] = new("Note", "#0969DA"), + ["TIP"] = new("Tip", "#1A7F37"), + ["IMPORTANT"] = new("Important", "#8250DF"), + ["WARNING"] = new("Warning", "#9A6700"), + ["CAUTION"] = new("Caution", "#CF222E") + }; } } diff --git a/src/ShinyPDF.Markdown/MarkdownRenderer.cs b/src/ShinyPDF.Markdown/MarkdownRenderer.cs index 099876452..e0f687232 100644 --- a/src/ShinyPDF.Markdown/MarkdownRenderer.cs +++ b/src/ShinyPDF.Markdown/MarkdownRenderer.cs @@ -2,10 +2,15 @@ using System.Collections.Generic; using System.Linq; using Markdig; +using Markdig.Extensions.Alerts; +using Markdig.Extensions.Tables; +using Markdig.Extensions.TaskLists; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using ShinyPDF.Elements.Table; using ShinyPDF.Fluent; using ShinyPDF.Infrastructure; +using SkiaSharp; namespace ShinyPDF.Markdown { @@ -17,6 +22,9 @@ internal class MarkdownRenderer private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() .UseEmphasisExtras(Markdig.Extensions.EmphasisExtras.EmphasisExtraOptions.Strikethrough) + .UsePipeTables() + .UseTaskLists() + .UseAlertBlocks() .DisableHtml() .Build(); @@ -36,7 +44,7 @@ internal void Compose(IContainer container, string markdown) .Element(x => ComposeBlocks(x, document, Options.BlockSpacing)); } - private void ComposeBlocks(IContainer container, ContainerBlock blocks, float spacing) + private void ComposeBlocks(IContainer container, ContainerBlock blocks, float spacing, HorizontalAlignment? textAlignment = null) { var visibleBlocks = blocks.Where(IsRendered).ToList(); @@ -45,31 +53,40 @@ private void ComposeBlocks(IContainer container, ContainerBlock blocks, float sp column.Spacing(spacing); foreach (var block in visibleBlocks) - ComposeBlock(column.Item(), block); + ComposeBlock(column.Item(), block, textAlignment); }); } private static bool IsRendered(Block block) { - return block is not (LinkReferenceDefinitionGroup or BlankLineBlock); + return block switch + { + LinkReferenceDefinitionGroup or BlankLineBlock => false, + ParagraphBlock paragraph => paragraph.Inline?.FirstChild != null, + _ => true + }; } - private void ComposeBlock(IContainer container, Block block) + private void ComposeBlock(IContainer container, Block block, HorizontalAlignment? textAlignment) { switch (block) { case HeadingBlock heading: - container.Text(text => ComposeInlines(text, heading.Inline, GetHeadingStyle(heading.Level), null)); + ComposeText(container, heading.Inline, GetHeadingStyle(heading.Level), textAlignment); break; case ParagraphBlock paragraph: - container.Text(text => ComposeInlines(text, paragraph.Inline, TextStyle.Default, null)); + ComposeText(container, paragraph.Inline, TextStyle.Default, textAlignment); break; case ListBlock list: ComposeList(container, list); break; + case AlertBlock alert: + ComposeAlert(container, alert); + break; + case QuoteBlock quote: container .BorderLeft(3) @@ -87,16 +104,34 @@ private void ComposeBlock(IContainer container, Block block) ComposeCodeBlock(container, code); break; + case Table table: + ComposeTable(container, table); + break; + case ContainerBlock other: ComposeBlocks(container, other, Options.BlockSpacing); break; case LeafBlock leaf: - container.Text(text => ComposeInlines(text, leaf.Inline, TextStyle.Default, null)); + ComposeText(container, leaf.Inline, TextStyle.Default, textAlignment); break; } } + private void ComposeText(IContainer container, ContainerInline? inlines, TextStyle style, HorizontalAlignment? alignment) + { + container.Text(text => + { + // aligned per line, so wrapped text in centered or right-aligned table columns lines up + if (alignment == HorizontalAlignment.Center) + text.AlignCenter(); + else if (alignment == HorizontalAlignment.Right) + text.AlignRight(); + + ComposeInlines(text, inlines, style, null); + }); + } + private void ComposeList(IContainer container, ListBlock list) { var number = list.IsOrdered && int.TryParse(list.OrderedStart, out var start) ? start : 1; @@ -108,31 +143,197 @@ private void ComposeList(IContainer container, ListBlock list) foreach (var item in list.OfType()) { var marker = list.IsOrdered ? $"{number++}{list.OrderedDelimiter}" : Bullet; + var task = GetTask(item); column.Item().Row(row => { - row.ConstantItem(Options.ListIndent).Text(marker); + if (task != null) + row.ConstantItem(Options.ListIndent).Element(x => ComposeCheckbox(x, task.Checked)); + else + row.ConstantItem(Options.ListIndent).Text(marker); + row.RelativeItem().Element(x => ComposeBlocks(x, item, Options.ListItemSpacing)); }); } }); } + private static TaskList? GetTask(ListItemBlock item) + { + return (item.FirstOrDefault() as ParagraphBlock)?.Inline?.FirstChild as TaskList; + } + + private void ComposeCheckbox(IContainer container, bool isChecked) + { + var size = Options.BaseFontSize * 0.75f; + + var box = container + .PaddingTop(Options.BaseFontSize * 0.25f) + .AlignLeft() + .AlignTop() + .Width(size) + .Height(size) + .Border(1) + .BorderColor(Options.CheckboxColor); + + if (!isChecked) + return; + + box.Background(Options.CheckboxColor).Canvas((canvas, space) => + { + using var paint = new SKPaint + { + Color = SKColors.White, + Style = SKPaintStyle.Stroke, + StrokeWidth = space.Width * 0.15f, + StrokeCap = SKStrokeCap.Round, + IsAntialias = true + }; + + var corner = new SKPoint(space.Width * 0.42f, space.Height * 0.72f); + canvas.DrawLine(new SKPoint(space.Width * 0.22f, space.Height * 0.52f), corner, paint); + canvas.DrawLine(corner, new SKPoint(space.Width * 0.78f, space.Height * 0.3f), paint); + }); + } + + private void ComposeAlert(IContainer container, AlertBlock alert) + { + var kind = alert.Kind.ToString(); + + if (!Options.AlertStyles.TryGetValue(kind, out var style)) + style = new MarkdownAlertStyle(ToTitleCase(kind), Options.BlockquoteBorderColor); + + container + .BorderLeft(3) + .BorderColor(style.Color) + .PaddingLeft(10) + .Column(column => + { + column.Spacing(Options.BlockSpacing / 2); + column.Item().Text(style.Title).Style(TextStyle.Default.Bold().FontColor(style.Color)); + + if (alert.Any(IsRendered)) + column.Item().Element(x => ComposeBlocks(x, alert, Options.BlockSpacing)); + }); + + static string ToTitleCase(string value) + { + return value.Length == 0 ? value : char.ToUpperInvariant(value[0]) + value[1..].ToLowerInvariant(); + } + } + private void ComposeCodeBlock(IContainer container, CodeBlock code) { var lines = code.Lines.Lines .Take(code.Lines.Count) .Select(x => x.Slice.ToString()); + var content = string.Join("\n", lines); + var language = (code as FencedCodeBlock)?.Info?.Trim() ?? string.Empty; + + if (Options.CodeBlockRenderers.TryGetValue(language, out var renderer)) + { + renderer(container, content); + return; + } + var codeStyle = TextStyle.Default .FontFamily(Options.CodeFontFamily) .FontSize(Options.BaseFontSize * 0.9f); + var syntax = language.Length == 0 ? null : Options.CodeLanguages.LastOrDefault(x => x.HasName(language)); + var tokens = syntax?.Tokenize(content, Options.SyntaxHighlightingTimeout) ?? new[] { ((SyntaxTokenKind?)null, content) }; + container .Background(Options.CodeBackgroundColor) .Padding(8) - .Text(string.Join("\n", lines)) - .Style(codeStyle); + .Text(text => + { + foreach (var (kind, value) in tokens) + { + var style = kind != null && Options.SyntaxColors.TryGetValue(kind.Value, out var color) + ? codeStyle.FontColor(color) + : codeStyle; + + text.Span(value).Style(style); + } + }); + } + + private void ComposeTable(IContainer container, Table table) + { + var rows = table.OfType().ToList(); + var columnCount = rows + .Select(row => row.OfType().Sum(cell => Math.Max(1, cell.ColumnSpan))) + .DefaultIfEmpty(0) + .Max(); + + if (columnCount == 0) + return; + + var headerRows = rows.TakeWhile(x => x.IsHeader).ToList(); + var bodyRows = rows.Skip(headerRows.Count).ToList(); + + container.Table(descriptor => + { + descriptor.ColumnsDefinition(columns => + { + for (var i = 0; i < columnCount; i++) + columns.RelativeColumn(); + }); + + // the header repeats on every page the table spans + if (headerRows.Any()) + descriptor.Header(header => ComposeTableRows(table, headerRows, header.Cell, true)); + + ComposeTableRows(table, bodyRows, descriptor.Cell, false); + }); + } + + private void ComposeTableRows(Table table, List rows, Func createCell, bool isHeader) + { + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) + { + var columnIndex = 0; + + foreach (var cell in rows[rowIndex].OfType()) + { + if (cell.ColumnIndex >= 0) + columnIndex = cell.ColumnIndex; + + var columnSpan = Math.Max(1, cell.ColumnSpan); + + IContainer cellContainer = createCell() + .Row((uint)rowIndex + 1) + .Column((uint)columnIndex + 1) + .ColumnSpan((uint)columnSpan) + .Border(0.5f) + .BorderColor(Options.TableBorderColor); + + if (isHeader) + { + cellContainer = cellContainer + .Background(Options.TableHeaderBackgroundColor) + .DefaultTextStyle(x => x.Bold()); + } + + cellContainer = cellContainer.Padding(Options.TableCellPadding); + + var alignment = columnIndex < table.ColumnDefinitions.Count + ? table.ColumnDefinitions[columnIndex].Alignment + : null; + + var textAlignment = alignment switch + { + TableColumnAlign.Center => HorizontalAlignment.Center, + TableColumnAlign.Right => HorizontalAlignment.Right, + _ => (HorizontalAlignment?)null + }; + + ComposeBlocks(cellContainer, cell, Options.ListItemSpacing, textAlignment); + columnIndex += columnSpan; + } + } } private TextStyle GetHeadingStyle(int level) @@ -157,6 +358,14 @@ private void ComposeInline(TextDescriptor text, Inline inline, TextStyle style, { switch (inline) { + case TaskList: + // rendered as the checkbox of its list item + break; + + case LiteralInline literal when literal.PreviousSibling is TaskList: + AddSpan(text, literal.Content.ToString().TrimStart(), style, url); + break; + case LiteralInline literal: AddSpan(text, literal.Content.ToString(), style, url); break; @@ -178,8 +387,7 @@ private void ComposeInline(TextDescriptor text, Inline inline, TextStyle style, break; case LinkInline { IsImage: true } image: - // images are not supported yet, show their alternative text instead - ComposeInlines(text, image, style, url); + ComposeImage(text, image, style, url); break; case LinkInline link: @@ -201,6 +409,81 @@ private void ComposeInline(TextDescriptor text, Inline inline, TextStyle style, } } + private void ComposeImage(TextDescriptor text, LinkInline image, TextStyle style, string? url) + { + var data = LoadImage(image.Url); + var size = data == null ? null : DecodeImageSize(data, Options.MaxImagePixels); + + if (data == null || size == null) + { + // unavailable or undecodable image, show its alternative text instead + ComposeInlines(text, image, style, url); + return; + } + + // natural size (one pixel per point), scaled down to MaxImageHeight and to the available width; + // inline elements are measured without a height limit, so the height has to be bounded here + var (width, height) = size.Value; + var maxWidth = Math.Min(width, Options.MaxImageHeight * width / height); + + var container = text.Element(); + + if (!string.IsNullOrWhiteSpace(url)) + container = container.Hyperlink(url); + + container.MaxWidth(maxWidth).Image(data, ImageScaling.FitWidth); + } + + private byte[]? LoadImage(string? source) + { + if (string.IsNullOrWhiteSpace(source)) + return null; + + if (source.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + return DecodeDataUri(source); + + return Options.ImageResolver?.Invoke(source); + } + + private static byte[]? DecodeDataUri(string uri) + { + var separator = uri.IndexOf(','); + + if (separator < 0 || !uri[..separator].EndsWith(";base64", StringComparison.OrdinalIgnoreCase)) + return null; + + try + { + return Convert.FromBase64String(uri[(separator + 1)..]); + } + catch (FormatException) + { + return null; + } + } + + private static (float Width, float Height)? DecodeImageSize(byte[] data, long maxPixels) + { + using var stream = new SKMemoryStream(data); + using var codec = SKCodec.Create(stream); + + if (codec == null || codec.Info.Width <= 0 || codec.Info.Height <= 0) + return null; + + // check the size from the header before allocating; long arithmetic cannot overflow for int dimensions + if ((long)codec.Info.Width * codec.Info.Height > maxPixels) + return null; + + // decode all pixels: a valid header alone does not guarantee the image is complete + var info = new SKImageInfo(codec.Info.Width, codec.Info.Height); + using var bitmap = new SKBitmap(info); + + if (codec.GetPixels(info, bitmap.GetPixels()) != SKCodecResult.Success) + return null; + + return (info.Width, info.Height); + } + private void ComposeLink(TextDescriptor text, LinkInline link, string? url, TextStyle style) { if (string.IsNullOrWhiteSpace(url)) diff --git a/src/ShinyPDF.Markdown/PackageReadme.md b/src/ShinyPDF.Markdown/PackageReadme.md index 455670566..44c0e2ecd 100644 --- a/src/ShinyPDF.Markdown/PackageReadme.md +++ b/src/ShinyPDF.Markdown/PackageReadme.md @@ -44,4 +44,6 @@ page.Content().Markdown(markdown, options => ## Supported Markdown -Headings, paragraphs, bold, italic, strikethrough, inline code, code blocks, links, bullet and numbered lists (nested), blockquotes and horizontal rules. Raw HTML is shown as plain text, and images are replaced by their alternative text. Parsing is done by [Markdig](https://github.com/xoofx/markdig). +Headings, paragraphs, bold, italic, strikethrough, inline code, links, bullet, numbered and task lists (nested), tables, blockquotes, GitHub alerts (`> [!NOTE]`), horizontal rules and HTML entities. Code blocks are syntax highlighted for C#, JavaScript, TypeScript, JSON, XML/HTML, CSS, SQL, Python, Shell, PowerShell and YAML, and you can add your own languages. + +Images are rendered from `data:` URIs or loaded through `options.ImageResolver`, so Markdown never reads files or the network on its own. Diagrams and formulas (for example `mermaid` or `math` code blocks) can be drawn by your own renderer through `options.CodeBlockRenderers`. Raw HTML is shown as plain text. Parsing is done by [Markdig](https://github.com/xoofx/markdig). diff --git a/src/ShinyPDF.Markdown/SyntaxLanguage.cs b/src/ShinyPDF.Markdown/SyntaxLanguage.cs new file mode 100644 index 000000000..038c2fe7d --- /dev/null +++ b/src/ShinyPDF.Markdown/SyntaxLanguage.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; + +namespace ShinyPDF.Markdown +{ + /// + /// Highlighting rules for one language, matched against the language name of a fenced code block. + /// Rules are regular expressions tried in the order they were added, so add comments and strings + /// before keywords. Text that no rule matches keeps the default code color. + /// + public class SyntaxLanguage + { + private const string GroupPrefix = "shinypdf"; + + private readonly List<(SyntaxTokenKind Kind, string Pattern)> Rules = new(); + private Regex? CompiledRules; + private TimeSpan CompiledTimeout; + + /// Name and aliases used after the opening fence, e.g. "csharp", "cs". Matched case-insensitively. + public SyntaxLanguage(params string[] names) + { + if (names == null || names.Length == 0 || names.Any(string.IsNullOrWhiteSpace)) + throw new ArgumentException("A syntax language needs at least one non-empty name.", nameof(names)); + + Names = names; + } + + public IReadOnlyList Names { get; } + + /// Adds a regular expression whose matches are colored as . Patterns run in multiline mode. + public SyntaxLanguage Rule(SyntaxTokenKind kind, string pattern) + { + if (string.IsNullOrEmpty(pattern)) + throw new ArgumentException("Pattern must not be empty.", nameof(pattern)); + + // fail early on invalid patterns instead of on the first render + _ = new Regex(pattern); + + Rules.Add((kind, pattern)); + CompiledRules = null; + return this; + } + + /// Colors the given words as keywords (whole words only, case-sensitive). + public SyntaxLanguage Keywords(params string[] words) => Words(SyntaxTokenKind.Keyword, words, false); + + /// Colors the given words as types (whole words only, case-sensitive). + public SyntaxLanguage Types(params string[] words) => Words(SyntaxTokenKind.Type, words, false); + + internal SyntaxLanguage Words(SyntaxTokenKind kind, IEnumerable words, bool ignoreCase) + { + var alternatives = string.Join("|", words.Select(Regex.Escape)); + var pattern = $@"(? string.Equals(x, name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Splits code into consecutive pieces; pieces without a kind are plain code. + /// When highlighting takes longer than in total, the remaining code is returned as plain text. + /// + internal IEnumerable<(SyntaxTokenKind? Kind, string Text)> Tokenize(string code, TimeSpan timeout) + { + if (Rules.Count == 0) + { + yield return (null, code); + yield break; + } + + var regex = GetRegex(timeout); + var stopwatch = Stopwatch.StartNew(); + var position = 0; + var searchFrom = 0; + + while (searchFrom <= code.Length && stopwatch.Elapsed < timeout) + { + var match = FindMatch(regex, code, searchFrom); + + if (match == null) + break; + + if (match.Length == 0) + { + searchFrom = match.Index + 1; + continue; + } + + if (match.Index > position) + yield return (null, code[position..match.Index]); + + yield return (GetKind(match), match.Value); + position = searchFrom = match.Index + match.Length; + } + + if (position < code.Length) + yield return (null, code[position..]); + } + + private static Match? FindMatch(Regex regex, string code, int searchFrom) + { + try + { + var match = regex.Match(code, searchFrom); + return match.Success ? match : null; + } + catch (RegexMatchTimeoutException) + { + // slow pattern or hostile input: keep the rest as plain code + return null; + } + } + + private Regex GetRegex(TimeSpan timeout) + { + if (CompiledRules != null && CompiledTimeout == timeout) + return CompiledRules; + + var alternatives = Rules.Select((rule, index) => $"(?<{GroupPrefix}{index}>{rule.Pattern})"); + CompiledRules = new Regex(string.Join("|", alternatives), RegexOptions.Multiline | RegexOptions.CultureInvariant, timeout); + CompiledTimeout = timeout; + return CompiledRules; + } + + private SyntaxTokenKind GetKind(Match match) + { + for (var index = 0; index < Rules.Count; index++) + { + if (match.Groups[$"{GroupPrefix}{index}"].Success) + return Rules[index].Kind; + } + + throw new InvalidOperationException("Matched text does not belong to any rule."); + } + } +} diff --git a/src/ShinyPDF.Markdown/SyntaxLanguages.cs b/src/ShinyPDF.Markdown/SyntaxLanguages.cs new file mode 100644 index 000000000..6552e3da0 --- /dev/null +++ b/src/ShinyPDF.Markdown/SyntaxLanguages.cs @@ -0,0 +1,202 @@ +using System.Collections.Generic; + +namespace ShinyPDF.Markdown +{ + /// + /// Ready-made highlighting rules. Every method returns a new instance, so it can be extended + /// without affecting other documents. starts with . + /// + public static class SyntaxLanguages + { + private const string LineComment = @"//.*"; + private const string BlockComment = @"/\*[\s\S]*?(?:\*/|\z)"; + private const string HashComment = @"(? All() + { + yield return CSharp(); + yield return JavaScript(); + yield return TypeScript(); + yield return Json(); + yield return Xml(); + yield return Css(); + yield return Sql(); + yield return Python(); + yield return Shell(); + yield return PowerShell(); + yield return Yaml(); + } + + public static SyntaxLanguage CSharp() + { + return new SyntaxLanguage("csharp", "cs", "c#") + .Rule(SyntaxTokenKind.Comment, LineComment) + .Rule(SyntaxTokenKind.Comment, BlockComment) + .Rule(SyntaxTokenKind.String, @"\$*""""""[\s\S]*?(?:""""""|\z)") + .Rule(SyntaxTokenKind.String, @"(?:\$@|@\$|@)""(?:""""|[^""])*""") + .Rule(SyntaxTokenKind.String, @"\$?" + DoubleQuoted) + .Rule(SyntaxTokenKind.String, @"'(?:\\.|[^'\\\n])'") + .Rule(SyntaxTokenKind.Keyword, @"^[ \t]*#[a-z]+.*") + .Rule(SyntaxTokenKind.Number, Number) + .Keywords( + "abstract", "as", "async", "await", "base", "break", "case", "catch", "checked", "class", "const", + "continue", "default", "delegate", "do", "else", "enum", "event", "explicit", "extern", "false", + "finally", "fixed", "for", "foreach", "get", "goto", "if", "implicit", "in", "init", "interface", + "internal", "is", "lock", "namespace", "new", "null", "operator", "out", "override", "params", + "partial", "private", "protected", "public", "readonly", "record", "ref", "required", "return", + "sealed", "set", "sizeof", "stackalloc", "static", "struct", "switch", "this", "throw", "true", + "try", "typeof", "unchecked", "unsafe", "using", "var", "virtual", "void", "volatile", "when", + "where", "while", "with", "yield") + .Types( + "bool", "byte", "char", "decimal", "double", "dynamic", "float", "int", "long", "nint", "nuint", + "object", "sbyte", "short", "string", "uint", "ulong", "ushort"); + } + + public static SyntaxLanguage JavaScript() + { + return AddEcmaScriptRules(new SyntaxLanguage("javascript", "js", "jsx", "mjs", "cjs")); + } + + public static SyntaxLanguage TypeScript() + { + return AddEcmaScriptRules(new SyntaxLanguage("typescript", "ts", "tsx")) + .Keywords("abstract", "declare", "enum", "implements", "interface", "keyof", "namespace", "private", + "protected", "public", "readonly", "type", "satisfies") + .Types("any", "boolean", "never", "number", "object", "string", "symbol", "unknown", "void"); + } + + private static SyntaxLanguage AddEcmaScriptRules(SyntaxLanguage language) + { + return language + .Rule(SyntaxTokenKind.Comment, LineComment) + .Rule(SyntaxTokenKind.Comment, BlockComment) + .Rule(SyntaxTokenKind.String, @"`(?:\\.|[^`\\])*`") + .Rule(SyntaxTokenKind.String, DoubleQuoted) + .Rule(SyntaxTokenKind.String, SingleQuoted) + .Rule(SyntaxTokenKind.Number, Number) + .Keywords( + "async", "await", "break", "case", "catch", "class", "const", "continue", "debugger", "default", + "delete", "do", "else", "export", "extends", "false", "finally", "for", "from", "function", "if", + "import", "in", "instanceof", "let", "new", "null", "of", "return", "static", "super", "switch", + "this", "throw", "true", "try", "typeof", "undefined", "var", "void", "while", "with", "yield"); + } + + public static SyntaxLanguage Json() + { + return new SyntaxLanguage("json", "jsonc") + .Rule(SyntaxTokenKind.Comment, LineComment) + .Rule(SyntaxTokenKind.Comment, BlockComment) + .Rule(SyntaxTokenKind.Attribute, DoubleQuoted + @"(?=\s*:)") + .Rule(SyntaxTokenKind.String, DoubleQuoted) + .Rule(SyntaxTokenKind.Number, @"-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?") + .Keywords("true", "false", "null"); + } + + public static SyntaxLanguage Xml() + { + return new SyntaxLanguage("xml", "html", "xhtml", "svg", "xaml", "csproj", "razor") + .Rule(SyntaxTokenKind.Comment, @"|\z)") + .Rule(SyntaxTokenKind.Keyword, @"|\z)|<[!?][^>]*>") + .Rule(SyntaxTokenKind.Tag, @"") + .Rule(SyntaxTokenKind.Attribute, @"(?<=\s)[\w:.\-]+(?=\s*=\s*[""'])") + .Rule(SyntaxTokenKind.String, @"(?<==\s*)(?:""[^""]*""|'[^']*')"); + } + + public static SyntaxLanguage Css() + { + return new SyntaxLanguage("css", "scss", "less") + .Rule(SyntaxTokenKind.Comment, BlockComment) + .Rule(SyntaxTokenKind.String, DoubleQuoted) + .Rule(SyntaxTokenKind.String, SingleQuoted) + .Rule(SyntaxTokenKind.Keyword, @"@[\w\-]+|!important") + .Rule(SyntaxTokenKind.Attribute, @"(?<=[{;\s])[\w\-]+(?=\s*:[^{]*?;)") + .Rule(SyntaxTokenKind.Number, @"#[0-9a-fA-F]{3,8}\b|(?|\z)") + .Rule(SyntaxTokenKind.Comment, HashComment) + .Rule(SyntaxTokenKind.String, @"@""[\s\S]*?(?:\n""@|\z)|@'[\s\S]*?(?:\n'@|\z)") + .Rule(SyntaxTokenKind.String, @"""(?:`.|[^""`])*""") + .Rule(SyntaxTokenKind.String, @"'(?:''|[^'])*'") + .Rule(SyntaxTokenKind.Attribute, @"\$(?:\{[^}]*\}|[\w:]+)") + .Rule(SyntaxTokenKind.Type, @"\b[A-Za-z]+-[A-Za-z]+\b|\[[\w.\[\]]+\]") + .Rule(SyntaxTokenKind.Number, Number) + .Words(SyntaxTokenKind.Keyword, new[] + { + "begin", "break", "catch", "class", "continue", "data", "do", "dynamicparam", "else", "elseif", + "end", "enum", "exit", "filter", "finally", "for", "foreach", "function", "if", "in", "param", + "process", "return", "switch", "throw", "trap", "try", "until", "using", "while" + }, true); + } + + public static SyntaxLanguage Yaml() + { + return new SyntaxLanguage("yaml", "yml") + .Rule(SyntaxTokenKind.Comment, HashComment) + .Rule(SyntaxTokenKind.Attribute, @"(?<=^[ \t]*(?:-[ \t]+)?)[\w.\-/]+(?=[ \t]*:(?:[ \t]|$))") + .Rule(SyntaxTokenKind.String, DoubleQuoted) + .Rule(SyntaxTokenKind.String, @"'(?:''|[^'])*'") + .Rule(SyntaxTokenKind.Number, @"(?<=:[ \t]+|^[ \t]*-[ \t]+)-?\d+(?:\.\d+)?(?=[ \t]*(?:#|$))") + .Rule(SyntaxTokenKind.Keyword, @"(?<=:[ \t]+|^[ \t]*-[ \t]+)(?:true|false|null|yes|no|on|off|~)(?=[ \t]*(?:#|$))"); + } + } +} diff --git a/src/ShinyPDF.Markdown/SyntaxTokenKind.cs b/src/ShinyPDF.Markdown/SyntaxTokenKind.cs new file mode 100644 index 000000000..c76532245 --- /dev/null +++ b/src/ShinyPDF.Markdown/SyntaxTokenKind.cs @@ -0,0 +1,14 @@ +namespace ShinyPDF.Markdown +{ + /// Category of a highlighted piece of code; each kind gets its own color. + public enum SyntaxTokenKind + { + Keyword, + Type, + String, + Number, + Comment, + Tag, + Attribute + } +} diff --git a/src/ShinyPDF.UnitTests/MarkdownTests.cs b/src/ShinyPDF.UnitTests/MarkdownTests.cs index b45eace43..2bff1b5a7 100644 --- a/src/ShinyPDF.UnitTests/MarkdownTests.cs +++ b/src/ShinyPDF.UnitTests/MarkdownTests.cs @@ -9,6 +9,7 @@ using ShinyPDF.Helpers; using ShinyPDF.Infrastructure; using ShinyPDF.Markdown; +using SkiaSharp; namespace ShinyPDF.UnitTests { @@ -157,6 +158,330 @@ public void Image_IsReplacedByAltText() Assert.That(Spans(root).Single().Text, Is.EqualTo("a logo")); } + [Test] + public void Image_WithoutResolver_DoesNotLoadFiles() + { + var root = Render("![a logo](logo.png)"); + + Assert.That(Descendants(root).OfType(), Is.Empty); + } + + [Test] + public void Image_FromDataUri_IsRendered() + { + var root = Render($"![dot](data:image/png;base64,{Convert.ToBase64String(CreatePng(4, 2))})"); + + Assert.That(Descendants(root).OfType().Single().InternalImage.Width, Is.EqualTo(4)); + Assert.That(Descendants(root).OfType().Any(x => x.MaxWidth == 4), Is.True); + Assert.That(Spans(root), Is.Empty); + } + + [Test] + public void Image_IsLoadedThroughResolver() + { + var requested = new List(); + var root = Render("text ![chart](images/chart.png) text", x => x.ImageResolver = url => + { + requested.Add(url); + return CreatePng(10, 10); + }); + + Assert.That(requested, Is.EqualTo(new[] { "images/chart.png" })); + Assert.That(Descendants(root).OfType().Count(), Is.EqualTo(1)); + } + + [Test] + public void Image_ThatCannotBeDecoded_FallsBackToAltText() + { + var root = Render("![broken](broken.png)", x => x.ImageResolver = _ => new byte[] { 1, 2, 3 }); + + Assert.That(Spans(root).Single().Text, Is.EqualTo("broken")); + Assert.That(Descendants(root).OfType(), Is.Empty); + } + + [Test] + public void Image_WithValidHeaderButCorruptPixels_FallsBackToAltText() + { + var png = CreatePng(64, 64, noisy: true); + var truncated = png.Take(png.Length / 2).ToArray(); + + using (var codec = SKCodec.Create(new SKMemoryStream(truncated))) + Assert.That(codec, Is.Not.Null, "the header must stay readable for this test"); + + var root = Render("![truncated](a.png)", x => x.ImageResolver = _ => truncated); + + Assert.That(Spans(root).Single().Text, Is.EqualTo("truncated")); + Assert.That(Descendants(root).OfType(), Is.Empty); + } + + [Test] + public void Image_LargerThanMaxImagePixels_FallsBackToAltText() + { + var root = Render("![big](big.png)", x => + { + x.ImageResolver = _ => CreatePng(4, 4); + x.MaxImagePixels = 15; + }); + + Assert.That(Spans(root).Single().Text, Is.EqualTo("big")); + Assert.That(Descendants(root).OfType(), Is.Empty); + } + + [Test] + public void Image_InsideLink_IsClickable() + { + var root = Render("[![logo](logo.png)](https://example.com)", x => x.ImageResolver = _ => CreatePng(4, 4)); + + var hyperlink = Descendants(root).OfType().Single(); + Assert.That(hyperlink.Url, Is.EqualTo("https://example.com")); + Assert.That(Descendants(hyperlink).OfType().Count(), Is.EqualTo(1)); + } + + [Test] + public void Image_TallerThanMaxImageHeight_IsScaledDown() + { + var root = Render("![tall](tall.png)", x => x.ImageResolver = _ => CreatePng(200, 1200)); + + var expectedWidth = 200 * new MarkdownOptions().MaxImageHeight / 1200; + Assert.That(Descendants(root).OfType().Single(x => x.MaxWidth != null).MaxWidth, Is.EqualTo(expectedWidth).Within(0.01)); + } + + [Test] + public void Image_TallerThanPage_StillGeneratesPdf() + { + var pdf = Document + .Create(document => document.Page(page => + { + page.Size(PageSizes.A4); + page.Content().Markdown("![tall](tall.png)", x => x.ImageResolver = _ => CreatePng(200, 1200)); + })) + .GeneratePdf(); + + Assert.That(pdf, Is.Not.Empty); + } + + [Test] + public void Table_RendersHeaderAndBodyCells() + { + var options = new MarkdownOptions(); + var root = Render("| Name | Qty |\n|---|---|\n| Apple | 3 |\n| Pear |"); + + Assert.That(Spans(root).Select(x => x.Text), Is.EquivalentTo(new[] { "Name", "Qty", "Apple", "3", "Pear" })); + + var header = Descendants(root).OfType().Where(x => x.Color == options.TableHeaderBackgroundColor).ToList(); + Assert.That(header, Has.Count.EqualTo(2)); + Assert.That(header.Select(x => Descendants(x).OfType().First().TextStyle.FontWeight), Is.All.EqualTo(FontWeight.Bold)); + + var cellBorders = Descendants(root).OfType().Where(x => x.Color == options.TableBorderColor); + Assert.That(cellBorders.Count(), Is.EqualTo(6)); + } + + [Test] + public void Table_AlignsEachTextLineOfAlignedColumns() + { + var root = Render("| L | C | R |\n|:--|:-:|--:|\n| a | b | c |"); + + // alignment is set on the text block, so every wrapped line is aligned, not only the block as a whole + var alignments = Descendants(root).OfType().ToDictionary(x => x.Text, x => x.Alignment); + Assert.That(alignments["L"], Is.Null); + Assert.That(alignments["a"], Is.Null); + Assert.That(alignments["C"], Is.EqualTo(HorizontalAlignment.Center)); + Assert.That(alignments["b"], Is.EqualTo(HorizontalAlignment.Center)); + Assert.That(alignments["R"], Is.EqualTo(HorizontalAlignment.Right)); + Assert.That(alignments["c"], Is.EqualTo(HorizontalAlignment.Right)); + } + + [Test] + public void Table_WithWrappedAlignedText_GeneratesPdf() + { + var markdown = "| Centered | Right |\n|:-:|--:|\n| " + string.Join(" ", Enumerable.Repeat("long text", 30)) + " | x |"; + + var pdf = Document + .Create(document => document.Page(page => page.Content().Markdown(markdown))) + .GeneratePdf(); + + Assert.That(pdf, Is.Not.Empty); + } + + [Test] + public void TaskList_ReplacesBulletsWithCheckboxes() + { + var options = new MarkdownOptions(); + var root = Render("- [x] done\n- [ ] open\n- regular"); + + Assert.That(Spans(root).Select(x => x.Text), Is.EqualTo(new[] { "done", "open", "•", "regular" })); + + var checkboxes = Descendants(root).OfType().Where(x => x.Color == options.CheckboxColor).ToList(); + Assert.That(checkboxes, Has.Count.EqualTo(2)); + Assert.That(Descendants(checkboxes[0]).OfType().Single().Color, Is.EqualTo(options.CheckboxColor)); + Assert.That(Descendants(checkboxes[1]).OfType(), Is.Empty); + } + + [Test] + public void HtmlEntities_AreDecoded() + { + var root = Render("© & € <tag>"); + + Assert.That(string.Concat(Spans(root).Select(x => x.Text)), Is.EqualTo("© & € ")); + } + + [Test] + public void Alert_HasTitleAndAccentColor() + { + var style = new MarkdownOptions().AlertStyles["WARNING"]; + var root = Render("> [!WARNING]\n> Mind the gap."); + + var title = Span(root, style.Title); + Assert.That(title.Style.Color, Is.EqualTo(style.Color)); + Assert.That(title.Style.FontWeight, Is.EqualTo(FontWeight.Bold)); + Assert.That(Descendants(root).OfType().Single().Color, Is.EqualTo(style.Color)); + Assert.That(Span(root, "Mind the gap."), Is.Not.Null); + Assert.That(Spans(root), Has.Count.EqualTo(2)); + } + + [Test] + public void AlertStyle_CanBeOverridden() + { + var root = Render("> [!NOTE]\n> text", x => x.AlertStyles["note"] = new MarkdownAlertStyle("Hinweis", Colors.Green.Darken2)); + + Assert.That(Span(root, "Hinweis").Style.Color, Is.EqualTo(Colors.Green.Darken2)); + } + + [Test] + public void CodeBlock_WithKnownLanguage_IsHighlighted() + { + var options = new MarkdownOptions(); + var root = Render("```csharp\nvar name = \"x\"; // note\nreturn 42;\n```"); + + var colors = Spans(root) + .Where(x => x.Text.Trim().Length > 0) + .GroupBy(x => x.Text.Trim()) + .ToDictionary(x => x.Key, x => x.First().Style.Color); + Assert.That(colors["var"], Is.EqualTo(options.SyntaxColors[SyntaxTokenKind.Keyword])); + Assert.That(colors["\"x\""], Is.EqualTo(options.SyntaxColors[SyntaxTokenKind.String])); + Assert.That(colors["// note"], Is.EqualTo(options.SyntaxColors[SyntaxTokenKind.Comment])); + Assert.That(colors["42"], Is.EqualTo(options.SyntaxColors[SyntaxTokenKind.Number])); + Assert.That(colors["name ="], Is.Null); + } + + [Test] + public void CodeBlock_WithUnknownLanguage_IsPlain() + { + var root = Render("```brainfuck\nvar x = 1;\n```"); + + Assert.That(Spans(root).Single().Text, Is.EqualTo("var x = 1;")); + } + + [Test] + public void CodeBlock_CustomLanguage_CanBeAdded() + { + var root = Render("```Kotlin\nfun main() {}\n```", x => x.CodeLanguages.Add(new SyntaxLanguage("kotlin").Keywords("fun"))); + + Assert.That(Span(root, "fun").Style.Color, Is.EqualTo(new MarkdownOptions().SyntaxColors[SyntaxTokenKind.Keyword])); + } + + [Test] + public void CodeBlock_ClearedLanguages_DisableHighlighting() + { + var root = Render("```csharp\nvar x = 1;\n```", x => x.CodeLanguages.Clear()); + + Assert.That(Spans(root).Single().Text, Is.EqualTo("var x = 1;")); + } + + [Test] + public void CodeBlock_CustomRenderer_ReplacesCodeBox() + { + string received = null; + var root = Render("```mermaid\ngraph TD\n A --> B\n```", x => x.CodeBlockRenderers["mermaid"] = (container, code) => + { + received = code; + container.Text("diagram"); + }); + + Assert.That(received, Is.EqualTo("graph TD\n A --> B")); + Assert.That(Spans(root).Single().Text, Is.EqualTo("diagram")); + Assert.That(Descendants(root).OfType(), Is.Empty); + } + + [Test] + public void SyntaxLanguage_TokensCoverTheWholeCode() + { + const string code = "a /* b */ \"c\" 1 // d\nx"; + var tokens = SyntaxLanguages.JavaScript().Tokenize(code, DefaultTimeout).ToList(); + + Assert.That(string.Concat(tokens.Select(x => x.Text)), Is.EqualTo(code)); + Assert.That(tokens.Where(x => x.Kind == SyntaxTokenKind.Comment).Select(x => x.Text), Is.EqualTo(new[] { "/* b */", "// d" })); + } + + [Test] + public void SyntaxLanguage_StringsWinOverCommentMarkersInside() + { + var tokens = SyntaxLanguages.Python().Tokenize("x = \"# not a comment\"", DefaultTimeout).ToList(); + + Assert.That(tokens.Single(x => x.Kind != null), Is.EqualTo(((SyntaxTokenKind?)SyntaxTokenKind.String, "\"# not a comment\""))); + } + + [TestCase("csharp", "public class A { int x = 0x1F; string s = @\"a\"\"b\"; }")] + [TestCase("js", "const a = `t ${b}`; // c")] + [TestCase("ts", "type A = { b: number };")] + [TestCase("json", "{ \"a\": [1, true, null] }")] + [TestCase("html", "t")] + [TestCase("css", "a:hover { color: #fff; margin: 1px; }")] + [TestCase("sql", "SELECT * FROM t WHERE a = 'x' -- c")] + [TestCase("py", "def f(): return '''x'''")] + [TestCase("bash", "echo \"$HOME\" # c")] + [TestCase("powershell", "Get-Item $env:PATH # c")] + [TestCase("yaml", "key: value\n- item: 1 # c")] + public void BuiltInLanguages_HighlightSomethingAndKeepAllText(string name, string code) + { + var language = SyntaxLanguages.All().Single(x => x.HasName(name)); + var tokens = language.Tokenize(code, DefaultTimeout).ToList(); + + Assert.That(string.Concat(tokens.Select(x => x.Text)), Is.EqualTo(code)); + Assert.That(tokens.Count(x => x.Kind != null), Is.GreaterThanOrEqualTo(2)); + } + + [Test] + public void Shell_HighlightsWholeUnquotedVariableNames() + { + var variables = SyntaxLanguages.Shell() + .Tokenize("echo $HOME ${PATH} $1 $?", DefaultTimeout) + .Where(x => x.Kind == SyntaxTokenKind.Attribute) + .Select(x => x.Text); + + Assert.That(variables, Is.EqualTo(new[] { "$HOME", "${PATH}", "$1", "$?" })); + } + + [Test] + public void SyntaxLanguage_SlowPattern_FallsBackToPlainTextWithinTimeout() + { + var language = new SyntaxLanguage("slow").Keywords("ok").Rule(SyntaxTokenKind.String, "(a+)+b"); + var code = "ok " + new string('a', 40) + "!"; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var tokens = language.Tokenize(code, TimeSpan.FromMilliseconds(50)).ToList(); + + Assert.That(stopwatch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5))); + Assert.That(string.Concat(tokens.Select(x => x.Text)), Is.EqualTo(code)); + Assert.That(tokens.First(), Is.EqualTo(((SyntaxTokenKind?)SyntaxTokenKind.Keyword, "ok"))); + } + + [Test] + public void SyntaxLanguage_UnterminatedBlockComment_RunsToTheEnd() + { + var code = string.Concat(Enumerable.Repeat("/*a ", 20_000)); + + var tokens = SyntaxLanguages.CSharp().Tokenize(code, DefaultTimeout).ToList(); + + Assert.That(tokens.Single(), Is.EqualTo(((SyntaxTokenKind?)SyntaxTokenKind.Comment, code))); + } + + [Test] + public void SyntaxLanguage_RejectsInvalidPattern() + { + Assert.Catch(() => new SyntaxLanguage("x").Rule(SyntaxTokenKind.Keyword, "(")); + } + [Test] public void HardLineBreak_SplitsParagraphIntoLines() { @@ -181,7 +506,7 @@ public void NullMarkdown_Throws() [Test] public void Document_WithAllFeatures_GeneratesPdf() { - const string markdown = """ + var markdown = $$""" # Release notes Version **2.1** adds *Markdown* support. See [the docs](https://github.com/LM-Development/ShinyPDF). @@ -202,6 +527,18 @@ public void Document_WithAllFeatures_GeneratesPdf() ```csharp container.Markdown(text); ``` + + | Feature | Status | + |---|:-:| + | Tables | done | + + - [x] task lists + - [ ] more + + > [!TIP] + > Alerts work too. + + ![pixel](data:image/png;base64,{{Convert.ToBase64String(CreatePng(2, 2))}}) """; var pdf = Document @@ -218,11 +555,32 @@ private static Element Render(string markdown, Action configure return container; } + private static readonly TimeSpan DefaultTimeout = new MarkdownOptions().SyntaxHighlightingTimeout; + + private static byte[] CreatePng(int width, int height, bool noisy = false) + { + using var bitmap = new SKBitmap(width, height); + bitmap.Erase(SKColors.Red); + + // noise keeps the compressed pixel data large, so truncating the file cuts into it + if (noisy) + { + for (var x = 0; x < width; x++) + for (var y = 0; y < height; y++) + bitmap.SetPixel(x, y, new SKColor(unchecked((uint)(x * 7919 ^ y * 104729) * 2654435761u) | 0xFF000000)); + } + + using var data = bitmap.Encode(SKEncodedImageFormat.Png, 100); + return data.ToArray(); + } + private static IEnumerable Descendants(Element element) { yield return element; - foreach (var child in element.GetChildren().OfType()) + var inlineElements = (element as TextBlock)?.Items.OfType().Select(x => x.Element) ?? Enumerable.Empty(); + + foreach (var child in element.GetChildren().OfType().Concat(inlineElements)) foreach (var descendant in Descendants(child)) yield return descendant; }