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.
- excalidraw_render/__init__.py +6 -0
- excalidraw_render/__main__.py +5 -0
- excalidraw_render/cli.py +34 -0
- excalidraw_render/render.py +83 -0
- excalidraw_render/render_template.html +57 -0
- excalidraw_render/theme.py +89 -0
- excalidraw_skill_pack_render-0.1.0.dist-info/METADATA +38 -0
- excalidraw_skill_pack_render-0.1.0.dist-info/RECORD +10 -0
- excalidraw_skill_pack_render-0.1.0.dist-info/WHEEL +4 -0
- excalidraw_skill_pack_render-0.1.0.dist-info/entry_points.txt +2 -0
excalidraw_render/cli.py
ADDED
|
@@ -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,,
|