predraw 0.1.1__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.
- predraw/__init__.py +1 -0
- predraw/__main__.py +5 -0
- predraw/bbox.py +304 -0
- predraw/cli.py +349 -0
- predraw/fonts.py +198 -0
- predraw/loader.py +192 -0
- predraw/model.py +70 -0
- predraw/output.py +64 -0
- predraw/pipeline.py +281 -0
- predraw/renderer.py +200 -0
- predraw/validator.py +38 -0
- predraw-0.1.1.dist-info/METADATA +250 -0
- predraw-0.1.1.dist-info/RECORD +16 -0
- predraw-0.1.1.dist-info/WHEEL +4 -0
- predraw-0.1.1.dist-info/entry_points.txt +2 -0
- predraw-0.1.1.dist-info/licenses/LICENSE +21 -0
predraw/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
predraw/__main__.py
ADDED
predraw/bbox.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Bounding box computation for predraw elements.
|
|
2
|
+
|
|
3
|
+
Computes axis-aligned bounding boxes from element geometry, including
|
|
4
|
+
SVG path data parsing and transform application.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from .model import Element, Transform
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def compute_bbox(element: Element) -> tuple[float, float, float, float] | None:
|
|
15
|
+
"""Compute the bounding box (min_x, min_y, max_x, max_y) of an element.
|
|
16
|
+
|
|
17
|
+
Handles:
|
|
18
|
+
- rect: (x, y, x+width, y+height)
|
|
19
|
+
- text: (x, y - font_size, x + estimated_width, y) -- rough estimate
|
|
20
|
+
- path: parse the d string and find coordinate extremes
|
|
21
|
+
- group: union of children's bboxes, with transform applied
|
|
22
|
+
- background: (0, 0, 0, 0) -- skip
|
|
23
|
+
|
|
24
|
+
Returns None if bbox can't be determined.
|
|
25
|
+
"""
|
|
26
|
+
if element.type == "background":
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
if element.type == "rect":
|
|
30
|
+
bbox = (element.x, element.y, element.x + element.width, element.y + element.height)
|
|
31
|
+
|
|
32
|
+
elif element.type == "text":
|
|
33
|
+
font_size = element.font.size if element.font else 16
|
|
34
|
+
# Rough width estimate: 0.6 * font_size per character
|
|
35
|
+
content = element.content or ""
|
|
36
|
+
estimated_width = len(content) * font_size * 0.6
|
|
37
|
+
# y is baseline; top is approximately y - font_size
|
|
38
|
+
x = element.x
|
|
39
|
+
if element.anchor == "middle":
|
|
40
|
+
x = element.x - estimated_width / 2
|
|
41
|
+
elif element.anchor == "end":
|
|
42
|
+
x = element.x - estimated_width
|
|
43
|
+
bbox = (x, element.y - font_size, x + estimated_width, element.y)
|
|
44
|
+
|
|
45
|
+
elif element.type == "path":
|
|
46
|
+
if not element.d:
|
|
47
|
+
return None
|
|
48
|
+
path_bbox = _bbox_from_path_d(element.d)
|
|
49
|
+
if path_bbox is None:
|
|
50
|
+
return None
|
|
51
|
+
bbox = path_bbox
|
|
52
|
+
|
|
53
|
+
elif element.type == "group":
|
|
54
|
+
if not element.elements:
|
|
55
|
+
return None
|
|
56
|
+
# Union of children bboxes
|
|
57
|
+
min_x = float("inf")
|
|
58
|
+
min_y = float("inf")
|
|
59
|
+
max_x = float("-inf")
|
|
60
|
+
max_y = float("-inf")
|
|
61
|
+
has_any = False
|
|
62
|
+
for child in element.elements:
|
|
63
|
+
child_bbox = compute_bbox(child)
|
|
64
|
+
if child_bbox is not None:
|
|
65
|
+
has_any = True
|
|
66
|
+
min_x = min(min_x, child_bbox[0])
|
|
67
|
+
min_y = min(min_y, child_bbox[1])
|
|
68
|
+
max_x = max(max_x, child_bbox[2])
|
|
69
|
+
max_y = max(max_y, child_bbox[3])
|
|
70
|
+
if not has_any:
|
|
71
|
+
return None
|
|
72
|
+
bbox = (min_x, min_y, max_x, max_y)
|
|
73
|
+
|
|
74
|
+
else:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
# Apply element transform if present
|
|
78
|
+
if element.transform is not None:
|
|
79
|
+
bbox = _apply_transform_to_bbox(bbox, element.transform)
|
|
80
|
+
|
|
81
|
+
return bbox
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Regex to tokenize SVG path d strings: command letters and numbers
|
|
85
|
+
_PATH_TOKEN_RE = re.compile(r"[A-Za-z]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _bbox_from_path_d(d: str) -> tuple[float, float, float, float] | None:
|
|
89
|
+
"""Parse SVG path d string and extract coordinate bounding box.
|
|
90
|
+
|
|
91
|
+
Handles M, L, H, V, C, S, Q, T, A, Z commands (uppercase = absolute).
|
|
92
|
+
For curves (C, S, Q), use control points as bbox approximation.
|
|
93
|
+
Lowercase (relative) commands need accumulation from current point.
|
|
94
|
+
"""
|
|
95
|
+
tokens = _PATH_TOKEN_RE.findall(d)
|
|
96
|
+
if not tokens:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
xs: list[float] = []
|
|
100
|
+
ys: list[float] = []
|
|
101
|
+
# Current point for relative commands
|
|
102
|
+
cx, cy = 0.0, 0.0
|
|
103
|
+
i = 0
|
|
104
|
+
|
|
105
|
+
def next_num() -> float:
|
|
106
|
+
nonlocal i
|
|
107
|
+
i += 1
|
|
108
|
+
return float(tokens[i])
|
|
109
|
+
|
|
110
|
+
while i < len(tokens):
|
|
111
|
+
token = tokens[i]
|
|
112
|
+
|
|
113
|
+
if token == "M":
|
|
114
|
+
# Absolute moveto; consumes pairs of coordinates
|
|
115
|
+
cx = next_num()
|
|
116
|
+
cy = next_num()
|
|
117
|
+
xs.append(cx)
|
|
118
|
+
ys.append(cy)
|
|
119
|
+
# Implicit lineto pairs follow
|
|
120
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
121
|
+
cx = next_num()
|
|
122
|
+
cy = next_num()
|
|
123
|
+
xs.append(cx)
|
|
124
|
+
ys.append(cy)
|
|
125
|
+
|
|
126
|
+
elif token == "m":
|
|
127
|
+
# Relative moveto
|
|
128
|
+
cx += next_num()
|
|
129
|
+
cy += next_num()
|
|
130
|
+
xs.append(cx)
|
|
131
|
+
ys.append(cy)
|
|
132
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
133
|
+
cx += next_num()
|
|
134
|
+
cy += next_num()
|
|
135
|
+
xs.append(cx)
|
|
136
|
+
ys.append(cy)
|
|
137
|
+
|
|
138
|
+
elif token == "L":
|
|
139
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
140
|
+
cx = next_num()
|
|
141
|
+
cy = next_num()
|
|
142
|
+
xs.append(cx)
|
|
143
|
+
ys.append(cy)
|
|
144
|
+
|
|
145
|
+
elif token == "l":
|
|
146
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
147
|
+
cx += next_num()
|
|
148
|
+
cy += next_num()
|
|
149
|
+
xs.append(cx)
|
|
150
|
+
ys.append(cy)
|
|
151
|
+
|
|
152
|
+
elif token == "H":
|
|
153
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
154
|
+
cx = next_num()
|
|
155
|
+
xs.append(cx)
|
|
156
|
+
|
|
157
|
+
elif token == "h":
|
|
158
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
159
|
+
cx += next_num()
|
|
160
|
+
xs.append(cx)
|
|
161
|
+
|
|
162
|
+
elif token == "V":
|
|
163
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
164
|
+
cy = next_num()
|
|
165
|
+
ys.append(cy)
|
|
166
|
+
|
|
167
|
+
elif token == "v":
|
|
168
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
169
|
+
cy += next_num()
|
|
170
|
+
ys.append(cy)
|
|
171
|
+
|
|
172
|
+
elif token == "C":
|
|
173
|
+
# Cubic bezier: 3 pairs of coords (control1, control2, endpoint)
|
|
174
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
175
|
+
for _ in range(3):
|
|
176
|
+
px = next_num()
|
|
177
|
+
py = next_num()
|
|
178
|
+
xs.append(px)
|
|
179
|
+
ys.append(py)
|
|
180
|
+
# Endpoint becomes current
|
|
181
|
+
cx, cy = xs[-1], ys[-1]
|
|
182
|
+
|
|
183
|
+
elif token == "c":
|
|
184
|
+
# Relative cubic bezier
|
|
185
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
186
|
+
for _ in range(3):
|
|
187
|
+
px = cx + next_num()
|
|
188
|
+
py = cy + next_num()
|
|
189
|
+
xs.append(px)
|
|
190
|
+
ys.append(py)
|
|
191
|
+
# Endpoint becomes current (last appended pair)
|
|
192
|
+
cx, cy = xs[-1], ys[-1]
|
|
193
|
+
|
|
194
|
+
elif token == "S":
|
|
195
|
+
# Smooth cubic: 2 pairs (control2, endpoint)
|
|
196
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
197
|
+
for _ in range(2):
|
|
198
|
+
px = next_num()
|
|
199
|
+
py = next_num()
|
|
200
|
+
xs.append(px)
|
|
201
|
+
ys.append(py)
|
|
202
|
+
cx, cy = xs[-1], ys[-1]
|
|
203
|
+
|
|
204
|
+
elif token == "s":
|
|
205
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
206
|
+
for _ in range(2):
|
|
207
|
+
px = cx + next_num()
|
|
208
|
+
py = cy + next_num()
|
|
209
|
+
xs.append(px)
|
|
210
|
+
ys.append(py)
|
|
211
|
+
cx, cy = xs[-1], ys[-1]
|
|
212
|
+
|
|
213
|
+
elif token == "Q":
|
|
214
|
+
# Quadratic bezier: 2 pairs (control, endpoint)
|
|
215
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
216
|
+
for _ in range(2):
|
|
217
|
+
px = next_num()
|
|
218
|
+
py = next_num()
|
|
219
|
+
xs.append(px)
|
|
220
|
+
ys.append(py)
|
|
221
|
+
cx, cy = xs[-1], ys[-1]
|
|
222
|
+
|
|
223
|
+
elif token == "q":
|
|
224
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
225
|
+
for _ in range(2):
|
|
226
|
+
px = cx + next_num()
|
|
227
|
+
py = cy + next_num()
|
|
228
|
+
xs.append(px)
|
|
229
|
+
ys.append(py)
|
|
230
|
+
cx, cy = xs[-1], ys[-1]
|
|
231
|
+
|
|
232
|
+
elif token == "T":
|
|
233
|
+
# Smooth quadratic: 1 pair (endpoint)
|
|
234
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
235
|
+
cx = next_num()
|
|
236
|
+
cy = next_num()
|
|
237
|
+
xs.append(cx)
|
|
238
|
+
ys.append(cy)
|
|
239
|
+
|
|
240
|
+
elif token == "t":
|
|
241
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
242
|
+
cx += next_num()
|
|
243
|
+
cy += next_num()
|
|
244
|
+
xs.append(cx)
|
|
245
|
+
ys.append(cy)
|
|
246
|
+
|
|
247
|
+
elif token == "A":
|
|
248
|
+
# Arc: rx ry x-rotation large-arc-flag sweep-flag x y
|
|
249
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
250
|
+
# Skip rx, ry, x-rotation, large-arc-flag, sweep-flag
|
|
251
|
+
next_num() # rx
|
|
252
|
+
next_num() # ry
|
|
253
|
+
next_num() # x-rotation
|
|
254
|
+
next_num() # large-arc-flag
|
|
255
|
+
next_num() # sweep-flag
|
|
256
|
+
cx = next_num()
|
|
257
|
+
cy = next_num()
|
|
258
|
+
xs.append(cx)
|
|
259
|
+
ys.append(cy)
|
|
260
|
+
|
|
261
|
+
elif token == "a":
|
|
262
|
+
while i + 1 < len(tokens) and not tokens[i + 1].isalpha():
|
|
263
|
+
next_num() # rx
|
|
264
|
+
next_num() # ry
|
|
265
|
+
next_num() # x-rotation
|
|
266
|
+
next_num() # large-arc-flag
|
|
267
|
+
next_num() # sweep-flag
|
|
268
|
+
cx += next_num()
|
|
269
|
+
cy += next_num()
|
|
270
|
+
xs.append(cx)
|
|
271
|
+
ys.append(cy)
|
|
272
|
+
|
|
273
|
+
elif token in ("Z", "z"):
|
|
274
|
+
pass # Close path, no coordinates
|
|
275
|
+
|
|
276
|
+
i += 1
|
|
277
|
+
|
|
278
|
+
if not xs or not ys:
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _apply_transform_to_bbox(
|
|
285
|
+
bbox: tuple[float, float, float, float], transform: Transform
|
|
286
|
+
) -> tuple[float, float, float, float]:
|
|
287
|
+
"""Apply translate and scale to a bounding box."""
|
|
288
|
+
min_x, min_y, max_x, max_y = bbox
|
|
289
|
+
tx, ty = transform.translate
|
|
290
|
+
sx, sy = transform.scale
|
|
291
|
+
|
|
292
|
+
# Scale then translate
|
|
293
|
+
new_min_x = min_x * sx + tx
|
|
294
|
+
new_min_y = min_y * sy + ty
|
|
295
|
+
new_max_x = max_x * sx + tx
|
|
296
|
+
new_max_y = max_y * sy + ty
|
|
297
|
+
|
|
298
|
+
# Handle negative scales flipping min/max
|
|
299
|
+
return (
|
|
300
|
+
min(new_min_x, new_max_x),
|
|
301
|
+
min(new_min_y, new_max_y),
|
|
302
|
+
max(new_min_x, new_max_x),
|
|
303
|
+
max(new_min_y, new_max_y),
|
|
304
|
+
)
|
predraw/cli.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""CLI entry point for predraw — argparse-based with build/pack/unpack subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import copy
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from itertools import groupby
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .loader import load_config, load_scene, resolve_styles
|
|
13
|
+
from .model import Element, Font, Scene, Style, Transform
|
|
14
|
+
from .output import write_outputs
|
|
15
|
+
from .pipeline import execute_pipeline
|
|
16
|
+
from .renderer import render_svg
|
|
17
|
+
from .validator import validate_config, validate_scene
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
"""Entry point for the predraw CLI."""
|
|
22
|
+
parser = argparse.ArgumentParser(prog="predraw", description="predraw scene builder")
|
|
23
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
24
|
+
|
|
25
|
+
# -- build --
|
|
26
|
+
build_parser = subparsers.add_parser("build", help="Build scene into output files")
|
|
27
|
+
build_parser.add_argument("path", nargs="?", default=".", help="Project directory or scene file (default: .)")
|
|
28
|
+
|
|
29
|
+
# -- pack --
|
|
30
|
+
pack_parser = subparsers.add_parser("pack", help="Pack a scene directory into a single JSON file")
|
|
31
|
+
pack_parser.add_argument("path", nargs="?", default=".", help="Project directory or scene file (default: .)")
|
|
32
|
+
pack_parser.add_argument("-o", "--output", default="packed.json", help="Output file path (default: packed.json)")
|
|
33
|
+
|
|
34
|
+
# -- unpack --
|
|
35
|
+
unpack_parser = subparsers.add_parser("unpack", help="Unpack a packed JSON file into a project directory")
|
|
36
|
+
unpack_parser.add_argument("file", help="Packed JSON file to unpack")
|
|
37
|
+
unpack_parser.add_argument("-o", "--output", default=".", help="Output directory (default: .)")
|
|
38
|
+
|
|
39
|
+
# -- validate --
|
|
40
|
+
validate_parser = subparsers.add_parser("validate", help="Validate a scene or config JSON file against its schema")
|
|
41
|
+
validate_parser.add_argument("file", help="JSON file to validate")
|
|
42
|
+
validate_parser.add_argument("--schema", choices=["scene", "config"], default=None, help="Force schema type (auto-detected if omitted)")
|
|
43
|
+
|
|
44
|
+
args = parser.parse_args()
|
|
45
|
+
|
|
46
|
+
if args.command is None:
|
|
47
|
+
parser.print_help()
|
|
48
|
+
sys.exit(0)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
if args.command == "build":
|
|
52
|
+
_cmd_build(args.path)
|
|
53
|
+
elif args.command == "pack":
|
|
54
|
+
_cmd_pack(args.path, args.output)
|
|
55
|
+
elif args.command == "unpack":
|
|
56
|
+
_cmd_unpack(args.file, args.output)
|
|
57
|
+
elif args.command == "validate":
|
|
58
|
+
_cmd_validate(args.file, args.schema)
|
|
59
|
+
except FileNotFoundError as e:
|
|
60
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
except json.JSONDecodeError as e:
|
|
63
|
+
print(f"Error: invalid JSON — {e}", file=sys.stderr)
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ─── Build ───────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _cmd_build(path: str) -> None:
|
|
74
|
+
"""Build all outputs for a scene, grouped by mode to avoid redundant work."""
|
|
75
|
+
scene = load_scene(path)
|
|
76
|
+
config = load_config(path)
|
|
77
|
+
outputs = config.get("outputs", [])
|
|
78
|
+
|
|
79
|
+
if not outputs:
|
|
80
|
+
print("No outputs defined in config.")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# Resolve output directory from path
|
|
84
|
+
p = Path(path)
|
|
85
|
+
output_dir = str(p if p.is_dir() else p.parent)
|
|
86
|
+
|
|
87
|
+
# Group outputs by mode so we only resolve/render once per mode
|
|
88
|
+
def mode_key(o: dict) -> str:
|
|
89
|
+
return o.get("mode", "dark")
|
|
90
|
+
|
|
91
|
+
sorted_outputs = sorted(outputs, key=mode_key)
|
|
92
|
+
|
|
93
|
+
total_written: list[str] = []
|
|
94
|
+
for mode, group in groupby(sorted_outputs, key=mode_key):
|
|
95
|
+
group_outputs = list(group)
|
|
96
|
+
print(f"Building mode: {mode} ({len(group_outputs)} output(s))")
|
|
97
|
+
|
|
98
|
+
# Deep copy, resolve styles, run pipeline, render
|
|
99
|
+
scene_copy = copy.deepcopy(scene)
|
|
100
|
+
resolve_styles(scene_copy, mode)
|
|
101
|
+
execute_pipeline(scene_copy)
|
|
102
|
+
svg = render_svg(scene_copy)
|
|
103
|
+
|
|
104
|
+
# Write all outputs for this mode
|
|
105
|
+
mode_config = {"outputs": group_outputs}
|
|
106
|
+
written = write_outputs(svg, mode_config, output_dir)
|
|
107
|
+
total_written.extend(written)
|
|
108
|
+
|
|
109
|
+
print(f"\nDone — {len(total_written)} file(s) written.")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ─── Validate ───────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cmd_validate(file: str, schema_type: str | None) -> None:
|
|
116
|
+
"""Validate a JSON file against its scene or config schema."""
|
|
117
|
+
file_path = Path(file)
|
|
118
|
+
if not file_path.exists():
|
|
119
|
+
raise FileNotFoundError(f"File not found: {file}")
|
|
120
|
+
|
|
121
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
122
|
+
data = json.load(f)
|
|
123
|
+
|
|
124
|
+
# Auto-detect schema type if not forced: presence of "outputs" key means config
|
|
125
|
+
if schema_type is None:
|
|
126
|
+
schema_type = "config" if "outputs" in data else "scene"
|
|
127
|
+
|
|
128
|
+
if schema_type == "config":
|
|
129
|
+
errors = validate_config(data)
|
|
130
|
+
label = "config"
|
|
131
|
+
else:
|
|
132
|
+
errors = validate_scene(data)
|
|
133
|
+
label = "scene"
|
|
134
|
+
|
|
135
|
+
if errors:
|
|
136
|
+
print(f"Invalid {label} file: {file_path}", file=sys.stderr)
|
|
137
|
+
for err in errors:
|
|
138
|
+
print(f" {err}", file=sys.stderr)
|
|
139
|
+
sys.exit(1)
|
|
140
|
+
else:
|
|
141
|
+
print(f"Valid {label} file")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ─── Pack ────────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _cmd_pack(path: str, output_file: str) -> None:
|
|
148
|
+
"""Pack a scene directory into a single self-contained JSON file."""
|
|
149
|
+
scene = load_scene(path)
|
|
150
|
+
packed = pack_scene(scene)
|
|
151
|
+
|
|
152
|
+
out_path = Path(output_file)
|
|
153
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
out_path.write_text(json.dumps(packed, indent=2), encoding="utf-8")
|
|
155
|
+
print(f"Packed scene written to: {out_path}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def pack_scene(scene: Scene) -> dict:
|
|
159
|
+
"""Convert a Scene to a packed JSON-serializable dict.
|
|
160
|
+
|
|
161
|
+
Removes imports (already resolved into defs) and assigns IDs
|
|
162
|
+
to elements that lack them for referenceability.
|
|
163
|
+
"""
|
|
164
|
+
data = _scene_to_dict(scene)
|
|
165
|
+
|
|
166
|
+
# Remove imports — they are already resolved into defs
|
|
167
|
+
data.pop("imports", None)
|
|
168
|
+
|
|
169
|
+
# Flatten: assign IDs to elements that don't have one
|
|
170
|
+
_assign_ids(data.get("elements", []))
|
|
171
|
+
|
|
172
|
+
return data
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _assign_ids(elements: list[dict], counter: list[int] | None = None) -> None:
|
|
176
|
+
"""Recursively assign auto-generated IDs to elements missing one."""
|
|
177
|
+
if counter is None:
|
|
178
|
+
counter = [0]
|
|
179
|
+
|
|
180
|
+
for el in elements:
|
|
181
|
+
if not el.get("id"):
|
|
182
|
+
el["id"] = f"el-{counter[0]}"
|
|
183
|
+
counter[0] += 1
|
|
184
|
+
# Recurse into child elements
|
|
185
|
+
if "elements" in el:
|
|
186
|
+
_assign_ids(el["elements"], counter)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ─── Unpack ──────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _cmd_unpack(file: str, output_dir: str) -> None:
|
|
193
|
+
"""Unpack a packed JSON file into a project directory."""
|
|
194
|
+
file_path = Path(file)
|
|
195
|
+
if not file_path.exists():
|
|
196
|
+
raise FileNotFoundError(f"File not found: {file}")
|
|
197
|
+
|
|
198
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
199
|
+
data = json.load(f)
|
|
200
|
+
|
|
201
|
+
scene = load_scene(str(file_path))
|
|
202
|
+
unpack_scene(scene, output_dir)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def unpack_scene(scene: Scene, output_dir: str) -> None:
|
|
206
|
+
"""Unpack a scene into a directory structure with components/ and main.json."""
|
|
207
|
+
out = Path(output_dir)
|
|
208
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
|
|
210
|
+
imports: dict[str, str] = {}
|
|
211
|
+
|
|
212
|
+
# Extract defs into separate component files
|
|
213
|
+
if scene.defs:
|
|
214
|
+
components_dir = out / "components"
|
|
215
|
+
components_dir.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
|
|
217
|
+
for name, element in scene.defs.items():
|
|
218
|
+
component_file = f"components/{name}.json"
|
|
219
|
+
component_path = components_dir / f"{name}.json"
|
|
220
|
+
component_data = _element_to_dict(element)
|
|
221
|
+
component_path.write_text(
|
|
222
|
+
json.dumps(component_data, indent=2), encoding="utf-8"
|
|
223
|
+
)
|
|
224
|
+
imports[name] = component_file
|
|
225
|
+
print(f"Extracted component: {component_path}")
|
|
226
|
+
|
|
227
|
+
# Build main.json without defs, with imports
|
|
228
|
+
main_data: dict = {
|
|
229
|
+
"width": scene.width,
|
|
230
|
+
"height": scene.height,
|
|
231
|
+
}
|
|
232
|
+
if scene.background:
|
|
233
|
+
main_data["background"] = scene.background
|
|
234
|
+
if scene.styles:
|
|
235
|
+
main_data["styles"] = {
|
|
236
|
+
name: {"light": s.light, "dark": s.dark}
|
|
237
|
+
for name, s in scene.styles.items()
|
|
238
|
+
}
|
|
239
|
+
if imports:
|
|
240
|
+
main_data["imports"] = imports
|
|
241
|
+
if scene.elements:
|
|
242
|
+
main_data["elements"] = [_element_to_dict(el) for el in scene.elements]
|
|
243
|
+
if scene.pipeline:
|
|
244
|
+
main_data["pipeline"] = scene.pipeline
|
|
245
|
+
|
|
246
|
+
main_path = out / "main.json"
|
|
247
|
+
main_path.write_text(json.dumps(main_data, indent=2), encoding="utf-8")
|
|
248
|
+
print(f"Wrote: {main_path}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ─── Serialization helpers ───────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _scene_to_dict(scene: Scene) -> dict:
|
|
255
|
+
"""Convert a Scene back to a JSON-serializable dict."""
|
|
256
|
+
data: dict = {
|
|
257
|
+
"width": scene.width,
|
|
258
|
+
"height": scene.height,
|
|
259
|
+
}
|
|
260
|
+
if scene.background:
|
|
261
|
+
data["background"] = scene.background
|
|
262
|
+
if scene.styles:
|
|
263
|
+
data["styles"] = {
|
|
264
|
+
name: {"light": s.light, "dark": s.dark}
|
|
265
|
+
for name, s in scene.styles.items()
|
|
266
|
+
}
|
|
267
|
+
if scene.imports:
|
|
268
|
+
data["imports"] = scene.imports
|
|
269
|
+
if scene.defs:
|
|
270
|
+
data["defs"] = {name: _element_to_dict(el) for name, el in scene.defs.items()}
|
|
271
|
+
if scene.elements:
|
|
272
|
+
data["elements"] = [_element_to_dict(el) for el in scene.elements]
|
|
273
|
+
if scene.pipeline:
|
|
274
|
+
data["pipeline"] = scene.pipeline
|
|
275
|
+
return data
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _element_to_dict(el: Element) -> dict:
|
|
279
|
+
"""Convert an Element back to a JSON-serializable dict.
|
|
280
|
+
|
|
281
|
+
Omits default/None values to keep output clean.
|
|
282
|
+
"""
|
|
283
|
+
data: dict = {"type": el.type}
|
|
284
|
+
|
|
285
|
+
if el.id:
|
|
286
|
+
data["id"] = el.id
|
|
287
|
+
if el.fill:
|
|
288
|
+
data["fill"] = el.fill
|
|
289
|
+
if el.opacity != 1.0:
|
|
290
|
+
data["opacity"] = el.opacity
|
|
291
|
+
if el.transform:
|
|
292
|
+
t: dict = {}
|
|
293
|
+
if el.transform.translate != (0.0, 0.0):
|
|
294
|
+
t["translate"] = list(el.transform.translate)
|
|
295
|
+
if el.transform.scale != (1.0, 1.0):
|
|
296
|
+
t["scale"] = list(el.transform.scale)
|
|
297
|
+
if t:
|
|
298
|
+
data["transform"] = t
|
|
299
|
+
|
|
300
|
+
# rect fields
|
|
301
|
+
if el.x != 0:
|
|
302
|
+
data["x"] = el.x
|
|
303
|
+
if el.y != 0:
|
|
304
|
+
data["y"] = el.y
|
|
305
|
+
if el.width != 0:
|
|
306
|
+
data["width"] = el.width
|
|
307
|
+
if el.height != 0:
|
|
308
|
+
data["height"] = el.height
|
|
309
|
+
|
|
310
|
+
# path
|
|
311
|
+
if el.d:
|
|
312
|
+
data["d"] = el.d
|
|
313
|
+
|
|
314
|
+
# text
|
|
315
|
+
if el.content:
|
|
316
|
+
data["content"] = el.content
|
|
317
|
+
if el.font:
|
|
318
|
+
font_data: dict = {"family": el.font.family, "size": el.font.size}
|
|
319
|
+
if el.font.weight != 400:
|
|
320
|
+
font_data["weight"] = el.font.weight
|
|
321
|
+
data["font"] = font_data
|
|
322
|
+
if el.anchor != "start":
|
|
323
|
+
data["anchor"] = el.anchor
|
|
324
|
+
if el.letter_spacing != 0:
|
|
325
|
+
data["letter_spacing"] = el.letter_spacing
|
|
326
|
+
if el.char_styles:
|
|
327
|
+
data["char_styles"] = [
|
|
328
|
+
_char_style_to_dict(cs) for cs in el.char_styles
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
# group children
|
|
332
|
+
if el.elements:
|
|
333
|
+
data["elements"] = [_element_to_dict(child) for child in el.elements]
|
|
334
|
+
|
|
335
|
+
# component reference
|
|
336
|
+
if el.use:
|
|
337
|
+
data["use"] = el.use
|
|
338
|
+
|
|
339
|
+
return data
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _char_style_to_dict(cs) -> dict:
|
|
343
|
+
"""Convert a CharStyle to a dict, omitting defaults."""
|
|
344
|
+
data: dict = {"chars": cs.chars}
|
|
345
|
+
if cs.opacity != 1.0:
|
|
346
|
+
data["opacity"] = cs.opacity
|
|
347
|
+
if cs.fill:
|
|
348
|
+
data["fill"] = cs.fill
|
|
349
|
+
return data
|