#!/usr/bin/env python3 """Render Mermaid, Graphviz DOT, PlantUML, or SVG diagram source to an output file. The script is intentionally dependency-tolerant. It uses common command-line renderers when available and prints actionable hints otherwise. """ from __future__ import annotations import argparse import os import shutil import subprocess import sys import tempfile from pathlib import Path MERMAID_EXTS = {".mmd", ".mermaid"} GRAPHVIZ_EXTS = {".dot", ".gv"} PLANTUML_EXTS = {".puml", ".plantuml"} SVG_EXTS = {".svg"} def run(cmd: list[str], cwd: Path | None = None) -> None: print("$ " + " ".join(cmd)) completed = subprocess.run(cmd, cwd=str(cwd) if cwd else None, text=True, capture_output=True) if completed.stdout: print(completed.stdout, end="") if completed.stderr: print(completed.stderr, end="", file=sys.stderr) if completed.returncode != 0: raise RuntimeError(f"command failed with exit code {completed.returncode}") def infer_kind(path: Path) -> str: ext = path.suffix.lower() if ext in MERMAID_EXTS: return "mermaid" if ext in GRAPHVIZ_EXTS: return "graphviz" if ext in PLANTUML_EXTS: return "plantuml" if ext in SVG_EXTS: return "svg" text = path.read_text(encoding="utf-8", errors="replace").lstrip() if text.startswith("@startuml"): return "plantuml" if text.startswith("digraph") or text.startswith("graph"): return "graphviz" if text.startswith(" None: mmdc = shutil.which("mmdc") if not mmdc: raise RuntimeError( "Mermaid rendering requires the Mermaid CLI executable `mmdc`. " "Install it with `npm install -g @mermaid-js/mermaid-cli`, or return the Mermaid source only." ) run([mmdc, "-i", str(src), "-o", str(out)]) def render_graphviz(src: Path, out: Path, fmt: str) -> None: dot = shutil.which("dot") if not dot: raise RuntimeError( "Graphviz rendering requires the `dot` executable. Install Graphviz, or return the DOT source only." ) run([dot, f"-T{fmt}", str(src), "-o", str(out)]) def render_plantuml(src: Path, out: Path, fmt: str) -> None: plantuml = shutil.which("plantuml") java = shutil.which("java") if plantuml: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) temp_src = tmp_path / src.name temp_src.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") run([plantuml, f"-t{fmt}", str(temp_src)], cwd=tmp_path) produced = temp_src.with_suffix("." + fmt) if not produced.exists(): candidates = list(tmp_path.glob(f"*.{fmt}")) if not candidates: raise RuntimeError("PlantUML completed but no output file was found") produced = candidates[0] shutil.copyfile(produced, out) return jar = os.environ.get("PLANTUML_JAR") if java and jar and Path(jar).exists(): with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) temp_src = tmp_path / src.name temp_src.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") run([java, "-jar", jar, f"-t{fmt}", str(temp_src)], cwd=tmp_path) produced = temp_src.with_suffix("." + fmt) if not produced.exists(): candidates = list(tmp_path.glob(f"*.{fmt}")) if not candidates: raise RuntimeError("PlantUML completed but no output file was found") produced = candidates[0] shutil.copyfile(produced, out) return raise RuntimeError( "PlantUML rendering requires `plantuml` on PATH, or Java plus PLANTUML_JAR pointing to plantuml.jar. " "Return the PlantUML source only if no renderer is available." ) def render_svg_source(src: Path, out: Path, fmt: str) -> None: if fmt == "svg": shutil.copyfile(src, out) return rsvg = shutil.which("rsvg-convert") if rsvg: run([rsvg, "-f", fmt, "-o", str(out), str(src)]) return inkscape = shutil.which("inkscape") if inkscape: run([inkscape, str(src), f"--export-type={fmt}", f"--export-filename={out}"]) return raise RuntimeError( "SVG conversion requires `rsvg-convert` or `inkscape`. The SVG source itself is usable as an image." ) def main() -> int: parser = argparse.ArgumentParser(description="Render text diagram source to SVG, PNG, or PDF.") parser.add_argument("input", type=Path, help="Input .mmd, .dot/.gv, .puml, or .svg file") parser.add_argument("--format", choices=["svg", "png", "pdf"], default="svg", help="Output format") parser.add_argument("--out", type=Path, help="Output path. Defaults to input name with the selected extension") parser.add_argument("--kind", choices=["auto", "mermaid", "graphviz", "plantuml", "svg"], default="auto") args = parser.parse_args() src = args.input.resolve() if not src.exists(): print(f"input file not found: {src}", file=sys.stderr) return 2 out = (args.out or src.with_suffix("." + args.format)).resolve() out.parent.mkdir(parents=True, exist_ok=True) kind = infer_kind(src) if args.kind == "auto" else args.kind try: if kind == "mermaid": render_mermaid(src, out, args.format) elif kind == "graphviz": render_graphviz(src, out, args.format) elif kind == "plantuml": render_plantuml(src, out, args.format) elif kind == "svg": render_svg_source(src, out, args.format) else: raise RuntimeError(f"unknown diagram kind: {kind}") except Exception as exc: print(f"render failed: {exc}", file=sys.stderr) return 1 if not out.exists() or out.stat().st_size == 0: print(f"render failed: output missing or empty: {out}", file=sys.stderr) return 1 print(f"rendered {kind} diagram to {out}") return 0 if __name__ == "__main__": raise SystemExit(main())