docs: update AGENTS.md and README.md for rules system, remove beads
- Add rules/ directory documentation to both files - Update skill count from 25 to 15 modules - Remove beads references (issue tracking removed) - Update skills list with current active skills - Document flake.nix as proper Nix flake (not flake=false) - Add rules system integration section - Clean up sisyphus planning artifacts - Remove deprecated skills (memory, msteams, outlook)
This commit is contained in:
205
skills/excalidraw/references/render_excalidraw.py
Normal file
205
skills/excalidraw/references/render_excalidraw.py
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render Excalidraw JSON to PNG using Playwright + headless Chromium.
|
||||
|
||||
Usage:
|
||||
python3 render_excalidraw.py <path-to-file.excalidraw> [--output path.png] [--scale 2] [--width 1920]
|
||||
|
||||
Dependencies (playwright, chromium) are provided by the Nix flake / direnv environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate_excalidraw(data: dict) -> list[str]:
|
||||
"""Validate Excalidraw JSON structure. Returns list of errors (empty = valid)."""
|
||||
errors: list[str] = []
|
||||
|
||||
if data.get("type") != "excalidraw":
|
||||
errors.append(f"Expected type 'excalidraw', got '{data.get('type')}'")
|
||||
|
||||
if "elements" not in data:
|
||||
errors.append("Missing 'elements' array")
|
||||
elif not isinstance(data["elements"], list):
|
||||
errors.append("'elements' must be an array")
|
||||
elif len(data["elements"]) == 0:
|
||||
errors.append("'elements' array is empty — nothing to render")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def compute_bounding_box(elements: list[dict]) -> tuple[float, float, float, float]:
|
||||
"""Compute bounding box (min_x, min_y, max_x, max_y) across all elements."""
|
||||
min_x = float("inf")
|
||||
min_y = float("inf")
|
||||
max_x = float("-inf")
|
||||
max_y = float("-inf")
|
||||
|
||||
for el in elements:
|
||||
if el.get("isDeleted"):
|
||||
continue
|
||||
x = el.get("x", 0)
|
||||
y = el.get("y", 0)
|
||||
w = el.get("width", 0)
|
||||
h = el.get("height", 0)
|
||||
|
||||
# For arrows/lines, points array defines the shape relative to x,y
|
||||
if el.get("type") in ("arrow", "line") and "points" in el:
|
||||
for px, py in el["points"]:
|
||||
min_x = min(min_x, x + px)
|
||||
min_y = min(min_y, y + py)
|
||||
max_x = max(max_x, x + px)
|
||||
max_y = max(max_y, y + py)
|
||||
else:
|
||||
min_x = min(min_x, x)
|
||||
min_y = min(min_y, y)
|
||||
max_x = max(max_x, x + abs(w))
|
||||
max_y = max(max_y, y + abs(h))
|
||||
|
||||
if min_x == float("inf"):
|
||||
return (0, 0, 800, 600)
|
||||
|
||||
return (min_x, min_y, max_x, max_y)
|
||||
|
||||
|
||||
def render(
|
||||
excalidraw_path: Path,
|
||||
output_path: Path | None = None,
|
||||
scale: int = 2,
|
||||
max_width: int = 1920,
|
||||
) -> Path:
|
||||
"""Render an .excalidraw file to PNG. Returns the output PNG path."""
|
||||
# Import playwright here so validation errors show before import errors
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
print("ERROR: playwright not installed.", file=sys.stderr)
|
||||
print("Ensure the Nix dev shell is active (direnv allow).", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Read and validate
|
||||
raw = excalidraw_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"ERROR: Invalid JSON in {excalidraw_path}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
errors = validate_excalidraw(data)
|
||||
if errors:
|
||||
print(f"ERROR: Invalid Excalidraw file:", file=sys.stderr)
|
||||
for err in errors:
|
||||
print(f" - {err}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Compute viewport size from element bounding box
|
||||
elements = [e for e in data["elements"] if not e.get("isDeleted")]
|
||||
min_x, min_y, max_x, max_y = compute_bounding_box(elements)
|
||||
padding = 80
|
||||
diagram_w = max_x - min_x + padding * 2
|
||||
diagram_h = max_y - min_y + padding * 2
|
||||
|
||||
# Cap viewport width, let height be natural
|
||||
vp_width = min(int(diagram_w), max_width)
|
||||
vp_height = max(int(diagram_h), 600)
|
||||
|
||||
# Output path
|
||||
if output_path is None:
|
||||
output_path = excalidraw_path.with_suffix(".png")
|
||||
|
||||
# Template path (same directory as this script)
|
||||
template_path = Path(__file__).parent / "render_template.html"
|
||||
if not template_path.exists():
|
||||
print(f"ERROR: Template not found at {template_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
template_url = template_path.as_uri()
|
||||
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
except Exception as e:
|
||||
if "Executable doesn't exist" in str(e) or "browserType.launch" in str(e):
|
||||
print("ERROR: Chromium not installed for Playwright.", file=sys.stderr)
|
||||
print("Ensure the Nix dev shell is active (direnv allow).", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise
|
||||
|
||||
page = browser.new_page(
|
||||
viewport={"width": vp_width, "height": vp_height},
|
||||
device_scale_factor=scale,
|
||||
)
|
||||
|
||||
# Load the template
|
||||
page.goto(template_url)
|
||||
|
||||
# Wait for the ES module to load (imports from esm.sh)
|
||||
page.wait_for_function("window.__moduleReady === true", timeout=30000)
|
||||
|
||||
# Inject the diagram data and render
|
||||
json_str = json.dumps(data)
|
||||
result = page.evaluate(f"window.renderDiagram({json_str})")
|
||||
|
||||
if not result or not result.get("success"):
|
||||
error_msg = (
|
||||
result.get("error", "Unknown render error")
|
||||
if result
|
||||
else "renderDiagram returned null"
|
||||
)
|
||||
print(f"ERROR: Render failed: {error_msg}", file=sys.stderr)
|
||||
browser.close()
|
||||
sys.exit(1)
|
||||
|
||||
# Wait for render completion signal
|
||||
page.wait_for_function("window.__renderComplete === true", timeout=15000)
|
||||
|
||||
# Screenshot the SVG element
|
||||
svg_el = page.query_selector("#root svg")
|
||||
if svg_el is None:
|
||||
print("ERROR: No SVG element found after render.", file=sys.stderr)
|
||||
browser.close()
|
||||
sys.exit(1)
|
||||
|
||||
svg_el.screenshot(path=str(output_path))
|
||||
browser.close()
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for rendering Excalidraw JSON files to PNG."""
|
||||
parser = argparse.ArgumentParser(description="Render Excalidraw JSON to PNG")
|
||||
parser.add_argument("input", type=Path, help="Path to .excalidraw JSON file")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Output PNG path (default: same name with .png)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scale", "-s", type=int, default=2, help="Device scale factor (default: 2)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--width",
|
||||
"-w",
|
||||
type=int,
|
||||
default=1920,
|
||||
help="Max viewport width (default: 1920)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.input.exists():
|
||||
print(f"ERROR: File not found: {args.input}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
png_path = render(args.input, args.output, args.scale, args.width)
|
||||
print(str(png_path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user