#!/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 特性: - 内容 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 \n" ) sys.exit(1) process( pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3]), )