Hakyll + TikZ

Written on January 9, 2019
Tags: coding, hakyll, blog

I’ve made a minimal working example of this code which you may find instructive.

The Problem

I recently needed to put commuting diagrams in my posts, and the easiest way of doing this, is with TikZ. However, unlike with plain \(\LaTeX\) equations, there’s no built in support in Pandoc for embedding TikZ in markdown. Hakyll doesn’t have support for this either. There is the package latex-formulae-hakyll, which does a pretty good job, but for various reasons is not exactly what I wanted. There are several other similar tools scattered around github, including cats, but they’re all bloated and don’t work very well. So I decided to make my own. It was a nightmare of a project, but mostly that’s because I didn’t know what I was doing; the end result is small, clean, and pretty.

The Solution

site.hs

At it’s core, the solution is a single function, which I’ll explain how to use and then how it works. In site.hs, add the following function.

tikzFilter :: Block -> Compiler Block
tikzFilter (CodeBlock (id, "tikzpicture":extraClasses, namevals) contents) =
  (imageBlock . ("data:image/svg+xml;utf8," ++) . URI.encode . filter (/= '\n') . itemBody <$>) $
    makeItem contents
     >>= loadAndApplyTemplate (fromFilePath "templates/tikz.tex") (bodyField "body")
     >>= withItemBody (return . pack 
                       >=> unixFilterLBS "rubber-pipe" ["--pdf"] 
                       >=> unixFilterLBS "pdftocairo" ["-svg", "-", "-"] 
                       >=> return . unpack)
  where imageBlock fname = Para [Image (id, "tikzpicture":extraClasses, namevals) [] (fname, "")]
tikzFilter x = return x

First of all, you’re going to need rubber-pipe and pdftocairo installed on your system. The former comes from the rubber package, and the latter from poppler_utils.

Next you’ll need a few Haskell imports at the top of site.hs.

import Text.Pandoc.Definition
import Text.Pandoc.Walk (walk, walkM)
import Control.Monad ((>=>))
import Hakyll
import Hakyll.Core.Compiler
import Data.ByteString.Lazy.Char8 (pack, unpack)
import qualified Network.URI.Encode as URI (encode)

Next you’ll want to declare an alias for your pandoc compiler.

myPandocCompiler = 
  pandocCompilerWithTransformM pandocReadOpts pandocWriteOpts 
    $ walkM tikzFilter

You’ll need to define pandocReadOpts and pandocWriteOpts for yourself; I don’t know what works for you. You can always use defaultHakyllReaderOptions and defaultHakyllWriterOptions from Hakyll.

Now you can use myPandocCompiler in the same way you would use pandocCompiler from Hakyll. I’m not going to go into that usage here, check out the hakyll tutorials for the details.

templates/tikz.tex

You’ll need to write a template in templates/tikz.tex. I have

\documentclass{article}
\usepackage[pdftex,active,tightpage]{preview}
\usepackage{amsmath}
\usepackage{tikz}
\usetikzlibrary{matrix}
\usepackage{xcolor}
\definecolor{fg}{HTML}{839496}

\begin{document}
\color{fg}
\begin{preview}
  \begin{tikzpicture}[auto]
    $body$
  \end{tikzpicture}
\end{preview}
\end{document}

Which sets up a standalone tikz environment and sets the color to the foreground color on my website.

css/default.css

The tikz diagrams will be left with the class .tikzpicture, so you can set up a css rule for that class, for example I center it and give it a height of 200px. The height is important because the resulting image is an svg which won’t have a standard size.

.tikzpicture {
  height: 200px;
  display: block;
  margin-right: auto;
  margin-left: auto;
}

some/page.md

So now you have some page being compiled with myPandocCompiler, how do you insert a tikz diagram? It’s simple, write a code block with type tikzpicture, as follows:

```tikzpicture
\node (X) {$X$};
\node (Y) [below of=X, left of=X] {$Y$};
\node (Y') [below of=X, right of=X] {$Y^\prime$};
\draw[->] (Y) to node {$i$} (X);
\draw[->] (Y') to node [swap] {$i^\prime$} (X);
\draw[transform canvas={yshift=0.5ex}, ->] (Y) to node {$\alpha$} (Y');
\draw[transform canvas={yshift=-0.5ex}, ->] (Y') to node {$\alpha^{-1}$} (Y);
```

And it will be replaced with the output:

If you want to include tikzpicture options, you can put the code in a scope environment as follows:

```{.tikzpicture style="height: 100px;"}
\begin{scope}[node distance=1.5cm]
  \node (X) {$X$};
  \node (Y) [below of=X, left of=X] {$Y$};
  \node (Y') [below of=X, right of=X] {$Y^\prime$};
  \draw[->] (Y) to node {$i$} (X);
  \draw[->] (Y') to node [swap] {$i^\prime$} (X);
  \draw[transform canvas={yshift=0.5ex}, ->] (Y) to node {$\alpha$} (Y');
  \draw[transform canvas={yshift=-0.5ex}, ->] (Y') to node {$\alpha^{-1}$} (Y);
\end{scope}
```

As demonstrated in the above example, you can add other attributes to the tikzpictures and they will apply to the resulting image.

How does it work?

So lets go back and understand what’s happening. Pandoc transforms markdown to html by first passing through an internal Pandoc representation. A Pandoc filter is a function on the Pandoc representation that gets applied in between reading the html and writing the html, and that’s where our function operates.

There are two strange things about the type signature of our function

tikzFilter :: Block -> Compiler Block

We would expect the function to be of type Pandoc -> Pandoc, so why is it acting on Blocks, and why is it returning something wrapped in the Compiler monad?

Pandoc has a handy function walk, which uses typeclass magic to allow us to specify a function that acts on a particular substructure (in this case Block) of the Pandoc data, and then generalize it to a function on the whole Pandoc structure which just applies it to every instance of that substructure within it.

walkM and pandocCompilerWithTransformM allow us to specify such an action monadically. This is helpful because we want to compile the tikz sections before we know what pandoc to output.

Now we get to the function declaration. It has two parts; if we receive a code block with the class “tikzpicture”, which corresponds to blocks starting ```tikzpicture, we will change it into an image. Otherwise, we will leave it untouched.

tikzFilter (CodeBlock (id, "tikzpicture":extraClasses, namevals) contents) =
  ...
tikzFilter x = return x

The rest of the code performs this substitution. The key here is that images can be placed inline into markdown and html using data URIs. There is no need to generate a separate image file. The first line expects to get the svg image data in the body of a Hakyll Item, wrapped in the Compiler monad. It strips newlines, and encodes the text so that it can be safely placed into an html URI, it adds the header that specifies it as a data URI, and it wraps it in a Pandoc image block, repackaging all of the extra html tags that were attached to the code block.

  (imageBlock . ("data:image/svg+xml;utf8," ++) . URI.encode . filter (/= '\n') . itemBody <$>) $
  ...
  where imageBlock fname = Para [Image (id, "tikzpicture":extraClasses, namevals) [] (fname, "")]

So we make an Item with the tikz code as it’s body,

    makeItem contents

and then we wrap it in our latex template

     >>= loadAndApplyTemplate (fromFilePath "templates/tikz.tex") (bodyField "body")

and we take the item body, and pack the data into a ByteString, and pass it through rubber-pipe to make a pdf output stream, and pass it through pdftocairo to turn that pdf into an svg, then we unpack it back into text.

     >>= withItemBody (return . pack 
                       >=> unixFilterLBS "rubber-pipe" ["--pdf"] 
                       >=> unixFilterLBS "pdftocairo" ["-svg", "-", "-"] 
                       >=> return . unpack)

Site proudly generated by Hakyll