Download render.py from algorembrant/mermaid-to-image: direct link, hf CLI and curl.
- Browser
- Download file 24.4 kB
-
https://huggingface.co/algorembrant/mermaid-to-image/resolve/main/render.py
- Command line
-
hf download hf://algorembrant/mermaid-to-image/render.py
-
curl -L -o render.py https://huggingface.co/algorembrant/mermaid-to-image/resolve/main/render.py
24.4 kB
| """ | |
| 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() | |