"""MkDocs hook that pre-renders LaTeX math at build time using KaTeX. Motivation ---------- The IHUFT proposal contains several hundred numbered equations and inline math expressions. Rendering these at *view* time using MathJax-in-JavaScript imposes a noticeable cost on the reader's browser: the entire JavaScript engine has to parse LaTeX, lay out math, and paint dozens of glyphs before the page becomes responsive. This hook moves that cost to *build* time instead. Each equation is rendered once by KaTeX during ``mkdocs build`` and stored in the output HTML as static markup and standard MathML. The runtime cost in the browser drops to a single download of a CSS stylesheet and the fonts that it references. LaTeX-source preservation ------------------------- KaTeX's ``output: 'htmlAndMathml'`` mode emits both: 1. A semantically-marked-up MathML subtree (for assistive technology, accessibility, and copy-as-MathML), and 2. A visually-styled HTML subtree (for layout in browsers without good MathML rendering). Inside the MathML subtree, KaTeX automatically wraps the original LaTeX source in an ```` element, exactly as the MathML 3 specification recommends. This means the LaTeX source travels with the rendered math forever and is recoverable by any conformant reader — including a tiny "show source" widget that this hook adds for display math (see the surrounding ``
`` block). Markdown integration -------------------- The hook runs at ``on_page_markdown``, *before* Python-Markdown parses the page. It replaces every ``$$…$$`` (display) and ``$…$`` (inline) span with a unique placeholder token, batches all extracted math into a single subprocess call to ``scripts/render_math.js`` (Node + KaTeX), and finally substitutes the rendered HTML back in place of the placeholders. Block math is wrapped in ``
`` so that the surrounding Markdown processor (and the ``md_in_html`` extension) leaves the pre-rendered HTML untouched. Failure mode ------------ If KaTeX raises an error on any individual expression, that expression falls back to a ```` block with the original LaTeX source visible. No build is aborted by a malformed equation. """ from __future__ import annotations import json import os import re import subprocess import sys from pathlib import Path from typing import Any # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- _HOOK_DIR = Path(__file__).resolve().parent _PROJECT_ROOT = _HOOK_DIR.parent _RENDER_SCRIPT = _PROJECT_ROOT / "scripts" / "render_math.js" _NODE_MODULES = _PROJECT_ROOT / "node_tools" / "node_modules" # --------------------------------------------------------------------------- # Regex patterns # --------------------------------------------------------------------------- # Display math: $$…$$ across multiple lines. Greedy with a non-greedy # inner match so that consecutive blocks don't merge. _DISPLAY_RE = re.compile(r"\$\$([\s\S]+?)\$\$", re.MULTILINE) # Inline math: $…$ on a single line, not preceded by another $ (to avoid # eating the second $ of a $$ pair) and not preceded by a backslash (so # that a literal escaped \$ is left alone). The content cannot contain a # newline or a bare $. _INLINE_RE = re.compile(r"(? str: # Preserve the leading newline (or start-of-string) prefix from the # match so block boundaries are unchanged. prefix = match.group(1) full = match.group(0)[len(prefix) :] idx = len(fences) fences.append(full) return f"{prefix}{_FENCE_SENTINEL.format(idx)}" text = _FENCE_RE.sub(_fence_repl, text) def _code_repl(match: re.Match[str]) -> str: idx = len(inline_codes) inline_codes.append(match.group(0)) return _CODE_SENTINEL.format(idx) text = _INLINE_CODE_RE.sub(_code_repl, text) return text, fences, inline_codes def _unshield_code(text: str, fences: list[str], inline_codes: list[str]) -> str: """Reverse of ``_shield_code`` — used after math substitution.""" for idx, original in enumerate(inline_codes): text = text.replace(_CODE_SENTINEL.format(idx), original, 1) for idx, original in enumerate(fences): text = text.replace(_FENCE_SENTINEL.format(idx), original, 1) return text # --------------------------------------------------------------------------- # KaTeX batch call # --------------------------------------------------------------------------- class KatexRenderError(RuntimeError): """Raised when the Node subprocess itself fails (not when a single equation fails to render — those produce a per-item ``ok: false`` entry in the JSON response).""" def _render_batch(items: list[dict[str, Any]]) -> list[dict[str, Any]]: """Invoke ``scripts/render_math.js`` once with the entire batch.""" if not items: return [] if not _RENDER_SCRIPT.exists(): raise KatexRenderError( f"KaTeX render script not found at {_RENDER_SCRIPT}. " "Did you forget to install KaTeX into node_tools/?" ) env = os.environ.copy() env["NODE_PATH"] = str(_NODE_MODULES) proc = subprocess.run( ["node", str(_RENDER_SCRIPT)], input=json.dumps(items), capture_output=True, text=True, env=env, cwd=str(_PROJECT_ROOT), ) if proc.returncode != 0: raise KatexRenderError( f"KaTeX subprocess failed with code {proc.returncode}:\n" f"STDERR:\n{proc.stderr}\n" f"STDOUT (truncated):\n{proc.stdout[:500]}" ) try: return json.loads(proc.stdout) except json.JSONDecodeError as exc: raise KatexRenderError( f"KaTeX subprocess produced invalid JSON: {exc}\n" f"STDOUT (truncated):\n{proc.stdout[:500]}" ) from exc # --------------------------------------------------------------------------- # HTML wrapping # --------------------------------------------------------------------------- def _html_escape(text: str) -> str: return ( text.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) .replace("'", "'") ) # --------------------------------------------------------------------------- # Copy-to-clipboard button for display math # --------------------------------------------------------------------------- # # The button itself carries no text — only an inline SVG clipboard glyph # styled in CSS to match the surrounding text colour by default and the # Material accent colour on hover. A small CSS ``::after`` pseudo-element # (see ``docs/stylesheets/extra.css``) supplies the "Copy LaTeX" / # "Copied!" tooltip and the success-state feedback driven by the # ``.is-copied`` class that ``docs/javascripts/math_copy.js`` toggles on # click. The SVG is the standard "content_copy" Material Design icon. _CLIPBOARD_SVG = ( '' '' '' "" "" ) _CHECK_SVG = ( '' '' "" ) def _wrap_display(katex_html: str, latex_source: str) -> str: """Wrap a rendered display equation with a click-to-copy button. The button reads ``data-latex`` from the wrapping ``
`` (set immediately below) and is driven by ``docs/javascripts/math_copy.js``. The KaTeX MathML continues to carry the LaTeX source in its standards-compliant ```` element, so the source is recoverable even with JavaScript disabled. """ safe_src = _html_escape(latex_source.strip()) return ( f'
' f"{katex_html}" '" "
" ) def _wrap_inline(katex_html: str, latex_source: str) -> str: """Wrap a rendered inline equation. Inline math does not get a visible copy button — placing one beside every ``$…$`` would shred prose flow. Instead, the ``data-latex`` attribute feeds a CSS-only ``::after`` hover tooltip (see ``docs/stylesheets/extra.css``) that reveals the LaTeX source immediately on mouseover, without the 1-1.5 s delay that browsers impose on the native ``title=`` attribute. The MathML ```` element continues to carry the source for programmatic access by screen readers and scripts. """ safe_src = _html_escape(latex_source.strip()) return f'{katex_html}' def _fallback_display(latex_source: str, error: str) -> str: safe_src = _html_escape(latex_source.strip()) safe_err = _html_escape(error) return ( '
' f'
{safe_src}
' "
" ) def _fallback_inline(latex_source: str, error: str) -> str: safe_src = _html_escape(latex_source.strip()) safe_err = _html_escape(error) return ( f'${safe_src}$' ) # --------------------------------------------------------------------------- # Main hook # --------------------------------------------------------------------------- def on_page_markdown(markdown, page, config, files): # noqa: ARG001 (mkdocs API) if "$" not in markdown: return markdown # 1. Shield fenced and inline code so we don't replace math inside them. shielded, fences, codes = _shield_code(markdown) # 2. Extract every math span, replacing it with a sentinel. items: list[dict[str, Any]] = [] def _grab_display(match: re.Match[str]) -> str: idx = len(items) items.append({"tex": match.group(1).strip(), "display": True}) return _MATH_SENTINEL.format(idx) def _grab_inline(match: re.Match[str]) -> str: idx = len(items) items.append({"tex": match.group(1), "display": False}) return _MATH_SENTINEL.format(idx) shielded = _DISPLAY_RE.sub(_grab_display, shielded) shielded = _INLINE_RE.sub(_grab_inline, shielded) if not items: # Nothing to render — restore code spans and return unchanged. return _unshield_code(shielded, fences, codes) # 3. Render the batch in a single KaTeX call. try: results = _render_batch(items) except KatexRenderError as exc: print(f"math_prerender: {exc}", file=sys.stderr) raise # 4. Substitute rendered HTML back in place of the sentinels. for idx, (item, result) in enumerate(zip(items, results, strict=True)): sentinel = _MATH_SENTINEL.format(idx) if result.get("ok"): html = result["html"] wrapper = _wrap_display if item["display"] else _wrap_inline replacement = wrapper(html, item["tex"]) else: err = result.get("error", "") print( f"math_prerender: KaTeX failed on equation #{idx} " f"({item['tex'][:60]!r}): {err}", file=sys.stderr, ) fallback = _fallback_display if item["display"] else _fallback_inline replacement = fallback(item["tex"], err) # Display math should sit on its own line so Markdown treats the # surrounding HTML as a block element. if item["display"]: replacement = "\n\n" + replacement + "\n\n" shielded = shielded.replace(sentinel, replacement, 1) # 5. Restore code spans. return _unshield_code(shielded, fences, codes)