0ea0f32711
- Fix vulture path from src/cleveragents to src/cleveractors - Add B905 strict=True to zip() in math_prerender.py - Fix B026 star-arg-after-kwarg in coverage_report session - Ignore vulture_whitelist.py in ruff - End-of-file newlines and trailing whitespace fixes from hooks
359 lines
14 KiB
Python
359 lines
14 KiB
Python
"""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 ``<annotation encoding="application/x-tex">…</annotation>``
|
|
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 ``<details>`` 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 ``<div class="math math-display" markdown="0">…</div>``
|
|
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 ``<code>…</code>`` 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"(?<![\\$])\$(?!\$)([^\n$]+?)(?<!\\)\$(?!\$)")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fenced-code shielding
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# We must not touch ``$…$`` that appears inside a fenced code block or an
|
|
# inline-code span — that would corrupt user-visible code samples. The
|
|
# strategy is to extract every fenced block and every inline-code span,
|
|
# replace each with an opaque sentinel, run the math-replacement pass, then
|
|
# put the code blocks back. The sentinels are designed to be inert to the
|
|
# math regexes and to Markdown.
|
|
|
|
_FENCE_RE = re.compile(
|
|
r"(^|\n)([ \t]{0,3})(```|~~~)(.*?)(\n\2\3[ \t]*(?:\n|$))", re.DOTALL
|
|
)
|
|
_INLINE_CODE_RE = re.compile(r"(`+)([^`\n]*?)\1")
|
|
|
|
_FENCE_SENTINEL = "\u241f__IHUFT_FENCE_{}__\u241f"
|
|
_CODE_SENTINEL = "\u241f__IHUFT_CODE_{}__\u241f"
|
|
_MATH_SENTINEL = "\u241f__IHUFT_MATH_{}__\u241f"
|
|
|
|
|
|
def _shield_code(text: str):
|
|
"""Replace fenced/inline code spans with sentinels so math regexes leave
|
|
them alone. Returns ``(shielded_text, list_of_originals_for_fences,
|
|
list_of_originals_for_inline_code)``."""
|
|
fences: list[str] = []
|
|
inline_codes: list[str] = []
|
|
|
|
def _fence_repl(match: re.Match[str]) -> 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 = (
|
|
'<svg class="math-copy__icon" '
|
|
'xmlns="http://www.w3.org/2000/svg" '
|
|
'viewBox="0 0 24 24" width="16" height="16" '
|
|
'fill="none" stroke="currentColor" stroke-width="2" '
|
|
'stroke-linecap="round" stroke-linejoin="round" '
|
|
'aria-hidden="true" focusable="false">'
|
|
'<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>'
|
|
'<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1">'
|
|
"</path>"
|
|
"</svg>"
|
|
)
|
|
|
|
_CHECK_SVG = (
|
|
'<svg class="math-copy__check" '
|
|
'xmlns="http://www.w3.org/2000/svg" '
|
|
'viewBox="0 0 24 24" width="16" height="16" '
|
|
'fill="none" stroke="currentColor" stroke-width="2.5" '
|
|
'stroke-linecap="round" stroke-linejoin="round" '
|
|
'aria-hidden="true" focusable="false">'
|
|
'<polyline points="20 6 9 17 4 12"></polyline>'
|
|
"</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 ``<div>`` (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 ``<annotation encoding="application/x-tex">``
|
|
element, so the source is recoverable even with JavaScript disabled.
|
|
"""
|
|
safe_src = _html_escape(latex_source.strip())
|
|
return (
|
|
f'<div class="math math-display" markdown="0" data-latex="{safe_src}">'
|
|
f"{katex_html}"
|
|
'<button class="math-copy" type="button" '
|
|
'aria-label="Copy LaTeX source for this equation" '
|
|
'data-tooltip="Copy LaTeX">'
|
|
f"{_CLIPBOARD_SVG}{_CHECK_SVG}"
|
|
"</button>"
|
|
"</div>"
|
|
)
|
|
|
|
|
|
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
|
|
``<annotation>`` element continues to carry the source for
|
|
programmatic access by screen readers and scripts.
|
|
"""
|
|
safe_src = _html_escape(latex_source.strip())
|
|
return f'<span class="math math-inline" data-latex="{safe_src}">{katex_html}</span>'
|
|
|
|
|
|
def _fallback_display(latex_source: str, error: str) -> str:
|
|
safe_src = _html_escape(latex_source.strip())
|
|
safe_err = _html_escape(error)
|
|
return (
|
|
'<div class="math math-display math-fallback" markdown="0" '
|
|
f'title="KaTeX error: {safe_err}">'
|
|
f'<pre><code class="language-latex">{safe_src}</code></pre>'
|
|
"</div>"
|
|
)
|
|
|
|
|
|
def _fallback_inline(latex_source: str, error: str) -> str:
|
|
safe_src = _html_escape(latex_source.strip())
|
|
safe_err = _html_escape(error)
|
|
return (
|
|
f'<code class="math math-inline math-fallback" '
|
|
f'title="KaTeX error: {safe_err}">${safe_src}$</code>'
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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", "<unknown KaTeX 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)
|