#!/usr/bin/env python3 import argparse import json import shutil import subprocess import sys from pathlib import Path def q(s): return json.dumps(str(s), ensure_ascii=False) def attrs(items): xs = [(k, v) for k, v in items if v is not None] if not xs: return "" return " [" + ", ".join(f"{k}={q(v)}" for k, v in xs) + "]" def load(path): with open(path, "r", encoding="utf-8") as f: spec = json.load(f) if "states" not in spec: raise ValueError("missing required field: states") if "transitions" not in spec: raise ValueError("missing required field: transitions") return spec def transition_label(t): if "label" in t: return t["label"] read = t.get("read") write = t.get("write") action = t.get("action") parts = [] if read is not None: parts.append(str(read)) if write is not None: parts.append("→ " + str(write)) if action is not None: parts.append(str(action)) return " ".join(parts) def to_dot(spec): name = spec.get("name", "automaton") rankdir = spec.get("rankdir", "LR") accepting = set(spec.get("accepting", [])) start = spec.get("start") lines = [ f"digraph {q(name)} {{", f" rankdir={q(rankdir)};", ' graph [pad="0.25", nodesep="0.7", ranksep="0.9"];', ' node [shape=circle, fontname="Times New Roman", fontsize=16];', ' edge [fontname="Times New Roman", fontsize=14, arrowsize=0.8];', ] if start is not None: lines.append(' "__start" [shape=point, label="", width=0.08];') for state in spec["states"]: if isinstance(state, str): sid = state label = state final = sid in accepting else: sid = state["id"] label = state.get("label", sid) final = state.get("accepting", sid in accepting) shape = "doublecircle" if final else "circle" lines.append(f" {q(sid)}{attrs([('label', label), ('shape', shape)])};") if start is not None: lines.append(f" \"__start\" -> {q(start)}{attrs([('label', spec.get('start_label', 'start'))])};") for t in spec["transitions"]: src = t["from"] dst = t["to"] label = transition_label(t) lines.append( f" {q(src)} -> {q(dst)}" f"{attrs([('label', label), ('xlabel', t.get('xlabel'))])};" ) lines.append("}") return "\n".join(lines) + "\n" def render(dot_path, out_path, fmt): dot = shutil.which("dot") if dot is None: raise RuntimeError("graphviz 'dot' executable not found") subprocess.run([dot, f"-T{fmt}", str(dot_path), "-o", str(out_path)], check=True) def main(argv): parser = argparse.ArgumentParser( description="Render finite automata/transducers from JSON using Graphviz." ) parser.add_argument("input", help="automaton JSON file") parser.add_argument("-o", "--output", help="output image path; extension selects format") parser.add_argument("--dot", help="write DOT to this path") parser.add_argument( "--format", choices=["svg", "png", "pdf", "dot"], help="render format; defaults to output extension, or dot when no output is given", ) args = parser.parse_args(argv) spec = load(args.input) dot_text = to_dot(spec) fmt = args.format if fmt is None: fmt = Path(args.output).suffix.lstrip(".") if args.output else "dot" if fmt == "": raise ValueError("cannot infer output format from path without extension") dot_path = Path(args.dot) if args.dot else None if dot_path is not None: dot_path.write_text(dot_text, encoding="utf-8") if fmt == "dot" and args.output is None: sys.stdout.write(dot_text) return 0 if args.output is None: raise ValueError("rendering requires --output unless --format dot is used") out_path = Path(args.output) if fmt == "dot": out_path.write_text(dot_text, encoding="utf-8") return 0 tmp_dot = dot_path if dot_path is not None else out_path.with_suffix(out_path.suffix + ".dot") if dot_path is None: tmp_dot.write_text(dot_text, encoding="utf-8") try: render(tmp_dot, out_path, fmt) finally: if dot_path is None: tmp_dot.unlink(missing_ok=True) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))