170 lines
6.1 KiB
Python
170 lines
6.1 KiB
Python
#!/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("<svg"):
|
|
return "svg"
|
|
return "mermaid"
|
|
|
|
|
|
def render_mermaid(src: Path, out: Path, fmt: str) -> 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())
|