Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
107 changes: 102 additions & 5 deletions docs/how-to/render-markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
```

Expand All @@ -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 |
Expand All @@ -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)`, `<https://...>`, `<mail@example.com>` | 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 |
| `&copy;`, `&#8364;`, ... | 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).
Binary file added docs/img/example-markdown-extended.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions src/ShinyPDF.Examples/MarkdownExamples.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.IO;
using NUnit.Framework;
using ShinyPDF.Examples.Engine;
using ShinyPDF.Fluent;
Expand Down Expand Up @@ -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()
{
Expand Down
5 changes: 5 additions & 0 deletions src/ShinyPDF.Markdown/MarkdownAlertStyle.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace ShinyPDF.Markdown
{
/// <summary>Title and accent color of an alert block such as <c>&gt; [!NOTE]</c>.</summary>
public record MarkdownAlertStyle(string Title, string Color);
}
9 changes: 5 additions & 4 deletions src/ShinyPDF.Markdown/MarkdownExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ namespace ShinyPDF.Fluent
public static class MarkdownExtensions
{
/// <summary>
/// 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 <c>data:</c> URIs or through
/// <see cref="MarkdownOptions.ImageResolver"/>; otherwise their alternative text is shown.
/// </summary>
public static void Markdown(this IContainer container, string markdown, Action<MarkdownOptions>? configure = null)
{
Expand Down
72 changes: 72 additions & 0 deletions src/ShinyPDF.Markdown/MarkdownOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ShinyPDF.Helpers;
using ShinyPDF.Infrastructure;

Expand Down Expand Up @@ -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;

/// <summary>Space between a table cell border and its content.</summary>
public float TableCellPadding { get; set; } = 4;

/// <summary>
/// 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.
/// </summary>
public float MaxImageHeight { get; set; } = 500;

/// <summary>
/// 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.
/// </summary>
public long MaxImagePixels { get; set; } = 40_000_000;

/// <summary>Border and fill color of task list checkboxes.</summary>
public string CheckboxColor { get; set; } = Colors.Grey.Darken2;

/// <summary>
/// Overrides the style of a heading. Receives the heading level (1-6).
/// When not set, headings are bold and scaled from <see cref="BaseFontSize"/>.
/// </summary>
public Func<int, TextStyle>? HeadingStyle { get; set; }

/// <summary>
/// Loads the bytes of an image from its Markdown URL (e.g. from disk, embedded resources or HTTP).
/// Return <c>null</c> to show the alternative text instead. <c>data:</c> URIs are decoded without a resolver.
/// When not set, only <c>data:</c> URIs are rendered, so Markdown never reads files or the network on its own.
/// </summary>
public Func<string, byte[]?>? ImageResolver { get; set; }

/// <summary>
/// Languages used to highlight fenced code blocks, matched by the name after the opening fence.
/// Starts with <see cref="SyntaxLanguages.All"/>; add your own or clear the list to disable highlighting.
/// When several languages share a name, the one added last wins.
/// </summary>
public List<SyntaxLanguage> CodeLanguages { get; } = SyntaxLanguages.All().ToList();

/// <summary>
/// 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.
/// </summary>
public TimeSpan SyntaxHighlightingTimeout { get; set; } = TimeSpan.FromMilliseconds(500);

/// <summary>Colors of highlighted code. Kinds without a color use the regular text color.</summary>
public Dictionary<SyntaxTokenKind, string> SyntaxColors { get; } = new()
{
[SyntaxTokenKind.Keyword] = "#CF222E",
[SyntaxTokenKind.Type] = "#8250DF",
[SyntaxTokenKind.String] = "#0A3069",
[SyntaxTokenKind.Number] = "#0550AE",
[SyntaxTokenKind.Comment] = "#6E7781",
[SyntaxTokenKind.Tag] = "#116329",
[SyntaxTokenKind.Attribute] = "#0550AE"
};

/// <summary>
/// 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.
/// </summary>
public Dictionary<string, Action<IContainer, string>> CodeBlockRenderers { get; } = new(StringComparer.OrdinalIgnoreCase);

/// <summary>Title and color of alert blocks (<c>&gt; [!NOTE]</c>, ...) by alert kind.</summary>
public Dictionary<string, MarkdownAlertStyle> 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")
};
}
}
Loading
Loading