LHKN Software Ltd. logo

QuestMark: A Markdown to PDF Renderer written in .NET

QuestMark is a library which can convert markdown into a QuestPDF component which can be embedded anywhere inside your document. This makes it a perfect fit for those situations where you have to render arbitrary markdown inside a document - something I ran into while working for DeskDirector.

DeskDirector is a ticketing system which stores all user inputted data as markdown and HTML. During my time there as an automation developer, I was working on a project to step through every ticket and generate a PDF. This PDF would be consumed both by AI and could be generated on the fly for use inside automation workflows and therefore needed to show all the same ticket data as the web app - data such as the ticket status, queue, priority, summary, description, all time entries entered by engineers, all ticket notes, and so on.

The first approach I took was to use Puppeteer Sharp to render a Liquid template (using dotliquid). This had the benefit of being very simple to implement with familiar tools (HTML and CSS), and I had a function app working locally within a few hours.

There were a few downsides with this approach however:

  1. Rendering speed was slow as new browser contexts had to be spun up for each ticket
  2. The memory footprint was rather large due to having a browser process constantly running
  3. In order to run inside Azure App Services, the function app had to run inside a Docker container with the browser dependency included. This was the main reason I scrapped the idea.

While running a function app from a Docker container isn’t the end of the world, I did not want to add another layer of complexity and another moving part where things might go wrong for the next maintainer. The CI/CD configuration was already complex enough without adding Docker images into the mix.

How it works

Under the hood it uses the awesome Markdig library to parse the markdown, with heavy inspiration taken from their HtmlRenderer.

The first thing we need to do is derive from their abstract RendererBase class. This requires us to implement an abstract method called Render. In my case this was a simple implementation, I only need to call the RendererBase Write method and pass the MarkdownObject in. So we now have a PdfRenderer class which looks something like:

using Markdig.Renderers;
using Markdig.Syntax;
using QuestPDF.Fluent;
using QuestPDF.Infrastructure;

namespace QuestMark.Renderers;

public class PdfRenderer : RendererBase
{
    public ColumnDescriptor? CurrentColumn { get; set; }

    public TextDescriptor? CurrentText { get; set; }

    /// <summary>
    /// QuestPDF IContainer (this is what gets passed into QuestPDF IComponent implementations)
    /// </summary>
    private readonly IContainer _container;

    public PdfRenderer(IContainer container)
    {
        _container = container;

        _container.Column(column => {
            CurrentColumn = column;
        });

        // Block renderers:
        ObjectRenderers.Add(new BlockQuoteRenderer());
        ObjectRenderers.Add(new CodeBlockRenderer());
        ObjectRenderers.Add(new HeadingRenderer());
        ObjectRenderers.Add(new ListRenderer());
        ObjectRenderers.Add(new ParagraphRenderer());
        ObjectRenderers.Add(new ThematicBreakRenderer());

        // Inline renderers:
        ObjectRenderers.Add(new AutolinkInlineRenderer());
        ObjectRenderers.Add(new CodeInlineRenderer());
        ObjectRenderers.Add(new DelimiterInlineRenderer());
        ObjectRenderers.Add(new EmphasisInlineRenderer());
        ObjectRenderers.Add(new HtmlEntityInlineRenderer());
        ObjectRenderers.Add(new HtmlInlineRenderer());
        ObjectRenderers.Add(new LineBreakInlineRenderer());
        ObjectRenderers.Add(new LinkInlineRenderer());
        ObjectRenderers.Add(new LiteralInlineRenderer());
    }

    public override object Render(MarkdownObject markdownObject)
    {
        Write(markdownObject);
        return _container;
    }
}

The next thing we need to do is actually define some renderers for each piece of markdown syntax. These are broken up into inline and block elements.

Inline Elements

These can either be ContainerInline where the inline element will have children, or LeafInline where the content of the element should be rendered directly.

Leafs

Inline leaf elements do need to be aware of what kind of inline container they inside of. For example, if we are rendering a LiteralInline, we need to know whether to apply italic, bold, and/or link styling. This means that we need to know whether the parent is an EmphasisInline, or LinkInline. Inline leaf renderers that need to be implemented:

Containers

Inline containers in this implementation are actually quite simple, because we’re not rendering to text, we’re using the QuestPDF fluent API to build up our markdown document. The inline leaf renderers do the heavy lifting of checking whether they are a child of a specific type of container. If we were rendering the document as HTML, we would be writing the container’s corresponding opening tag, followed by the childrens HTML (by calling the WriteChildren method of the MarkdownObjectRenderer), and finally the container’s closing tag. These are the inline containers that need to be implemented:

Here’s an example of a container renderer as well as a leaf renderer:

// Leaf renderer (LiteralInline):

using Markdig.Renderers;
using Markdig.Syntax.Inlines;
using QuestMark.Extensions;
using QuestPDF.Fluent;

namespace QuestMark.Renderers.Inlines;

public class LiteralInlineRenderer : MarkdownObjectRenderer<PdfRenderer, LiteralInline>
{
    protected override void Write(PdfRenderer renderer, LiteralInline literal)
    {
        TextDescriptor? text = renderer.CurrentText.ThrowIfNull();
        LinkInline? link = literal.GetAncestorOfType<LinkInline>();

        TextSpanDescriptor span;

        if (link is null)
        {
            span = text.Span(literal.Content.ToString());
        }
        else
        {
            string url = string.IsNullOrWhiteSpace(link.Url) ? "/" : link.Url;
            span = text.Hyperlink(literal.Content.ToString(), url)
                .Style(renderer.StyleOptions.LinkTextStyle);
        }

        span.Italic(literal.IsItalic());

        if (literal.IsBold())
        {
            span.Bold();
        }
    }
}

// Container renderer (EmphasisInline):

using Markdig.Renderers;
using Markdig.Syntax.Inlines;

namespace QuestMark.Renderers.Inlines;

internal class EmphasisInlineRenderer : MarkdownObjectRenderer<PdfRenderer, EmphasisInline>
{
    protected override void Write(PdfRenderer renderer, EmphasisInline emphasis)
    {
        renderer.WriteChildren(emphasis);
    }
}

The LiteralInlineRenderer is the renderer that actually renders text content. It therefore needs to know whether it is has a parent container that is an EmphasisInline or LinkInline in order to style and colour the text. Notice how simple the EmphasisInlineRenderer is. In an HTML renderer this would instead be writing either a <strong> or <em> opening tag to a TextWriter before writing its children.

Also take note of how the PdfRenderer instance gets passed in to our markdown object renderers. This offers a convenient place to store state for each renderer to access as we walk down through each block element and into each inline element. In our case, a QuestPDF TestDescriptor property called CurrentText is set by the parent container renderer. Every container block element will create a new ColumnDescriptor and a new TextDescriptor for its children to write to. Some examples of block renderers will be shown in the next section.

Block Elements

These can either be LeafBlock, which has no block level children or ContainerBlock which have block level children. Note that leaf blocks will still have inline elements as children. In QuestMark, block renderers generally follow a pattern of:

  1. Store a reference to the current QuestPDF column from the PdfRenderer
  2. Create a new column and style it (e.g. add a background to a quote block)
  3. Create a new text descriptor and set the CurrentText of the parent PdfRenderer
  4. Write children
  5. Set the CurrentText descriptor back to null
  6. Set the current column back to the previous column (from step 1)

It is important to reset the current column back after rendering a block element, as we might be nested inside a block container and we want other blocks to append to the parent container.

Leafs

These are the block leaf elements that must be implemented:

For example, the HeadingRenderer:

using Markdig.Renderers;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using QuestMark.Extensions;
using QuestPDF.Fluent;
using QuestPDF.Infrastructure;

namespace QuestMark.Renderers.Blocks;

public class HeadingRenderer : MarkdownObjectRenderer<PdfRenderer, HeadingBlock>
{
    protected override void Write(PdfRenderer renderer, HeadingBlock heading)
    {
        ColumnDescriptor? previousColumn = renderer.CurrentColumn.ThrowIfNull();
        ContainerInline? items = heading.Inline.ThrowIfNull();

        Int32 level = heading.Level;
        TextStyle style = renderer.StyleOptions.HeadingTextStyler(level);

        previousColumn
            .Item()
            .Column(column =>
            {
                column
                    .Item()
                    .Text(text =>
                    {
                        renderer.CurrentColumn = column;
                        renderer.CurrentText = text;
                        text.DefaultTextStyle(style);
                        renderer.Write(items);

                        if (!heading.IsLastChild())
                        {
                            column.Item().Text(text => text.EmptyLine());
                        }

                        renderer.CurrentText = null;
                        renderer.CurrentColumn = previousColumn;
                    });
            });
    }
}
Containers

These are the block container elements that must be implemented:

For example, the ListRenderer:

using Markdig.Renderers;
using Markdig.Syntax;
using QuestMark.Extensions;
using QuestPDF.Fluent;
using QuestPDF.Infrastructure;

namespace QuestMark.Renderers.Blocks;

internal class ListRenderer : MarkdownObjectRenderer<PdfRenderer, ListBlock>
{
    protected override void Write(PdfRenderer renderer, ListBlock list)
    {
        ColumnDescriptor previousColumn = renderer.CurrentColumn.ThrowIfNull();
        Int32 depth = list.GetDepth();
        IContainer container = renderer.StyleOptions.ListStyler(previousColumn.Item(), depth);

        container.Column(outerColumn =>
        {
            foreach (ListItemBlock item in list.Cast<ListItemBlock>())
            {
                outerColumn
                    .Item()
                    .Row(row =>
                    {
                        row.ConstantItem(12)
                            .Text(text =>
                            {
                                if (list.IsOrdered)
                                {
                                    text.Span($"{item.Order}{list.OrderedDelimiter}");
                                }
                                else
                                {
                                    text.Span($"{GetBullet(depth)}");
                                }
                            });

                        row.RelativeItem()
                            .Column(innerColumn =>
                            {
                                renderer.CurrentColumn = innerColumn;
                                renderer.Write(item);
                                renderer.CurrentColumn = previousColumn;
                            });
                    });
            }
        });
    }

    private static string GetBullet(Int32 depth) =>
        (depth % 4) switch
        {
            1 => "•",
            2 => "○",
            3 => "-",
            0 => "‣",
            _ => "•",
        };
}

Wrap Up and Source Code

This was quite a fun project to work on, and I actually learnt a lot. It was a good challenge trying to get these two libraries to work together. There is still more work to be done on it, for example I want to support rendering images (instead of just linking to an image) and more markdown extensions like tables and footnotes etc.

If you want to grab the source code, head to the repo and give it a clone. Feel free to steal the parts you need. Alternatively if you want to use the library yourself, just run dotnet add package QuestMark and follow the instructions on the readme.

Streamline Your Business Processes with LHKN Software Today

Whether you want to automate time consuming and tedious tasks or modernise some legacy software we'd love to hear from you.

Contact us