From f773fa65e839a476c2db83a416288bb48f3863b5 Mon Sep 17 00:00:00 2001 From: Rakulan Subramaniam Date: Thu, 8 Oct 2026 10:28:45 +0200 Subject: [PATCH 1/3] feat: add tables, task lists, images, alerts and syntax highlighting to Markdown - Pipe tables with bold repeating header and column alignment - Task list checkboxes - Images from data: URIs or a user-provided ImageResolver (no file or network access by default), alt text as fallback - GitHub alerts with configurable titles and colors - Regex-based syntax highlighting with built-in languages, extensible through SyntaxLanguage and SyntaxLanguages templates - CodeBlockRenderers hook for custom rendering such as Mermaid or math Refs #33 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- docs/how-to/render-markdown.md | 107 +++++++- docs/img/example-markdown-extended.png | Bin 0 -> 94736 bytes src/ShinyPDF.Examples/MarkdownExamples.cs | 49 ++++ src/ShinyPDF.Markdown/MarkdownAlertStyle.cs | 5 + src/ShinyPDF.Markdown/MarkdownExtensions.cs | 9 +- src/ShinyPDF.Markdown/MarkdownOptions.cs | 54 ++++ src/ShinyPDF.Markdown/MarkdownRenderer.cs | 257 +++++++++++++++++++- src/ShinyPDF.Markdown/PackageReadme.md | 4 +- src/ShinyPDF.Markdown/SyntaxLanguage.cs | 108 ++++++++ src/ShinyPDF.Markdown/SyntaxLanguages.cs | 203 ++++++++++++++++ src/ShinyPDF.Markdown/SyntaxTokenKind.cs | 14 ++ src/ShinyPDF.UnitTests/MarkdownTests.cs | 238 +++++++++++++++++- 13 files changed, 1031 insertions(+), 19 deletions(-) create mode 100644 docs/img/example-markdown-extended.png create mode 100644 src/ShinyPDF.Markdown/MarkdownAlertStyle.cs create mode 100644 src/ShinyPDF.Markdown/SyntaxLanguage.cs create mode 100644 src/ShinyPDF.Markdown/SyntaxLanguages.cs create mode 100644 src/ShinyPDF.Markdown/SyntaxTokenKind.cs 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..a715d46ef 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. 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()`. + +## 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 0000000000000000000000000000000000000000..0b4264ebd61f1d1cc43c4d7564ecb730e42e0fbc GIT binary patch literal 94736 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 literal 0 HcmV?d00001 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..fbd2e768e 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,62 @@ 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; + + /// 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(); + + /// 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..2e0fa63e2 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(); @@ -51,7 +59,12 @@ private void ComposeBlocks(IContainer container, ContainerBlock blocks, float sp 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) @@ -70,6 +83,10 @@ private void ComposeBlock(IContainer container, Block block) ComposeList(container, list); break; + case AlertBlock alert: + ComposeAlert(container, alert); + break; + case QuoteBlock quote: container .BorderLeft(3) @@ -87,6 +104,10 @@ 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; @@ -108,31 +129,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) ?? 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; + + cellContainer = alignment switch + { + TableColumnAlign.Center => cellContainer.AlignCenter(), + TableColumnAlign.Right => cellContainer.AlignRight(), + _ => cellContainer + }; + + ComposeBlocks(cellContainer, cell, Options.ListItemSpacing); + columnIndex += columnSpan; + } + } } private TextStyle GetHeadingStyle(int level) @@ -157,6 +344,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 +373,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 +395,57 @@ 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 width = data == null ? 0 : GetImageWidth(data); + + if (data == null || width == 0) + { + // unavailable or undecodable image, show its alternative text instead + ComposeInlines(text, image, style, url); + return; + } + + // natural size (one pixel per point), scaled down when wider than the available space + text.Element().MaxWidth(width).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 int GetImageWidth(byte[] data) + { + using var stream = new SKMemoryStream(data); + using var codec = SKCodec.Create(stream); + return codec?.Info.Width ?? 0; + } + 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..ecb40d7db --- /dev/null +++ b/src/ShinyPDF.Markdown/SyntaxLanguage.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +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; + + /// 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. + internal IEnumerable<(SyntaxTokenKind? Kind, string Text)> Tokenize(string code) + { + if (Rules.Count == 0) + { + yield return (null, code); + yield break; + } + + var regex = CompiledRules ??= Compile(); + var position = 0; + + foreach (Match match in regex.Matches(code)) + { + if (match.Length == 0) + continue; + + if (match.Index > position) + yield return (null, code[position..match.Index]); + + yield return (GetKind(match), match.Value); + position = match.Index + match.Length; + } + + if (position < code.Length) + yield return (null, code[position..]); + } + + private Regex Compile() + { + var alternatives = Rules.Select((rule, index) => $"(?<{GroupPrefix}{index}>{rule.Pattern})"); + return new Regex(string.Join("|", alternatives), RegexOptions.Multiline | RegexOptions.CultureInvariant); + } + + 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..e05ff55c1 --- /dev/null +++ b/src/ShinyPDF.Markdown/SyntaxLanguages.cs @@ -0,0 +1,203 @@ +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]*?\*/"; + 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]*?""""""") + .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, @"") + .Rule(SyntaxTokenKind.Keyword, @"|<[!?][^>]*>") + .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|(?") + .Rule(SyntaxTokenKind.Comment, HashComment) + .Rule(SyntaxTokenKind.String, @"@""[\s\S]*?\n""@|@'[\s\S]*?\n'@") + .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..c46977c65 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,217 @@ 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 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_AppliesColumnAlignment() + { + var root = Render("| L | C | R |\n|:--|:-:|--:|\n| a | b | c |"); + + var alignments = Descendants(root).OfType().Select(x => x.Horizontal).ToList(); + Assert.That(alignments.Count(x => x == HorizontalAlignment.Center), Is.EqualTo(2)); + Assert.That(alignments.Count(x => x == HorizontalAlignment.Right), Is.EqualTo(2)); + } + + [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).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\"").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).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 SyntaxLanguage_RejectsInvalidPattern() + { + Assert.Catch(() => new SyntaxLanguage("x").Rule(SyntaxTokenKind.Keyword, "(")); + } + [Test] public void HardLineBreak_SplitsParagraphIntoLines() { @@ -181,7 +393,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 +414,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 +442,21 @@ private static Element Render(string markdown, Action configure return container; } + private static byte[] CreatePng(int width, int height) + { + using var bitmap = new SKBitmap(width, height); + bitmap.Erase(SKColors.Red); + 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; } From 4e050cdec84d4fb825e68360ec3f7197a875c159 Mon Sep 17 00:00:00 2001 From: Rakulan Subramaniam Date: Thu, 8 Oct 2026 15:42:11 +0200 Subject: [PATCH 2/3] fix: address review findings on Markdown tables, images and shell highlighting - Align each text line in centered and right-aligned table columns - Fully decode images before rendering; incomplete data falls back to alt text - Keep the enclosing link on rendered images - Bound image height with MaxImageHeight so tall images fit on a page - Highlight full unquoted shell variable names Co-Authored-By: Claude Opus 5.5 --- docs/how-to/render-markdown.md | 2 +- src/ShinyPDF.Markdown/MarkdownOptions.cs | 6 ++ src/ShinyPDF.Markdown/MarkdownRenderer.cs | 68 ++++++++++++---- src/ShinyPDF.Markdown/SyntaxLanguages.cs | 3 +- src/ShinyPDF.UnitTests/MarkdownTests.cs | 95 +++++++++++++++++++++-- 5 files changed, 149 insertions(+), 25 deletions(-) diff --git a/docs/how-to/render-markdown.md b/docs/how-to/render-markdown.md index a715d46ef..8b0d358ef 100644 --- a/docs/how-to/render-markdown.md +++ b/docs/how-to/render-markdown.md @@ -108,7 +108,7 @@ options.ImageResolver = url => }; ``` -Images are shown at their natural size (one pixel per point) and scaled down to the available width. PNG, JPEG, WebP, GIF and BMP are supported; SVG is not. Images that cannot be decoded fall back to their alternative text. +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. PNG, JPEG, WebP, GIF and BMP are supported; SVG is not. Images that cannot be decoded fall back to their alternative text. ## Syntax highlighting diff --git a/src/ShinyPDF.Markdown/MarkdownOptions.cs b/src/ShinyPDF.Markdown/MarkdownOptions.cs index fbd2e768e..2d554419e 100644 --- a/src/ShinyPDF.Markdown/MarkdownOptions.cs +++ b/src/ShinyPDF.Markdown/MarkdownOptions.cs @@ -39,6 +39,12 @@ public class MarkdownOptions /// 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; + /// Border and fill color of task list checkboxes. public string CheckboxColor { get; set; } = Colors.Grey.Darken2; diff --git a/src/ShinyPDF.Markdown/MarkdownRenderer.cs b/src/ShinyPDF.Markdown/MarkdownRenderer.cs index 2e0fa63e2..32cb38c0c 100644 --- a/src/ShinyPDF.Markdown/MarkdownRenderer.cs +++ b/src/ShinyPDF.Markdown/MarkdownRenderer.cs @@ -44,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(); @@ -53,7 +53,7 @@ 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); }); } @@ -67,16 +67,16 @@ private static bool IsRendered(Block block) }; } - 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: @@ -113,11 +113,25 @@ private void ComposeBlock(IContainer container, Block block) 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; @@ -309,14 +323,14 @@ private void ComposeTableRows(Table table, List rows, Func cellContainer.AlignCenter(), - TableColumnAlign.Right => cellContainer.AlignRight(), - _ => cellContainer + TableColumnAlign.Center => HorizontalAlignment.Center, + TableColumnAlign.Right => HorizontalAlignment.Right, + _ => (HorizontalAlignment?)null }; - ComposeBlocks(cellContainer, cell, Options.ListItemSpacing); + ComposeBlocks(cellContainer, cell, Options.ListItemSpacing, textAlignment); columnIndex += columnSpan; } } @@ -398,17 +412,26 @@ 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 width = data == null ? 0 : GetImageWidth(data); + var size = data == null ? null : DecodeImageSize(data); - if (data == null || width == 0) + 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 when wider than the available space - text.Element().MaxWidth(width).Image(data, ImageScaling.FitWidth); + // 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) @@ -439,11 +462,22 @@ private void ComposeImage(TextDescriptor text, LinkInline image, TextStyle style } } - private static int GetImageWidth(byte[] data) + private static (float Width, float Height)? DecodeImageSize(byte[] data) { using var stream = new SKMemoryStream(data); using var codec = SKCodec.Create(stream); - return codec?.Info.Width ?? 0; + + if (codec == null || codec.Info.Width == 0 || codec.Info.Height == 0) + 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) diff --git a/src/ShinyPDF.Markdown/SyntaxLanguages.cs b/src/ShinyPDF.Markdown/SyntaxLanguages.cs index e05ff55c1..acab04618 100644 --- a/src/ShinyPDF.Markdown/SyntaxLanguages.cs +++ b/src/ShinyPDF.Markdown/SyntaxLanguages.cs @@ -163,8 +163,7 @@ public static SyntaxLanguage Shell() .Rule(SyntaxTokenKind.Comment, HashComment) .Rule(SyntaxTokenKind.String, DoubleQuoted) .Rule(SyntaxTokenKind.String, @"'[^']*'") - .Rule(SyntaxTokenKind.Attribute, @"\$\{[^}]*\}|\$[\w@#?*!$-]") - .Rule(SyntaxTokenKind.Attribute, @"\$\w+") + .Rule(SyntaxTokenKind.Attribute, @"\$\{[^}]*\}|\$\w+|\$[@#?*!$-]") .Keywords( "case", "do", "done", "elif", "else", "esac", "export", "fi", "for", "function", "if", "in", "local", "return", "then", "until", "while"); diff --git a/src/ShinyPDF.UnitTests/MarkdownTests.cs b/src/ShinyPDF.UnitTests/MarkdownTests.cs index c46977c65..ad8001164 100644 --- a/src/ShinyPDF.UnitTests/MarkdownTests.cs +++ b/src/ShinyPDF.UnitTests/MarkdownTests.cs @@ -199,6 +199,54 @@ public void Image_ThatCannotBeDecoded_FallsBackToAltText() 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_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() { @@ -216,13 +264,30 @@ public void Table_RendersHeaderAndBodyCells() } [Test] - public void Table_AppliesColumnAlignment() + public void Table_AlignsEachTextLineOfAlignedColumns() { var root = Render("| L | C | R |\n|:--|:-:|--:|\n| a | b | c |"); - var alignments = Descendants(root).OfType().Select(x => x.Horizontal).ToList(); - Assert.That(alignments.Count(x => x == HorizontalAlignment.Center), Is.EqualTo(2)); - Assert.That(alignments.Count(x => x == HorizontalAlignment.Right), Is.EqualTo(2)); + // 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] @@ -363,6 +428,17 @@ public void BuiltInLanguages_HighlightSomethingAndKeepAllText(string name, strin 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 $?") + .Where(x => x.Kind == SyntaxTokenKind.Attribute) + .Select(x => x.Text); + + Assert.That(variables, Is.EqualTo(new[] { "$HOME", "${PATH}", "$1", "$?" })); + } + [Test] public void SyntaxLanguage_RejectsInvalidPattern() { @@ -442,10 +518,19 @@ private static Element Render(string markdown, Action configure return container; } - private static byte[] CreatePng(int width, int height) + 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(); } From 9bfc4bb50c1043c4762d5e57a8b6e43a89076fdd Mon Sep 17 00:00:00 2001 From: Rakulan Subramaniam Date: Thu, 8 Oct 2026 15:59:55 +0200 Subject: [PATCH 3/3] fix: limit image pixels and highlighting time for untrusted Markdown - MaxImagePixels (default 40 MP) is checked from the image header before any pixel buffer is allocated; larger images show their alt text - SyntaxHighlightingTimeout (default 500 ms) bounds highlighting of one code block, both per regex match and in total; the rest stays plain text - Unterminated block comments and multi-line strings run to the end of the code instead of rescanning the input for every opening marker Co-Authored-By: Claude Opus 5.5 --- docs/how-to/render-markdown.md | 4 +- src/ShinyPDF.Markdown/MarkdownOptions.cs | 12 ++++++ src/ShinyPDF.Markdown/MarkdownRenderer.cs | 12 ++++-- src/ShinyPDF.Markdown/SyntaxLanguage.cs | 48 +++++++++++++++++++---- src/ShinyPDF.Markdown/SyntaxLanguages.cs | 14 +++---- src/ShinyPDF.UnitTests/MarkdownTests.cs | 47 ++++++++++++++++++++-- 6 files changed, 113 insertions(+), 24 deletions(-) diff --git a/docs/how-to/render-markdown.md b/docs/how-to/render-markdown.md index 8b0d358ef..91d7c46da 100644 --- a/docs/how-to/render-markdown.md +++ b/docs/how-to/render-markdown.md @@ -108,7 +108,7 @@ options.ImageResolver = url => }; ``` -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. PNG, JPEG, WebP, GIF and BMP are supported; SVG is not. Images that cannot be decoded fall back to their alternative text. +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 @@ -138,7 +138,7 @@ options.CodeLanguages.Add(new SyntaxLanguage("kotlin", "kt") .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()`. +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 diff --git a/src/ShinyPDF.Markdown/MarkdownOptions.cs b/src/ShinyPDF.Markdown/MarkdownOptions.cs index 2d554419e..6eea3e362 100644 --- a/src/ShinyPDF.Markdown/MarkdownOptions.cs +++ b/src/ShinyPDF.Markdown/MarkdownOptions.cs @@ -45,6 +45,12 @@ public class MarkdownOptions /// 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; @@ -68,6 +74,12 @@ public class MarkdownOptions /// 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() { diff --git a/src/ShinyPDF.Markdown/MarkdownRenderer.cs b/src/ShinyPDF.Markdown/MarkdownRenderer.cs index 32cb38c0c..e0f687232 100644 --- a/src/ShinyPDF.Markdown/MarkdownRenderer.cs +++ b/src/ShinyPDF.Markdown/MarkdownRenderer.cs @@ -242,7 +242,7 @@ private void ComposeCodeBlock(IContainer container, CodeBlock code) .FontSize(Options.BaseFontSize * 0.9f); var syntax = language.Length == 0 ? null : Options.CodeLanguages.LastOrDefault(x => x.HasName(language)); - var tokens = syntax?.Tokenize(content) ?? new[] { ((SyntaxTokenKind?)null, content) }; + var tokens = syntax?.Tokenize(content, Options.SyntaxHighlightingTimeout) ?? new[] { ((SyntaxTokenKind?)null, content) }; container .Background(Options.CodeBackgroundColor) @@ -412,7 +412,7 @@ 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); + var size = data == null ? null : DecodeImageSize(data, Options.MaxImagePixels); if (data == null || size == null) { @@ -462,12 +462,16 @@ private void ComposeImage(TextDescriptor text, LinkInline image, TextStyle style } } - private static (float Width, float Height)? DecodeImageSize(byte[] data) + 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) + 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 diff --git a/src/ShinyPDF.Markdown/SyntaxLanguage.cs b/src/ShinyPDF.Markdown/SyntaxLanguage.cs index ecb40d7db..038c2fe7d 100644 --- a/src/ShinyPDF.Markdown/SyntaxLanguage.cs +++ b/src/ShinyPDF.Markdown/SyntaxLanguage.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Text.RegularExpressions; @@ -16,6 +17,7 @@ public class SyntaxLanguage 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) @@ -60,8 +62,11 @@ internal bool HasName(string name) return Names.Any(x => string.Equals(x, name, StringComparison.OrdinalIgnoreCase)); } - /// Splits code into consecutive pieces; pieces without a kind are plain code. - internal IEnumerable<(SyntaxTokenKind? Kind, string Text)> Tokenize(string code) + /// + /// 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) { @@ -69,29 +74,58 @@ internal bool HasName(string name) yield break; } - var regex = CompiledRules ??= Compile(); + var regex = GetRegex(timeout); + var stopwatch = Stopwatch.StartNew(); var position = 0; + var searchFrom = 0; - foreach (Match match in regex.Matches(code)) + 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 = match.Index + match.Length; + position = searchFrom = match.Index + match.Length; } if (position < code.Length) yield return (null, code[position..]); } - private Regex Compile() + 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})"); - return new Regex(string.Join("|", alternatives), RegexOptions.Multiline | RegexOptions.CultureInvariant); + CompiledRules = new Regex(string.Join("|", alternatives), RegexOptions.Multiline | RegexOptions.CultureInvariant, timeout); + CompiledTimeout = timeout; + return CompiledRules; } private SyntaxTokenKind GetKind(Match match) diff --git a/src/ShinyPDF.Markdown/SyntaxLanguages.cs b/src/ShinyPDF.Markdown/SyntaxLanguages.cs index acab04618..6552e3da0 100644 --- a/src/ShinyPDF.Markdown/SyntaxLanguages.cs +++ b/src/ShinyPDF.Markdown/SyntaxLanguages.cs @@ -9,7 +9,7 @@ namespace ShinyPDF.Markdown public static class SyntaxLanguages { private const string LineComment = @"//.*"; - private const string BlockComment = @"/\*[\s\S]*?\*/"; + private const string BlockComment = @"/\*[\s\S]*?(?:\*/|\z)"; private const string HashComment = @"(?") - .Rule(SyntaxTokenKind.Keyword, @"|<[!?][^>]*>") + .Rule(SyntaxTokenKind.Comment, @"|\z)") + .Rule(SyntaxTokenKind.Keyword, @"|\z)|<[!?][^>]*>") .Rule(SyntaxTokenKind.Tag, @"") .Rule(SyntaxTokenKind.Attribute, @"(?<=\s)[\w:.\-]+(?=\s*=\s*[""'])") .Rule(SyntaxTokenKind.String, @"(?<==\s*)(?:""[^""]*""|'[^']*')"); @@ -145,7 +145,7 @@ public static SyntaxLanguage Python() { return new SyntaxLanguage("python", "py") .Rule(SyntaxTokenKind.Comment, @"#.*") - .Rule(SyntaxTokenKind.String, @"(?i:[rbuf]{0,2})(?:""""""[\s\S]*?""""""|'''[\s\S]*?''')") + .Rule(SyntaxTokenKind.String, @"(?i:[rbuf]{0,2})(?:""""""[\s\S]*?(?:""""""|\z)|'''[\s\S]*?(?:'''|\z))") .Rule(SyntaxTokenKind.String, @"(?i:[rbuf]{0,2})(?:" + DoubleQuoted + "|" + SingleQuoted + ")") .Rule(SyntaxTokenKind.Keyword, @"^[ \t]*@[\w.]+") .Rule(SyntaxTokenKind.Number, Number) @@ -172,9 +172,9 @@ public static SyntaxLanguage Shell() public static SyntaxLanguage PowerShell() { return new SyntaxLanguage("powershell", "pwsh", "ps1", "ps") - .Rule(SyntaxTokenKind.Comment, @"<#[\s\S]*?#>") + .Rule(SyntaxTokenKind.Comment, @"<#[\s\S]*?(?:#>|\z)") .Rule(SyntaxTokenKind.Comment, HashComment) - .Rule(SyntaxTokenKind.String, @"@""[\s\S]*?\n""@|@'[\s\S]*?\n'@") + .Rule(SyntaxTokenKind.String, @"@""[\s\S]*?(?:\n""@|\z)|@'[\s\S]*?(?:\n'@|\z)") .Rule(SyntaxTokenKind.String, @"""(?:`.|[^""`])*""") .Rule(SyntaxTokenKind.String, @"'(?:''|[^'])*'") .Rule(SyntaxTokenKind.Attribute, @"\$(?:\{[^}]*\}|[\w:]+)") diff --git a/src/ShinyPDF.UnitTests/MarkdownTests.cs b/src/ShinyPDF.UnitTests/MarkdownTests.cs index ad8001164..2bff1b5a7 100644 --- a/src/ShinyPDF.UnitTests/MarkdownTests.cs +++ b/src/ShinyPDF.UnitTests/MarkdownTests.cs @@ -214,6 +214,19 @@ public void Image_WithValidHeaderButCorruptPixels_FallsBackToAltText() 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() { @@ -394,7 +407,7 @@ public void CodeBlock_CustomRenderer_ReplacesCodeBox() public void SyntaxLanguage_TokensCoverTheWholeCode() { const string code = "a /* b */ \"c\" 1 // d\nx"; - var tokens = SyntaxLanguages.JavaScript().Tokenize(code).ToList(); + 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" })); @@ -403,7 +416,7 @@ public void SyntaxLanguage_TokensCoverTheWholeCode() [Test] public void SyntaxLanguage_StringsWinOverCommentMarkersInside() { - var tokens = SyntaxLanguages.Python().Tokenize("x = \"# not a comment\"").ToList(); + 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\""))); } @@ -422,7 +435,7 @@ public void SyntaxLanguage_StringsWinOverCommentMarkersInside() public void BuiltInLanguages_HighlightSomethingAndKeepAllText(string name, string code) { var language = SyntaxLanguages.All().Single(x => x.HasName(name)); - var tokens = language.Tokenize(code).ToList(); + 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)); @@ -432,13 +445,37 @@ public void BuiltInLanguages_HighlightSomethingAndKeepAllText(string name, strin public void Shell_HighlightsWholeUnquotedVariableNames() { var variables = SyntaxLanguages.Shell() - .Tokenize("echo $HOME ${PATH} $1 $?") + .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() { @@ -518,6 +555,8 @@ 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);