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 setLIBGS
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 optionps
is present