Generating PDF from Markdown with Go

Generating PDF from Markdown with Go

In this quick-n-dirty article, we will see how to generate a PDF from a user-submitted markdown with Go. We will build a simple service using the standard library's net/http for the backend and EasyMDE, a browser editor, for handling markdown on the front-end.

Markdown is a popular authoring format and has gained wide adoption in many different places. Markdown is generally converted to HTML, but there do exist tools like Pandoc which can additionally convert markdown to different formats like .docx documents, PDF etc...

In the Go ecosystem, a popular library for working with Markdown is goldmark - we which we will use to process user-submitted markdown text and render it to a PDF file. You can imagine this being used for basic authoring of content including letters, articles, simple newsletters, basic reports etc...

Markdown PDF generator backend

For us to process Markdown, we will rely on the popular library goldmark which has some community-built extensions, the one we are particularly interested in is the goldmark-pdf library which will allow us to render Goldmark's Markdown AST into PDF. Firstly, we will create a bare-bones (i.e. not production-grade) web server using the standard library's net/http.

The complete server code is shown below:

package main

import (
    "bytes"
    "context"
    "encoding/base64"
    "fmt"
    "io/ioutil"
    "net/http"
    "os"
    "text/template"

    pdf "github.com/stephenafamo/goldmark-pdf"
    "github.com/yuin/goldmark"
)

func main() {
    markdown := goldmark.New(
        goldmark.WithRenderer(
            pdf.New(
                pdf.WithTraceWriter(os.Stdout),
                pdf.WithContext(context.Background()),
                pdf.WithImageFS(os.DirFS(".")),
                pdf.WithHeadingFont(pdf.GetTextFont("Arial", pdf.FontCourier)),
                pdf.WithBodyFont(pdf.GetTextFont("Arial", pdf.FontCourier)),
                pdf.WithCodeFont(pdf.GetCodeFont("Arial", pdf.FontCourier)),
            ),
        ),
    )

    mux := http.NewServeMux()

    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Add("Cache-Control", "cache")
        indexHtmlTemplate := template.Must(template.ParseFiles("index.html"))
        indexHtmlTemplate.ExecuteTemplate(w, "index.html", nil)
    })

    mux.HandleFunc("/generate", func(w http.ResponseWriter, r *http.Request) {
        source, err := ioutil.ReadAll(r.Body)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        var buf bytes.Buffer
        if err := markdown.Convert([]byte(source), &buf); err != nil {
            http.Error(w, "failed to generate pdf", http.StatusInternalServerError)
            return
        }
        base64Encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
        dataURL := fmt.Sprintf("data:application/octet-stream;base64,%s", base64Encoded)
        w.Write([]byte(dataURL))
    })

    http.ListenAndServe(":8001", mux)
}

The frontend - adding an in-browser editor

We are going to complete our simple service by adding a frontend to it which is going to be a simple Markdown Editor with a button for Generating and Downloading the PDF. We will use EasyMDE for this - I went through several different options for an editor and at the moment I like EasyMDE, especially for how easy it is to add extra functionality to the Editor. Will will be extending EasyMDE to enable downloading the generated PDF by adding a button with a PDF icon for the user to generate the PDF.

The complete code for the index.html page is shown below

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Markdown to PDF</title>
    <base href="/">

    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" rel="stylesheet"
        integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
    <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/styles/default.min.css">

    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"
        integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.bundle.min.js"
        integrity="sha384-pjaaA8dDz/5BgdFUPX6M/9SUZv4d12SUPF0axWc+VRZkx5xU3daN+lYb49+Ax+Tl"
        crossorigin="anonymous"></script>
    <script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/highlight.min.js"></script>
    <script>hljs.initHighlightingOnLoad();</script>

    <script defer src="https://use.fontawesome.com/releases/v5.4.1/js/all.js"
        integrity="sha384-L469/ELG4Bg9sDQbl0hvjMq8pOcqFgkSpwhwnslzvVVGpDjYJ6wJJyYjvG3u8XW7"
        crossorigin="anonymous"></script>

    <script src="https://unpkg.com/axios@0.26.0/dist/axios.min.js"></script>

    <link rel="stylesheet" href="https://unpkg.com/easymde@2.18.0/dist/easymde.min.css">
    <script src="https://unpkg.com/easymde@2.18.0/dist/easymde.min.js"></script>
</head>

<body>
    <div class="content">

        <div id="app">
            <textarea id="editor"></textarea>
            <a id="downloadEl" href="#" style="display: none;" download="Document.pdf">Download PDF</a>
        </div>
    </div>
    <script type="text/javascript">
        let downloadEl = document.getElementById('downloadEl')
        new EasyMDE({
            autoDownloadFontAwesome: false,
            toolbar: [
            "bold", "italic", "heading", "|", "quote", 'strikethrough', 'code', 'heading','|', 'undo','redo'
            ,'|', { // Separator
                name: "generate-pdf",
                action: (editor) => {
                    axios.post("/generate", editor.value())
                    .then(response => {
                        let pdfDataURL = response.data
                        if (pdfDataURL.indexOf("base64") > 0) {
                            downloadEl.setAttribute('href', pdfDataURL)
                            downloadEl.click()
                        }
                    })
                },
                className: "fa fa-file-pdf",
                title: "Download PDF",
            }, '|', {
                name: "link",
                action: 'https://blog.nndi.cloud',
                className: "fa fa-globe",
                title: "Go to the NNDI Blog"
            }],
            element: document.getElementById('editor'),
            initialValue: '## Welcome to Markdown to PDF using Go.\nType some markdown in here and then click the PDF button to render the markdown to PDF'
        });
    </script>
</body>

</html>

With those two bits of code done, we can now build and run our service. Make sure you copy the Go code into a file named main.go and the HTML code in a file named index.html in the same directory - you can then run the following commands:

$ go mod init markdown-pdf
$ go mod tidy
$ go run main.go

The last command should start a server on localhost:8001 visit that in your browser and try it out.

Thanks for reading.