├── .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 | 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 | [![Nuget](https://img.shields.io/nuget/v/QuestPDF.Markdown)](https://www.nuget.org/packages/QuestPDF.Markdown) 7 | [![Nuget Prerelease](https://img.shields.io/nuget/vpre/QuestPDF.Markdown?label=nuget%20prerelease)](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 | ![Usage](/img/usage.png?raw=true) 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 = @"![title](https://placehold.co/100x50.jpg)"; 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 | 5 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 42 | 43 | 44 | 45 | 46 | 47 | 52 | 53 | 54 | 55 | 56 | 57 | 59 | 60 | 61 | 62 | 63 | 64 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 77 | 79 | 80 | 81 | 82 | 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 | ![alt text](https://placehold.co/48.jpg "Logo Title Text 1") 289 | 290 | Base64 encoded: 291 | ![alt text](data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAwADADASIAAhEBAxEB/8QAGQABAAMBAQAAAAAAAAAAAAAAAAMEBQIH/8QAJRAAAQQBBAEEAwAAAAAAAAAAAgABAxEhBAUSMWETIkFCUXGR/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/APfEREBERAREQFTn3CKGeSJxMnjjKQnFrZqrH7yyuKhPtWnlMz94OYmxcSfLlVvXXwgl0+tjl05zF7AAnEnd2dm822K8qQtTEOnedi5Rt0455PdU35yo4NFHHBLEREbSlyP631jFYwui0cBQBC4P6YPyFmJ24v4/qCuG6RmenH0zZ5hEmuscrrF56zXS0FmxbRDGULjJJUbBh6e+L22e27+FpICIiAiIgIiIP//Z "Logo base64 Title Text 1") 292 | 293 | Reference-style: 294 | ![alt text][logo] 295 | 296 | [logo]: https://placehold.co/48.jpg "Logo Title Text 2" 297 | 298 | ![200x200 image](https://placehold.co/200.jpg) 299 | 300 | ![200x200 image with title](https://placehold.co/200.jpg "The title") 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 | [![200x200 image](https://placehold.co/200.jpg)](https://example.com) 311 | 312 | ------ 313 | 314 | ``` 315 | Inline images: 316 | 317 | Inline-style: 318 | ![48x48 image](https://placehold.co/48.jpg "Logo Title Text 1") 319 | 320 | Base64 encoded: 321 | ![alt text](data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAwADADASIAAhEBAxEB/8QAGQABAAMBAQAAAAAAAAAAAAAAAAMEBQIH/8QAJRAAAQQBBAEEAwAAAAAAAAAAAgABAxEhBAUSMWETIkFCUXGR/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/APfEREBERAREQFTn3CKGeSJxMnjjKQnFrZqrH7yyuKhPtWnlMz94OYmxcSfLlVvXXwgl0+tjl05zF7AAnEnd2dm822K8qQtTEOnedi5Rt0455PdU35yo4NFHHBLEREbSlyP631jFYwui0cBQBC4P6YPyFmJ24v4/qCuG6RmenH0zZ5hEmuscrrF56zXS0FmxbRDGULjJJUbBh6e+L22e27+FpICIiAiIgIiIP//Z "Logo base64 Title Text 1") 322 | 323 | Reference-style: 324 | ![48x48 image][logo] 325 | 326 | [logo]: https://placehold.co/48.jpg "Logo Title Text 2" 327 | 328 | ![200x200 image](https://placehold.co/200.jpg) 329 | 330 | ![200x200 image with title](https://placehold.co/200.jpg "The title") 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 | [![200x200 image](https://placehold.co/200.jpg)](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 | ![Alt text](https://placehold.co/100x50.jpg "Optional title") 201 | 202 | Inline image: ![Logo](https://placehold.co/100x50.jpg) 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 | ![Alt text](data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAyAGQDASIAAhEBAxEB/8QAGgABAAMBAQEAAAAAAAAAAAAAAAIDBAUGB//EACcQAAEEAQMDBAMBAAAAAAAAAAABAgMEEQUSExQhUSIxQWEGMnGh/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/APvgAAAAAAAAAAAAAAAAAAAADla7qcunwKsFd0rkTcrlT0NTOO6+fos1C7LFJVr1WMfZsZ271Xa1ETKquCr8mV79MlrxQTSySomONmUTCovcqt8q2KGoxVp3NiRzJItuH4VMZRADtZlihnjmhZ10crYUY1fS5Xfqv89zTSu2OvdSvMibNx8rHRKu1yZwvv8AJypqVqw6xqLYHtk6iKaOF3ZytYmO/hVz/hvqJLc1nrXQSwQxw8bUlTa5yquV7eAOk+1CydYXyNbIjOTDu3p85JV5mWIWSxO3RvTLVxjKGDVtMdqUjGyyNZAxMt2plyv+8/H18m6skrazEn2LKiYXZ2Rf4By6F+5csrxrSSukjm7VcvJtRcZwQm1W2rbVitBE6nWerHbnKj3491T4KHwJZtVEqaZJTljmSSSVWI1EanumU/bJF0dqtUv6eypLI6d7+KRqeja7yvxgD0UMjZYmSMXLHtRyfxSZTTh6epDDnPGxrM+cJguAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/9k=) 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 | ![Alt text](./local.jpg) 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 | ![Alt text](./local.jpg) 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 | ![Alt text](../../local.jpg) 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 | ![Alt text](https://placehold.co/500x200.jpg) 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 | ![Alt text](https://placehold.co/500x200.jpg) 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 | ![Alt text](https://placehold.co/100x50.jpg "Optional title") 319 | 320 | Inline image: ![Logo](https://placehold.co/100x50.jpg) 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 | } --------------------------------------------------------------------------------