maths in excalidraw

latex
quarto

from LaTeX (quarto) course notes to virtual whiteboard

Author

baptiste

Published

January 29, 2023

Excalidraw is a fun online sketchboard with a simple interface, no sign-in, and collaborative features.

With the ahem enthusiastic rise of online teaching opportunities, it can be a very useful tool to replace the physical whiteboard. It can also be used in new ways, one of which could be suggesting to the students that they collaboratively sketch their own artwork / map / story board to illustrate the course. Think of RSA Animate or minutephysics, etc.1

The current version of Excalidraw does not support equations, unfortunately. However, thanks to the awesome work of a contributor, there is a perfectly usable development branch deployed at math.preview.excalidraw.com that implements a conversion via Mathjax, seamlessly displaying LaTeX equations as SVG, which can be scaled, rotated, coloured, etc. The equations can be edited by double-clicking, as with normal text.

I’ll describe below the ridiculously hacky workflow I’ve developed to automate the import of a large number of equations – basically every equation in my lecture notes – so that I could provide the class with a bunch of raw material to reorganise/edit/adapt in a collaborative illustration (or just individually, as a mind map or cheat sheet).

Step one: extracting all equations from rmarkdown/quarto documents

I make heavy use of macros in my notes, for things like

% blablabla aliases 
\newcommand{\Grad}{\nabla}
\newcommand{\Div}{\nabla\cdot}
\newcommand{\Curl}{\nabla\times}

$$\begin{aligned}
\Div\vecE  &=\rho / \epsnot\\
\Div\vecB  &=0 \\
\Curl\vecE &=-\partial_t \vecB \\
\Curl\vecB &=\mu_0 \vecJ 
\end{aligned}
$$

which, if one were to copy and paste manually into Excalidraw, would require adding the macros alongside each equation, or, alternatively, running the .tex file first through de-macro. Luckily, this step isn’t even necessary when working with pandoc, since it takes care of expanding all macros automatically.

To extract all equations from the document, I use the following custom lua filter,

_ENV = pandoc
local math_elements = List {}

return {
   -- first document pass
   { Math = function(m)

      local comment = "math start"
      math_elements:insert(comment)
      math_elements:insert(m)

   end },
   -- second document pass
   { Pandoc = function(_) return Pandoc(math_elements:map(Plain)) end },
}

This produces an intermediate .tex file with just the equations, separated with the line "math start".

Step 2: wrap the output to JSON

Excalidraw’s scenes are stored as JSON; a quick and dirty way to produce them with R is to use the {minixcali} package, which wraps the basic elements (rect, text, etc.) using an object-oriented approach stolen from {minixml} via the {R6} package, and convert the nested structure into JSON via {jsonlite}.

A minimal document is produced as:

library(minixcali)

d <- Excali_doc()
shape <- xkd_rectangle(width = 300, height=200,
                       fillStyle = 'hachure', roughness = 2)
d$add(shape)
d$export(file='output.json')

For math elements, the syntax is simply

xkd_math(text = "\\alpha\\approx \\frac\\pi 2")

The only thing left to do2 is to grab each equation from the previous step, and pass it to this function to generate an excalidraw scene.

Step 3: interlude

I lied. Well, kind of. One piece of information that we’re missing is the size of those equations. We can ignore it, and leave a default value of 100x100px which will simply crop the rendered SVG in Excalidraw; this isn’t too problematic because one can simply double-click on it and re-render it, which triggers (I assume) an auto-sizing function to correct the bounding box. But with dozens of equations, this isn’t ideal.

How do we measure the bounding boxes? Ideally using Mathjax itself, or Katex, but I don’t know enough javascript. From an R perspective, tikzDevice could be used to call tex and return the bounding box information, but that’s quite slow and overkill.

Since we’ve already processed the quarto file, we might as well use that to size the equations. Perhaps the easiest way, short of knowing arcane tex magic, is to use the preview package to extract all equations and turn them into a pdf with one equation per page. Somehow, the bounding boxes are not quite right, so we can post-process the pdf file with pdfcrop.

I’m sure there’s a way with pdfinfo or similar to extract the size of each page, but I decided to convert the equations into standalone SVG images instead, using pdf2svg. Producing those SVG images has the advantage that I can also import them directly into the standard version of Excalidraw, if one wanted to use that instead. It’s an alternative option (and much easier), although in this case the equations cannot be edited online.

Extracting the size information from SVG files is easy – I think it would make sense to to use sed or awk etc. to extract it from each file,

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="176pt" height="56pt" viewBox="0 0 176 56" version="1.1">

Of course, I went with a more tortuous route, using {minisvg} to parse the file and get the attributes ¯_(ツ)_/¯3. (in my defence, 20 seconds of googling did not return a usable awk incantation).

svg <- minisvg::parse_svg_doc('tmp.tex')
as.numeric(gsub("pt","",c(svg$attribs$width,  svg$attribs$height)))

With the width and height at our disposal, we can now finish the job and produce the excalidraw scene:

{
  "type": "excalidraw",
  "version": 2,
  "source": "minixcali",
  "elements": [
    {
      "originalText": "\\begin{aligned} \\nabla\\cdot\\mathbf{E}& =\\rho / \\varepsilon_0\\qquad\\text{(Gauß)}\\\\ \\nabla\\cdot\\mathbf{B}&=0\\qquad\\text{(no magnetic monopoles)}\\\\ \\nabla\\times\\mathbf{E}&=-\\partial_t \\mathbf{B}\\qquad\\text{(Faraday)}\\\\ \\nabla\\times\\mathbf{B}&=\\mu_0 \\mathbf{J}\\qquad\\text{(Ampere)} \\end{aligned}",
      "type": "text",
      "x": 0,
      "y": 0,
      "width": 516.2667,
      "height": 164.2667,
      "angle": 0,
      "text": "\\begin{aligned} \\nabla\\cdot\\mathbf{E}& =\\rho / \\varepsilon_0\\qquad\\text{(Gauß)}\\\\ \\nabla\\cdot\\mathbf{B}&=0\\qquad\\text{(no magnetic monopoles)}\\\\ \\nabla\\times\\mathbf{E}&=-\\partial_t \\mathbf{B}\\qquad\\text{(Faraday)}\\\\ \\nabla\\times\\mathbf{B}&=\\mu_0 \\mathbf{J}\\qquad\\text{(Ampere)} \\end{aligned}",
      "strokeColor": "#000000",
      "backgroundColor": "#868e96",
      "fillStyle": "solid",
      "strokeWidth": 2,
      "strokeStyle": "solid",
      "roughness": 0,
      "opacity": 100,
      "groupIds": [],
      "strokeSharpness": "sharp",
      "boundElementIds": null,
      "fontSize": 24,
      "fontFamily": 1,
      "textAlign": "left",
      "verticalAlign": "top",
      "baseline": 24,
      "version": 300,
      "versionNonce": 12345,
      "isDeleted": false,
      "subtype": "math",
      "containerId": null,
      "customData": {
        "useTex": true,
        "mathOnly": true,
        "ariaLabel": "\\begin{aligned} \\nabla\\cdot\\mathbf{E}& =\\rho / \\varepsilon_0\\qquad\\text{(Gauß)}\\\\ \\nabla\\cdot\\mathbf{B}&=0\\qquad\\text{(no magnetic monopoles)}\\\\ \\nabla\\times\\mathbf{E}&=-\\partial_t \\mathbf{B}\\qquad\\text{(Faraday)}\\\\ \\nabla\\times\\mathbf{B}&=\\mu_0 \\mathbf{J}\\qquad\\text{(Ampere)} \\end{aligned}"
      },
      "id": "b6c3e606c6b4a50b328f45864707cb9c",
      "seed": 6984049
    }
  ],
  "appState": {
    "viewBackgroundColor": "#ffffff",
    "gridSize": null
  },
  "files": []
}

and open it online:

Final step: automate the process with quarto projects

Since I typically have multiple .qmd files to process, I decided to use a post-render script that automates the steps described above. It’s a bit messy, but the logic is as follows:

  1. set up a quarto project with a post-render script

  2. adjust the custom template (normally for beamer output) to use the preview package instead,

    \usepackage[active,tightpage,displaymath,textmath]{preview}
  3. quarto render will then produce, for each qmd file, a pdf with one equation per page

  4. The post-render script will then run pandoc once more on the intermediate .tex file (keep-tex: true), using the Lua filter to extract all equations into a new intermediate .tex file, say maxwell-math.tex

  5. the pdf file is converted into SVG images, from which the bounding box information is extracted

  6. the latex equations and size information are combined to produce the JSON description of the scene.

A minimal example of this workflow is at github.com/baptiste/quarto-excalidraw.

Addendum

This is obviously a very hacky process, but I think there’s a clear path to make it more widely usable and robust: instead of messing about with intermediate tex files, pandoc could directly write the final JSON of the scene with a custom writer. Feel free to reach out if you’re interested in exploring this.

Footnotes

  1. of course, ideally we would also be able to make the maths look hand-drawn…↩︎

  2. you poor reader: it is not the only thing↩︎

  3. oh look, I forgot to escape a backslash and lost an arm. I guess you can never use enough↩︎

Reuse