Native LaTeX for Nikola blogs

Whenever I need a static website or blog for a hobby project, I typically resort to Nikola. It is based on python and supports various formats (markdown, reST, jupyter, ...). I typically build it with GitHub Actions and host it on GitHub pages for free. In fact, this very website it a good example of such a setup.

For an unrelated side project, my pages had to render advanced expressions. Basic LaTeX is handled quite well by MathJax or KaTeX, but neither supports the feature from a custom package I needed. After some trial and error, I ended up writing my own locallatex plugin for Nikola. It allows me to embed actual LaTeX code in my markdown files like so:

```locallatex
\obscureLatexCommandHere
```

This code will be wrapped in a basic document template, compiled to DVI by a local LaTeX executable and then converted to SVG by dvisvgm. The final HTML page generated by this markdown file will simply reference that SVG file.

Only use this as a last resort. It works, but it is slow to build and the output does not blend in with the rest of the document as nicely as native KaTeX output.

Here is the code for the plugin:

import os
import subprocess
from logging import DEBUG, Logger
from typing import List, Optional

from markdown.core import Markdown
from markdown.extensions import Extension
from markdown.extensions.fenced_code import FencedBlockPreprocessor
from nikola.plugin_categories import MarkdownExtension

LATEX_OUTPUT_NUM_LINES = 10
DEFAULT_LOCALLATEX_DVISVGM_CMD = ['dvisvgm', '--no-style', '--font-format=woff,autohint']
DEFAULT_LOCALLATEX_LATEX_CMD = ['latex', '-interaction', 'nonstopmode', '-halt-on-error']
DEFAULT_LOCALLATEX_TEMPLATE = r"""
    \documentclass[12pt,preview]{standalone}
    \usepackage{amsmath}
    \usepackage{amsfonts}
    \usepackage{amssymb}
    \begin{document}
    \begin{preview}
    {{ code }}
    \end{preview}
    \end{document}
"""
ERROR_SVG = r"""
<?xml version='1.0' encoding='UTF-8'?>
<svg version='1.1' xmlns='http://www.w3.org/2000/svg' width='110' height='25' viewBox='0 0 110 25'>
<text x='0' y='20' fill='red'>INVALID LATEX</text>
</svg>
"""


class LaTeX2SVG:

    def __init__(
        self,
        tmp_dir: str,
        template: str,
        latex_cmd: List[str],
        dvisvgm_cmd: List[str],
        logger: Logger,
    ):
        self.tmp_dir = tmp_dir
        self.template = template
        self.latex_cmd = latex_cmd
        self.dvisvgm_cmd = dvisvgm_cmd
        self.logger = logger

    def convert(self, latex):
        self.logger.debug(f'LaTeX -> SVG in {self.tmp_dir}: "{latex}"')
        document = self.template.replace('{{ code }}', latex)

        os.makedirs(self.tmp_dir, exist_ok=True)
        with open(os.path.join(self.tmp_dir, 'code.tex'), 'w') as f:
            f.write(document)

        ret = subprocess.run(self.latex_cmd + ['code.tex'],
                             stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                             cwd=self.tmp_dir, encoding='cp437')
        self.logger.debug(f"latex stdout: {ret.stdout}")
        if ret.returncode != 0:
            last_lines = '\n'.join(ret.stdout.split('\n')[-LATEX_OUTPUT_NUM_LINES:])
            self.logger.warn(f"latex error for '{latex}':\n...\n{last_lines}")
            return ERROR_SVG

        ret = subprocess.run(self.dvisvgm_cmd + ['code.dvi'],
                             stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                             cwd=self.tmp_dir, encoding='utf-8')
        self.logger.debug(f"dvisvgm stdout: {ret.stdout}")
        self.logger.debug(f"dvisvgm stderr: {ret.stderr}")
        ret.check_returncode()

        with open(os.path.join(self.tmp_dir, 'code.svg'), 'r', encoding="utf-8") as f:
            return f.read()


class LocalLatexMarkdownProcessor(FencedBlockPreprocessor):
    def __init__(self, md: Markdown, config: dict, logger: Logger, latex2svg: LaTeX2SVG):
        super().__init__(md, config)
        self._logger = logger
        self.latex2svg = latex2svg

    def run(self, lines):
        def replace(match):
            lang = match.group('lang')
            if lang != 'locallatex':
                # the normal FencedBlockPreprocessor extension will process it later
                return match.group()

            latex = match.group('code').strip()
            svg = self.latex2svg.convert(latex)
            html = f'<div class="locallatex">{svg}</div>'
            return self.md.htmlStash.store(html)

        return self.FENCED_BLOCK_RE.sub(replace, '\n'.join(lines)).split('\n')


class LocalLatexMarkdownExtension(MarkdownExtension, Extension):
    name = "locallatex"

    def extendMarkdown(self, md: Markdown):
        # self.logger.setLevel(DEBUG)

        dvisvgm_cmd = self.site.config.get('LOCALLATEX_DVISVGM_CMD', DEFAULT_LOCALLATEX_DVISVGM_CMD)
        latex_cmd = self.site.config.get('LOCALLATEX_LATEX_CMD', DEFAULT_LOCALLATEX_LATEX_CMD)
        template = self.site.config.get('LOCALLATEX_TEMPLATE', DEFAULT_LOCALLATEX_TEMPLATE)
        tmp_dir = self.site.config.get('LOCALLATEX_TEMP_DIR', os.path.abspath("cache/latex_tmp"))
        latex2svg = LaTeX2SVG(
            dvisvgm_cmd=dvisvgm_cmd,
            latex_cmd=latex_cmd,
            template=template,
            logger=self.logger,
            tmp_dir=tmp_dir,
        )
        processor = LocalLatexMarkdownProcessor(md, self.getConfigs(), self.logger, latex2svg)
        md.preprocessors.register(processor, 'fenced_code_block_latex', 27)
        md.registerExtension(self)

And here is an example GitHub Action that supports the plugin. It first installs ghostscript and texlive. Then it sets up a python environment for Nikola and runs nikola build. Finally it deploys everything to GitHub Pages.

name: CICD

on:
  push:
    branches: [ master ]

  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - run: |
        sudo apt update && \
        sudo apt install -y --no-install-recommends ghostscript && \
        sudo apt clean
    - run: |
        mkdir /tmp/install-tl-unx && \
        curl -L ftp://tug.org/historic/systems/texlive/2022/install-tl-unx.tar.gz | \
        tar -xz -C /tmp/install-tl-unx --strip-components=1 && \
        printf "%s\n" \
          "selected_scheme scheme-basic" \
          "tlpdbopt_install_docfiles 0" \
          "tlpdbopt_install_srcfiles 0" \
          > /tmp/install-tl-unx/texlive.profile && \
        sudo /tmp/install-tl-unx/install-tl -profile=/tmp/install-tl-unx/texlive.profile && \
        rm -rf /tmp/install-tl-unx && \
        echo "/usr/local/texlive/2022/bin/x86_64-linux" >> $GITHUB_PATH
    - run: |
        sudo /usr/local/texlive/2022/bin/x86_64-linux/tlmgr update --self
        sudo /usr/local/texlive/2022/bin/x86_64-linux/tlmgr install dvisvgm xkeyval standalone preview simplekv
        latex --version
        dvisvgm -l
    - name: Check out
      uses: actions/checkout@v2
    - uses: actions/setup-python@v3
      with:
        python-version: '3.10'
        cache: 'pip'
    - run: |
        pip install -r requirements.txt && \
        nikola build
    - uses: actions/upload-pages-artifact@v1
      with:
        path: ./output
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v1

Some gotcha's:

  • ghostscript must have the same architecure (x86 or x64) as latex
  • you may have to update your PATH or set LIBGS for this to work locally
    • PATH must point to the ghostscript bin folder
    • LIBGS must point to the ghostscript library file (not folder)
  • run dvisvgm -l and check if option ps is present