diff --git a/scripts/visual_compare.py b/scripts/visual_compare.py new file mode 100644 index 0000000..0d4579b --- /dev/null +++ b/scripts/visual_compare.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Render the same text through Python and Rust, save PNGs side-by-side, and +also stitch a single comparison image with labels for easy visual inspection. + +Usage: + python3 scripts/visual_compare.py [out_dir] + +Output: + /python_.png + /rust_.png + /compare_.png (side-by-side with labels) + /README.txt (dim + byte size summary) +""" + +import io +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +import proxy as py_proxy # noqa: E402 +from PIL import Image, ImageDraw, ImageFont # noqa: E402 + +RUST_BIN = ROOT / "src" / "rust" / "target" / "release" / "examples" / "diff_render" + +# Three representative samples +SAMPLES = { + "tiny": "echo hello world\n", + "code": ( + "def render_chunks(text):\n" + " \"\"\"Render text into 5pt PNG, multi-column.\"\"\"\n" + " font = get_font(FONT_SIZE)\n" + " lines = wrap(text, FIXED_COL_CHARS)\n" + " return paint(font, lines)\n" + ), + "tools_schema": json.dumps({ + "tools": [ + { + "name": "Bash", + "description": "Execute a bash command", + "input_schema": { + "type": "object", + "properties": { + "command": {"type": "string", "description": "the command"}, + "timeout": {"type": "integer", "description": "timeout in ms"}, + }, + "required": ["command"], + }, + }, + { + "name": "Read", + "description": "Read a file from disk", + "input_schema": { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "offset": {"type": "integer"}, + "limit": {"type": "integer"}, + }, + "required": ["file_path"], + }, + }, + ] + }, indent=2), +} + + +def label_image(img: Image.Image, label: str) -> Image.Image: + """Add a black bar at top with the label in white.""" + bar_h = 24 + out = Image.new("RGB", (img.width, img.height + bar_h), (0, 0, 0)) + out.paste(img.convert("RGB"), (0, bar_h)) + d = ImageDraw.Draw(out) + try: + f = ImageFont.truetype("/System/Library/Fonts/Menlo.ttc", 14) + except Exception: + f = ImageFont.load_default() + d.text((6, 4), label, fill=(255, 255, 255), font=f) + return out + + +def main(): + out_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/tmp/cip_compare") + out_dir.mkdir(parents=True, exist_ok=True) + + if not RUST_BIN.exists(): + print(f"build rust first: cd src/rust && cargo build --release --example diff_render") + sys.exit(1) + + # Run rust + spec_path = out_dir / "spec.json" + spec_path.write_text(json.dumps(SAMPLES)) + rust_outdir = out_dir / "_rust" + rust_outdir.mkdir(exist_ok=True) + r = subprocess.run( + [str(RUST_BIN), str(spec_path), str(rust_outdir)], + capture_output=True, text=True, + ) + if r.returncode != 0: + print("RUST FAILED:", r.stderr); sys.exit(2) + rust_manifest = json.loads((rust_outdir / "manifest.json").read_text()) + + summary_lines = ["Visual compare: Python proxy.py vs Rust src/rust/", ""] + + for name, text in SAMPLES.items(): + # Python + py_pngs = py_proxy.render_chunks(text) + assert len(py_pngs) == 1, f"unexpected multi-chunk python output for {name}" + py_path = out_dir / f"python_{name}.png" + py_path.write_bytes(py_pngs[0]) + py_img = Image.open(io.BytesIO(py_pngs[0])) + + # Rust + rs_files = rust_manifest.get(name, []) + assert len(rs_files) == 1, f"unexpected multi-chunk rust output for {name}" + rs_bytes = (rust_outdir / rs_files[0]).read_bytes() + rs_path = out_dir / f"rust_{name}.png" + rs_path.write_bytes(rs_bytes) + rs_img = Image.open(io.BytesIO(rs_bytes)) + + # Side-by-side compare + py_labeled = label_image(py_img, f"PYTHON {py_img.width}x{py_img.height} ({len(py_pngs[0])}B)") + rs_labeled = label_image(rs_img, f"RUST {rs_img.width}x{rs_img.height} ({len(rs_bytes)}B)") + gap = 8 + cmp_w = max(py_labeled.width, rs_labeled.width) + cmp_h = py_labeled.height + gap + rs_labeled.height + cmp = Image.new("RGB", (cmp_w, cmp_h), (40, 40, 40)) + cmp.paste(py_labeled, (0, 0)) + cmp.paste(rs_labeled, (0, py_labeled.height + gap)) + cmp.save(out_dir / f"compare_{name}.png", "PNG") + + line = ( + f"[{name:14s}] py={py_img.width:4d}x{py_img.height:<4d} ({len(py_pngs[0]):>6d}B) " + f"rs={rs_img.width:4d}x{rs_img.height:<4d} ({len(rs_bytes):>6d}B) " + f"text_len={len(text)}" + ) + print(line) + summary_lines.append(line) + + (out_dir / "README.txt").write_text("\n".join(summary_lines) + "\n") + print(f"\nwrote PNGs to {out_dir}/") + + +if __name__ == "__main__": + main() diff --git a/src/proxy.py b/src/proxy.py index b150f1b..e789ac1 100644 --- a/src/proxy.py +++ b/src/proxy.py @@ -120,9 +120,12 @@ def render_chunks(text: str, font_size: int = FONT_SIZE) -> list[bytes]: tallest = lines_per_col if c_needed > 1 else len(chunk) else: tallest = len(chunk) - # All non-last columns are full lines_per_col; last col has the remainder + # All non-last columns are full lines_per_col; last col has the remainder. + # Single-column case → tight bound (just last_col_lines, no canvas pad). + # Multi-column case → all earlier cols are full lines_per_col tall, so + # the image must be lines_per_col rows tall to fit them. last_col_lines = len(chunk) - (c_needed - 1) * lines_per_col - tallest = max(lines_per_col, last_col_lines) if c_needed == 1 else lines_per_col + tallest = max(1, last_col_lines) if c_needed == 1 else lines_per_col chunk_img_h = line_h * tallest img = Image.new("L", (chunk_img_w, chunk_img_h), 255) d = ImageDraw.Draw(img)