├── .idea
└── .idea.QuestPDF.Markdown
│ └── .idea
│ ├── .name
│ ├── encodings.xml
│ ├── vcs.xml
│ ├── indexLayout.xml
│ └── .gitignore
├── .github
├── ISSUE_TEMPLATE
│ ├── config.yml
│ └── bug_report.md
├── FUNDING.yml
├── dependabot.yml
└── workflows
│ ├── release.yml
│ ├── codeql.yml
│ └── build-and-test.yml
├── img
├── logo.png
├── usage.png
└── logo.svg
├── tests
└── QuestPDF.Markdown.Tests
│ ├── test.pdf
│ ├── Images
│ └── local.jpg
│ ├── Fonts
│ ├── NotoSansMono-Regular.ttf
│ └── NotoSansSymbols2-Regular.ttf
│ ├── VerifyChecksTests.cs
│ ├── Verify
│ ├── RenderTests.RendersTags.verified.png
│ ├── RenderTests.RendersLinks.verified.png
│ ├── RenderTests.RendersLists.verified.png
│ ├── RenderTests.RendersTables.verified.png
│ ├── RenderTests.RedersEmphasis.verified.png
│ ├── RenderTests.RendersHeadings.verified.png
│ ├── RenderTests.RendersBlockquotes.verified.png
│ ├── RenderTests.RendersGridTables.verified.png
│ ├── RenderTests.RendersParagraphs.verified.png
│ ├── RenderTests.RendersHtmlEntities.verified.png
│ ├── RenderTests.RendersImagesDownload.verified.png
│ ├── RenderTests.RendersNestedElements.verified.png
│ ├── RenderTests.RendersExtendedEmphasis.verified.png
│ ├── RenderTests.RendersHorizontalRules.verified.png
│ ├── RenderTests.RendersImagesNoDownload.verified.png
│ ├── RenderTests.RendersAlternativeHeadings.verified.png
│ ├── RenderTests.RendersImagesDownloadLocal.verified.png
│ ├── RenderTests.RendersImagesDownloadBase64.verified.png
│ ├── RenderTests.RendersCodeBlocksAndInlineCode.verified.png
│ ├── RenderTests.RendersImagesDownloadLocalNoRoot.verified.png
│ ├── RenderTests.RendersImagesDownloadMaxSizeValid.verified.png
│ ├── RenderTests.RendersImagesDownloadLocalPathTraversal.verified.png
│ ├── RenderTests.RendersTags.verified.txt
│ ├── RenderTests.RedersEmphasis.verified.txt
│ ├── RenderTests.RendersHeadings.verified.txt
│ ├── RenderTests.RendersLinks.verified.txt
│ ├── RenderTests.RendersLists.verified.txt
│ ├── RenderTests.RendersTables.verified.txt
│ ├── RenderTests.RendersBlockquotes.verified.txt
│ ├── RenderTests.RendersGridTables.verified.txt
│ ├── RenderTests.RendersHtmlEntities.verified.txt
│ ├── RenderTests.RendersImagesDownload.verified.txt
│ ├── RenderTests.RendersNestedElements.verified.txt
│ ├── RenderTests.RendersParagraphs.verified.txt
│ ├── RenderTests.RendersAlternativeHeadings.verified.txt
│ ├── RenderTests.RendersExtendedEmphasis.verified.txt
│ ├── RenderTests.RendersHorizontalRules.verified.txt
│ ├── RenderTests.RendersImagesDownloadLocal.verified.txt
│ ├── RenderTests.RendersImagesNoDownload.verified.txt
│ ├── RenderTests.RendersCodeBlocksAndInlineCode.verified.txt
│ ├── RenderTests.RendersImagesDownloadBase64.verified.txt
│ ├── RenderTests.RendersImagesDownloadLocalNoRoot.verified.txt
│ ├── RenderTests.RendersImagesDownloadMaxSizeValid.verified.txt
│ └── RenderTests.RendersImagesDownloadLocalPathTraversal.verified.txt
│ ├── tests.sh
│ ├── Parsing
│ └── TemplateTests.cs
│ ├── GlobalInitializer.cs
│ ├── Helpers
│ └── PathHelpersTests.cs
│ ├── QuestPDF.Markdown.Tests.csproj
│ ├── Rendering
│ ├── SamplePdfTests.cs
│ └── RenderTests.cs
│ └── test.md
├── global.json
├── .gitattributes
├── src
└── QuestPDF.Markdown
│ ├── Compatibility
│ ├── IsExternalInit.cs
│ ├── NotNullAttribute.cs
│ ├── NotNullWhenAttribuute.cs
│ ├── MaybeNullWhenAttribute.cs
│ ├── CallerArgumentExpressionAttribute.cs
│ ├── ExceptionHelper.cs
│ └── CompatibilityShims.cs
│ ├── Parsing
│ ├── TemplateInline.cs
│ ├── TemplateExtension.cs
│ └── TemplateParser.cs
│ ├── TextProperties.cs
│ ├── ImageWithDimensions.cs
│ ├── Extensions
│ ├── TextExtensions.cs
│ └── TableCellExtensions.cs
│ ├── Helpers
│ └── PathHelpers.cs
│ ├── QuestPDF.Markdown.csproj
│ ├── MarkdownExtensions.cs
│ ├── MarkdownRendererOptions.cs
│ ├── ParsedMarkdownDocument.cs
│ └── MarkdownRenderer.cs
├── compose.yaml
├── .editorconfig
├── QuestPDF.Markdown.slnx
├── SECURITY.md
├── Directory.Build.props
├── .gitignore
├── .run
└── Watch ShowInCompanion.run.xml
├── LICENSE
├── Directory.Packages.props
└── README.md
/.idea/.idea.QuestPDF.Markdown/.idea/.name:
--------------------------------------------------------------------------------
1 | QuestPDF.Markdown
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 |
--------------------------------------------------------------------------------
/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/img/logo.png
--------------------------------------------------------------------------------
/img/usage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/img/usage.png
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [christiaanderidder]
4 |
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/test.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/test.pdf
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Images/local.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Images/local.jpg
--------------------------------------------------------------------------------
/global.json:
--------------------------------------------------------------------------------
1 | {
2 | "sdk": {
3 | "version": "10.0.101",
4 | "rollForward": "latestMinor"
5 | },
6 | "test": {
7 | "runner": "Microsoft.Testing.Platform"
8 | }
9 | }
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.verified.txt text eol=lf working-tree-encoding=UTF-8
2 | *.verified.xml text eol=lf working-tree-encoding=UTF-8
3 | *.verified.json text eol=lf working-tree-encoding=UTF-8
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/Compatibility/IsExternalInit.cs:
--------------------------------------------------------------------------------
1 | #if NETSTANDARD2_0
2 | namespace System.Runtime.CompilerServices
3 | {
4 | internal static class IsExternalInit { }
5 | }
6 | #endif
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Fonts/NotoSansMono-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Fonts/NotoSansMono-Regular.ttf
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Fonts/NotoSansSymbols2-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Fonts/NotoSansSymbols2-Regular.ttf
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/VerifyChecksTests.cs:
--------------------------------------------------------------------------------
1 | namespace QuestPDF.Markdown.Tests;
2 |
3 | public sealed class VerifyChecksTests
4 | {
5 | [Fact]
6 | public Task Run() => VerifyChecks.Run();
7 | }
--------------------------------------------------------------------------------
/compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | test-runner:
3 | image: mcr.microsoft.com/dotnet/sdk:10.0
4 | working_dir: /code
5 | volumes:
6 | - .:/code
7 | command: ./tests/QuestPDF.Markdown.Tests/tests.sh
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersTags.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersTags.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersLinks.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersLinks.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersLists.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersLists.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersTables.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersTables.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RedersEmphasis.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RedersEmphasis.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersHeadings.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersHeadings.verified.png
--------------------------------------------------------------------------------
/.idea/.idea.QuestPDF.Markdown/.idea/encodings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/.idea.QuestPDF.Markdown/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersBlockquotes.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersBlockquotes.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersGridTables.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersGridTables.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersParagraphs.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersParagraphs.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersHtmlEntities.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersHtmlEntities.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownload.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownload.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersNestedElements.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersNestedElements.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/tests.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | apt-get -y update &&
3 | apt-get install rename &&
4 | dotnet test ;
5 | cd tests/QuestPDF.Markdown.Tests/Verify &&
6 | echo $PWD &&
7 | rename -v -f 's/\.received\./.verified./' *.*
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersExtendedEmphasis.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersExtendedEmphasis.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersHorizontalRules.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersHorizontalRules.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesNoDownload.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesNoDownload.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersAlternativeHeadings.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersAlternativeHeadings.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadLocal.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadLocal.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadBase64.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadBase64.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersCodeBlocksAndInlineCode.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersCodeBlocksAndInlineCode.verified.png
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # Verify settings
2 | [*.{received,verified}.{json,txt,xml}]
3 | charset = "utf-8-bom"
4 | end_of_line = lf
5 | indent_size = unset
6 | indent_style = unset
7 | insert_final_newline = false
8 | tab_width = unset
9 | trim_trailing_whitespace = false
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadLocalNoRoot.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadLocalNoRoot.verified.png
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadMaxSizeValid.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadMaxSizeValid.verified.png
--------------------------------------------------------------------------------
/.idea/.idea.QuestPDF.Markdown/.idea/indexLayout.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadLocalPathTraversal.verified.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christiaanderidder/QuestPDF.Markdown/HEAD/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadLocalPathTraversal.verified.png
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/Parsing/TemplateInline.cs:
--------------------------------------------------------------------------------
1 | using Markdig.Syntax.Inlines;
2 |
3 | namespace QuestPDF.Markdown.Parsing;
4 |
5 | internal sealed class TemplateInline : LeafInline
6 | {
7 | public TemplateInline(string tag) => Tag = tag;
8 |
9 | public string Tag { get; }
10 | }
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/Compatibility/NotNullAttribute.cs:
--------------------------------------------------------------------------------
1 | #if NETSTANDARD2_0
2 | namespace System.Diagnostics.CodeAnalysis
3 | {
4 | [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.ReturnValue)]
5 | public sealed class NotNullAttribute : Attribute
6 | {
7 | }
8 | }
9 | #endif
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/TextProperties.cs:
--------------------------------------------------------------------------------
1 | using QuestPDF.Fluent;
2 |
3 | namespace QuestPDF.Markdown;
4 |
5 | internal sealed class TextProperties
6 | {
7 | public Stack> TextStyles { get; } = new();
8 | public string? LinkUrl { get; set; }
9 | public string? ImageUrl { get; set; }
10 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersTags.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RedersEmphasis.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersHeadings.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersLinks.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersLists.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersTables.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersBlockquotes.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersGridTables.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersHtmlEntities.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownload.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersNestedElements.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersParagraphs.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/Compatibility/NotNullWhenAttribuute.cs:
--------------------------------------------------------------------------------
1 | #if NETSTANDARD2_0
2 | namespace System.Diagnostics.CodeAnalysis
3 | {
4 | [AttributeUsage(AttributeTargets.Parameter)]
5 | public sealed class NotNullWhenAttribute : Attribute
6 | {
7 | public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue;
8 | public bool ReturnValue { get; }
9 | }
10 | }
11 | #endif
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersAlternativeHeadings.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersExtendedEmphasis.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersHorizontalRules.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadLocal.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesNoDownload.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersCodeBlocksAndInlineCode.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadBase64.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/QuestPDF.Markdown.slnx:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadLocalNoRoot.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadMaxSizeValid.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Verify/RenderTests.RendersImagesDownloadLocalPathTraversal.verified.txt:
--------------------------------------------------------------------------------
1 | {
2 | Pages: 1,
3 | Metadata: {
4 | CreationDate: DateTimeOffset_1,
5 | ModifiedDate: DateTimeOffset_1
6 | },
7 | Settings: {
8 | ContentDirection: LeftToRight,
9 | PDFA_Conformance: None,
10 | PDFUA_Conformance: None,
11 | ImageCompressionQuality: High,
12 | ImageRasterDpi: 288
13 | }
14 | }
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/Compatibility/MaybeNullWhenAttribute.cs:
--------------------------------------------------------------------------------
1 | #if NETSTANDARD2_0
2 | namespace System.Diagnostics.CodeAnalysis
3 | {
4 | [AttributeUsage(AttributeTargets.Parameter)]
5 | public sealed class MaybeNullWhenAttribute : Attribute
6 | {
7 | public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue;
8 |
9 | public bool ReturnValue { get; }
10 | }
11 | }
12 | #endif
--------------------------------------------------------------------------------
/.idea/.idea.QuestPDF.Markdown/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Rider ignored files
5 | /modules.xml
6 | /.idea.QuestPDF.Markdown.iml
7 | /contentModel.xml
8 | /projectSettingsUpdater.xml
9 | # Editor-based HTTP Client requests
10 | /httpRequests/
11 | # Datasource local storage ignored files
12 | /dataSources/
13 | /dataSources.local.xml
14 | # Copilot plugin files
15 | copilot.data.migration.*.xml
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/Parsing/TemplateExtension.cs:
--------------------------------------------------------------------------------
1 | using Markdig;
2 | using Markdig.Renderers;
3 |
4 | namespace QuestPDF.Markdown.Parsing;
5 |
6 | internal sealed class TemplateExtension : IMarkdownExtension
7 | {
8 | public void Setup(MarkdownPipelineBuilder pipeline) => pipeline.InlineParsers.AddIfNotAlready();
9 |
10 | public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
11 | {
12 | }
13 | }
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/Compatibility/CallerArgumentExpressionAttribute.cs:
--------------------------------------------------------------------------------
1 | #if NETSTANDARD2_0
2 | namespace System.Runtime.CompilerServices;
3 |
4 | [AttributeUsage(AttributeTargets.Parameter)]
5 | public sealed class CallerArgumentExpressionAttribute : Attribute
6 | {
7 | public CallerArgumentExpressionAttribute(string parameterName)
8 | {
9 | ParameterName = parameterName;
10 | }
11 |
12 | public string ParameterName { get; }
13 | }
14 | #endif
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/ImageWithDimensions.cs:
--------------------------------------------------------------------------------
1 | using QuestPDF.Infrastructure;
2 |
3 | namespace QuestPDF.Markdown;
4 |
5 | internal sealed class ImageWithDimensions
6 | {
7 | internal int Width { get; }
8 | internal int Height { get; }
9 | internal Image Image { get; }
10 |
11 | internal ImageWithDimensions(int width, int height, Image image)
12 | {
13 | Width = width;
14 | Height = height;
15 | Image = image;
16 | }
17 | }
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/Compatibility/ExceptionHelper.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 | using System.Runtime.CompilerServices;
3 |
4 | namespace QuestPDF.Markdown.Compatibility;
5 |
6 | internal static class ExceptionHelper
7 | {
8 | public static void ThrowIfNull([NotNull] object? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null)
9 | {
10 | if (argument is null) throw new ArgumentNullException(paramName);
11 | }
12 | }
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 | The following versions of QuestPDF.Markdown will receive security updates:
5 |
6 | | Version | Supported |
7 | | ------- | ------------------ |
8 | | 1.x.x | :white_check_mark: |
9 |
10 | ## Reporting a Vulnerability
11 |
12 | Vulnerabilities can be reported directly using the [GitHub report a vulnerability feature](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability)
13 |
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Parsing/TemplateTests.cs:
--------------------------------------------------------------------------------
1 | using Markdig.Syntax;
2 | using QuestPDF.Markdown.Parsing;
3 |
4 | namespace QuestPDF.Markdown.Tests.Parsing;
5 |
6 | public sealed class TemplateTests
7 | {
8 | [Fact]
9 | public void ParsesTemplate()
10 | {
11 | const string markdown = "This is a {template} test";
12 | var document = ParsedMarkdownDocument.FromText(markdown);
13 |
14 | var block = document.MarkdigDocument
15 | .OfType()
16 | .Single()
17 | .Inline!
18 | .OfType()
19 | .Single();
20 |
21 | Assert.Equal("template", block.Tag);
22 | Assert.Equal(11, block.Span.Start);
23 | Assert.Equal(19, block.Span.End);
24 | }
25 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/GlobalInitializer.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.CompilerServices;
2 | using QuestPDF.Drawing;
3 | using QuestPDF.Infrastructure;
4 |
5 | namespace QuestPDF.Markdown.Tests;
6 |
7 | internal static class GlobalInitializer
8 | {
9 | [ModuleInitializer]
10 | public static void Init()
11 | {
12 | Settings.License = LicenseType.Community;
13 | Settings.EnableDebugging = true;
14 |
15 | FontManager.RegisterFontFromEmbeddedResource("QuestPDF.Markdown.Tests.Fonts.NotoSansMono-Regular.ttf");
16 | FontManager.RegisterFontFromEmbeddedResource("QuestPDF.Markdown.Tests.Fonts.NotoSansSymbols2-Regular.ttf");
17 |
18 | UseProjectRelativeDirectory("Verify");
19 |
20 | VerifyImageMagick.RegisterComparers();
21 | VerifyQuestPdf.Initialize();
22 | }
23 | }
--------------------------------------------------------------------------------
/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 | true
4 | enable
5 | enable
6 | latest
7 | true
8 | latest
9 | All
10 |
11 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Environment (please complete the following information):**
27 | - OS and version: [e.g. macOS 14 Sonoma]
28 | - OS Platform [e.g. x64, arm64]
29 | - QuestPDF Version [e.g. 2023.10.2]
30 | - QuestPDF.Markdown Version [e.g. 1.1.0]
31 |
32 | **Additional context**
33 | Add any other context about the problem here.
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Common IntelliJ Platform excludes
2 |
3 | # User specific
4 | **/.idea/**/workspace.xml
5 | **/.idea/**/tasks.xml
6 | **/.idea/shelf/*
7 | **/.idea/dictionaries
8 | **/.idea/httpRequests/
9 |
10 | # Sensitive or high-churn files
11 | **/.idea/**/dataSources/
12 | **/.idea/**/dataSources.ids
13 | **/.idea/**/dataSources.xml
14 | **/.idea/**/dataSources.local.xml
15 | **/.idea/**/sqlDataSources.xml
16 | **/.idea/**/dynamic.xml
17 |
18 | # Android files
19 | **/.idea/**/AndroidProjectSystem.xml
20 |
21 | # Rider
22 | # Rider auto-generates .iml files, and contentModel.xml
23 | **/.idea/**/*.iml
24 | **/.idea/**/contentModel.xml
25 | **/.idea/**/modules.xml
26 |
27 | *.suo
28 | *.user
29 | .vs/
30 | [Bb]in/
31 | [Oo]bj/
32 | _UpgradeReport_Files/
33 | [Pp]ackages/
34 |
35 | Thumbs.db
36 | Desktop.ini
37 | .DS_Store
38 |
39 | # dotnet pack output directory
40 | package/
41 |
42 | # Verify
43 | *.received.*
44 | *.received/
45 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "dotnet-sdk"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 | day: "saturday"
8 | time: "10:00"
9 | timezone: "Europe/Amsterdam"
10 | - package-ecosystem: "nuget"
11 | directory: "/"
12 | schedule:
13 | interval: "weekly"
14 | day: "saturday"
15 | time: "10:00"
16 | timezone: "Europe/Amsterdam"
17 | groups:
18 | nuget:
19 | patterns:
20 | - "*"
21 | update-types:
22 | - "minor"
23 | - "patch"
24 | - package-ecosystem: "github-actions"
25 | directory: "/"
26 | schedule:
27 | interval: "weekly"
28 | day: "saturday"
29 | time: "10:00"
30 | timezone: "Europe/Amsterdam"
31 | groups:
32 | actions:
33 | patterns:
34 | - "*"
35 | update-types:
36 | - "minor"
37 | - "patch"
38 |
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/Compatibility/CompatibilityShims.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 |
3 | namespace QuestPDF.Markdown.Compatibility;
4 |
5 | public static class CompatibilityShims
6 | {
7 | public static bool EndsWith(this string source, char value)
8 | {
9 | ExceptionHelper.ThrowIfNull(source);
10 | #if NETSTANDARD2_0
11 | return source.EndsWith(value.ToString(), StringComparison.Ordinal);
12 | #else
13 | return source.EndsWith(value);
14 | #endif
15 | }
16 |
17 | public static HashSet ToHashSetShim(this IEnumerable source) => [..source];
18 | public static bool IsNullOrEmpty([NotNullWhen(false)] this string? data) => string.IsNullOrEmpty(data);
19 | public static Task ReadAllBytesAsync(string path)
20 | {
21 | #if NETSTANDARD2_0
22 | return Task.FromResult(File.ReadAllBytes(path));
23 | #else
24 | return File.ReadAllBytesAsync(path);
25 | #endif
26 | }
27 | }
--------------------------------------------------------------------------------
/.run/Watch ShowInCompanion.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/Extensions/TextExtensions.cs:
--------------------------------------------------------------------------------
1 | using QuestPDF.Fluent;
2 | using QuestPDF.Infrastructure;
3 |
4 | namespace QuestPDF.Markdown.Extensions;
5 |
6 | internal static class TextExtensions
7 | {
8 | internal static void Align(this TextDescriptor text, TextHorizontalAlignment alignment) =>
9 | GetAlignment(text, alignment).Invoke();
10 |
11 | private static Action GetAlignment(TextDescriptor text, TextHorizontalAlignment alignment) =>
12 | alignment switch
13 | {
14 | TextHorizontalAlignment.Left => text.AlignLeft,
15 | TextHorizontalAlignment.Center => text.AlignCenter,
16 | TextHorizontalAlignment.Right => text.AlignRight,
17 | TextHorizontalAlignment.Justify => text.Justify,
18 | TextHorizontalAlignment.Start => text.AlignStart,
19 | TextHorizontalAlignment.End => text.AlignEnd,
20 | _ => throw new ArgumentOutOfRangeException(nameof(alignment), alignment, null)
21 | };
22 | }
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/Extensions/TableCellExtensions.cs:
--------------------------------------------------------------------------------
1 | using QuestPDF.Elements.Table;
2 | using QuestPDF.Fluent;
3 | using QuestPDF.Infrastructure;
4 |
5 | namespace QuestPDF.Markdown.Extensions;
6 |
7 | internal static class TableCellExtensions
8 | {
9 | internal static IContainer Border(this ITableCellContainer cell, MarkdownRendererOptions options, bool isHeader, bool isLast) =>
10 | options.TableBorderStyle switch
11 | {
12 | TableBorderStyle.None => cell,
13 | TableBorderStyle.Horizontal => cell
14 | .BorderBottom(isLast ? 0 : (isHeader ? options.TableHeaderBorderThickness : options.TableBorderThickness))
15 | .BorderColor(options.TableBorderColor),
16 | TableBorderStyle.Full => cell
17 | .Border(options.TableBorderThickness)
18 | .BorderBottom(isHeader ? options.TableHeaderBorderThickness : options.TableBorderThickness)
19 | .BorderColor(options.TableBorderColor),
20 | _ => throw new InvalidOperationException()
21 | };
22 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Helpers/PathHelpersTests.cs:
--------------------------------------------------------------------------------
1 | using QuestPDF.Markdown.Helpers;
2 |
3 | namespace QuestPDF.Markdown.Tests.Helpers;
4 |
5 | public class PathHelpersTests
6 | {
7 | [Theory]
8 | [InlineData("./image.png", "/safe/root", "/safe/root/image.png", true)]
9 | [InlineData("image.png", "/safe/root", "/safe/root/image.png", true)]
10 | [InlineData("subdir/../image.png", "/safe/root", "/safe/root/image.png", true)]
11 | [InlineData("subdir/image.png", "/safe/root", "/safe/root/subdir/image.png", true)]
12 | [InlineData("../image.png", "/safe/root", "", false)]
13 | [InlineData("/absolute/path/image.png", "/safe/root", "", false)]
14 | [InlineData("invaid\0.png", "/safe/root", "", false)]
15 | public void ResolvesSafeLocalPath(string imagePath, string safeRootPath, string expectedSafeImagePath, bool expectedResult)
16 | {
17 | var result = PathHelpers.TryResolveSafeLocalPath(imagePath, safeRootPath, out var safeImagePath);
18 |
19 | Assert.Equal(expectedSafeImagePath, safeImagePath);
20 | Assert.Equal(expectedResult, result);
21 | }
22 | }
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release package
2 | on:
3 | release:
4 | types: [published]
5 | jobs:
6 | release:
7 | name: Release package
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout repository
11 | uses: actions/checkout@v6
12 |
13 | - name: Setup .NET
14 | uses: actions/setup-dotnet@v5
15 | with:
16 | dotnet-version: 10.0.x
17 |
18 | - name: Create package
19 | run: dotnet pack -c Release -p:Version=${{ github.event.release.tag_name }}
20 |
21 | - name: Archive package
22 | uses: actions/upload-artifact@v6
23 | with:
24 | name: QuestPDF.Markdown
25 | path: ./package/QuestPDF.Markdown.${{ github.event.release.tag_name }}.nupkg
26 |
27 | - name: Publish package (GitHub)
28 | run: dotnet nuget push ./package/*.nupkg --api-key ${{ secrets.GH_API_KEY }} --source https://nuget.pkg.github.com/christiaanderidder/index.json
29 |
30 | - name: Publish package (NuGet)
31 | run: dotnet nuget push ./package/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Christiaan de Ridder
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Directory.Packages.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | true
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/Helpers/PathHelpers.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.InteropServices;
2 | using QuestPDF.Markdown.Compatibility;
3 |
4 | namespace QuestPDF.Markdown.Helpers;
5 |
6 | internal static class PathHelpers
7 | {
8 | public static bool TryResolveSafeLocalPath(string imagePath, string safeRootPath, out string safeImagePath)
9 | {
10 | safeImagePath = string.Empty;
11 |
12 | // Check for invalid path characters
13 | if (imagePath.IndexOfAny(Path.GetInvalidPathChars()) != -1)
14 | return false;
15 |
16 | // Ensure safe root path is absolute and normalized
17 | safeRootPath = NormalizePath(safeRootPath);
18 |
19 | // Make sure that a trailing separator is present
20 | if (!safeRootPath.EndsWith(Path.DirectorySeparatorChar))
21 | safeRootPath += Path.DirectorySeparatorChar;
22 |
23 | // Resolve absolute image path relative to safe root
24 | imagePath = NormalizePath(Path.Combine(safeRootPath, imagePath));
25 |
26 | // Windows paths are case-insensitive
27 | var pathComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
28 | ? StringComparison.OrdinalIgnoreCase
29 | : StringComparison.Ordinal;
30 |
31 | // Ensure the combined path is within the safe root
32 | if (!imagePath.StartsWith(safeRootPath, pathComparison))
33 | return false;
34 |
35 | safeImagePath = imagePath;
36 | return true;
37 | }
38 |
39 | private static string NormalizePath(string path) => Path.GetFullPath(path)
40 | .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
41 | }
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/Parsing/TemplateParser.cs:
--------------------------------------------------------------------------------
1 | using Markdig.Helpers;
2 | using Markdig.Parsers;
3 | using Markdig.Syntax;
4 | using QuestPDF.Markdown.Compatibility;
5 |
6 | namespace QuestPDF.Markdown.Parsing;
7 |
8 | internal sealed class TemplateParser : InlineParser
9 | {
10 | private const char OpeningCharacter = '{';
11 | private const char ClosingCharacter = '}';
12 |
13 | public TemplateParser()
14 | {
15 | OpeningCharacters = [OpeningCharacter];
16 | }
17 |
18 | public override bool Match(InlineProcessor processor, ref StringSlice slice)
19 | {
20 | ExceptionHelper.ThrowIfNull(processor);
21 |
22 | var match = slice.CurrentChar;
23 | if (slice.PeekCharExtra(-1) == match) return false;
24 |
25 | var span = slice.AsSpan();
26 |
27 | for (var i = 1; i < span.Length; i++)
28 | {
29 | var c = span[i];
30 |
31 | if (c == ClosingCharacter)
32 | {
33 | var tag = span.Slice(1, i - 1).ToString();
34 | var start = processor.GetSourcePosition(slice.Start + 1, out var line, out var column);
35 | var end = processor.GetSourcePosition(slice.Start + i);
36 |
37 | var template = new TemplateInline(tag)
38 | {
39 | Span = new SourceSpan(start, end),
40 | Line = line,
41 | Column = column
42 | };
43 |
44 | processor.Inline = template;
45 | slice.Start = end + 1;
46 |
47 | return true;
48 | }
49 | if (!c.IsAlphaNumeric()) return false;
50 | }
51 |
52 | return false;
53 | }
54 | }
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/QuestPDF.Markdown.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | netstandard2.0;net10.0
4 | QuestPDF.Markdown
5 | Christiaan de Ridder
6 | QuestPDF.Markdown is an open-source helper library that allows rendering markdown into a QuestPDF document
7 | https://github.com/christiaanderidder/QuestPDF.Markdown
8 | README.md
9 | LICENSE
10 | logo.png
11 | https://github.com/christiaanderidder/QuestPDF.Markdown
12 | git
13 | Christiaan de Ridder
14 | pdf questpdf markdown convert
15 | true
16 | true
17 | snupkg
18 | ../../package
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/QuestPDF.Markdown.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net10.0
4 | false
5 | true
6 |
7 |
8 |
9 |
10 |
11 |
12 | all
13 | runtime; build; native; contentfiles; analyzers; buildtransitive
14 |
15 |
16 |
17 |
18 |
19 |
20 | all
21 | runtime; build; native; contentfiles; analyzers; buildtransitive
22 |
23 |
24 | all
25 | runtime; build; native; contentfiles; analyzers; buildtransitive
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/MarkdownExtensions.cs:
--------------------------------------------------------------------------------
1 | using QuestPDF.Fluent;
2 | using QuestPDF.Infrastructure;
3 |
4 | namespace QuestPDF.Markdown;
5 |
6 | public static class MarkdownExtensions
7 | {
8 | ///
9 | /// Parses and renders a markdown text into a QuestPDF container
10 | ///
11 | ///
12 | /// This method will not download and render external images.
13 | ///
14 | /// The QuestPDF container to render in
15 | /// The markdown text to render
16 | public static void Markdown(this IContainer container, string markdown) =>
17 | container.Component(MarkdownRenderer.Create(markdown));
18 |
19 | ///
20 | /// Parses and renders a markdown text into a QuestPDF container
21 | ///
22 | ///
23 | /// This method will not download and render external images.
24 | ///
25 | /// The QuestPDF container to render in
26 | /// The markdown text to render
27 | /// Action to configure the renderer
28 | public static void Markdown(this IContainer container, string markdown, Action configure) =>
29 | container.Component(MarkdownRenderer.Create(markdown, configure));
30 |
31 | ///
32 | /// Renders a pre-parsed markdown document into a QuestPDF container
33 | ///
34 | /// The QuestPDF container to render in
35 | /// An instance of ParsedMarkdownDocument, which allows for preloading external resources
36 | public static void Markdown(this IContainer container, ParsedMarkdownDocument markdown) =>
37 | container.Component(MarkdownRenderer.Create(markdown));
38 |
39 | ///
40 | /// Renders a pre-parsed markdown document into a QuestPDF container
41 | ///
42 | /// The QuestPDF container to render in
43 | /// An instance of ParsedMarkdownDocument, which allows for preloading external resources
44 | /// Action to configure the renderer
45 | public static void Markdown(this IContainer container, ParsedMarkdownDocument markdown, Action configure) =>
46 | container.Component(MarkdownRenderer.Create(markdown, configure));
47 |
48 | internal static IContainer PaddedDebugArea(this IContainer container, string label, string color) =>
49 | container.DebugArea(label, color).PaddingTop(20);
50 |
51 | internal static TextSpanDescriptor ApplyStyles(this TextSpanDescriptor span, IList> applyStyles)
52 | {
53 | foreach(var applyStyle in applyStyles)
54 | span = applyStyle(span);
55 |
56 | return span;
57 | }
58 | }
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Rendering/SamplePdfTests.cs:
--------------------------------------------------------------------------------
1 | using System.Reflection;
2 | using QuestPDF.Companion;
3 | using QuestPDF.Fluent;
4 | using QuestPDF.Helpers;
5 | using QuestPDF.Infrastructure;
6 |
7 | namespace QuestPDF.Markdown.Tests.Rendering;
8 |
9 | public class SamplePdfTests
10 | {
11 | private readonly string _markdown;
12 |
13 | public SamplePdfTests()
14 | {
15 | var assembly = Assembly.GetExecutingAssembly();
16 | const string resourceName = "QuestPDF.Markdown.Tests.test.md";
17 | using var stream = assembly.GetManifestResourceStream(resourceName);
18 | using var reader = new StreamReader(stream!);
19 | _markdown = reader.ReadToEnd();
20 | }
21 |
22 | [Fact(Skip = "This test is not run in CI")]
23 | public async Task SaveToFile()
24 | {
25 | var markdown = ParsedMarkdownDocument.FromText(_markdown);
26 | await markdown.DownloadImages();
27 |
28 | var document = GenerateDocument(item => item.Markdown(markdown));
29 | document.GeneratePdf(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "test.pdf"));
30 | }
31 |
32 | [Fact(Skip = "This test is not run in CI")]
33 | public async Task ShowInCompanion()
34 | {
35 | var markdown = ParsedMarkdownDocument.FromText(_markdown);
36 | await markdown.DownloadImages();
37 |
38 | var document = GenerateDocument(item => item.Markdown(markdown));
39 |
40 | try
41 | {
42 | await document.ShowInCompanionAsync(cancellationToken: TestContext.Current.CancellationToken);
43 | }
44 | catch (OperationCanceledException)
45 | {
46 | // Ignore
47 | }
48 | }
49 |
50 | private static Document GenerateDocument(Action body)
51 | {
52 | return Document.Create(container =>
53 | {
54 | container.Page(page =>
55 | {
56 | page.PageColor(Colors.White);
57 | page.Size(PageSizes.A4);
58 | page.Margin(1, Unit.Centimetre);
59 | page.DefaultTextStyle(text =>
60 | {
61 | text.FontFamily(Fonts.Arial);
62 | text.LineHeight(1.5f);
63 | return text;
64 | });
65 |
66 | page.Content()
67 | .PaddingVertical(20)
68 | .Column(main =>
69 | {
70 | main.Spacing(20);
71 | body(main.Item());
72 | });
73 | });
74 | }).WithMetadata(new DocumentMetadata
75 | {
76 | Author = "QuestPDF.Markdown",
77 | Title = "QuestPDF.Markdown",
78 | Subject = "QuestPDF.Markdown",
79 | Keywords = "questpdf, markdown, pdf",
80 | CreationDate = new DateTime(2023, 11, 15, 12, 00, 00),
81 | ModifiedDate = new DateTime(2023, 11, 15, 12, 00, 00),
82 | });
83 | }
84 | }
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | schedule:
16 | - cron: '39 11 * * 3'
17 |
18 | jobs:
19 | analyze:
20 | name: Analyze
21 | # Runner size impacts CodeQL analysis time. To learn more, please see:
22 | # - https://gh.io/recommended-hardware-resources-for-running-codeql
23 | # - https://gh.io/supported-runners-and-hardware-resources
24 | # - https://gh.io/using-larger-runners
25 | # Consider using larger runners for possible analysis time improvements.
26 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
27 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
28 | permissions:
29 | actions: read
30 | contents: read
31 | security-events: write
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'csharp' ]
36 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ]
37 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both
38 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
40 |
41 | steps:
42 | - name: Checkout repository
43 | uses: actions/checkout@v6
44 |
45 | - name: Setup .NET
46 | uses: actions/setup-dotnet@v5
47 | with:
48 | dotnet-version: 10.0.x
49 |
50 | - name: Initialize CodeQL
51 | uses: github/codeql-action/init@v4
52 | with:
53 | languages: ${{ matrix.language }}
54 | # If you wish to specify custom queries, you can do so here or in a config file.
55 | # By default, queries listed here will override any specified in a config file.
56 | # Prefix the list here with "+" to use these queries and those in the config file.
57 |
58 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
59 | # queries: security-extended,security-and-quality
60 |
61 | - name: Restore packages
62 | run: dotnet restore
63 |
64 | - name: Build
65 | run: dotnet build --no-restore
66 |
67 | - name: Perform CodeQL Analysis
68 | uses: github/codeql-action/analyze@v4
69 | with:
70 | category: "/language:${{matrix.language}}"
71 |
--------------------------------------------------------------------------------
/.github/workflows/build-and-test.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build a .NET project
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
3 |
4 | name: Build and test
5 | on:
6 | push:
7 | branches: [ "main" ]
8 | pull_request:
9 | branches: [ "main" ]
10 | jobs:
11 | build:
12 | name: Build and test
13 | # Runner size impacts CodeQL analysis time. To learn more, please see:
14 | # - https://gh.io/recommended-hardware-resources-for-running-codeql
15 | # - https://gh.io/supported-runners-and-hardware-resources
16 | # - https://gh.io/using-larger-runners
17 | # Consider using larger runners for possible analysis time improvements.
18 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
19 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
20 | permissions:
21 | actions: read
22 | contents: read
23 | security-events: write
24 | strategy:
25 | fail-fast: false
26 | matrix:
27 | language: [ 'csharp' ]
28 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ]
29 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both
30 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
31 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
32 |
33 | steps:
34 | - name: Checkout repository
35 | uses: actions/checkout@v6
36 |
37 | - name: Setup .NET
38 | uses: actions/setup-dotnet@v5
39 | with:
40 | dotnet-version: 10.0.x
41 |
42 | - name: Initialize CodeQL
43 | uses: github/codeql-action/init@v4
44 | with:
45 | languages: ${{ matrix.language }}
46 | # If you wish to specify custom queries, you can do so here or in a config file.
47 | # By default, queries listed here will override any specified in a config file.
48 | # Prefix the list here with "+" to use these queries and those in the config file.
49 |
50 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
51 | # queries: security-extended,security-and-quality
52 |
53 | - name: Restore packages
54 | run: dotnet restore
55 |
56 | - name: Build
57 | run: dotnet build --no-restore
58 |
59 | - name: Test
60 | run: >
61 | dotnet test
62 | --no-build
63 | --report-github
64 | --report-github-summary-include-passed=true
65 | --report-github-summary-include-skipped=true
66 |
67 | - name: Upload Test Results
68 | if: failure()
69 | uses: actions/upload-artifact@v6
70 | with:
71 | name: verify-test-results
72 | path: |
73 | **/*.received.*
74 | **/*.verified.*
75 |
76 | - name: Perform CodeQL Analysis
77 | uses: github/codeql-action/analyze@v4
78 | with:
79 | category: "/language:${{matrix.language}}"
80 |
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/MarkdownRendererOptions.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.InteropServices;
2 | using QuestPDF.Fluent;
3 | using QuestPDF.Helpers;
4 | using QuestPDF.Infrastructure;
5 |
6 | namespace QuestPDF.Markdown;
7 |
8 | public class MarkdownRendererOptions
9 | {
10 | ///
11 | /// Render DebugAreas in the output
12 | ///
13 | public bool Debug { get; set; }
14 |
15 | public TextHorizontalAlignment ParagraphAlignment { get; set; } = TextHorizontalAlignment.Left;
16 |
17 | public Color LinkTextColor { get; set; } = Colors.Blue.Medium;
18 | public Color MarkedTextBackgroundColor { get; set; } = Colors.Yellow.Lighten2;
19 |
20 | public Color BlockQuoteBorderColor { get; set; } = Colors.Grey.Lighten2;
21 | public Color BlockQuoteTextColor { get; set; } = Colors.Grey.Darken1;
22 | public int BlockQuoteBorderThickness { get; set; } = 2;
23 |
24 | public string CodeFont { get; set; } = Fonts.CourierNew;
25 | public Color CodeBlockBackground { get; set; } = Colors.Grey.Lighten4;
26 | public Color CodeInlineBackground { get; set; } = Colors.Grey.Lighten3;
27 |
28 | public string TaskListCheckedGlyph { get; set; } = "☑";
29 | public string TaskListUncheckedGlyph { get; set; } = "☐";
30 | public string UnicodeGlyphFont { get; set; } = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "Arial Unicode MS" : Fonts.Arial;
31 |
32 | public Color TableBorderColor { get; set; } = Colors.Grey.Lighten2;
33 | public Color TableEvenRowBackgroundColor { get; set; } = Colors.Grey.Lighten4;
34 | public Color TableOddRowBackgroundColor { get; set; } = Colors.White;
35 | public int TableHeaderBorderThickness { get; set; } = 3;
36 | public int TableBorderThickness { get; set; } = 1;
37 | public TableBorderStyle TableBorderStyle { get; set; } = TableBorderStyle.Horizontal;
38 |
39 | public Color HorizontalRuleColor { get; set; } = Colors.Grey.Lighten2;
40 | public int HorizontalRuleThickness { get; set; } = 2;
41 |
42 | ///
43 | /// The conversion factor used to scale images from pixel size to point size
44 | ///
45 | public float ImageScalingFactor { get; set; } = 0.5f;
46 |
47 | ///
48 | /// The maximum allowed width for rendered images, in points.
49 | /// A value of 0 (the default) indicates no maximum width limit.
50 | ///
51 | public float MaxImageWidth { get; set; }
52 |
53 | ///
54 | /// The maximum allowed height for rendered images, in points.
55 | /// A value of 0 (the default) indicates no maximum height limit.
56 | ///
57 | public float MaxImageHeight { get; set; }
58 |
59 | public float ParagraphSpacing { get; set; } = 10;
60 | public float ListItemSpacing { get; set; } = 5;
61 | public string UnorderedListGlyph { get; set; } = "•";
62 |
63 | public Color HeadingTextColor { get; set; } = Colors.Black;
64 |
65 | ///
66 | /// The formula used to calculate heading sizes based on their level.
67 | ///
68 | ///
69 | /// Level is non zero-indexed and starts at 1 for the largest heading
70 | ///
71 | public Func CalculateHeadingSize { get; set; } = level => 28 - 2 * (level - 1);
72 |
73 | public Dictionary> RenderTemplates { get; } = [];
74 |
75 | ///
76 | /// Register a render function to replace a template tag in the markdown text with custom content
77 | /// The tag 'my_tag' can be used in the markdown as {my_tag}
78 | ///
79 | /// The rendered content must fit within a single line. Larger block elements are currently not supported
80 | /// The tag to replace.
81 | /// The render function to render custom text in place of the template tag
82 | public MarkdownRendererOptions AddTemplateTag(string tag, Func render)
83 | {
84 | RenderTemplates[tag] = render;
85 | return this;
86 | }
87 | }
88 |
89 | public enum TableBorderStyle
90 | {
91 | None,
92 | Horizontal,
93 | Full
94 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # QuestPDF.Markdown
4 | QuestPDF.Markdown allows rendering markdown into a [QuestPDF](https://www.questpdf.com/) document using the [markdig](https://github.com/xoofx/markdig) parser.
5 |
6 | [](https://www.nuget.org/packages/QuestPDF.Markdown)
7 | [](https://www.nuget.org/packages/QuestPDF.Markdown)
8 |
9 | > [!IMPORTANT]
10 | > QuestPDF.Markdown is **not** a HTML-to-PDF conversion library and does intend to become one. It aims to use markdown to add basic user-provided content into PDFs without the pitfalls of HTML-to-PDF conversion.
11 |
12 | ## Usage
13 | ```csharp
14 | var text = """
15 | # Hello, world!
16 |
17 | *Greetings* from **markdown**!
18 |
19 | > Hello, back!
20 | """;
21 |
22 | var document = Document.Create(container =>
23 | {
24 | container.Page(page =>
25 | {
26 | page.PageColor(Colors.White);
27 | page.Margin(20);
28 | page.Content().Markdown(text);
29 | });
30 | });
31 | ```
32 |
33 | 
34 |
35 | ### Styling the output
36 | The styling used by QuestPDF.Markdown can be configured using the configure action.
37 | ```csharp
38 | var text = @"> Hello, world!";
39 |
40 | var document = Document.Create(container =>
41 | {
42 | container.Page(page =>
43 | {
44 | page.PageColor(Colors.White);
45 | page.Margin(20);
46 | page.Content().Markdown(text, options =>
47 | {
48 | options.BlockQuoteBorderColor = Colors.Red.Medium;
49 | options.BlockQuoteBorderThickness = 5;
50 | });
51 | });
52 | });
53 | ```
54 |
55 | ### Rendering images
56 | By default, downloading and rendering images is disabled.
57 | To opt-in to loading images use the `ParsedMarkdownDocument.DownloadImages()` method.
58 | This method will load any remote images, base64 images and local images (if a safe root path is provided).
59 |
60 | The parsed markdown document can then be passed to the `Markdown()` extension method.
61 | ```csharp
62 | var text = @"";
63 |
64 | var markdown = ParsedMarkdownDocument.FromText(text);
65 | await markdown.DownloadImages(
66 | httpClient: myHttpClient /* Optionally provide your own HttpClient */
67 | safeRootPath: "/my/safe/path" /* Optionally provide a safe root path from which relative local images paths can be read */
68 | );
69 |
70 | var document = Document.Create(container =>
71 | {
72 | container.Page(page =>
73 | {
74 | page.PageColor(Colors.White);
75 | page.Margin(20);
76 | page.Content().Markdown(markdown);
77 | });
78 | });
79 | ```
80 |
81 | ### Rendering custom text
82 | In some cases it can be helpful to render custom text content in the PDF (e.g. allowing a user to inject the page number).
83 |
84 | This is possible by using the template tag feature:
85 | ```csharp
86 | var text = @"This is page {currentPage}/{totalPages}";
87 |
88 | var document = Document.Create(container =>
89 | {
90 | container.Page(page =>
91 | {
92 | page.PageColor(Colors.White);
93 | page.Margin(20);
94 | page.Content().Markdown(text, options =>
95 | {
96 | options.AddTemplateTag("currentPage", t => t.CurrentPageNumber());
97 | options.AddTemplateTag("totalPages", t => t.TotalPages());
98 | }));
99 | });
100 | });
101 | ```
102 | > [!NOTE]
103 | > Note that the template tags are only used to replace simple text content and not larger blocks like tables.
104 |
105 | ## What's supported?
106 | The aim of this library is to support all basic markdown functionality and some of the extensions supported by markdig.
107 |
108 | Currently the following features are supported:
109 | - Headings
110 | - Block quotes
111 | - Code blocks
112 | - Lists (ordered, unordered)
113 | - Emphasis (bold, italic)
114 | - Task lists
115 | - Extra emphasis (subscript, superscript, strikethrough, marked, inserted)
116 | - Tables
117 | - Images (remote, base64 encoded, local paths)
118 | - HTML encoded entities (e.g. `&`, `<`, `>`)
119 | - Custom renderers for text content
120 |
121 | Support for the following extensions is currently not planned:
122 | - Raw HTML
123 | - Math blocks
124 | - Diagrams
125 |
126 | A full sample can be found in [test.md](tests/QuestPDF.Markdown.Tests/test.md) and the resulting [test.pdf](tests/QuestPDF.Markdown.Tests/test.pdf).
127 |
128 | ## Contributing
129 | To quickly test changes made in the library, you can make use of the excellent QuestPDF previewer in combination with the QuestPDF.Markdown.Tests project and `dotnet watch`
130 |
131 | To render the test markdown file in the companion app, first start the companion app and then run the following command in the root directory of the repository:
132 | ```zsh
133 | cd ./tests/QuestPDF.Markdown.Tests && dotnet watch test --filter Name=Render
134 | ```
135 |
136 | To render the test markdown file in the previewer with additional background colors and margins, run the following command in the root directory of the repository:
137 | ```zsh
138 | cd ./tests/QuestPDF.Markdown.Tests && dotnet watch test --filter Name=RenderDebug
139 | ```
140 |
141 | Any changes made to the MarkdownRenderer class will be automatically reflected in the previewer.
142 |
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/ParsedMarkdownDocument.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Concurrent;
2 | using System.Diagnostics.CodeAnalysis;
3 | using System.Text.RegularExpressions;
4 | using Markdig;
5 | using Markdig.Syntax;
6 | using Markdig.Syntax.Inlines;
7 | using QuestPDF.Infrastructure;
8 | using QuestPDF.Markdown.Compatibility;
9 | using QuestPDF.Markdown.Helpers;
10 | using QuestPDF.Markdown.Parsing;
11 | using SkiaSharp;
12 |
13 | namespace QuestPDF.Markdown;
14 |
15 | ///
16 | /// Represents a parsed markdown text.
17 | /// This helper is used to download external images before rendering them.
18 | ///
19 | public class ParsedMarkdownDocument
20 | {
21 | private readonly MarkdownDocument _document;
22 | private readonly ConcurrentDictionary _imageCache = new();
23 | private static readonly Regex DataUri = new(@"data:image\/.+?;base64,(?.+)", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
24 |
25 | private static readonly HttpClient HttpClient = new();
26 |
27 | private ParsedMarkdownDocument(string markdownText)
28 | {
29 | var pipeline = new MarkdownPipelineBuilder()
30 | .DisableHtml()
31 | .UseEmphasisExtras()
32 | .UseGridTables()
33 | .UsePipeTables()
34 | .UseTaskLists()
35 | .UseAutoLinks()
36 | .Use()
37 | .Build();
38 |
39 | _document = Markdig.Markdown.Parse(markdownText, pipeline);
40 | }
41 |
42 | ///
43 | /// Downloads all external images from the parsed markdown document and caches them for rendering
44 | ///
45 | /// Optionally provide the maximum number of request to execute in parallel
46 | /// Optionally provide your own HttpClient instance used to download images
47 | /// Optionally provide a path from which local relative image paths are allowed to be loaded, leaving this empty disables local image loading
48 | public async Task DownloadImages(int imageDownloaderMaxParallelism = 4, HttpClient? httpClient = null, string safeRootPath = "")
49 | {
50 | var parallelism = Math.Max(1, imageDownloaderMaxParallelism);
51 | var semaphore = new SemaphoreSlim(parallelism);
52 |
53 | var urls = _document.Descendants()
54 | .Where(l => l.IsImage && l.Url != null)
55 | .Select(l => l.Url)
56 | .ToHashSetShim();
57 |
58 | // The semaphore is disposed after all tasks have completed, we can safely disable AccessToDisposedClosure
59 | var tasks = urls.Select([SuppressMessage("ReSharper", "AccessToDisposedClosure")] async (url) =>
60 | {
61 | if (url == null) return;
62 |
63 | await semaphore.WaitAsync().ConfigureAwait(false);
64 |
65 | try
66 | {
67 | var (success, imageData) = await GetImageData(httpClient, url, safeRootPath).ConfigureAwait(false);
68 | if (!success) return;
69 |
70 | // QuestPDF does not allow accessing image dimensions on loaded images
71 | // To work around this we will parse the image ourselves first and keep track of the dimensions
72 | using var skImage = SKImage.FromEncodedData(imageData);
73 | var pdfImage = Image.FromBinaryData(skImage.EncodedData.ToArray());
74 |
75 | var image = new ImageWithDimensions(skImage.Width, skImage.Height, pdfImage);
76 | _imageCache.TryAdd(url, image);
77 | }
78 | finally
79 | {
80 | semaphore.Release();
81 | }
82 | });
83 |
84 | await Task.WhenAll(tasks).ConfigureAwait(false);
85 |
86 | // Dispose semaphore after completing all tasks
87 | semaphore.Dispose();
88 | }
89 |
90 | private static async Task<(bool Success, byte[] ImageData)> GetImageData(HttpClient? httpClient, string url, string safeRootPath)
91 | {
92 | // Check for Base64 data URI first, as this might exceed Uri size
93 | var base64Match = DataUri.Match(url);
94 | if (base64Match.Success && base64Match.Groups["data"].Success)
95 | return (true, Convert.FromBase64String(base64Match.Groups["data"].Value));
96 |
97 | // Check for valid external URI
98 | if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
99 | return (true, await DownloadImage(httpClient, uri).ConfigureAwait(false));
100 |
101 | // Check for valid local file path relative to safe root
102 | if (!string.IsNullOrEmpty(safeRootPath) && PathHelpers.TryResolveSafeLocalPath(url, safeRootPath, out var path) && File.Exists(path))
103 | return (true, await CompatibilityShims.ReadAllBytesAsync(path).ConfigureAwait(false));
104 |
105 | return (false, []);
106 | }
107 |
108 | private static async Task DownloadImage(HttpClient? httpClient, Uri uri)
109 | {
110 | var client = httpClient ?? HttpClient;
111 | var stream = await client.GetStreamAsync(uri).ConfigureAwait(false);
112 |
113 | using var ms = new MemoryStream();
114 | await stream.CopyToAsync(ms).ConfigureAwait(false);
115 | return ms.ToArray();
116 | }
117 |
118 | internal MarkdownDocument MarkdigDocument => _document;
119 |
120 | internal bool TryGetImageFromCache(string url, [MaybeNullWhen(false)] out ImageWithDimensions image)
121 | {
122 | return _imageCache.TryGetValue(url, out image);
123 | }
124 |
125 | ///
126 | /// Parses the provided markdown text into a ParsedMarkdownDocument instance
127 | ///
128 | /// The markdown text
129 | /// An instance of ParsesMarkdownDocument
130 | public static ParsedMarkdownDocument FromText(string markdownText) => new(markdownText);
131 | }
132 |
--------------------------------------------------------------------------------
/img/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
83 |
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/test.md:
--------------------------------------------------------------------------------
1 | # Headers
2 |
3 | # h1 Heading
4 | ## h2 Heading
5 | ### h3 Heading
6 | #### h4 Heading
7 | ##### h5 Heading
8 | ###### h6 Heading
9 |
10 | Alternatively, for H1 and H2, an underline-ish style:
11 |
12 | Alt-H1
13 | ======
14 |
15 | Alt-H2
16 | ------
17 |
18 | ```
19 | # h1 Heading 8-)
20 | ## h2 Heading
21 | ### h3 Heading
22 | #### h4 Heading
23 | ##### h5 Heading
24 | ###### h6 Heading
25 |
26 | Alternatively, for H1 and H2, an underline-ish style:
27 |
28 | Alt-H1
29 | ======
30 |
31 | Alt-H2
32 | ------
33 |
34 | ```
35 |
36 | ------
37 |
38 | # Paragraphs
39 |
40 | A paragraph is followed by a blank line.
41 |
42 | Newlines within paragraphs
43 | are ignored.
44 |
45 | Text should be followed by two trailing spaces
46 | or a backlash \
47 | to force a newline.
48 |
49 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
50 |
51 | ```
52 | # Paragraphs
53 |
54 | A paragraph is followed by a blank line.
55 |
56 | Newlines within paragraphs
57 | are ignored.
58 |
59 | Text should be followed by two trailing spaces
60 | or a backlash \
61 | to force a newline.
62 |
63 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
64 |
65 | ```
66 |
67 | ------
68 |
69 | # Emphasis
70 |
71 | Emphasis, aka italics, with *asterisks* or _underscores_.
72 |
73 | Strong emphasis, aka bold, with **asterisks** or __underscores__.
74 |
75 | Combined emphasis with **asterisks and _underscores_**.
76 |
77 | Strikethrough uses two tildes. ~~Scratch this.~~
78 |
79 | **This is bold text**
80 |
81 | __This is bold text__
82 |
83 | *This is italic text*
84 |
85 | _This is italic text_
86 |
87 | ~~Strikethrough~~
88 |
89 | ```
90 | Emphasis, aka italics, with *asterisks* or _underscores_.
91 |
92 | Strong emphasis, aka bold, with **asterisks** or __underscores__.
93 |
94 | Combined emphasis with **asterisks and _underscores_**.
95 |
96 | Strikethrough uses two tildes. ~~Scratch this.~~
97 |
98 | **This is bold text**
99 |
100 | __This is bold text__
101 |
102 | *This is italic text*
103 |
104 | _This is italic text_
105 |
106 | ~~Strikethrough~~
107 | ```
108 |
109 | ------
110 |
111 | # Extended emphasis
112 |
113 | 19^th^
114 |
115 | H~2~O
116 |
117 | ++Inserted text++
118 |
119 | ==Marked text==
120 |
121 | ```
122 | 19^th^
123 |
124 | H~2~O
125 |
126 | ++Inserted text++
127 |
128 | ==Marked text==
129 | ```
130 |
131 | ------
132 |
133 | # Lists
134 |
135 | 1. First ordered list item
136 | 2. Another item
137 | * Unordered sub-list.
138 | 1. Actual numbers don't matter, just that it's a number
139 | 1. Ordered sub-list
140 | 4. And another item.
141 |
142 | You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
143 | * Unordered list can use asterisks
144 | - Or minuses
145 | + Or pluses
146 |
147 | 1. Make my changes
148 | 1. Fix bug
149 | 2. Improve formatting
150 | - Make the headings bigger
151 | 2. Push my commits to GitHub
152 | 3. Open a pull request
153 | * Describe my changes
154 | * Mention all the members of my team
155 | * Ask for feedback
156 |
157 | + Create a list by starting a line with `+`, `-`, or `*`
158 | + Sub-lists are made by indenting 2 spaces:
159 | - Marker character change forces new list start:
160 | * Ac tristique libero volutpat at
161 | + Facilisis in pretium nisl aliquet
162 | - Nulla volutpat aliquam velit
163 | + Very easy!
164 |
165 | ```
166 | 1. First ordered list item
167 | 2. Another item
168 | * Unordered sub-list.
169 | 1. Actual numbers don't matter, just that it's a number
170 | 1. Ordered sub-list
171 | 4. And another item.
172 |
173 | You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
174 | * Unordered list can use asterisks
175 | - Or minuses
176 | + Or pluses
177 |
178 | 1. Make my changes
179 | 1. Fix bug
180 | 2. Improve formatting
181 | - Make the headings bigger
182 | 2. Push my commits to GitHub
183 | 3. Open a pull request
184 | * Describe my changes
185 | * Mention all the members of my team
186 | * Ask for feedback
187 |
188 | + Create a list by starting a line with `+`, `-`, or `*`
189 | + Sub-lists are made by indenting 2 spaces:
190 | - Marker character change forces new list start:
191 | * Ac tristique libero volutpat at
192 | + Facilisis in pretium nisl aliquet
193 | - Nulla volutpat aliquam velit
194 | + Very easy!
195 | ```
196 |
197 | ------
198 |
199 | # Task lists
200 |
201 | - [x] Finish my changes
202 | - [ ] Push my commits to GitHub
203 | - [ ] Open a pull request
204 | - [x] @mentions, #refs, [links](), **formatting**, and tags supported
205 | - [x] list syntax required (any unordered or ordered list supported)
206 | - [ ] this is a complete item
207 | - [ ] this is an incomplete item
208 |
209 | ```
210 | - [x] Finish my changes
211 | - [ ] Push my commits to GitHub
212 | - [ ] Open a pull request
213 | - [x] @mentions, #refs, [links](), **formatting**, and tags supported
214 | - [x] list syntax required (any unordered or ordered list supported)
215 | - [x] this is a complete item
216 | - [ ] this is an incomplete item
217 | ```
218 |
219 | ------
220 |
221 | # Ignoring Markdown formatting
222 |
223 | You can tell GitHub to ignore (or escape) Markdown formatting by using \ before the Markdown character.
224 |
225 | Let's rename \*our-new-project\* to \*our-old-project\*.
226 |
227 | ```
228 | Let's rename \*our-new-project\* to \*our-old-project\*.
229 | ```
230 |
231 | ------
232 |
233 | # Links
234 |
235 | [I'm an inline-style link](https://www.google.com)
236 |
237 | [I'm an inline-style link with title](https://www.google.com "Google's Homepage")
238 |
239 | [I'm a reference-style link][Arbitrary case-insensitive reference text]
240 |
241 | [I'm a relative reference to a repository file](../blob/master/LICENSE)
242 |
243 | [You can use numbers for reference-style link definitions][1]
244 |
245 | Or leave it empty and use the [link text itself].
246 |
247 | URLs and URLs in angle brackets will automatically get turned into links.
248 | http://www.example.com or and sometimes
249 | example.com (but not on Github, for example).
250 |
251 | Some text to show that the reference links can follow later.
252 |
253 | [arbitrary case-insensitive reference text]: https://www.mozilla.org
254 | [1]: http://slashdot.org
255 | [link text itself]: http://www.reddit.com
256 |
257 | ```
258 | [I'm an inline-style link](https://www.google.com)
259 |
260 | [I'm an inline-style link with title](https://www.google.com "Google's Homepage")
261 |
262 | [I'm a reference-style link][Arbitrary case-insensitive reference text]
263 |
264 | [I'm a relative reference to a repository file](../blob/master/LICENSE)
265 |
266 | [You can use numbers for reference-style link definitions][1]
267 |
268 | Or leave it empty and use the [link text itself].
269 |
270 | URLs and URLs in angle brackets will automatically get turned into links.
271 | http://www.example.com or and sometimes
272 | example.com (but not on Github, for example).
273 |
274 | Some text to show that the reference links can follow later.
275 |
276 | [arbitrary case-insensitive reference text]: https://www.mozilla.org
277 | [1]: http://slashdot.org
278 | [link text itself]: http://www.reddit.com
279 | ```
280 |
281 | ------
282 |
283 | # Images
284 |
285 | Inline images:
286 |
287 | Inline-style:
288 | 
289 |
290 | Base64 encoded:
291 | 
292 |
293 | Reference-style:
294 | ![alt text][logo]
295 |
296 | [logo]: https://placehold.co/48.jpg "Logo Title Text 2"
297 |
298 | 
299 |
300 | 
301 |
302 | Like links, Images also have a footnote style syntax with a reference later in the document defining the URL location:
303 |
304 | ![200x200 image with reference][id]
305 |
306 | [id]:https://placehold.co/200.jpg "The title"
307 |
308 | Images can also be links:
309 |
310 | [](https://example.com)
311 |
312 | ------
313 |
314 | ```
315 | Inline images:
316 |
317 | Inline-style:
318 | 
319 |
320 | Base64 encoded:
321 | 
322 |
323 | Reference-style:
324 | ![48x48 image][logo]
325 |
326 | [logo]: https://placehold.co/48.jpg "Logo Title Text 2"
327 |
328 | 
329 |
330 | 
331 |
332 | Like links, Images also have a footnote style syntax with a reference later in the document defining the URL location:
333 |
334 | ![200x200 image with reference][id]
335 |
336 | [id]:https://placehold.co/200.jpg "The title"
337 |
338 | Images can also be links:
339 |
340 | [](https://example.com)
341 |
342 | ```
343 |
344 | # Code and Syntax Highlighting
345 |
346 | Inline `code` has `back-ticks around` it.
347 |
348 | ```
349 | Inline `code` has `back-ticks around` it.
350 | ```
351 |
352 | ```csharp
353 | var text =
354 | @"# Hello, world!
355 | *Greetings* from **markdown**!
356 | > Hello, back!";
357 |
358 | var document = Document.Create(container =>
359 | {
360 | container.Page(page =>
361 | {
362 | page.Margin(20);
363 | page.Content().Markdown(text);
364 | });
365 | });
366 | ```
367 |
368 | ------
369 |
370 | # Tables
371 |
372 | Colons can be used to align columns.
373 |
374 | | Tables | Are | Cool |
375 | | ------------- |:-------------:| -----:|
376 | | col 3 is | right-aligned | $1600 |
377 | | col 2 is | centered | $12 |
378 | | zebra stripes | are neat | $1 |
379 |
380 | There must be at least 3 dashes separating each header cell.
381 | The outer pipes (|) are optional, and you don't need to make the
382 | raw Markdown line up prettily. You can also use inline Markdown.
383 |
384 | Markdown | Less | Pretty
385 | --- | --- | ---
386 | *Still* | `renders` | **nicely**
387 | 1 | 2 | 3
388 |
389 | | First Header | Second Header |
390 | | ------------- | ------------- |
391 | | Content Cell | Content Cell |
392 | | Content Cell | Content Cell |
393 |
394 | | Command | Description |
395 | | --- | --- |
396 | | git status | List all new or modified files |
397 | | git diff | Show file differences that haven't been staged |
398 |
399 | | Command | Description |
400 | | --- | --- |
401 | | `git status` | List all *new or modified* files |
402 | | `git diff` | Show file differences that **haven't been** staged |
403 |
404 | | Left-aligned | Center-aligned | Right-aligned |
405 | | :--- | :---: | ---: |
406 | | git status | git status | git status |
407 | | git diff | git diff | git diff |
408 |
409 | | Name | Character |
410 | | --- | --- |
411 | | Backtick | ` |
412 | | Pipe | \| |
413 |
414 | ```
415 | Colons can be used to align columns.
416 |
417 | | Tables | Are | Cool |
418 | | ------------- |:-------------:| -----:|
419 | | col 3 is | right-aligned | $1600 |
420 | | col 2 is | centered | $12 |
421 | | zebra stripes | are neat | $1 |
422 |
423 | There must be at least 3 dashes separating each header cell.
424 | The outer pipes (|) are optional, and you don't need to make the
425 | raw Markdown line up prettily. You can also use inline Markdown.
426 |
427 | Markdown | Less | Pretty
428 | --- | --- | ---
429 | *Still* | `renders` | **nicely**
430 | 1 | 2 | 3
431 |
432 | | First Header | Second Header |
433 | | ------------- | ------------- |
434 | | Content Cell | Content Cell |
435 | | Content Cell | Content Cell |
436 |
437 | | Command | Description |
438 | | --- | --- |
439 | | git status | List all new or modified files |
440 | | git diff | Show file differences that haven't been staged |
441 |
442 | | Command | Description |
443 | | --- | --- |
444 | | `git status` | List all *new or modified* files |
445 | | `git diff` | Show file differences that **haven't been** staged |
446 |
447 | | Left-aligned | Center-aligned | Right-aligned |
448 | | :--- | :---: | ---: |
449 | | git status | git status | git status |
450 | | git diff | git diff | git diff |
451 |
452 | | Name | Character |
453 | | --- | --- |
454 | | Backtick | ` |
455 | | Pipe | \| |
456 | ```
457 |
458 | ## Grid tables
459 |
460 | Grid tables are also supported, including relative widths
461 |
462 | +-----+----------+-----+
463 | | A | B | C |
464 | +=====+==========+=====+
465 | | 25% | 50% | 25% |
466 | +-----+----------+-----+
467 |
468 | +---+---+---+
469 | | AAA \ | B |
470 | + AAA \ +---+
471 | | AAA | C |
472 | +---+---+---+
473 | | DDDDDDDDD |
474 | +---+---+---+
475 | | E | F | G |
476 | +---+---+---+
477 | ```
478 | +-----+----------+-----+
479 | | A | B | C |
480 | +=====+==========+=====+
481 | | 25% | 50% | 25% |
482 | +-----+----------+-----+
483 |
484 | +---+---+---+
485 | | AAA \ | B |
486 | + AAA \ +---+
487 | | AAA | C |
488 | +---+---+---+
489 | | DDDDDDDDD |
490 | +---+---+---+
491 | | E | F | G |
492 | +---+---+---+
493 | ```
494 |
495 | ------
496 |
497 | # Blockquotes
498 |
499 | > Blockquotes are very handy in email to emulate reply text.
500 | > This line is part of the same quote.
501 |
502 | Quote break.
503 |
504 | > This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
505 |
506 | > Blockquotes can also be nested...
507 | >> ...by using additional greater-than signs right next to each other...
508 | > > > ...or with spaces between arrows.
509 |
510 | ```
511 | > Blockquotes are very handy in email to emulate reply text.
512 | > This line is part of the same quote.
513 |
514 | Quote break.
515 |
516 | > This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
517 |
518 | > Blockquotes can also be nested...
519 | >> ...by using additional greater-than signs right next to each other...
520 | > > > ...or with spaces between arrows.
521 | ```
522 |
523 | ------
524 |
525 | # HTML entities
526 |
527 | HTML entities should be converted to characters: © & > ™ ¡ £
528 |
529 | ```
530 | HTML entities should be converted to actual characters: © & > ™ ¡ £
531 | ```
532 |
533 | ------
534 |
535 | # Horizontal Rules
536 |
537 | Three or more...
538 |
539 | ---
540 |
541 | Hyphens
542 |
543 | ***
544 |
545 | Asterisks
546 |
547 | ___
548 |
549 | Underscores
550 |
551 | ```
552 | Three or more...
553 |
554 | ---
555 |
556 | Hyphens
557 |
558 | ***
559 |
560 | Asterisks
561 |
562 | ___
563 |
564 | Underscores
565 | ```
566 |
567 | ------
--------------------------------------------------------------------------------
/tests/QuestPDF.Markdown.Tests/Rendering/RenderTests.cs:
--------------------------------------------------------------------------------
1 | using QuestPDF.Drawing.Exceptions;
2 | using QuestPDF.Fluent;
3 | using QuestPDF.Helpers;
4 | using QuestPDF.Infrastructure;
5 |
6 | namespace QuestPDF.Markdown.Tests.Rendering;
7 |
8 | public sealed class RenderTests
9 | {
10 | const string NotoSansMono = "Noto Sans Mono";
11 | const string NotoSansSymbols2 = "Noto Sans Symbols 2";
12 |
13 | [Fact]
14 | public async Task RendersHeadings()
15 | {
16 | const string md = """
17 | # h1 Heading
18 | ## h2 Heading
19 | ### h3 Heading
20 | #### h4 Heading
21 | ##### h5 Heading
22 | ###### h6 Heading
23 | """;
24 |
25 | var document = GenerateDocument(item => item.Markdown(md));
26 |
27 | await Verify(document);
28 | }
29 |
30 | [Fact]
31 | public async Task RendersAlternativeHeadings()
32 | {
33 | const string md = """
34 | h1 heading
35 | ======
36 | h2 heading
37 | ------
38 | """;
39 |
40 | var document = GenerateDocument(item => item.Markdown(md));
41 |
42 | await Verify(document);
43 | }
44 |
45 | [Fact]
46 | public async Task RendersParagraphs()
47 | {
48 | const string md = """
49 | A paragraph is followed by a blank line.
50 |
51 | Newlines within paragraphs
52 | are ignored.
53 |
54 | Text should be followed by two trailing spaces
55 | or a backlash \
56 | to force a newline.
57 |
58 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
59 | """;
60 |
61 | var document = GenerateDocument(item => item.Markdown(md));
62 |
63 | await Verify(document);
64 | }
65 |
66 | [Fact]
67 | public async Task RedersEmphasis()
68 | {
69 | const string md = """
70 | **This is bold text**
71 |
72 | __This is bold text__
73 |
74 | *This is italic text*
75 |
76 | _This is italic text_
77 |
78 | **This is bold and _italic_ text**
79 |
80 | ~~This is strikethrough text~~
81 | """;
82 |
83 | var document = GenerateDocument(item => item.Markdown(md));
84 |
85 | await Verify(document);
86 | }
87 |
88 | [Fact]
89 | public async Task RendersExtendedEmphasis()
90 | {
91 | const string md = """
92 | 19^th^
93 |
94 | H~2~O
95 |
96 | ++Inserted text++
97 |
98 | ==Marked text==
99 | """;
100 |
101 | var document = GenerateDocument(item => item.Markdown(md));
102 |
103 | await Verify(document);
104 | }
105 |
106 | [Fact]
107 | public async Task RendersLinks()
108 | {
109 | const string md = """
110 | Inline style link: [link](https://example.com)
111 |
112 | Inline-style link with title: [link](https://example.com "example title")
113 |
114 | Reference-style link with text: [link][ref]
115 |
116 | Reference-style link with number: [link][1]
117 |
118 | Reference-style link with link text: [link]
119 |
120 | Plaintext URL: https://example.com
121 |
122 | [ref]: https://example.com
123 | [1]: https://example.com
124 | [link]: https://example.com
125 | """;
126 |
127 | var document = GenerateDocument(item => item.Markdown(md));
128 |
129 | await Verify(document);
130 | }
131 |
132 | [Fact]
133 | public async Task RendersLists()
134 | {
135 | const string md = """
136 | - Item 1
137 | - Item 2
138 | - Nested Item 2.1
139 | - Nested Item 2.2
140 | - Item 3
141 |
142 | 1. First
143 | 2. Second
144 | 3. Third
145 | 1. Subitem 3.1
146 | 2. Subitem 3.2
147 |
148 | - [ ] Task list item 1
149 | - [x] Task list item 2
150 | - [ ] Task list item 3
151 | """;
152 |
153 | var document = GenerateDocument(item => item.Markdown(md, options =>
154 | {
155 | options.UnicodeGlyphFont = NotoSansSymbols2;
156 | options.UnorderedListGlyph = "🞴";
157 | options.TaskListCheckedGlyph = "🞚";
158 | options.TaskListUncheckedGlyph = "◇";
159 | }));
160 | await Verify(document);
161 | }
162 |
163 | [Fact]
164 | public async Task RendersBlockquotes()
165 | {
166 | const string md = """
167 | > Blockquote level 1
168 | >
169 | > > Blockquote level 2
170 | >
171 | > Back to level 1
172 | """;
173 |
174 | var document = GenerateDocument(item => item.Markdown(md));
175 |
176 | await Verify(document);
177 | }
178 |
179 | [Fact]
180 | public async Task RendersCodeBlocksAndInlineCode()
181 | {
182 | const string md = """
183 | Inline code: `var x = 1;`
184 |
185 | ```
186 | // Fenced code block
187 | Console.WriteLine("Hello, world!");
188 | ```
189 | """;
190 |
191 | var document = GenerateDocument(item => item.Markdown(md, options => options.CodeFont = NotoSansMono));
192 |
193 | await Verify(document);
194 | }
195 |
196 | [Fact]
197 | public async Task RendersImagesDownload()
198 | {
199 | const string md = """
200 | 
201 |
202 | Inline image: 
203 | """;
204 |
205 | var markdown = ParsedMarkdownDocument.FromText(md);
206 |
207 | await markdown.DownloadImages();
208 |
209 | var document = GenerateDocument(item => item.Markdown(markdown));
210 |
211 | await Verify(document);
212 | }
213 |
214 | [Fact]
215 | public async Task RendersImagesDownloadBase64()
216 | {
217 | const string md = """
218 | 
219 | """;
220 |
221 | var markdown = ParsedMarkdownDocument.FromText(md);
222 |
223 | await markdown.DownloadImages();
224 |
225 | var document = GenerateDocument(item => item.Markdown(markdown));
226 |
227 | await Verify(document);
228 | }
229 |
230 | [Fact]
231 | public async Task RendersImagesDownloadLocal()
232 | {
233 | const string md = """
234 | 
235 | """;
236 |
237 | var markdown = ParsedMarkdownDocument.FromText(md);
238 |
239 | await markdown.DownloadImages(safeRootPath: Path.Combine(AppContext.BaseDirectory, "Images"));
240 |
241 | var document = GenerateDocument(item => item.Markdown(markdown));
242 |
243 | await Verify(document);
244 | }
245 |
246 | [Fact]
247 | public async Task RendersImagesDownloadLocalNoRoot()
248 | {
249 | const string md = """
250 | 
251 | """;
252 |
253 | var markdown = ParsedMarkdownDocument.FromText(md);
254 |
255 | await markdown.DownloadImages();
256 |
257 | var document = GenerateDocument(item => item.Markdown(markdown));
258 |
259 | await Verify(document);
260 | }
261 |
262 | [Fact]
263 | public async Task RendersImagesDownloadLocalPathTraversal()
264 | {
265 | const string md = """
266 | 
267 | """;
268 |
269 | var markdown = ParsedMarkdownDocument.FromText(md);
270 |
271 | await markdown.DownloadImages(safeRootPath: Path.Combine(AppContext.BaseDirectory, "Images"));
272 |
273 | var document = GenerateDocument(item => item.Markdown(markdown));
274 |
275 | await Verify(document);
276 | }
277 |
278 | [Fact]
279 | public async Task RendersImagesDownloadMaxSizeInvalid()
280 | {
281 | const string md = """
282 | 
283 | """;
284 |
285 | var markdown = ParsedMarkdownDocument.FromText(md);
286 |
287 | await markdown.DownloadImages();
288 |
289 | var document = GenerateDocument(item => item.Markdown(markdown));
290 |
291 | Assert.Throws(() => document.GeneratePdf());
292 | }
293 |
294 | [Fact]
295 | public async Task RendersImagesDownloadMaxSizeValid()
296 | {
297 | const string md = """
298 | 
299 | """;
300 |
301 | var markdown = ParsedMarkdownDocument.FromText(md);
302 |
303 | await markdown.DownloadImages();
304 |
305 | var document = GenerateDocument(item => item.Markdown(markdown, options =>
306 | {
307 | options.MaxImageWidth = 200;
308 | options.MaxImageHeight = 50;
309 | }));
310 |
311 | await Verify(document);
312 | }
313 |
314 | [Fact]
315 | public async Task RendersImagesNoDownload()
316 | {
317 | const string md = """
318 | 
319 |
320 | Inline image: 
321 | """;
322 |
323 | var document = GenerateDocument(item => item.Markdown(md));
324 |
325 | await Verify(document);
326 | }
327 |
328 | [Fact]
329 | public async Task RendersHorizontalRules()
330 | {
331 | const string md = """
332 | First paragraph.
333 |
334 | ---
335 |
336 | Second paragraph.
337 |
338 | ***
339 |
340 | Third paragraph.
341 |
342 | ___
343 |
344 | Fourth paragraph.
345 | """;
346 |
347 | var document = GenerateDocument(item => item.Markdown(md));
348 |
349 | await Verify(document);
350 | }
351 |
352 | [Fact]
353 | public async Task RendersTables()
354 | {
355 | const string md = """
356 | | Header 1 | Header 2 | Header 3 |
357 | |:---------|:--------:|---------:|
358 | | Cell 1 | Cell 2 | Cell 3 |
359 | | Cell 4 | Cell 5 | Cell 6 |
360 | """;
361 |
362 | var document = GenerateDocument(item => item.Markdown(md));
363 |
364 | await Verify(document);
365 | }
366 |
367 | [Fact]
368 | public async Task RendersGridTables()
369 | {
370 | const string md = """
371 | +---+---+---+
372 | | AAA \ | B |
373 | + AAA \ +---+
374 | | AAA | C |
375 | +===+===+===+
376 | | DDDDDDDDD |
377 | +---+---+---+
378 | | E | F | G |
379 | +---+---+---+
380 | """;
381 |
382 | var document = GenerateDocument(item => item.Markdown(md));
383 |
384 | await Verify(document);
385 | }
386 |
387 | [Fact]
388 | public async Task RendersNestedElements()
389 | {
390 | const string md = """
391 | 1. List item with paragraph
392 |
393 | This is a paragraph inside a list item.
394 |
395 | > Blockquote inside list
396 | >
397 | > - Nested list in blockquote
398 | > - Another item
399 |
400 | 2. Another item
401 |
402 | - List with `inline code` and **bold text**
403 | """;
404 |
405 | var document = GenerateDocument(item => item.Markdown(md, options => options.CodeFont = NotoSansMono));
406 |
407 | await Verify(document);
408 | }
409 |
410 | [Fact]
411 | public async Task RendersTags()
412 | {
413 | const string md = """
414 | This is page [**{currentPage}**](https://example.com) / *{totalPages}*.
415 | """;
416 |
417 | var document = GenerateDocument(item => item.Markdown(md,
418 | options =>
419 | {
420 | options.AddTemplateTag("currentPage", t => t.CurrentPageNumber());
421 | options.AddTemplateTag("totalPages", t => t.TotalPages());
422 | }));
423 |
424 | await Verify(document);
425 | }
426 |
427 | [Fact]
428 | public async Task RendersHtmlEntities()
429 | {
430 | const string md = """
431 | Basic entities: & < > " '
432 |
433 | Named entities: © ® ™ — – … €
434 |
435 | Numeric entities: © ® ™ — – … €
436 |
437 | Hex entities: © ® ™ — – … €
438 | """;
439 |
440 | var document = GenerateDocument(item => item.Markdown(md));
441 |
442 | await Verify(document);
443 | }
444 |
445 | private static Document GenerateDocument(Action body)
446 | {
447 | return Document.Create(container =>
448 | {
449 | container.Page(page =>
450 | {
451 | page.PageColor(Colors.White);
452 | page.Size(PageSizes.A6);
453 | page.Margin(1, Unit.Centimetre);
454 | page.DefaultTextStyle(text =>
455 | {
456 | text.FontFamily(Fonts.Lato);
457 | text.LineHeight(1.5f);
458 | return text;
459 | });
460 |
461 | page.Content()
462 | .PaddingVertical(20)
463 | .Column(main =>
464 | {
465 | main.Spacing(20);
466 | body(main.Item());
467 | });
468 | });
469 | }).WithMetadata(new DocumentMetadata
470 | {
471 | CreationDate = new DateTime(2023, 11, 15, 12, 00, 00),
472 | ModifiedDate = new DateTime(2023, 11, 15, 12, 00, 00),
473 | });
474 | }
475 | }
476 |
--------------------------------------------------------------------------------
/src/QuestPDF.Markdown/MarkdownRenderer.cs:
--------------------------------------------------------------------------------
1 | using Markdig.Extensions.Tables;
2 | using Markdig.Extensions.TaskLists;
3 | using Markdig.Syntax;
4 | using Markdig.Syntax.Inlines;
5 | using QuestPDF.Fluent;
6 | using QuestPDF.Helpers;
7 | using QuestPDF.Infrastructure;
8 | using QuestPDF.Markdown.Compatibility;
9 | using QuestPDF.Markdown.Extensions;
10 | using QuestPDF.Markdown.Parsing;
11 |
12 | namespace QuestPDF.Markdown;
13 |
14 | ///
15 | /// This class uses markdig to parse a provided markdown text and convert it to QuestPDF elements
16 | ///
17 | ///
18 | /// The markdig parser is documented in https://github.com/xoofx/markdig/blob/master/doc/parsing-ast.md
19 | ///
20 | internal sealed class MarkdownRenderer : IComponent
21 | {
22 | private readonly MarkdownRendererOptions _options;
23 | private readonly ParsedMarkdownDocument _document;
24 | private readonly TextProperties _textProperties = new();
25 |
26 | private MarkdownRenderer(ParsedMarkdownDocument document, Action? configure = null)
27 | {
28 | _document = document;
29 | _options = new MarkdownRendererOptions();
30 |
31 | configure?.Invoke(_options);
32 | }
33 |
34 | internal static MarkdownRenderer Create(string markdownText) =>
35 | new(ParsedMarkdownDocument.FromText(markdownText));
36 |
37 | internal static MarkdownRenderer Create(string markdownText, Action configure) =>
38 | new(ParsedMarkdownDocument.FromText(markdownText), configure);
39 |
40 | internal static MarkdownRenderer Create(ParsedMarkdownDocument document) =>
41 | new(document);
42 |
43 | internal static MarkdownRenderer Create(ParsedMarkdownDocument document, Action configure) =>
44 | new(document, configure);
45 |
46 | public void Compose(IContainer pdf) => Render(_document.MarkdigDocument, pdf);
47 |
48 | private void Render(Block block, IContainer pdf)
49 | {
50 | if (_options.Debug) pdf = pdf.PaddedDebugArea(block.GetType().Name, block is LeafBlock ? Colors.Red.Medium : Colors.Blue.Medium);
51 |
52 | _ = block switch
53 | {
54 | QuoteBlock quoteBlock => Render(quoteBlock, pdf),
55 | Table table => Render(table, pdf),
56 | ListBlock listBlock => Render(listBlock, pdf),
57 | ListItemBlock listItemBlock => Render(listItemBlock, pdf),
58 | HeadingBlock headingBlock => Render(headingBlock, pdf),
59 | ThematicBreakBlock thematicBreakBlock => Render(thematicBreakBlock, pdf),
60 | CodeBlock codeBlock => Render(codeBlock, pdf),
61 | TableRow tableRow => Render(tableRow, pdf),
62 | TableCell tableCell => Render(tableCell, pdf),
63 | ContainerBlock containerBlock => Render(containerBlock, pdf),
64 | LeafBlock leafBlock => Render(leafBlock, pdf),
65 | _ => throw new InvalidOperationException($"Unsupported block type {block.GetType().Name}")
66 | };
67 | }
68 |
69 | private IContainer Render(ContainerBlock block, IContainer pdf)
70 | {
71 | if (block.Count == 0) return pdf;
72 |
73 | pdf.Column(col =>
74 | {
75 | foreach (var item in block)
76 | {
77 | // Blocks inside a list get the same spacing as the list items themselves
78 | col.Spacing(item.Parent is ListBlock or ListItemBlock
79 | ? _options.ListItemSpacing
80 | : _options.ParagraphSpacing);
81 |
82 | Render(item, col.Item());
83 | }
84 | });
85 |
86 | return pdf;
87 | }
88 |
89 | private IContainer Render(Table table, IContainer pdf)
90 | {
91 | pdf.Table(td =>
92 | {
93 | td.ColumnsDefinition(cd =>
94 | {
95 | foreach (var col in table.ColumnDefinitions)
96 | {
97 | // Widths are provided as a percentage
98 | cd.RelativeColumn(col.Width > 0 ? col.Width : 1f);
99 | }
100 | });
101 |
102 | var rows = table.OfType().ToList();
103 | RenderTableRows(table, rows, td);
104 | });
105 |
106 | return pdf;
107 | }
108 |
109 | private void RenderTableRows(Table table, List rows, TableDescriptor td)
110 | {
111 | uint rowIdx = 0;
112 | foreach (var row in rows)
113 | {
114 | if (row.IsHeader) _textProperties.TextStyles.Push(t => t.Bold());
115 | var isLast = rowIdx + 1 == table.Count;
116 |
117 | var cells = row.OfType().ToList();
118 | RenderTableCells(table, td, cells, rowIdx, row, isLast);
119 |
120 | if (row.IsHeader) _textProperties.TextStyles.Pop();
121 |
122 | rowIdx++;
123 | }
124 | }
125 |
126 | private void RenderTableCells(Table table, TableDescriptor td, List cells, uint rowIdx, TableRow row, bool isLast)
127 | {
128 | uint columnIdx = 0;
129 | foreach (var cell in cells)
130 | {
131 | var cd = table.ColumnDefinitions[(int)columnIdx];
132 | RenderTableCell(cell, rowIdx, columnIdx, row.IsHeader, isLast, td, cd);
133 |
134 | columnIdx++;
135 | }
136 | }
137 |
138 | private IContainer RenderTableCell(TableCell cell, uint rowIdx, uint columnIdx, bool isHeader, bool isLast, TableDescriptor table, TableColumnDefinition columnDefinition)
139 | {
140 | var container = table.Cell()
141 | .RowSpan((uint)cell.RowSpan)
142 | .Row(rowIdx + 1)
143 | .Column((cell.ColumnIndex >= 0 ? (uint)cell.ColumnIndex : columnIdx) + 1)
144 | .ColumnSpan((uint)cell.ColumnSpan)
145 | .Border(_options, isHeader, isLast)
146 | .Background(rowIdx % 2 == 1
147 | ? _options.TableEvenRowBackgroundColor
148 | : _options.TableOddRowBackgroundColor)
149 | .Padding(5);
150 |
151 | switch (columnDefinition.Alignment)
152 | {
153 | case TableColumnAlign.Left:
154 | container = container.AlignLeft();
155 | break;
156 | case TableColumnAlign.Center:
157 | container = container.AlignCenter();
158 | break;
159 | case TableColumnAlign.Right:
160 | container = container.AlignRight();
161 | break;
162 | }
163 |
164 | return Render(cell, container);
165 | }
166 |
167 | private IContainer Render(LeafBlock block, IContainer pdf)
168 | {
169 | // Some blocks don't contain any further inline elements, render the text directly
170 | if (block.Inline != null && block.Inline.Any())
171 | {
172 | pdf.Text(text =>
173 | {
174 | text.Align(_options.ParagraphAlignment);
175 |
176 | // Process the block's inline elements
177 | foreach (var item in block.Inline)
178 | {
179 | Render(item, text);
180 | }
181 | });
182 |
183 | }
184 | else if (block.Lines.Count != 0)
185 | {
186 | pdf.Text(block.Lines.ToString())
187 | .ApplyStyles(_textProperties.TextStyles.ToList());
188 | }
189 |
190 | return pdf;
191 | }
192 |
193 | private TextDescriptor Render(ContainerInline inline, TextDescriptor text)
194 | {
195 | foreach (var item in inline)
196 | {
197 | Render(item, text);
198 | }
199 |
200 | return text;
201 | }
202 |
203 | private IContainer Render(QuoteBlock block, IContainer pdf)
204 | {
205 | pdf = pdf.BorderLeft(_options.BlockQuoteBorderThickness)
206 | .BorderColor(_options.BlockQuoteBorderColor)
207 | .PaddingLeft(10);
208 |
209 | // Push any styles that should be applied to the entire container on the stack
210 | _textProperties.TextStyles.Push(t => t.FontColor(_options.BlockQuoteTextColor));
211 |
212 | Render(block as ContainerBlock, pdf);
213 |
214 | // Pop any styles that were applied to the entire container off the stack
215 | _textProperties.TextStyles.Pop();
216 |
217 | return pdf;
218 | }
219 |
220 | private IContainer Render(ListBlock block, IContainer pdf)
221 | {
222 | return Render(block as ContainerBlock, pdf);
223 | }
224 |
225 | private IContainer Render(ListItemBlock block, IContainer pdf)
226 | {
227 | if (block.Parent is not ListBlock list) return pdf;
228 |
229 | pdf.Row(li =>
230 | {
231 | li.Spacing(5);
232 | var delimiter = li.AutoItem().PaddingLeft(10);
233 |
234 | if (list.IsOrdered) delimiter.Text($"{block.Order}{list.OrderedDelimiter}");
235 | else delimiter.Text(_options.UnorderedListGlyph).FontFamily(_options.UnicodeGlyphFont);
236 |
237 | Render(block as ContainerBlock, li.RelativeItem());
238 | });
239 |
240 | return pdf;
241 | }
242 |
243 | private IContainer Render(HeadingBlock block, IContainer pdf)
244 | {
245 | // Push any styles that should be applied to the entire block on the stack
246 | _textProperties.TextStyles.Push(t => t
247 | .FontColor(_options.HeadingTextColor)
248 | .FontSize(Math.Max(0, _options.CalculateHeadingSize(block.Level)))
249 | .Bold());
250 |
251 | Render(block as LeafBlock, pdf);
252 |
253 | // Pop any styles that were applied to the entire block off the stack
254 | _textProperties.TextStyles.Pop();
255 |
256 | return pdf;
257 | }
258 |
259 | private IContainer Render(ThematicBreakBlock block, IContainer pdf)
260 | {
261 | pdf.LineHorizontal(_options.HorizontalRuleThickness)
262 | .LineColor(_options.HorizontalRuleColor);
263 |
264 | return pdf;
265 | }
266 |
267 | private IContainer Render(CodeBlock block, IContainer pdf)
268 | {
269 | // Push any styles that should be applied to the entire block on the stack
270 | _textProperties.TextStyles.Push(t => t.FontFamily(_options.CodeFont));
271 |
272 | pdf = pdf.Background(_options.CodeBlockBackground).Padding(5);
273 | pdf = Render(block as LeafBlock, pdf);
274 |
275 | // Pop any styles that were applied to the entire block off the stack
276 | _textProperties.TextStyles.Pop();
277 |
278 | return pdf;
279 | }
280 |
281 | private TextDescriptor Render(Inline inline, TextDescriptor text) => inline switch
282 | {
283 | TemplateInline templateInline => Render(templateInline, text),
284 | LinkInline linkInline => Render(linkInline, text),
285 | EmphasisInline emphasisInline => Render(emphasisInline, text),
286 | AutolinkInline autolinkInline => Render(autolinkInline, text),
287 | LineBreakInline lineBreakInline => Render(lineBreakInline, text),
288 | TaskList taskList => Render(taskList, text),
289 | LiteralInline literalInline => Render(literalInline, text),
290 | CodeInline codeInline => Render(codeInline, text),
291 | HtmlEntityInline htmlEntityInline => Render(htmlEntityInline, text),
292 | ContainerInline containerInline => Render(containerInline, text),
293 | LeafInline leafInline => Render(leafInline, text),
294 | _ => throw new InvalidOperationException($"Unsupported inline type {inline.GetType().Name}")
295 | };
296 |
297 | private static TextDescriptor Render(LeafInline inline, TextDescriptor text)
298 | {
299 | text.Span($"Unknown LeafInline: {inline.GetType()}").BackgroundColor(Colors.Orange.Medium);
300 |
301 | return text;
302 | }
303 |
304 | private TextDescriptor Render(TemplateInline inline, TextDescriptor text)
305 | {
306 | if (!_options.RenderTemplates.TryGetValue(inline.Tag, out var render) || render == null) return text;
307 |
308 | render(text).ApplyStyles(_textProperties.TextStyles.ToList());
309 |
310 | return text;
311 | }
312 |
313 | private TextDescriptor Render(LinkInline inline, TextDescriptor text)
314 | {
315 | // Push any styles that should be applied to the entire span on the stack
316 | _textProperties.TextStyles.Push(t => t
317 | .FontColor(_options.LinkTextColor)
318 | .DecorationColor(_options.LinkTextColor)
319 | .Underline()
320 | );
321 |
322 | if (inline.IsImage) _textProperties.ImageUrl = inline.Url;
323 | if (!inline.IsImage) _textProperties.LinkUrl = inline.Url;
324 |
325 | Render(inline as ContainerInline, text);
326 |
327 | // Pop any styles that were applied to the entire span off the stack
328 | _textProperties.TextStyles.Pop();
329 | if (inline.IsImage) _textProperties.ImageUrl = null;
330 | if (!inline.IsImage) _textProperties.LinkUrl = null;
331 |
332 | return text;
333 | }
334 |
335 | private TextDescriptor Render(EmphasisInline inline, TextDescriptor text)
336 | {
337 | _textProperties.TextStyles.Push(t =>
338 | {
339 | switch (inline.DelimiterChar, inline.DelimiterCount)
340 | {
341 | case ('^', 1):
342 | return t.Superscript();
343 | case ('~', 1):
344 | return t.Subscript();
345 | case ('~', 2):
346 | return t.Strikethrough();
347 | case ('+', 2):
348 | return t.Underline();
349 | case ('=', 2):
350 | return t.BackgroundColor(_options.MarkedTextBackgroundColor);
351 | }
352 |
353 | return inline.DelimiterCount == 2 ? t.Bold() : t.Italic();
354 | });
355 |
356 | Render(inline as ContainerInline, text);
357 |
358 | _textProperties.TextStyles.Pop();
359 |
360 | return text;
361 | }
362 |
363 | private TextDescriptor Render(AutolinkInline inline, TextDescriptor text)
364 | {
365 | var linkSpan = text.Hyperlink(inline.Url, inline.Url);
366 | linkSpan.ApplyStyles(_textProperties.TextStyles.ToList());
367 |
368 | return text;
369 | }
370 |
371 | private static TextDescriptor Render(LineBreakInline inline, TextDescriptor text)
372 | {
373 | // Only add a line break within a paragraph if trailing spaces or a backslash are used.
374 | if (inline.IsBackslash || inline.IsHard) text.Span("\n");
375 | else text.Span(" ");
376 |
377 | return text;
378 | }
379 |
380 | private TextDescriptor Render(TaskList inline, TextDescriptor text)
381 | {
382 | text.Span(inline.Checked ? _options.TaskListCheckedGlyph : _options.TaskListUncheckedGlyph)
383 | .FontFamily(_options.UnicodeGlyphFont);
384 |
385 | return text;
386 | }
387 |
388 | private TextDescriptor Render(LiteralInline inline, TextDescriptor text)
389 | {
390 | if (!_textProperties.ImageUrl.IsNullOrEmpty())
391 | {
392 | // Image could not be downloaded, display link to source
393 | if (!_document.TryGetImageFromCache(_textProperties.ImageUrl, out var image))
394 | {
395 | text.Hyperlink(inline.ToString(), _textProperties.ImageUrl)
396 | .ApplyStyles(_textProperties.TextStyles.ToList());
397 |
398 | return text;
399 | }
400 |
401 | // Render image
402 | text.Element(e =>
403 | {
404 | // Image element wrapped in link
405 | if (!_textProperties.LinkUrl.IsNullOrEmpty())
406 | e = e.Hyperlink(_textProperties.LinkUrl);
407 |
408 | double scaledWidth = image.Width * _options.ImageScalingFactor;
409 | double scaledHeight = image.Height * _options.ImageScalingFactor;
410 |
411 | // Get maximum allowed dimensions from options
412 | double maxWidth = _options.MaxImageWidth;
413 | double maxHeight = _options.MaxImageHeight;
414 |
415 | // Adjust dimensions to fit within max constraints
416 | if (maxWidth > 0 || maxHeight > 0)
417 | {
418 | const double epsilon = 1e-6;
419 |
420 | var widthNonZero = Math.Abs(scaledWidth) > epsilon;
421 | var heightNonZero = Math.Abs(scaledHeight) > epsilon;
422 |
423 | var widthRatio = (maxWidth > 0 && widthNonZero)
424 | ? maxWidth / scaledWidth
425 | : double.MaxValue;
426 |
427 | var heightRatio = (maxHeight > 0 && heightNonZero)
428 | ? maxHeight / scaledHeight
429 | : double.MaxValue;
430 |
431 | var minRatio = Math.Min(widthRatio, heightRatio);
432 |
433 | // Apply scaling only if necessary
434 | if (minRatio < 1)
435 | {
436 | scaledWidth *= minRatio;
437 | scaledHeight *= minRatio;
438 | }
439 | }
440 |
441 | // Set adjusted dimensions and render
442 | e.Width((float)scaledWidth)
443 | .Height((float)scaledHeight)
444 | .Image(image.Image)
445 | .FitArea();
446 | });
447 |
448 | return text;
449 | }
450 |
451 | // Regular links
452 | if (!_textProperties.LinkUrl.IsNullOrEmpty())
453 | {
454 | text.Hyperlink(inline.ToString(), _textProperties.LinkUrl)
455 | .ApplyStyles(_textProperties.TextStyles.ToList());
456 |
457 | return text;
458 | }
459 |
460 | // Fallback to plain text
461 | text.Span(inline.ToString())
462 | .ApplyStyles(_textProperties.TextStyles.ToList());
463 |
464 | return text;
465 | }
466 |
467 | private TextDescriptor Render(CodeInline inline, TextDescriptor text)
468 | {
469 | text.Span(inline.Content)
470 | .BackgroundColor(_options.CodeInlineBackground)
471 | .FontFamily(_options.CodeFont);
472 |
473 | return text;
474 | }
475 |
476 | private static TextDescriptor Render(HtmlEntityInline inline, TextDescriptor text)
477 | {
478 | text.Span(inline.Transcoded.ToString());
479 |
480 | return text;
481 | }
482 | }
--------------------------------------------------------------------------------