excalidraw-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,5 @@
1
+ """excalidraw-render — clean, deterministic, browser-free renderer for .excalidraw files."""
2
+
3
+ from excalidraw_render._version import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,143 @@
1
+ """Command-line entry point for excalidraw-render.
2
+
3
+ Usage:
4
+ excalidraw-render FILE.excalidraw
5
+ → renders to FILE.png next to the source
6
+
7
+ excalidraw-render FILE.excalidraw -o out.svg --format svg
8
+ → renders to out.svg
9
+
10
+ excalidraw-render FILE.excalidraw --width 1200
11
+ → PNG at 1200px wide (height auto, aspect preserved)
12
+
13
+ excalidraw-render DIR/
14
+ → batch mode: every .excalidraw file in DIR rendered to <name>.png next to source
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from excalidraw_render._version import __version__
24
+ from excalidraw_render.render import load_scene, render_png, render_svg
25
+
26
+
27
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
28
+ parser = argparse.ArgumentParser(
29
+ prog="excalidraw-render",
30
+ description="Clean, browser-free renderer for .excalidraw files.",
31
+ )
32
+ parser.add_argument("input", help="path to a .excalidraw file or directory")
33
+ parser.add_argument(
34
+ "-o", "--output",
35
+ help="output file path (single-file mode) or output directory (batch mode). "
36
+ "default: same path as input with the format's extension",
37
+ )
38
+ parser.add_argument(
39
+ "-f", "--format",
40
+ choices=("png", "svg"),
41
+ default="png",
42
+ help="output format. default: png",
43
+ )
44
+ parser.add_argument(
45
+ "--width",
46
+ type=int,
47
+ help="output width in pixels (PNG only; height computed from aspect ratio)",
48
+ )
49
+ parser.add_argument(
50
+ "--scale",
51
+ type=float,
52
+ default=1.0,
53
+ help="scale factor for PNG output when --width is not set. default: 1.0",
54
+ )
55
+ parser.add_argument(
56
+ "--no-background",
57
+ action="store_true",
58
+ help="render with a transparent background instead of white",
59
+ )
60
+ parser.add_argument(
61
+ "--padding",
62
+ type=float,
63
+ default=20.0,
64
+ help="padding around the scene's bounding box in SVG units. default: 20",
65
+ )
66
+ parser.add_argument(
67
+ "-V", "--version",
68
+ action="version",
69
+ version=f"excalidraw-render {__version__}",
70
+ )
71
+ return parser.parse_args(argv)
72
+
73
+
74
+ def _output_path_for(source: Path, requested: str | None, fmt: str, *, batch_dir: Path | None) -> Path:
75
+ """Resolve the output path for a single source file."""
76
+ suffix = f".{fmt}"
77
+ if batch_dir is not None:
78
+ return batch_dir / (source.stem + suffix)
79
+ if requested:
80
+ out = Path(requested)
81
+ # If the requested path has no extension, append the format's extension.
82
+ if out.suffix == "":
83
+ out = out.with_suffix(suffix)
84
+ return out
85
+ return source.with_suffix(suffix)
86
+
87
+
88
+ def _render_one(source: Path, output: Path, args: argparse.Namespace) -> None:
89
+ scene = load_scene(source)
90
+ background = None if args.no_background else "#ffffff"
91
+ if args.format == "svg":
92
+ svg = render_svg(scene, padding=args.padding, background=background)
93
+ output.write_text(svg)
94
+ else:
95
+ render_png(
96
+ scene,
97
+ output,
98
+ width=args.width,
99
+ scale=args.scale,
100
+ padding=args.padding,
101
+ background=background,
102
+ )
103
+ print(f"{source} -> {output}", file=sys.stderr)
104
+
105
+
106
+ def _iter_excalidraw_files(directory: Path) -> list[Path]:
107
+ return sorted(p for p in directory.iterdir() if p.is_file() and p.suffix == ".excalidraw")
108
+
109
+
110
+ def main(argv: list[str] | None = None) -> int:
111
+ args = _parse_args(argv)
112
+ input_path = Path(args.input)
113
+
114
+ if not input_path.exists():
115
+ print(f"excalidraw-render: not found: {input_path}", file=sys.stderr)
116
+ return 1
117
+
118
+ if input_path.is_dir():
119
+ sources = _iter_excalidraw_files(input_path)
120
+ if not sources:
121
+ print(f"excalidraw-render: no .excalidraw files in {input_path}", file=sys.stderr)
122
+ return 1
123
+ batch_dir = Path(args.output) if args.output else input_path
124
+ batch_dir.mkdir(parents=True, exist_ok=True)
125
+ for src in sources:
126
+ _render_one(src, _output_path_for(src, None, args.format, batch_dir=batch_dir), args)
127
+ return 0
128
+
129
+ if input_path.suffix != ".excalidraw":
130
+ print(
131
+ f"excalidraw-render: expected .excalidraw file, got {input_path.suffix or '(no extension)'}",
132
+ file=sys.stderr,
133
+ )
134
+ return 1
135
+
136
+ output = _output_path_for(input_path, args.output, args.format, batch_dir=None)
137
+ output.parent.mkdir(parents=True, exist_ok=True)
138
+ _render_one(input_path, output, args)
139
+ return 0
140
+
141
+
142
+ if __name__ == "__main__":
143
+ sys.exit(main())
@@ -0,0 +1,347 @@
1
+ """Typed dataclasses for Excalidraw elements + JSON loader.
2
+
3
+ Schema reference:
4
+ https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts
5
+
6
+ We model only what the renderer needs. Fields we ignore (e.g., `versionNonce`,
7
+ `seed`, `updated`, `frameId`, `boundElements` reverse-references handled at
8
+ layout time) are dropped during parsing.
9
+
10
+ Forward-compat: unknown fields are tolerated. Unknown element types are surfaced
11
+ through `parse_scene()` so callers can decide whether to skip, warn, or fail.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Literal
18
+
19
+ # ----------------------------------------------------------------------- enums
20
+
21
+ StrokeStyle = Literal["solid", "dashed", "dotted"]
22
+ FillStyle = Literal["solid", "hachure", "cross-hatch", "zigzag", "zigzag-line", "dots", "dashed"]
23
+ TextAlign = Literal["left", "center", "right"]
24
+ VerticalAlign = Literal["top", "middle", "bottom"]
25
+ FontFamily = Literal[1, 2, 3, 4, 5, 6, 7, 8] # Excalidraw uses ints for font families
26
+ Arrowhead = Literal[
27
+ "arrow", "bar", "dot", "triangle", "triangle_outline",
28
+ "diamond", "diamond_outline",
29
+ "crowfoot_one", "crowfoot_many", "crowfoot_one_or_many",
30
+ ]
31
+ LinearElementType = Literal["arrow", "line"]
32
+
33
+
34
+ # --------------------------------------------------------------- common bases
35
+
36
+ @dataclass(frozen=True, kw_only=True)
37
+ class ExcalidrawElementBase:
38
+ """Fields shared by every Excalidraw element."""
39
+
40
+ id: str
41
+ type: str
42
+ x: float
43
+ y: float
44
+ width: float
45
+ height: float
46
+ angle: float = 0.0
47
+ stroke_color: str = "#1e1e1e"
48
+ background_color: str = "transparent"
49
+ fill_style: FillStyle = "solid"
50
+ stroke_width: float = 2.0
51
+ stroke_style: StrokeStyle = "solid"
52
+ roughness: int = 1
53
+ opacity: float = 100.0
54
+ group_ids: tuple[str, ...] = ()
55
+ frame_id: str | None = None
56
+ is_deleted: bool = False
57
+ link: str | None = None
58
+ locked: bool = False
59
+ # Per-element type-specific data goes in subclasses.
60
+
61
+
62
+ # ----------------------------------------------------------- shape elements
63
+
64
+ @dataclass(frozen=True, kw_only=True)
65
+ class RectangleElement(ExcalidrawElementBase):
66
+ type: Literal["rectangle"] = "rectangle"
67
+ roundness: Roundness | None = None
68
+
69
+
70
+ @dataclass(frozen=True, kw_only=True)
71
+ class EllipseElement(ExcalidrawElementBase):
72
+ type: Literal["ellipse"] = "ellipse"
73
+
74
+
75
+ @dataclass(frozen=True, kw_only=True)
76
+ class DiamondElement(ExcalidrawElementBase):
77
+ type: Literal["diamond"] = "diamond"
78
+ roundness: Roundness | None = None
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class Roundness:
83
+ """Excalidraw stores roundness as { type: int, value?: float }."""
84
+
85
+ type: int
86
+ value: float | None = None
87
+
88
+
89
+ # ---------------------------------------------------- linear elements (arrow/line)
90
+
91
+ @dataclass(frozen=True, kw_only=True)
92
+ class LinearElement(ExcalidrawElementBase):
93
+ """Common base for arrow and line. Points are deltas from (x, y)."""
94
+
95
+ points: tuple[tuple[float, float], ...] = ()
96
+ start_arrowhead: Arrowhead | None = None
97
+ end_arrowhead: Arrowhead | None = None
98
+ start_binding: Binding | None = None
99
+ end_binding: Binding | None = None
100
+
101
+
102
+ @dataclass(frozen=True, kw_only=True)
103
+ class ArrowElement(LinearElement):
104
+ type: Literal["arrow"] = "arrow"
105
+
106
+
107
+ @dataclass(frozen=True, kw_only=True)
108
+ class LineElement(LinearElement):
109
+ type: Literal["line"] = "line"
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class Binding:
114
+ """Linear element bound to a shape (start/end). element_id is the target shape."""
115
+
116
+ element_id: str
117
+ focus: float = 0.0
118
+ gap: float = 0.0
119
+
120
+
121
+ # ----------------------------------------------------------------- text
122
+
123
+ @dataclass(frozen=True, kw_only=True)
124
+ class TextElement(ExcalidrawElementBase):
125
+ type: Literal["text"] = "text"
126
+ text: str = ""
127
+ font_size: float = 20.0
128
+ font_family: FontFamily = 1
129
+ text_align: TextAlign = "left"
130
+ vertical_align: VerticalAlign = "top"
131
+ container_id: str | None = None
132
+ original_text: str | None = None
133
+ line_height: float = 1.25
134
+
135
+
136
+ # ---------------------------------------------------------------- freedraw
137
+
138
+ @dataclass(frozen=True, kw_only=True)
139
+ class FreeDrawElement(ExcalidrawElementBase):
140
+ type: Literal["freedraw"] = "freedraw"
141
+ points: tuple[tuple[float, float], ...] = ()
142
+ pressures: tuple[float, ...] = ()
143
+ simulate_pressure: bool = True
144
+ last_committed_point: tuple[float, float] | None = None
145
+
146
+
147
+ # ----------------------------------------------------------------- image
148
+
149
+ @dataclass(frozen=True, kw_only=True)
150
+ class ImageElement(ExcalidrawElementBase):
151
+ type: Literal["image"] = "image"
152
+ file_id: str | None = None
153
+ status: Literal["pending", "saved", "error"] = "saved"
154
+ scale: tuple[float, float] = (1.0, 1.0)
155
+
156
+
157
+ # ----------------------------------------------------------------- frame
158
+
159
+ @dataclass(frozen=True, kw_only=True)
160
+ class FrameElement(ExcalidrawElementBase):
161
+ type: Literal["frame"] = "frame"
162
+ name: str | None = None
163
+
164
+
165
+ # ---------------------------------------------------------- unknown element
166
+
167
+ @dataclass(frozen=True, kw_only=True)
168
+ class UnknownElement(ExcalidrawElementBase):
169
+ """Element with a type we don't recognize. Preserved so callers can decide."""
170
+
171
+ raw: dict[str, Any] = field(default_factory=dict)
172
+
173
+
174
+ ExcalidrawElement = (
175
+ RectangleElement
176
+ | EllipseElement
177
+ | DiamondElement
178
+ | ArrowElement
179
+ | LineElement
180
+ | TextElement
181
+ | FreeDrawElement
182
+ | ImageElement
183
+ | FrameElement
184
+ | UnknownElement
185
+ )
186
+
187
+
188
+ # ----------------------------------------------------------------- files
189
+
190
+ @dataclass(frozen=True)
191
+ class EmbeddedFile:
192
+ """Image data referenced from ImageElement.file_id."""
193
+
194
+ id: str
195
+ mime_type: str
196
+ data_url: str # e.g. "data:image/png;base64,..."
197
+ created: int = 0
198
+
199
+
200
+ # ----------------------------------------------------------------- scene
201
+
202
+ @dataclass(frozen=True)
203
+ class Scene:
204
+ """A parsed .excalidraw document — elements + embedded files."""
205
+
206
+ elements: tuple[ExcalidrawElement, ...]
207
+ files: dict[str, EmbeddedFile] = field(default_factory=dict)
208
+ app_state: dict[str, Any] = field(default_factory=dict)
209
+ source: str = ""
210
+
211
+
212
+ # ---------------------------------------------------------------- parsing
213
+
214
+ def _common_fields(raw: dict[str, Any]) -> dict[str, Any]:
215
+ """Extract fields present on every element."""
216
+ return {
217
+ "id": raw["id"],
218
+ "type": raw["type"],
219
+ "x": float(raw.get("x", 0)),
220
+ "y": float(raw.get("y", 0)),
221
+ "width": float(raw.get("width", 0)),
222
+ "height": float(raw.get("height", 0)),
223
+ "angle": float(raw.get("angle", 0)),
224
+ "stroke_color": raw.get("strokeColor", "#1e1e1e"),
225
+ "background_color": raw.get("backgroundColor", "transparent"),
226
+ "fill_style": raw.get("fillStyle", "solid"),
227
+ "stroke_width": float(raw.get("strokeWidth", 2)),
228
+ "stroke_style": raw.get("strokeStyle", "solid"),
229
+ "roughness": int(raw.get("roughness", 1)),
230
+ "opacity": float(raw.get("opacity", 100)),
231
+ "group_ids": tuple(raw.get("groupIds", []) or []),
232
+ "frame_id": raw.get("frameId"),
233
+ "is_deleted": bool(raw.get("isDeleted", False)),
234
+ "link": raw.get("link"),
235
+ "locked": bool(raw.get("locked", False)),
236
+ }
237
+
238
+
239
+ def _parse_roundness(raw: Any) -> Roundness | None:
240
+ if not isinstance(raw, dict):
241
+ return None
242
+ return Roundness(type=int(raw.get("type", 0)), value=raw.get("value"))
243
+
244
+
245
+ def _parse_binding(raw: Any) -> Binding | None:
246
+ if not isinstance(raw, dict):
247
+ return None
248
+ return Binding(
249
+ element_id=raw.get("elementId", ""),
250
+ focus=float(raw.get("focus", 0)),
251
+ gap=float(raw.get("gap", 0)),
252
+ )
253
+
254
+
255
+ def _parse_points(raw: Any) -> tuple[tuple[float, float], ...]:
256
+ if not raw:
257
+ return ()
258
+ return tuple((float(p[0]), float(p[1])) for p in raw if len(p) >= 2)
259
+
260
+
261
+ def parse_element(raw: dict[str, Any]) -> ExcalidrawElement:
262
+ """Parse a single element dict into the matching typed dataclass."""
263
+ base = _common_fields(raw)
264
+ etype = raw.get("type")
265
+
266
+ if etype == "rectangle":
267
+ return RectangleElement(**base, roundness=_parse_roundness(raw.get("roundness")))
268
+ if etype == "ellipse":
269
+ return EllipseElement(**base)
270
+ if etype == "diamond":
271
+ return DiamondElement(**base, roundness=_parse_roundness(raw.get("roundness")))
272
+ if etype in ("arrow", "line"):
273
+ cls = ArrowElement if etype == "arrow" else LineElement
274
+ return cls(
275
+ **base,
276
+ points=_parse_points(raw.get("points")),
277
+ start_arrowhead=raw.get("startArrowhead"),
278
+ end_arrowhead=raw.get("endArrowhead"),
279
+ start_binding=_parse_binding(raw.get("startBinding")),
280
+ end_binding=_parse_binding(raw.get("endBinding")),
281
+ )
282
+ if etype == "text":
283
+ return TextElement(
284
+ **base,
285
+ text=raw.get("text", ""),
286
+ font_size=float(raw.get("fontSize", 20)),
287
+ font_family=raw.get("fontFamily", 1),
288
+ text_align=raw.get("textAlign", "left"),
289
+ vertical_align=raw.get("verticalAlign", "top"),
290
+ container_id=raw.get("containerId"),
291
+ original_text=raw.get("originalText"),
292
+ line_height=float(raw.get("lineHeight", 1.25)),
293
+ )
294
+ if etype == "freedraw":
295
+ lcp_raw = raw.get("lastCommittedPoint")
296
+ last_committed: tuple[float, float] | None = (
297
+ (float(lcp_raw[0]), float(lcp_raw[1]))
298
+ if lcp_raw is not None and len(lcp_raw) >= 2
299
+ else None
300
+ )
301
+ return FreeDrawElement(
302
+ **base,
303
+ points=_parse_points(raw.get("points")),
304
+ pressures=tuple(float(p) for p in raw.get("pressures", []) or []),
305
+ simulate_pressure=bool(raw.get("simulatePressure", True)),
306
+ last_committed_point=last_committed,
307
+ )
308
+ if etype == "image":
309
+ scale_raw = raw.get("scale", [1, 1])
310
+ scale: tuple[float, float] = (float(scale_raw[0]), float(scale_raw[1]))
311
+ return ImageElement(
312
+ **base,
313
+ file_id=raw.get("fileId"),
314
+ status=raw.get("status", "saved"),
315
+ scale=scale,
316
+ )
317
+ if etype == "frame":
318
+ return FrameElement(**base, name=raw.get("name"))
319
+
320
+ return UnknownElement(**base, raw=raw)
321
+
322
+
323
+ def parse_files(raw: Any) -> dict[str, EmbeddedFile]:
324
+ if not isinstance(raw, dict):
325
+ return {}
326
+ return {
327
+ fid: EmbeddedFile(
328
+ id=fid,
329
+ mime_type=meta.get("mimeType", "application/octet-stream"),
330
+ data_url=meta.get("dataURL", ""),
331
+ created=int(meta.get("created", 0)),
332
+ )
333
+ for fid, meta in raw.items()
334
+ if isinstance(meta, dict)
335
+ }
336
+
337
+
338
+ def parse_scene(data: dict[str, Any], *, source: str = "") -> Scene:
339
+ """Parse a top-level .excalidraw JSON dict into a Scene."""
340
+ elements = tuple(
341
+ parse_element(el)
342
+ for el in data.get("elements", [])
343
+ if isinstance(el, dict) and not el.get("isDeleted", False)
344
+ )
345
+ files = parse_files(data.get("files"))
346
+ app_state = data.get("appState", {}) if isinstance(data.get("appState"), dict) else {}
347
+ return Scene(elements=elements, files=files, app_state=app_state, source=source)
@@ -0,0 +1,169 @@
1
+ """Scene → SVG, optionally → PNG. Top-level rendering entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from io import BytesIO
8
+ from pathlib import Path
9
+ from typing import IO, Any
10
+
11
+ from excalidraw_render.element import (
12
+ ArrowElement,
13
+ DiamondElement,
14
+ EllipseElement,
15
+ ExcalidrawElement,
16
+ FrameElement,
17
+ FreeDrawElement,
18
+ ImageElement,
19
+ LineElement,
20
+ RectangleElement,
21
+ Scene,
22
+ TextElement,
23
+ UnknownElement,
24
+ parse_scene,
25
+ )
26
+ from excalidraw_render.renderers import (
27
+ render_arrow,
28
+ render_diamond,
29
+ render_ellipse,
30
+ render_frame,
31
+ render_freedraw,
32
+ render_image,
33
+ render_line,
34
+ render_rectangle,
35
+ render_text,
36
+ )
37
+ from excalidraw_render.renderers._util import fmt
38
+
39
+ DEFAULT_PADDING = 20
40
+ DEFAULT_BACKGROUND = "#ffffff"
41
+
42
+
43
+ def _render_element(el: ExcalidrawElement, scene: Scene) -> str:
44
+ if isinstance(el, RectangleElement):
45
+ return render_rectangle(el)
46
+ if isinstance(el, EllipseElement):
47
+ return render_ellipse(el)
48
+ if isinstance(el, DiamondElement):
49
+ return render_diamond(el)
50
+ if isinstance(el, ArrowElement):
51
+ return render_arrow(el)
52
+ if isinstance(el, LineElement):
53
+ return render_line(el)
54
+ if isinstance(el, TextElement):
55
+ return render_text(el)
56
+ if isinstance(el, FreeDrawElement):
57
+ return render_freedraw(el)
58
+ if isinstance(el, ImageElement):
59
+ return render_image(el, scene.files)
60
+ if isinstance(el, FrameElement):
61
+ return render_frame(el)
62
+ if isinstance(el, UnknownElement):
63
+ return f'<!-- unhandled element type: {el.type} -->'
64
+ return ""
65
+
66
+
67
+ def _scene_bbox(scene: Scene) -> tuple[float, float, float, float]:
68
+ """Compute the bounding box covering all rendered elements."""
69
+ if not scene.elements:
70
+ return (0.0, 0.0, 100.0, 100.0)
71
+
72
+ min_x = math.inf
73
+ min_y = math.inf
74
+ max_x = -math.inf
75
+ max_y = -math.inf
76
+
77
+ for el in scene.elements:
78
+ # Account for rotation when computing the bbox.
79
+ if el.angle:
80
+ cx = el.x + el.width / 2
81
+ cy = el.y + el.height / 2
82
+ corners = [
83
+ (el.x, el.y),
84
+ (el.x + el.width, el.y),
85
+ (el.x + el.width, el.y + el.height),
86
+ (el.x, el.y + el.height),
87
+ ]
88
+ cos_a = math.cos(el.angle)
89
+ sin_a = math.sin(el.angle)
90
+ for px, py in corners:
91
+ rx = cx + (px - cx) * cos_a - (py - cy) * sin_a
92
+ ry = cy + (px - cx) * sin_a + (py - cy) * cos_a
93
+ min_x = min(min_x, rx)
94
+ max_x = max(max_x, rx)
95
+ min_y = min(min_y, ry)
96
+ max_y = max(max_y, ry)
97
+ else:
98
+ min_x = min(min_x, el.x)
99
+ max_x = max(max_x, el.x + el.width)
100
+ min_y = min(min_y, el.y)
101
+ max_y = max(max_y, el.y + el.height)
102
+
103
+ if min_x == math.inf:
104
+ return (0.0, 0.0, 100.0, 100.0)
105
+ return (min_x, min_y, max_x, max_y)
106
+
107
+
108
+ def render_svg(
109
+ scene: Scene,
110
+ *,
111
+ padding: float = DEFAULT_PADDING,
112
+ background: str | None = DEFAULT_BACKGROUND,
113
+ ) -> str:
114
+ """Render a Scene to a complete SVG document string."""
115
+ min_x, min_y, max_x, max_y = _scene_bbox(scene)
116
+ width = max_x - min_x + 2 * padding
117
+ height = max_y - min_y + 2 * padding
118
+ vx = min_x - padding
119
+ vy = min_y - padding
120
+
121
+ parts: list[str] = [
122
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
123
+ f'width="{fmt(width)}" height="{fmt(height)}" '
124
+ f'viewBox="{fmt(vx)} {fmt(vy)} {fmt(width)} {fmt(height)}">'
125
+ ]
126
+ if background:
127
+ parts.append(
128
+ f'<rect x="{fmt(vx)}" y="{fmt(vy)}" '
129
+ f'width="{fmt(width)}" height="{fmt(height)}" fill="{background}"/>'
130
+ )
131
+ parts.extend(_render_element(el, scene) for el in scene.elements)
132
+ parts.append("</svg>")
133
+ return "\n".join(p for p in parts if p)
134
+
135
+
136
+ def render_png(
137
+ scene: Scene,
138
+ out: IO[bytes] | Path | str,
139
+ *,
140
+ width: int | None = None,
141
+ scale: float = 1.0,
142
+ padding: float = DEFAULT_PADDING,
143
+ background: str | None = DEFAULT_BACKGROUND,
144
+ ) -> None:
145
+ """Render a Scene to a PNG. Writes to `out` (file path or binary file-like)."""
146
+ import cairosvg # local import — cairosvg is heavy; let SVG-only use skip it.
147
+
148
+ svg = render_svg(scene, padding=padding, background=background)
149
+ target: str | IO[bytes] = str(out) if isinstance(out, (str, Path)) else out
150
+
151
+ kwargs: dict[str, Any] = {}
152
+ if width:
153
+ kwargs["output_width"] = width
154
+ else:
155
+ kwargs["scale"] = scale
156
+
157
+ if isinstance(target, str):
158
+ cairosvg.svg2png(bytestring=svg.encode("utf-8"), write_to=target, **kwargs)
159
+ else:
160
+ buf = BytesIO()
161
+ cairosvg.svg2png(bytestring=svg.encode("utf-8"), write_to=buf, **kwargs)
162
+ target.write(buf.getvalue())
163
+
164
+
165
+ def load_scene(path: Path | str) -> Scene:
166
+ """Read a .excalidraw file from disk and parse to a Scene."""
167
+ p = Path(path)
168
+ data = json.loads(p.read_text())
169
+ return parse_scene(data, source=str(p))
@@ -0,0 +1,32 @@
1
+ """SVG renderers for each Excalidraw element type."""
2
+
3
+ from excalidraw_render.renderers._util import (
4
+ fmt,
5
+ opacity_fraction,
6
+ stroke_dasharray,
7
+ text_escape,
8
+ transform_attr,
9
+ )
10
+ from excalidraw_render.renderers.frame import render_frame
11
+ from excalidraw_render.renderers.freedraw import render_freedraw
12
+ from excalidraw_render.renderers.image import render_image
13
+ from excalidraw_render.renderers.linear import render_arrow, render_line
14
+ from excalidraw_render.renderers.shapes import render_diamond, render_ellipse, render_rectangle
15
+ from excalidraw_render.renderers.text import render_text
16
+
17
+ __all__ = [
18
+ "fmt",
19
+ "opacity_fraction",
20
+ "render_arrow",
21
+ "render_diamond",
22
+ "render_ellipse",
23
+ "render_frame",
24
+ "render_freedraw",
25
+ "render_image",
26
+ "render_line",
27
+ "render_rectangle",
28
+ "render_text",
29
+ "stroke_dasharray",
30
+ "text_escape",
31
+ "transform_attr",
32
+ ]