133 lines
4.3 KiB
Python
133 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
render_mermaid.py
|
|
=================
|
|
扫描合并后的 markdown 中的 ```mermaid 代码块,用 mmdc(@mermaid-js/mermaid-cli)
|
|
渲染为 PNG,并把这些块替换为可被 pandoc 直接吃下的 LaTeX includegraphics 命令。
|
|
|
|
用法(被 build_book.sh 调用):
|
|
python3 render_mermaid.py <input_md> <out_md> <fig_dir>
|
|
|
|
特性:
|
|
- 内容 hash 缓存,二次构建不重复渲染
|
|
- 单个块渲染失败不会中断整体构建,会降级为 verbatim 源码 + 警告
|
|
- 详细错误信息打到 stderr 方便调试
|
|
|
|
依赖:
|
|
mmdc 必须在 plans/PRISM/tools/node_modules/.bin/mmdc
|
|
"""
|
|
from __future__ import annotations
|
|
import hashlib
|
|
import pathlib
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
TOOLS_DIR = pathlib.Path(__file__).resolve().parent
|
|
MMDC = TOOLS_DIR / "node_modules" / ".bin" / "mmdc"
|
|
|
|
|
|
def render_one(code: str, fig_dir: pathlib.Path) -> pathlib.Path:
|
|
"""渲染一个 mermaid 代码块,基于内容 hash 缓存。"""
|
|
digest = hashlib.sha1(code.encode("utf-8")).hexdigest()[:12]
|
|
out_png = fig_dir / f"mmd_{digest}.png"
|
|
if out_png.exists():
|
|
return out_png
|
|
|
|
src_mmd = fig_dir / f"mmd_{digest}.mmd"
|
|
src_mmd.write_text(code, encoding="utf-8")
|
|
|
|
cmd = [
|
|
str(MMDC),
|
|
"-i", str(src_mmd),
|
|
"-o", str(out_png),
|
|
"-b", "white",
|
|
"-s", "2",
|
|
"--width", "1400",
|
|
]
|
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
if proc.returncode != 0:
|
|
# 抽取 mermaid 的 Parse / Lexical error 行
|
|
err_lines = [ln for ln in (proc.stdout + proc.stderr).splitlines()
|
|
if "Error" in ln or "error" in ln]
|
|
err_head = "\n ".join(err_lines[:5]) or "(no error line found)"
|
|
sys.stderr.write(
|
|
f"\n[mmdc ERROR] block {digest}:\n"
|
|
f" {err_head}\n"
|
|
f" --- mermaid source ---\n{code}\n"
|
|
f" ----------------------\n"
|
|
)
|
|
raise RuntimeError(f"mmdc failed on block {digest}")
|
|
return out_png
|
|
|
|
|
|
MERMAID_RE = re.compile(
|
|
r"```mermaid\s*\n(.*?)\n```",
|
|
re.DOTALL,
|
|
)
|
|
|
|
|
|
def process(input_md: pathlib.Path, out_md: pathlib.Path, fig_dir: pathlib.Path) -> int:
|
|
if not MMDC.exists():
|
|
sys.stderr.write(
|
|
f"[ERROR] mmdc not found at {MMDC}\n"
|
|
f" run: cd plans/PRISM/tools && npm install @mermaid-js/mermaid-cli\n"
|
|
)
|
|
sys.exit(2)
|
|
fig_dir.mkdir(parents=True, exist_ok=True)
|
|
text = input_md.read_text(encoding="utf-8")
|
|
|
|
ok = 0
|
|
fail = 0
|
|
failures: list[str] = []
|
|
|
|
def _sub(m: re.Match) -> str:
|
|
nonlocal ok, fail
|
|
code = m.group(1)
|
|
try:
|
|
png = render_one(code, fig_dir)
|
|
except RuntimeError:
|
|
fail += 1
|
|
digest = hashlib.sha1(code.encode("utf-8")).hexdigest()[:12]
|
|
failures.append(digest)
|
|
# 降级:渲染失败 → 在 PDF 中插入"渲染失败"标记 + 源码 verbatim,
|
|
# 这样书构建不会因为一个块挂掉而中断。
|
|
return (
|
|
f"\n\\begin{{quote}}\\textbf{{[mermaid 渲染失败 {digest}]}}"
|
|
f"\\end{{quote}}\n\n"
|
|
f"\\begin{{verbatim}}\n{code}\n\\end{{verbatim}}\n"
|
|
)
|
|
ok += 1
|
|
rel = png.relative_to(input_md.parent)
|
|
# 同时限制宽 (0.92 linewidth) 与高 (0.82 textheight),
|
|
# keepaspectratio 自动取较紧者,保证任何极端长宽比的图都不溢出 A4 页面。
|
|
return (
|
|
f"\n\\begin{{center}}\n"
|
|
f"\\includegraphics[width=0.92\\linewidth,"
|
|
f"height=0.82\\textheight,keepaspectratio]{{{rel.as_posix()}}}\n"
|
|
f"\\end{{center}}\n"
|
|
)
|
|
|
|
new_text = MERMAID_RE.sub(_sub, text)
|
|
out_md.write_text(new_text, encoding="utf-8")
|
|
if fail:
|
|
print(f"[mermaid] rendered {ok} ok, {fail} FAILED → 降级为 verbatim")
|
|
print(f"[mermaid] failed digests: {', '.join(failures)}")
|
|
else:
|
|
print(f"[mermaid] rendered {ok} block(s) → {fig_dir}")
|
|
return ok
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 4:
|
|
sys.stderr.write(
|
|
"usage: render_mermaid.py <input_md> <out_md> <fig_dir>\n"
|
|
)
|
|
sys.exit(1)
|
|
process(
|
|
pathlib.Path(sys.argv[1]),
|
|
pathlib.Path(sys.argv[2]),
|
|
pathlib.Path(sys.argv[3]),
|
|
)
|