excalidraw-skill-pack-render 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """excalidraw-render: render Excalidraw JSON to PNG."""
2
+
3
+ from excalidraw_render.render import render_to_png
4
+
5
+ __all__ = ["render_to_png"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """python -m excalidraw_render entry."""
2
+
3
+ from excalidraw_render.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from excalidraw_render.render import render_to_png
8
+
9
+
10
+ def main() -> int:
11
+ parser = argparse.ArgumentParser(description="Render Excalidraw JSON to PNG")
12
+ parser.add_argument("input", type=Path, help="Path to .excalidraw JSON file")
13
+ parser.add_argument(
14
+ "--theme", default="default-sketchy", help="Theme name (default: default-sketchy)"
15
+ )
16
+ parser.add_argument(
17
+ "--output", "-o", type=Path, default=None, help="Output PNG path (default: input with .png)"
18
+ )
19
+ parser.add_argument("--scale", type=int, default=2, help="Device scale factor (default: 2)")
20
+ parser.add_argument(
21
+ "--width", type=int, default=1920, help="Max viewport width (default: 1920)"
22
+ )
23
+ args = parser.parse_args()
24
+
25
+ if not args.input.exists():
26
+ print(f"ERROR: File not found: {args.input}", file=sys.stderr)
27
+ return 1
28
+
29
+ output = args.output if args.output is not None else args.input.with_suffix(".png")
30
+ json_str = args.input.read_text(encoding="utf-8")
31
+ png_bytes = render_to_png(json_str, theme=args.theme, scale=args.scale, width=args.width)
32
+ output.write_bytes(png_bytes)
33
+ print(str(output))
34
+ return 0
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.resources
4
+ import json
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+
9
+ def _compute_bounding_box(elements: list[dict]) -> tuple[float, float, float, float]:
10
+ min_x = float("inf")
11
+ min_y = float("inf")
12
+ max_x = float("-inf")
13
+ max_y = float("-inf")
14
+
15
+ for el in elements:
16
+ if el.get("isDeleted"):
17
+ continue
18
+ x = el.get("x", 0)
19
+ y = el.get("y", 0)
20
+ w = el.get("width", 0)
21
+ h = el.get("height", 0)
22
+
23
+ if el.get("type") in ("arrow", "line") and "points" in el:
24
+ for px, py in el["points"]:
25
+ min_x = min(min_x, x + px)
26
+ min_y = min(min_y, y + py)
27
+ max_x = max(max_x, x + px)
28
+ max_y = max(max_y, y + py)
29
+ else:
30
+ min_x = min(min_x, x)
31
+ min_y = min(min_y, y)
32
+ max_x = max(max_x, x + abs(w))
33
+ max_y = max(max_y, y + abs(h))
34
+
35
+ if min_x == float("inf"):
36
+ return (0, 0, 800, 600)
37
+
38
+ return (min_x, min_y, max_x, max_y)
39
+
40
+
41
+ def render_to_png(json_str: str, *, theme: str, scale: int, width: int) -> bytes:
42
+ from playwright.sync_api import sync_playwright
43
+
44
+ data = json.loads(json_str)
45
+ elements = [e for e in data.get("elements", []) if not e.get("isDeleted")]
46
+ min_x, min_y, max_x, max_y = _compute_bounding_box(elements)
47
+ padding = 80
48
+ vp_w = min(int(max_x - min_x + padding * 2), width)
49
+ vp_h = max(int(max_y - min_y + padding * 2), 600)
50
+
51
+ ref = importlib.resources.files("excalidraw_render").joinpath("render_template.html")
52
+ with importlib.resources.as_file(ref) as template_path:
53
+ template_url = Path(template_path).as_uri()
54
+ with sync_playwright() as p:
55
+ browser = p.chromium.launch(headless=True)
56
+ page = browser.new_page(
57
+ viewport={"width": vp_w, "height": vp_h},
58
+ device_scale_factor=scale,
59
+ )
60
+ page.goto(template_url)
61
+ # Wait for the ES module to load (imports from esm.sh)
62
+ page.wait_for_function("window.__moduleReady === true", timeout=30000)
63
+ result = page.evaluate(f"window.renderDiagram({json_str})")
64
+ if not result or not result.get("success"):
65
+ error_msg = (
66
+ result.get("error", "Unknown render error")
67
+ if result
68
+ else "renderDiagram returned null"
69
+ )
70
+ browser.close()
71
+ raise RuntimeError(f"Render failed: {error_msg}")
72
+ page.wait_for_function("window.__renderComplete === true", timeout=15000)
73
+ svg_el = page.query_selector("#root svg")
74
+ if svg_el is None:
75
+ browser.close()
76
+ raise RuntimeError("No SVG element found after render")
77
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
78
+ tmp_path = Path(tmp.name)
79
+ svg_el.screenshot(path=str(tmp_path))
80
+ png_bytes = tmp_path.read_bytes()
81
+ tmp_path.unlink(missing_ok=True)
82
+ browser.close()
83
+ return png_bytes
@@ -0,0 +1,57 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <style>
6
+ * { margin: 0; padding: 0; box-sizing: border-box; }
7
+ body { background: #ffffff; overflow: hidden; }
8
+ #root { display: inline-block; }
9
+ #root svg { display: block; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+
15
+ <script type="module">
16
+ import { exportToSvg } from "https://esm.sh/@excalidraw/excalidraw@0.18.0";
17
+
18
+ window.renderDiagram = async function(jsonData) {
19
+ try {
20
+ const data = typeof jsonData === "string" ? JSON.parse(jsonData) : jsonData;
21
+ const elements = data.elements || [];
22
+ const appState = data.appState || {};
23
+ const files = data.files || {};
24
+
25
+ // Force white background in appState
26
+ appState.viewBackgroundColor = appState.viewBackgroundColor || "#ffffff";
27
+ appState.exportWithDarkMode = false;
28
+
29
+ const svg = await exportToSvg({
30
+ elements: elements,
31
+ appState: {
32
+ ...appState,
33
+ exportBackground: true,
34
+ },
35
+ files: files,
36
+ });
37
+
38
+ // Clear any previous render
39
+ const root = document.getElementById("root");
40
+ root.innerHTML = "";
41
+ root.appendChild(svg);
42
+
43
+ window.__renderComplete = true;
44
+ window.__renderError = null;
45
+ return { success: true, width: svg.getAttribute("width"), height: svg.getAttribute("height") };
46
+ } catch (err) {
47
+ window.__renderComplete = true;
48
+ window.__renderError = err.message;
49
+ return { success: false, error: err.message };
50
+ }
51
+ };
52
+
53
+ // Signal that the module is loaded and ready
54
+ window.__moduleReady = true;
55
+ </script>
56
+ </body>
57
+ </html>
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass
9
+ class ResolvedTheme:
10
+ manifest: dict
11
+ palette: dict
12
+ typography: dict
13
+ elements: dict
14
+ layouts: dict
15
+ palette_markdown: str
16
+
17
+
18
+ def _load_one(theme_dir: Path) -> dict:
19
+ manifest = json.loads((theme_dir / "theme.json").read_text(encoding="utf-8"))
20
+ try:
21
+ palette = json.loads((theme_dir / "palette.json").read_text(encoding="utf-8"))
22
+ except FileNotFoundError:
23
+ palette = {}
24
+ try:
25
+ typography = json.loads((theme_dir / "typography.json").read_text(encoding="utf-8"))
26
+ except FileNotFoundError:
27
+ typography = {}
28
+ elements: dict = {}
29
+ elements_dir = theme_dir / "elements"
30
+ if elements_dir.is_dir():
31
+ for f in elements_dir.iterdir():
32
+ if f.suffix == ".json":
33
+ elements[f.stem] = json.loads(f.read_text(encoding="utf-8"))
34
+ layouts: dict = {}
35
+ layouts_dir = theme_dir / "layouts"
36
+ if layouts_dir.is_dir():
37
+ for f in layouts_dir.iterdir():
38
+ if f.suffix == ".md":
39
+ layouts[f.stem] = f.read_text(encoding="utf-8")
40
+ try:
41
+ palette_markdown = (theme_dir / "palette.md").read_text(encoding="utf-8")
42
+ except FileNotFoundError:
43
+ palette_markdown = ""
44
+ return {
45
+ "manifest": manifest,
46
+ "palette": palette,
47
+ "typography": typography,
48
+ "elements": elements,
49
+ "layouts": layouts,
50
+ "palette_markdown": palette_markdown,
51
+ }
52
+
53
+
54
+ def _merge(parent: dict, child: dict) -> dict:
55
+ return {
56
+ "manifest": child.get("manifest") or parent.get("manifest"),
57
+ "palette": {**parent.get("palette", {}), **child.get("palette", {})},
58
+ "typography": child.get("typography") or parent.get("typography"),
59
+ "elements": {**parent.get("elements", {}), **child.get("elements", {})},
60
+ "layouts": {**parent.get("layouts", {}), **child.get("layouts", {})},
61
+ "palette_markdown": child.get("palette_markdown") or parent.get("palette_markdown") or "",
62
+ }
63
+
64
+
65
+ def resolve_theme(name: str, *, themes_dir: Path) -> ResolvedTheme:
66
+ theme_dir = themes_dir / name
67
+ if not (theme_dir / "theme.json").exists():
68
+ raise LookupError(f"Theme '{name}' not found at {theme_dir}")
69
+ resolved = _load_one(theme_dir)
70
+ seen: set[str] = {name}
71
+ while resolved["manifest"].get("extends"):
72
+ parent_name = resolved["manifest"]["extends"]
73
+ if parent_name in seen:
74
+ raise ValueError(f"Theme inheritance cycle at {parent_name}")
75
+ seen.add(parent_name)
76
+ parent_dir = themes_dir / parent_name
77
+ parent = _load_one(parent_dir)
78
+ child_manifest = {**resolved["manifest"]}
79
+ del child_manifest["extends"]
80
+ resolved["manifest"] = child_manifest
81
+ resolved = _merge(parent, resolved)
82
+ return ResolvedTheme(
83
+ manifest=resolved["manifest"],
84
+ palette=resolved["palette"],
85
+ typography=resolved["typography"],
86
+ elements=resolved["elements"],
87
+ layouts=resolved["layouts"],
88
+ palette_markdown=resolved["palette_markdown"],
89
+ )
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: excalidraw-skill-pack-render
3
+ Version: 0.1.0
4
+ Summary: Render Excalidraw JSON to PNG. Python binding for excalidraw-skill-pack.
5
+ Author: Timur Isachenko
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: playwright>=1.40.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # excalidraw-render
12
+
13
+ Python binding for rendering Excalidraw JSON to PNG, part of excalidraw-skill-pack.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install excalidraw-skill-pack-render
19
+ playwright install chromium
20
+ ```
21
+
22
+ ## Python API
23
+
24
+ ```python
25
+ from excalidraw_render import render_to_png
26
+
27
+ png_bytes = render_to_png(json_str, theme="default-sketchy", scale=2, width=1920)
28
+ ```
29
+
30
+ ## CLI
31
+
32
+ ```bash
33
+ excalidraw-render diagram.excalidraw --output diagram.png --scale 2 --width 1920
34
+ ```
35
+
36
+ ## Parity with Node binding
37
+
38
+ The Python and Node renderers share `packages/shared/render_template.html` and produce pixel-equivalent output at matching scale/width settings.
@@ -0,0 +1,10 @@
1
+ excalidraw_render/__init__.py,sha256=WJPvKsDWdWoRSgJDLkRhDyeC_DLTOSFLP7ZkKTJlpvM,159
2
+ excalidraw_render/__main__.py,sha256=79redNMchlyC3RA7zPFt4lrcFhbDVg6hSInD5Og1t7Q,107
3
+ excalidraw_render/cli.py,sha256=-Ey4Th-KUJI70n_HMF-wxAYgsid-5huqkpHSAXSNdKo,1251
4
+ excalidraw_render/render.py,sha256=1TdQezjjRlyy80-zfdiiqLX2Olf0g1Sj4muXjZymcpY,3146
5
+ excalidraw_render/theme.py,sha256=tsS6D8yBGbtmGW2Ih0JKHWUglJt-oXxlrat646wXU0E,3151
6
+ excalidraw_render/render_template.html,sha256=dO0ctd7o32kUZNMfu_s3nxtSSRv2aq0dvGdECewoG0o,1686
7
+ excalidraw_skill_pack_render-0.1.0.dist-info/METADATA,sha256=h2-wdySXOa_ItwpeYy0oHR-W1HfjyyU4r8zcEcnepUU,934
8
+ excalidraw_skill_pack_render-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ excalidraw_skill_pack_render-0.1.0.dist-info/entry_points.txt,sha256=q5LKF4I_6bYJ450gs0vWp5xg5ksQ19SnlsD9Ztpn0yg,65
10
+ excalidraw_skill_pack_render-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ excalidraw-render = excalidraw_render.cli:main