mermaid-to-image / render.py
algorembrant's picture
Upload 12 files
9e8fb65 verified
"""
mermaid-renderer — Convert Mermaid diagrams to images using @mermaid-js/mermaid-cli
Author: algorembrant
License: MIT
USAGE COMMANDS
==============
Single file:
python render.py diagram.mmd
python render.py diagram.mmd -o output.png
python render.py diagram.mmd -o output.svg
python render.py diagram.mmd -o output.pdf
Inline diagram:
python render.py --text "graph TD; A --> B --> C"
python render.py --text "sequenceDiagram; Alice->>Bob: Hello"
Themes (15 built-in from beautiful-mermaid):
python render.py diagram.mmd --theme tokyo-night
python render.py diagram.mmd --theme catppuccin-mocha
python render.py diagram.mmd --theme github-light
python render.py diagram.mmd --theme dracula
python render.py diagram.mmd --theme nord
python render.py diagram.mmd --theme one-dark
Custom colors:
python render.py diagram.mmd --bg "#1a1b26" --fg "#a9b1d6"
python render.py diagram.mmd --bg "#ffffff" --fg "#333333" --accent "#0969da"
Background:
python render.py diagram.mmd --background white
python render.py diagram.mmd --background transparent
python render.py diagram.mmd --background "#1e1e2e"
Scale / quality:
python render.py diagram.mmd --scale 2
python render.py diagram.mmd --scale 3
python render.py diagram.mmd --width 1920
Batch (directory or glob):
python render.py --batch ./diagrams/
python render.py --batch ./diagrams/ -o ./output/ --format png
python render.py --batch "./diagrams/*.mmd" --theme tokyo-night --scale 2
Batch parallel workers:
python render.py --batch ./diagrams/ --workers 4
Watch mode (re-render on change):
python render.py diagram.mmd --watch
python render.py --batch ./diagrams/ --watch
List built-in themes:
python render.py --list-themes
Check environment / dependencies:
python render.py --check
Verbose logging:
python render.py diagram.mmd -v
python render.py --batch ./diagrams/ -v --workers 2
Help:
python render.py --help
"""
from __future__ import annotations
import argparse
import glob
import json
import logging
import os
import shutil
import subprocess
import sys
import tempfile
import time
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Optional
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.WARNING,
format="[%(levelname)s] %(message)s",
)
log = logging.getLogger("mermaid-renderer")
# ---------------------------------------------------------------------------
# Theme definitions (mirrors beautiful-mermaid THEMES)
# ---------------------------------------------------------------------------
THEMES: dict[str, dict] = {
"default": {
"bg": "#FFFFFF",
"fg": "#27272A",
"mermaid_theme": "default",
},
"zinc-light": {
"bg": "#FFFFFF",
"fg": "#18181B",
"mermaid_theme": "default",
},
"zinc-dark": {
"bg": "#18181B",
"fg": "#E4E4E7",
"mermaid_theme": "dark",
},
"tokyo-night": {
"bg": "#1a1b26",
"fg": "#a9b1d6",
"accent": "#7aa2f7",
"mermaid_theme": "dark",
},
"tokyo-night-storm": {
"bg": "#24283b",
"fg": "#c0caf5",
"accent": "#7aa2f7",
"mermaid_theme": "dark",
},
"tokyo-night-light": {
"bg": "#d5d6db",
"fg": "#343b58",
"accent": "#34548a",
"mermaid_theme": "default",
},
"catppuccin-mocha": {
"bg": "#1e1e2e",
"fg": "#cdd6f4",
"accent": "#cba6f7",
"mermaid_theme": "dark",
},
"catppuccin-latte": {
"bg": "#eff1f5",
"fg": "#4c4f69",
"accent": "#8839ef",
"mermaid_theme": "default",
},
"nord": {
"bg": "#2e3440",
"fg": "#d8dee9",
"accent": "#88c0d0",
"mermaid_theme": "dark",
},
"nord-light": {
"bg": "#eceff4",
"fg": "#2e3440",
"accent": "#5e81ac",
"mermaid_theme": "default",
},
"dracula": {
"bg": "#282a36",
"fg": "#f8f8f2",
"accent": "#bd93f9",
"mermaid_theme": "dark",
},
"github-light": {
"bg": "#ffffff",
"fg": "#1F2328",
"accent": "#0969da",
"mermaid_theme": "default",
},
"github-dark": {
"bg": "#0d1117",
"fg": "#e6edf3",
"accent": "#4493f8",
"mermaid_theme": "dark",
},
"solarized-light": {
"bg": "#fdf6e3",
"fg": "#657b83",
"accent": "#268bd2",
"mermaid_theme": "default",
},
"solarized-dark": {
"bg": "#002b36",
"fg": "#839496",
"accent": "#268bd2",
"mermaid_theme": "dark",
},
"one-dark": {
"bg": "#282c34",
"fg": "#abb2bf",
"accent": "#c678dd",
"mermaid_theme": "dark",
},
}
# ---------------------------------------------------------------------------
# Environment detection
# ---------------------------------------------------------------------------
def find_mmdc() -> Optional[str]:
"""Return path to mmdc binary or None."""
# Prefer the npm-global install used by this environment
candidates = [
shutil.which("mmdc"),
shutil.which("mmdc.cmd"),
os.path.expanduser("~/.npm-global/bin/mmdc"),
os.path.join(os.environ.get("APPDATA", ""), "npm", "mmdc.cmd"),
"/usr/local/bin/mmdc",
"/usr/bin/mmdc",
]
for c in candidates:
if c and os.path.isfile(c):
return c
return None
def find_chrome() -> Optional[str]:
"""Return path to a Chromium/Chrome binary or None."""
candidates = [
# Windows paths
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
os.path.join(os.environ.get("LOCALAPPDATA", ""), r"Google\Chrome\Application\chrome.exe"),
# Puppeteer-managed chrome (most reliable in this env)
os.path.expanduser(
"~/.cache/puppeteer/chrome/linux-131.0.6778.204/chrome-linux64/chrome"
),
"/opt/google/chrome/chrome",
"/opt/pw-browsers/chromium-1194/chrome-linux/chrome",
shutil.which("google-chrome"),
shutil.which("chromium"),
shutil.which("chromium-browser"),
shutil.which("chrome"),
]
for c in candidates:
if c and os.path.isfile(c):
return c
return None
def build_puppeteer_config(chrome_path: str) -> str:
"""Write a puppeteer config JSON to a temp file, return its path."""
cfg = {
"executablePath": chrome_path,
"args": [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
],
}
fd, path = tempfile.mkstemp(suffix=".json", prefix="puppeteer_cfg_")
with os.fdopen(fd, "w") as f:
json.dump(cfg, f)
return path
def build_mermaid_config(theme_cfg: dict, bg: Optional[str]) -> str:
"""Write a mermaid config JSON, return its path."""
mermaid_theme = theme_cfg.get("mermaid_theme", "default")
cfg: dict = {
"theme": mermaid_theme,
}
if mermaid_theme == "dark":
cfg["themeVariables"] = {
"darkMode": True,
"background": theme_cfg.get("bg", "#1e1e2e"),
"primaryColor": theme_cfg.get("accent", theme_cfg.get("fg", "#88c0d0")),
"primaryTextColor": theme_cfg.get("fg", "#d8dee9"),
"lineColor": theme_cfg.get("accent", "#88c0d0"),
}
else:
cfg["themeVariables"] = {
"darkMode": False,
"background": theme_cfg.get("bg", "#ffffff"),
"primaryColor": theme_cfg.get("accent", "#ececff"),
"primaryTextColor": theme_cfg.get("fg", "#333333"),
"lineColor": theme_cfg.get("accent", "#333333"),
}
if bg:
cfg["themeVariables"]["background"] = bg
fd, path = tempfile.mkstemp(suffix=".json", prefix="mermaid_cfg_")
with os.fdopen(fd, "w") as f:
json.dump(cfg, f)
return path
# ---------------------------------------------------------------------------
# Core render function
# ---------------------------------------------------------------------------
def render_diagram(
source: str,
output_path: str,
*,
mmdc: str,
puppeteer_cfg: str,
theme_name: str = "default",
bg: Optional[str] = None,
scale: float = 2.0,
width: Optional[int] = None,
fmt: str = "png",
verbose: bool = False,
) -> dict:
"""
Render a Mermaid diagram string to an image file.
Parameters
----------
source : Mermaid diagram text
output_path : Destination file path
mmdc : Path to mmdc binary
puppeteer_cfg: Path to puppeteer config JSON
theme_name : One of THEMES keys or 'default'
bg : Override background color (hex or 'transparent')
scale : Device pixel ratio (default 2 = 2x)
width : Optional canvas width in pixels
fmt : Output format: png | svg | pdf
verbose : Log mmdc stdout/stderr
Returns
-------
dict with keys: success, output, error, duration_ms
"""
theme_cfg = THEMES.get(theme_name, THEMES["default"])
t0 = time.perf_counter()
# Write source to temp .mmd file
fd, src_path = tempfile.mkstemp(suffix=".mmd", prefix="mermaid_src_")
mermaid_cfg_path = None
try:
with os.fdopen(fd, "w") as f:
f.write(source)
mermaid_cfg_path = build_mermaid_config(theme_cfg, bg)
cmd = [
mmdc,
"-i", src_path,
"-o", output_path,
"--puppeteerConfigFile", puppeteer_cfg,
"--configFile", mermaid_cfg_path,
"--scale", str(scale),
]
if bg == "transparent":
cmd += ["-b", "transparent"]
elif bg:
cmd += ["-b", bg]
elif "bg" in theme_cfg:
cmd += ["-b", theme_cfg["bg"]]
if width:
cmd += ["-w", str(width)]
if fmt == "pdf":
cmd += ["--pdfFit"]
log.debug("CMD: %s", " ".join(cmd))
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60,
)
duration_ms = int((time.perf_counter() - t0) * 1000)
if verbose:
if result.stdout.strip():
print(f" [mmdc stdout] {result.stdout.strip()}")
if result.stderr.strip():
print(f" [mmdc stderr] {result.stderr.strip()}")
success = result.returncode == 0 and os.path.isfile(output_path)
error = None
if not success:
error = (result.stderr or result.stdout or "mmdc exited with code "
+ str(result.returncode)).strip()
return {
"success": success,
"output": output_path if success else None,
"error": error,
"duration_ms": duration_ms,
}
except subprocess.TimeoutExpired:
return {
"success": False,
"output": None,
"error": "Render timed out (60s)",
"duration_ms": int((time.perf_counter() - t0) * 1000),
}
except Exception as exc:
return {
"success": False,
"output": None,
"error": str(exc),
"duration_ms": int((time.perf_counter() - t0) * 1000),
}
finally:
try:
os.unlink(src_path)
except OSError:
pass
if mermaid_cfg_path:
try:
os.unlink(mermaid_cfg_path)
except OSError:
pass
# ---------------------------------------------------------------------------
# Batch helpers
# ---------------------------------------------------------------------------
def _batch_worker(args: tuple) -> dict:
"""Top-level function for ProcessPoolExecutor (must be picklable)."""
(
src_file, out_file, mmdc, puppeteer_cfg,
theme, bg, scale, width, fmt, verbose,
) = args
source = Path(src_file).read_text(encoding="utf-8")
return {
"input": src_file,
**render_diagram(
source, out_file,
mmdc=mmdc, puppeteer_cfg=puppeteer_cfg,
theme_name=theme, bg=bg, scale=scale,
width=width, fmt=fmt, verbose=verbose,
),
}
def resolve_batch_inputs(batch_path: str, fmt: str, out_dir: Optional[str]) -> list[tuple[str, str]]:
"""Return list of (input_path, output_path) tuples for a batch run."""
p = Path(batch_path)
pairs: list[tuple[str, str]] = []
if "*" in batch_path or "?" in batch_path:
inputs = sorted(glob.glob(batch_path))
elif p.is_dir():
inputs = sorted(
str(f) for f in p.rglob("*")
if f.suffix.lower() in {".mmd", ".mermaid", ".txt"}
and f.is_file()
)
elif p.is_file():
inputs = [str(p)]
else:
print(f"[ERROR] Batch path not found: {batch_path}", file=sys.stderr)
return []
for inp in inputs:
in_path = Path(inp)
if out_dir:
out_base = Path(out_dir)
out_base.mkdir(parents=True, exist_ok=True)
out_file = str(out_base / (in_path.stem + f".{fmt}"))
else:
out_file = str(in_path.with_suffix(f".{fmt}"))
pairs.append((inp, out_file))
return pairs
# ---------------------------------------------------------------------------
# Watch mode
# ---------------------------------------------------------------------------
def watch_file(path: str) -> float:
try:
return os.path.getmtime(path)
except OSError:
return 0.0
def run_watch(
input_files: list[str],
output_map: dict[str, str],
render_kwargs: dict,
poll_interval: float = 0.8,
) -> None:
"""Poll input files and re-render on modification."""
mtimes = {f: watch_file(f) for f in input_files}
print(f"Watching {len(input_files)} file(s). Press Ctrl+C to stop.")
try:
while True:
time.sleep(poll_interval)
for f in input_files:
mtime = watch_file(f)
if mtime != mtimes[f]:
mtimes[f] = mtime
print(f" Changed: {f} — re-rendering...")
source = Path(f).read_text(encoding="utf-8")
result = render_diagram(source, output_map[f], **render_kwargs)
if result["success"]:
print(f" OK: {result['output']} ({result['duration_ms']}ms)")
else:
print(f" FAIL: {result['error']}", file=sys.stderr)
except KeyboardInterrupt:
print("\nWatch mode stopped.")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="mermaid-renderer",
description="Convert Mermaid diagrams to PNG, SVG, or PDF images.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
p.add_argument(
"input",
nargs="?",
help="Path to .mmd / .mermaid file (omit when using --text or --batch)",
)
p.add_argument(
"-o", "--output",
default=None,
help="Output file path (default: same name as input, auto-extension)",
)
p.add_argument(
"--text",
default=None,
help="Inline Mermaid diagram text (alternative to file input)",
)
p.add_argument(
"--batch",
default=None,
metavar="DIR_OR_GLOB",
help="Render all .mmd/.mermaid files in a directory or matching a glob",
)
p.add_argument(
"--format", "-f",
default="png",
choices=["png", "svg", "pdf"],
help="Output format (default: png)",
)
p.add_argument(
"--theme", "-t",
default="default",
choices=list(THEMES.keys()),
help="Named theme (default: default)",
)
p.add_argument(
"--bg", "--background",
default=None,
dest="bg",
help='Background color hex or "transparent"',
)
p.add_argument(
"--fg", "--foreground",
default=None,
dest="fg",
help="Foreground color hex (overrides theme fg — informational only)",
)
p.add_argument(
"--accent",
default=None,
help="Accent color hex (overrides theme accent — informational only)",
)
p.add_argument(
"--scale",
type=float,
default=2.0,
help="Device pixel ratio / scale factor (default: 2.0)",
)
p.add_argument(
"--width", "-W",
type=int,
default=None,
help="Canvas width in pixels",
)
p.add_argument(
"--workers", "-j",
type=int,
default=1,
help="Parallel workers for batch mode (default: 1)",
)
p.add_argument(
"--watch", "-w",
action="store_true",
help="Watch input files and re-render on change",
)
p.add_argument(
"--list-themes",
action="store_true",
help="Print all available themes and exit",
)
p.add_argument(
"--check",
action="store_true",
help="Check environment dependencies and exit",
)
p.add_argument(
"-v", "--verbose",
action="store_true",
help="Show mmdc stdout/stderr",
)
return p
def cmd_check() -> None:
"""Print environment diagnostic info."""
mmdc = find_mmdc()
chrome = find_chrome()
print("mermaid-renderer — environment check")
print("-" * 40)
print(f" Python : {sys.version.split()[0]}")
print(f" mmdc : {mmdc or 'NOT FOUND'}")
print(f" Chrome/Chrom: {chrome or 'NOT FOUND'}")
if mmdc:
try:
r = subprocess.run([mmdc, "--version"], capture_output=True, text=True, timeout=5)
print(f" mmdc version: {r.stdout.strip()}")
except Exception:
print(" mmdc version: (could not query)")
if mmdc and chrome:
print("\n All dependencies OK. Rendering should work.")
else:
missing = []
if not mmdc:
missing.append("mmdc (install: npm install -g @mermaid-js/mermaid-cli)")
if not chrome:
missing.append("Chrome/Chromium")
print("\n MISSING:", ", ".join(missing))
sys.exit(1)
def cmd_list_themes() -> None:
col_w = 24
print(f"\n{'Theme':<{col_w}} {'Type':<8} {'Background':<12} {'Accent'}")
print("-" * 64)
for name, cfg in THEMES.items():
theme_type = "dark" if cfg.get("mermaid_theme") == "dark" else "light"
accent = cfg.get("accent", "(derived)")
print(f" {name:<{col_w-2}} {theme_type:<8} {cfg.get('bg',''):<12} {accent}")
print()
def main() -> None:
parser = build_parser()
args = parser.parse_args()
if args.verbose:
log.setLevel(logging.DEBUG)
if args.check:
cmd_check()
return
if args.list_themes:
cmd_list_themes()
return
# Resolve dependencies
mmdc = find_mmdc()
chrome = find_chrome()
if not mmdc:
print(
"[ERROR] mmdc not found. Install with:\n"
" npm install -g @mermaid-js/mermaid-cli\n"
"Then run: python render.py --check",
file=sys.stderr,
)
sys.exit(1)
if not chrome:
print(
"[ERROR] No Chrome/Chromium found. See README for install instructions.",
file=sys.stderr,
)
sys.exit(1)
puppeteer_cfg = build_puppeteer_config(chrome)
# Apply per-run theme overrides
theme_cfg = dict(THEMES.get(args.theme, THEMES["default"]))
if args.fg:
theme_cfg["fg"] = args.fg
if args.accent:
theme_cfg["accent"] = args.accent
render_kwargs = dict(
mmdc=mmdc,
puppeteer_cfg=puppeteer_cfg,
theme_name=args.theme,
bg=args.bg,
scale=args.scale,
width=args.width,
fmt=args.format,
verbose=args.verbose,
)
try:
# -----------------------------------------------------------------------
# BATCH MODE
# -----------------------------------------------------------------------
if args.batch:
pairs = resolve_batch_inputs(args.batch, args.format, args.output)
if not pairs:
print("[ERROR] No input files found.", file=sys.stderr)
sys.exit(1)
print(f"Batch: {len(pairs)} file(s) | theme={args.theme} | "
f"format={args.format} | workers={args.workers}")
if args.watch:
input_files = [p[0] for p in pairs]
output_map = {p[0]: p[1] for p in pairs}
run_watch(input_files, output_map, render_kwargs)
return
if args.workers > 1:
worker_args = [
(inp, out, mmdc, puppeteer_cfg,
args.theme, args.bg, args.scale, args.width,
args.format, args.verbose)
for inp, out in pairs
]
results = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
futures = {ex.submit(_batch_worker, a): a[0] for a in worker_args}
for fut in as_completed(futures):
results.append(fut.result())
else:
results = []
for inp, out in pairs:
source = Path(inp).read_text(encoding="utf-8")
r = render_diagram(source, out, **render_kwargs)
results.append({"input": inp, **r})
status = "OK" if r["success"] else "FAIL"
print(f" [{status}] {inp} -> {out} ({r['duration_ms']}ms)")
if not r["success"]:
print(f" Error: {r['error']}", file=sys.stderr)
ok = sum(1 for r in results if r["success"])
fail = len(results) - ok
print(f"\nDone: {ok} succeeded, {fail} failed.")
if fail > 0:
sys.exit(1)
return
# -----------------------------------------------------------------------
# SINGLE FILE OR INLINE TEXT
# -----------------------------------------------------------------------
if args.text:
source = args.text.replace("; ", "\n").replace(";", "\n")
default_stem = "diagram"
elif args.input:
in_path = Path(args.input)
if not in_path.is_file():
print(f"[ERROR] Input file not found: {args.input}", file=sys.stderr)
sys.exit(1)
source = in_path.read_text(encoding="utf-8")
default_stem = in_path.stem
else:
parser.print_help()
sys.exit(0)
# Determine output path
if args.output:
out_path = args.output
else:
suffix = f".{args.format}"
out_path = str(Path(default_stem).with_suffix(suffix))
print(f"Rendering: {out_path} | theme={args.theme} | format={args.format} | scale={args.scale}")
result = render_diagram(source, out_path, **render_kwargs)
if result["success"]:
size = os.path.getsize(result["output"])
print(f" OK: {result['output']} ({size:,} bytes, {result['duration_ms']}ms)")
else:
print(f" FAIL: {result['error']}", file=sys.stderr)
sys.exit(1)
# Watch mode for single file
if args.watch and args.input:
run_watch([args.input], {args.input: out_path}, render_kwargs)
finally:
try:
os.unlink(puppeteer_cfg)
except OSError:
pass
if __name__ == "__main__":
main()