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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
predraw/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running predraw as: python -m predraw"""
2
+
3
+ from .cli import main
4
+
5
+ main()
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