…`` 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 ``{safe_src}'
"${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", "