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.
- excalidraw_render/__init__.py +5 -0
- excalidraw_render/_version.py +1 -0
- excalidraw_render/cli.py +143 -0
- excalidraw_render/element.py +347 -0
- excalidraw_render/render.py +169 -0
- excalidraw_render/renderers/__init__.py +32 -0
- excalidraw_render/renderers/_util.py +79 -0
- excalidraw_render/renderers/frame.py +35 -0
- excalidraw_render/renderers/freedraw.py +65 -0
- excalidraw_render/renderers/image.py +33 -0
- excalidraw_render/renderers/linear.py +160 -0
- excalidraw_render/renderers/shapes.py +77 -0
- excalidraw_render/renderers/text.py +72 -0
- excalidraw_render-0.1.0.dist-info/METADATA +206 -0
- excalidraw_render-0.1.0.dist-info/RECORD +18 -0
- excalidraw_render-0.1.0.dist-info/WHEEL +4 -0
- excalidraw_render-0.1.0.dist-info/entry_points.txt +2 -0
- excalidraw_render-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
excalidraw_render/cli.py
ADDED
|
@@ -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
|
+
]
|