Ray1ee01 commited on
Commit
a389b7a
·
verified ·
1 Parent(s): 51fcbfd

Upload folder using huggingface_hub

Browse files
modules/slot_layout_planner/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Planning utilities for chart-only, slot-guided infographic generation."""
2
+
modules/slot_layout_planner/chart_sanitizer.py ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import re
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Iterable
9
+
10
+ from lxml import etree
11
+
12
+
13
+ SVG_NS = "http://www.w3.org/2000/svg"
14
+ REMOVABLE_SLOT_ATTRS = {
15
+ "data-asset-slot",
16
+ "data-reserved-slot",
17
+ "data-slot-id",
18
+ "data-slot-kind",
19
+ "data-slot-policy",
20
+ "data-asset-source-policy",
21
+ }
22
+ PROTECTED_TITLE_TOKENS = {
23
+ "axis-title",
24
+ "x-axis-title",
25
+ "y-axis-title",
26
+ "legend-title",
27
+ "chart-title",
28
+ }
29
+ REMOVABLE_CLASS_FRAGMENTS = (
30
+ "reserved-asset-slot",
31
+ "reference-title",
32
+ "decorative-image-slot",
33
+ "hero-photo",
34
+ "hero-image",
35
+ "topic-art",
36
+ "header-art",
37
+ "header-illustration",
38
+ "header-image",
39
+ "title-art-slot",
40
+ "illustration-slot",
41
+ "image-slot",
42
+ )
43
+ EXPLICIT_PROTECTED_ATTRS = (
44
+ "data-chart-internal",
45
+ "data-chart-protected",
46
+ "data-polisher-protected",
47
+ "data-data-bearing",
48
+ )
49
+ EXPLICIT_EDITABLE_ATTRS = (
50
+ "data-polisher-editable",
51
+ "data-planned-element",
52
+ "data-planned-slot",
53
+ )
54
+ EDITABLE_SLOT_FRAGMENTS = (
55
+ "header",
56
+ "hero",
57
+ "illustration",
58
+ "context",
59
+ "summary",
60
+ "callout",
61
+ "footer",
62
+ "bottom",
63
+ "title",
64
+ "subtitle",
65
+ "ornament",
66
+ "background",
67
+ "decorative",
68
+ "topic art",
69
+ "topic image",
70
+ "topic visual",
71
+ "primary visual",
72
+ "right column",
73
+ "lower right",
74
+ "insight",
75
+ )
76
+ PROTECTED_SLOT_FRAGMENTS = (
77
+ "point marker",
78
+ "data point",
79
+ "bubble point",
80
+ "bubble icon",
81
+ "bubble identity",
82
+ "plain bubble",
83
+ "bar logo",
84
+ "bar icon",
85
+ "bar icon slot",
86
+ "bar category",
87
+ "bar cap",
88
+ "card header icon",
89
+ "row icon",
90
+ "row badge",
91
+ "row logo",
92
+ "row source",
93
+ "row country",
94
+ "row group",
95
+ "row topic",
96
+ "category icon",
97
+ "category badge",
98
+ "category logo",
99
+ "legend icon",
100
+ "legend marker",
101
+ "axis icon",
102
+ "series icon",
103
+ "group icon",
104
+ "metric icon",
105
+ "field icon",
106
+ "item icon",
107
+ "item icon slot",
108
+ "platform icon",
109
+ "temporal icon",
110
+ "year card",
111
+ "perimeter category",
112
+ "stacked area series",
113
+ "donut center",
114
+ "rose sector",
115
+ "rose center",
116
+ "technique icon",
117
+ "unit symbol",
118
+ "measure icon",
119
+ "measure badge",
120
+ "measure pill",
121
+ "badge trend mark",
122
+ "histogram trend mark",
123
+ "classification chip",
124
+ "material icon",
125
+ "rank badge",
126
+ "source logo",
127
+ "subtitle icon",
128
+ "trend icon",
129
+ "annotation icon",
130
+ "cue endpoint",
131
+ )
132
+ PROTECTED_CONTEXT_FRAGMENTS = (
133
+ "data point",
134
+ "point layer",
135
+ "point mark",
136
+ "bubble point",
137
+ "plot area",
138
+ "plot layer",
139
+ "axis",
140
+ "legend",
141
+ "series",
142
+ )
143
+ DATA_BEARING_ATTRS = {
144
+ "data-category",
145
+ "data-date",
146
+ "data-group",
147
+ "data-index",
148
+ "data-key",
149
+ "data-label",
150
+ "data-name",
151
+ "data-rank",
152
+ "data-raw-value",
153
+ "data-series",
154
+ "data-series-name",
155
+ "data-time",
156
+ "data-unit",
157
+ "data-value",
158
+ "data-values",
159
+ "data-x",
160
+ "data-y",
161
+ "data-y0",
162
+ "data-y1",
163
+ "data-y2",
164
+ "data-year",
165
+ }
166
+ DATA_BEARING_ROLE_VALUES = {
167
+ "data",
168
+ "data mark",
169
+ "data node",
170
+ "data point",
171
+ "value",
172
+ "value node",
173
+ "value-node",
174
+ }
175
+
176
+
177
+ @dataclass
178
+ class RemovedNode:
179
+ tag: str
180
+ reason: str
181
+ class_name: str
182
+ slot_name: str
183
+
184
+
185
+ @dataclass
186
+ class SanitizeReport:
187
+ input_svg: str
188
+ output_svg: str
189
+ removed_count: int
190
+ removed_by_reason: dict[str, int]
191
+ removed_nodes: list[RemovedNode]
192
+
193
+
194
+ def _local_name(elem: etree._Element) -> str:
195
+ return etree.QName(elem).localname if isinstance(elem.tag, str) else ""
196
+
197
+
198
+ def _class_text(elem: etree._Element) -> str:
199
+ return str(elem.get("class") or "")
200
+
201
+
202
+ def _class_tokens(elem: etree._Element) -> set[str]:
203
+ return {part.strip().lower() for part in re.split(r"\s+", _class_text(elem)) if part.strip()}
204
+
205
+
206
+ def _slot_name(elem: etree._Element) -> str:
207
+ for attr in ("data-asset-slot", "data-reserved-slot", "data-slot-id", "data-slot-kind"):
208
+ value = elem.get(attr)
209
+ if value:
210
+ return str(value)
211
+ return ""
212
+
213
+
214
+ def _has_slot_attr(elem: etree._Element) -> bool:
215
+ return any(elem.get(attr) is not None for attr in REMOVABLE_SLOT_ATTRS)
216
+
217
+
218
+ def _truthy_attr(elem: etree._Element, attrs: Iterable[str]) -> bool:
219
+ for attr in attrs:
220
+ value = elem.get(attr)
221
+ if value is None:
222
+ continue
223
+ if str(value).strip().lower() not in {"", "0", "false", "no"}:
224
+ return True
225
+ return False
226
+
227
+
228
+ def _normalized_text(value: str) -> str:
229
+ return re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
230
+
231
+
232
+ def _slot_context_text(elem: etree._Element) -> str:
233
+ parts = [
234
+ _slot_name(elem),
235
+ _class_text(elem),
236
+ str(elem.get("id") or ""),
237
+ str(elem.get("data-slot-anchor") or ""),
238
+ str(elem.get("data-asset-anchor") or ""),
239
+ str(elem.get("data-anchor") or ""),
240
+ str(elem.get("data-collision-rule") or ""),
241
+ str(elem.get("data-collision-policy") or ""),
242
+ str(elem.get("data-asset-collision") or ""),
243
+ str(elem.get("data-asset-source-policy") or ""),
244
+ str(elem.get("data-slot-policy") or ""),
245
+ ]
246
+ return _normalized_text(" ".join(parts))
247
+
248
+
249
+ def _ancestor_context_text(elem: etree._Element) -> str:
250
+ parts = []
251
+ parent = elem.getparent()
252
+ while parent is not None:
253
+ parts.append(_class_text(parent))
254
+ parts.append(str(parent.get("id") or ""))
255
+ parent = parent.getparent()
256
+ return _normalized_text(" ".join(parts))
257
+
258
+
259
+ def is_chart_internal_slot(elem: etree._Element) -> bool:
260
+ """Return True for asset slots that are part of chart semantics.
261
+
262
+ Many templates historically used ``data-asset-slot`` for two different
263
+ things: editable decorative placeholders and data-bearing icons inside the
264
+ chart. The polisher must not remove or mask the latter.
265
+ """
266
+
267
+ if not _has_slot_attr(elem):
268
+ return False
269
+ if _truthy_attr(elem, EXPLICIT_PROTECTED_ATTRS):
270
+ return True
271
+ if _truthy_attr(elem, EXPLICIT_EDITABLE_ATTRS):
272
+ return False
273
+
274
+ text = _slot_context_text(elem)
275
+ if "input asset" in text or "preserve" in text or "original asset" in text:
276
+ return True
277
+ if any(fragment in text for fragment in PROTECTED_SLOT_FRAGMENTS):
278
+ return True
279
+ if any(fragment in text for fragment in EDITABLE_SLOT_FRAGMENTS):
280
+ return False
281
+
282
+ ancestor_text = _ancestor_context_text(elem)
283
+ if any(fragment in ancestor_text for fragment in PROTECTED_CONTEXT_FRAGMENTS):
284
+ return True
285
+ return False
286
+
287
+
288
+ def _has_data_bearing_attr(elem: etree._Element) -> bool:
289
+ for attr in DATA_BEARING_ATTRS:
290
+ value = elem.get(attr)
291
+ if value is not None and str(value).strip():
292
+ return True
293
+ role = elem.get("data-role")
294
+ if role and _normalized_text(str(role)) in DATA_BEARING_ROLE_VALUES:
295
+ return True
296
+ return False
297
+
298
+
299
+ def _subtree_has_data_bearing_attr(elem: etree._Element) -> bool:
300
+ if _has_data_bearing_attr(elem):
301
+ return True
302
+ return any(_has_data_bearing_attr(child) for child in elem.xpath(".//*"))
303
+
304
+
305
+ def _is_editable_asset_slot(elem: etree._Element) -> bool:
306
+ if not _has_slot_attr(elem):
307
+ return False
308
+ if _truthy_attr(elem, EXPLICIT_PROTECTED_ATTRS):
309
+ return False
310
+ if _truthy_attr(elem, EXPLICIT_EDITABLE_ATTRS):
311
+ return True
312
+
313
+ text = _slot_context_text(elem)
314
+ if "input asset" in text or "preserve" in text or "original asset" in text:
315
+ return False
316
+ if any(fragment in text for fragment in PROTECTED_SLOT_FRAGMENTS):
317
+ return False
318
+ return any(fragment in text for fragment in EDITABLE_SLOT_FRAGMENTS)
319
+
320
+
321
+ def is_inside_chart_internal_slot(elem: etree._Element) -> bool:
322
+ if is_chart_internal_slot(elem):
323
+ return True
324
+ parent = elem.getparent()
325
+ while parent is not None:
326
+ if is_chart_internal_slot(parent):
327
+ return True
328
+ parent = parent.getparent()
329
+ return False
330
+
331
+
332
+ def _is_axis_or_legend_title(elem: etree._Element) -> bool:
333
+ tokens = _class_tokens(elem)
334
+ class_text = " ".join(tokens)
335
+ return bool(tokens & PROTECTED_TITLE_TOKENS) or "axis" in class_text or "legend" in class_text
336
+
337
+
338
+ def _remove_reason(
339
+ elem: etree._Element,
340
+ *,
341
+ remove_images: bool,
342
+ remove_title_like: bool,
343
+ ) -> str | None:
344
+ tag = _local_name(elem)
345
+ class_text = _class_text(elem).lower()
346
+ tokens = _class_tokens(elem)
347
+
348
+ if is_inside_chart_internal_slot(elem):
349
+ return None
350
+
351
+ if _subtree_has_data_bearing_attr(elem):
352
+ return None
353
+
354
+ if _has_slot_attr(elem):
355
+ if _is_editable_asset_slot(elem):
356
+ return "slot_metadata"
357
+ return None
358
+
359
+ if remove_images and tag == "image":
360
+ return "image_node"
361
+
362
+ for fragment in REMOVABLE_CLASS_FRAGMENTS:
363
+ if fragment in class_text:
364
+ return f"class:{fragment}"
365
+
366
+ if remove_title_like and "title" in class_text and not _is_axis_or_legend_title(elem):
367
+ return "class:title_like"
368
+
369
+ if remove_title_like and tag in {"g", "text"}:
370
+ if {"header", "subtitle", "kicker", "headline"} & tokens:
371
+ return "class:header_text_like"
372
+
373
+ return None
374
+
375
+
376
+ def _remove_empty_groups(root: etree._Element) -> int:
377
+ removed = 0
378
+ changed = True
379
+ while changed:
380
+ changed = False
381
+ for elem in list(root.xpath(".//*[local-name()='g']")):
382
+ if len(elem) != 0:
383
+ continue
384
+ if (elem.text or "").strip():
385
+ continue
386
+ if any(
387
+ elem.get(attr) is not None
388
+ for attr in ("id", "clip-path", "mask", "filter", "transform")
389
+ ):
390
+ continue
391
+ parent = elem.getparent()
392
+ if parent is None:
393
+ continue
394
+ parent.remove(elem)
395
+ removed += 1
396
+ changed = True
397
+ return removed
398
+
399
+
400
+ def sanitize_chart_svg(
401
+ input_svg: Path,
402
+ output_svg: Path,
403
+ *,
404
+ remove_images: bool = False,
405
+ remove_title_like: bool = True,
406
+ remove_empty_groups: bool = True,
407
+ report_path: Path | None = None,
408
+ ) -> SanitizeReport:
409
+ parser = etree.XMLParser(remove_blank_text=False, recover=True, huge_tree=True)
410
+ tree = etree.parse(str(input_svg), parser)
411
+ root = tree.getroot()
412
+ removed_nodes: list[RemovedNode] = []
413
+ removed_by_reason: dict[str, int] = {}
414
+
415
+ for elem in list(root.xpath(".//*")):
416
+ reason = _remove_reason(
417
+ elem,
418
+ remove_images=remove_images,
419
+ remove_title_like=remove_title_like,
420
+ )
421
+ if not reason:
422
+ continue
423
+ parent = elem.getparent()
424
+ if parent is None:
425
+ continue
426
+ node = RemovedNode(
427
+ tag=_local_name(elem),
428
+ reason=reason,
429
+ class_name=_class_text(elem),
430
+ slot_name=_slot_name(elem),
431
+ )
432
+ removed_nodes.append(node)
433
+ removed_by_reason[reason] = removed_by_reason.get(reason, 0) + 1
434
+ parent.remove(elem)
435
+
436
+ if remove_empty_groups:
437
+ empty_removed = _remove_empty_groups(root)
438
+ if empty_removed:
439
+ removed_by_reason["empty_group"] = empty_removed
440
+
441
+ root.set("data-chart-sanitized", "true")
442
+ root.set("data-chart-sanitizer-version", "1")
443
+ output_svg.parent.mkdir(parents=True, exist_ok=True)
444
+ tree.write(
445
+ str(output_svg),
446
+ encoding="utf-8",
447
+ xml_declaration=False,
448
+ pretty_print=False,
449
+ )
450
+
451
+ report = SanitizeReport(
452
+ input_svg=str(input_svg),
453
+ output_svg=str(output_svg),
454
+ removed_count=len(removed_nodes),
455
+ removed_by_reason=removed_by_reason,
456
+ removed_nodes=removed_nodes,
457
+ )
458
+ if report_path:
459
+ report_path.parent.mkdir(parents=True, exist_ok=True)
460
+ payload = asdict(report)
461
+ report_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
462
+ return report
463
+
464
+
465
+ def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace:
466
+ parser = argparse.ArgumentParser(description="Remove non-chart template artifacts from a rendered SVG.")
467
+ parser.add_argument("--input-svg", type=Path, required=True)
468
+ parser.add_argument("--output-svg", type=Path, required=True)
469
+ parser.add_argument("--report", type=Path, default=None)
470
+ parser.add_argument(
471
+ "--remove-unmarked-images",
472
+ action="store_true",
473
+ help="Also remove plain <image> nodes that lack explicit slot/decorative metadata.",
474
+ )
475
+ parser.add_argument("--keep-title-like", action="store_true")
476
+ return parser.parse_args(argv)
477
+
478
+
479
+ def main(argv: Iterable[str] | None = None) -> int:
480
+ args = parse_args(argv)
481
+ report = sanitize_chart_svg(
482
+ input_svg=args.input_svg,
483
+ output_svg=args.output_svg,
484
+ remove_images=args.remove_unmarked_images,
485
+ remove_title_like=not args.keep_title_like,
486
+ report_path=args.report,
487
+ )
488
+ print(
489
+ json.dumps(
490
+ {
491
+ "output_svg": report.output_svg,
492
+ "removed_count": report.removed_count,
493
+ "removed_by_reason": report.removed_by_reason,
494
+ },
495
+ ensure_ascii=False,
496
+ )
497
+ )
498
+ return 0
499
+
500
+
501
+ if __name__ == "__main__":
502
+ raise SystemExit(main())
modules/slot_layout_planner/planner.py ADDED
@@ -0,0 +1,1177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import base64
5
+ import html
6
+ import importlib.util
7
+ import json
8
+ import os
9
+ import re
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Iterable
13
+
14
+ from PIL import Image, ImageDraw
15
+
16
+ from modules.asset_regenerator.asset_regenerator import _absolute_bbox, _element_transform, _load_svg
17
+ from modules.asset_regenerator.asset_regenerator import render_svg_to_png
18
+ from modules.slot_layout_planner.chart_sanitizer import (
19
+ is_inside_chart_internal_slot,
20
+ sanitize_chart_svg,
21
+ )
22
+
23
+
24
+ @dataclass
25
+ class SlotPlanItem:
26
+ id: str
27
+ kind: str
28
+ bbox_px: tuple[int, int, int, int]
29
+ may_overlap_chart: bool
30
+ element: str
31
+ anchor: str
32
+ collision_rule: str
33
+ asset_source_policy: str
34
+ slot_requirements: list[str]
35
+
36
+
37
+ @dataclass
38
+ class PlannedSlotPackage:
39
+ slot_plan: Path
40
+ planned_svg: Path
41
+ planned_png: Path
42
+ reference_map: Path
43
+ sanitized_svg: Path
44
+ sanitized_png: Path
45
+ sanitizer_report: Path
46
+
47
+
48
+ @dataclass
49
+ class ProtectedRegion:
50
+ id: str
51
+ reason: str
52
+ bbox_px: tuple[int, int, int, int]
53
+ source_slot: str
54
+ anchor: str = ""
55
+ collision_rule: str = ""
56
+ semantic: str = ""
57
+
58
+
59
+ GPT_LAYOUT_INSTRUCTIONS = """You are designing editable generation slots for a data infographic.
60
+
61
+ Return valid JSON only. Do not include markdown fences.
62
+
63
+ The chart image is already rendered and will be placed at chart_bbox_px on the final canvas.
64
+ Design 3 to 6 editable slots for GPT image editing around or near that chart. The slots should
65
+ make the final piece feel intentionally designed, not like a fixed template.
66
+
67
+ Hard constraints:
68
+ - Use the provided canvas coordinate system in pixels.
69
+ - Return only editable slots; do not return chart_internal_slots. They are collected separately.
70
+ - Avoid every chart_internal_slot bbox completely.
71
+ - Do not cover axis labels, numeric values, category labels, legends, or data marks unless the slot is
72
+ a small callout and may_overlap_chart is true.
73
+ - If allow_chart_overlap is false, every returned slot must avoid the chart_bbox_px.
74
+ - Keep text slots large enough for legible typography and image slots large enough for clear imagery.
75
+ - Do not invent exact numeric values in slot text.
76
+
77
+ Required JSON shape:
78
+ {
79
+ "background": "#F7F8F4",
80
+ "design_rationale": "short explanation",
81
+ "slots": [
82
+ {
83
+ "id": "short_snake_case_id",
84
+ "kind": "title|subtitle|image|callout|context|annotation|badge",
85
+ "bbox_px": [x, y, width, height],
86
+ "may_overlap_chart": false,
87
+ "element": "what the image model should generate inside this slot",
88
+ "anchor": "where it sits in the composition",
89
+ "collision_rule": "explicit preservation rule",
90
+ "asset_source_policy": "llm_generated_text|image2_generated|llm_generated_text_and_shape|llm_generated_text_and_image",
91
+ "slot_requirements": ["short requirement", "short requirement"]
92
+ }
93
+ ]
94
+ }
95
+ """
96
+
97
+ ALLOWED_SLOT_POLICIES = {
98
+ "llm_generated_text",
99
+ "image2_generated",
100
+ "llm_generated_text_and_shape",
101
+ "llm_generated_text_and_image",
102
+ }
103
+
104
+
105
+ def _read_json(path: Path | None) -> dict[str, Any]:
106
+ if not path or not path.is_file():
107
+ return {}
108
+ try:
109
+ return json.loads(path.read_text(encoding="utf-8"))
110
+ except Exception:
111
+ return {}
112
+
113
+
114
+ def _load_openai_config(
115
+ *,
116
+ api_key: str | None = None,
117
+ api_key_env: str = "OPENAI_API_KEY",
118
+ base_url: str | None = None,
119
+ ) -> tuple[str | None, str | None]:
120
+ resolved_key = api_key or os.environ.get(api_key_env)
121
+ resolved_base_url = base_url or os.environ.get("OPENAI_BASE_URL")
122
+ config_path = Path(__file__).resolve().parents[2] / "config.py"
123
+ if config_path.exists():
124
+ try:
125
+ spec = importlib.util.spec_from_file_location("_slot_layout_planner_config", config_path)
126
+ if spec is not None and spec.loader is not None:
127
+ module = importlib.util.module_from_spec(spec)
128
+ spec.loader.exec_module(module)
129
+ if not resolved_key:
130
+ for name in ("openai_api_key", "api_key", "client_key"):
131
+ value = getattr(module, name, None)
132
+ if isinstance(value, str) and value.strip():
133
+ resolved_key = value.strip()
134
+ break
135
+ if not resolved_base_url:
136
+ for name in ("openai_base_url", "base_url"):
137
+ value = getattr(module, name, None)
138
+ if isinstance(value, str) and value.strip():
139
+ resolved_base_url = value.strip()
140
+ break
141
+ except Exception:
142
+ pass
143
+ return resolved_key, resolved_base_url
144
+
145
+
146
+ def _summarize_data_for_layout(data: dict[str, Any]) -> dict[str, Any]:
147
+ if not data:
148
+ return {}
149
+ summary: dict[str, Any] = {}
150
+ for key in ("titles", "metadata", "description", "insights", "unit", "source"):
151
+ value = data.get(key)
152
+ if value:
153
+ summary[key] = value
154
+
155
+ rows = data.get("data") or data.get("rows") or data.get("values")
156
+ if isinstance(rows, list):
157
+ summary["row_count"] = len(rows)
158
+ summary["sample_rows"] = rows[:8]
159
+ elif isinstance(rows, dict):
160
+ summary["data_keys"] = list(rows.keys())[:20]
161
+
162
+ for key, value in data.items():
163
+ if key in summary or key in {"data", "rows", "values"}:
164
+ continue
165
+ if len(summary) >= 10:
166
+ break
167
+ if isinstance(value, (str, int, float, bool)) or value is None:
168
+ summary[key] = value
169
+ elif isinstance(value, list):
170
+ summary[key] = value[:5]
171
+ elif isinstance(value, dict):
172
+ summary[key] = {k: value[k] for k in list(value.keys())[:8]}
173
+ return summary
174
+
175
+
176
+ def _title_hint(data: dict[str, Any]) -> str:
177
+ titles = data.get("titles") if isinstance(data.get("titles"), dict) else {}
178
+ metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {}
179
+ for value in (
180
+ titles.get("main_title"),
181
+ titles.get("title"),
182
+ metadata.get("title"),
183
+ metadata.get("name"),
184
+ ):
185
+ if value:
186
+ return str(value)
187
+ return "Generate a concise data-aware headline"
188
+
189
+
190
+ def _extract_response_text(response: Any) -> str:
191
+ output_text = getattr(response, "output_text", None)
192
+ if isinstance(output_text, str) and output_text.strip():
193
+ return output_text.strip()
194
+ parts: list[str] = []
195
+ for item in getattr(response, "output", []) or []:
196
+ for content in getattr(item, "content", []) or []:
197
+ text = getattr(content, "text", None)
198
+ if isinstance(text, str):
199
+ parts.append(text)
200
+ elif isinstance(text, dict):
201
+ value = text.get("value")
202
+ if isinstance(value, str):
203
+ parts.append(value)
204
+ text = "\n".join(parts).strip()
205
+ if not text:
206
+ raise RuntimeError("OpenAI layout response did not include text output.")
207
+ return text
208
+
209
+
210
+ def _parse_json_object(text: str) -> dict[str, Any]:
211
+ cleaned = text.strip()
212
+ if cleaned.startswith("```"):
213
+ cleaned = cleaned.strip("`")
214
+ if cleaned.lower().startswith("json"):
215
+ cleaned = cleaned[4:].strip()
216
+ try:
217
+ data = json.loads(cleaned)
218
+ except json.JSONDecodeError:
219
+ start = cleaned.find("{")
220
+ end = cleaned.rfind("}")
221
+ if start < 0 or end <= start:
222
+ raise
223
+ data = json.loads(cleaned[start : end + 1])
224
+ if not isinstance(data, dict):
225
+ raise RuntimeError("OpenAI layout response must be a JSON object.")
226
+ return data
227
+
228
+
229
+ def _clamp_slot(
230
+ x: float,
231
+ y: float,
232
+ w: float,
233
+ h: float,
234
+ canvas_w: int,
235
+ canvas_h: int,
236
+ ) -> tuple[int, int, int, int]:
237
+ x0 = max(0, min(int(round(x)), canvas_w - 1))
238
+ y0 = max(0, min(int(round(y)), canvas_h - 1))
239
+ ww = max(8, min(int(round(w)), canvas_w - x0))
240
+ hh = max(8, min(int(round(h)), canvas_h - y0))
241
+ return x0, y0, ww, hh
242
+
243
+
244
+ def _intersection_area(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> int:
245
+ ax, ay, aw, ah = a
246
+ bx, by, bw, bh = b
247
+ ix0 = max(ax, bx)
248
+ iy0 = max(ay, by)
249
+ ix1 = min(ax + aw, bx + bw)
250
+ iy1 = min(ay + ah, by + bh)
251
+ return max(0, ix1 - ix0) * max(0, iy1 - iy0)
252
+
253
+
254
+ def _choose_overlay_slot(
255
+ chart_bbox: tuple[int, int, int, int],
256
+ canvas_w: int,
257
+ canvas_h: int,
258
+ protected_regions: list[ProtectedRegion],
259
+ ) -> tuple[int, int, int, int]:
260
+ chart_x, chart_y, chart_w, chart_h = chart_bbox
261
+ candidate_w = chart_w * 0.34
262
+ candidate_h = min(190, chart_h * 0.22)
263
+ candidates = [
264
+ (chart_x + chart_w * 0.56, chart_y + chart_h * 0.16),
265
+ (chart_x + chart_w * 0.08, chart_y + chart_h * 0.16),
266
+ (chart_x + chart_w * 0.56, chart_y + chart_h * 0.54),
267
+ (chart_x + chart_w * 0.08, chart_y + chart_h * 0.54),
268
+ (chart_x + chart_w * 0.33, chart_y + chart_h * 0.34),
269
+ ]
270
+ slots = [
271
+ _clamp_slot(x, y, candidate_w, candidate_h, canvas_w, canvas_h)
272
+ for x, y in candidates
273
+ ]
274
+ if not protected_regions:
275
+ return slots[0]
276
+ return min(
277
+ slots,
278
+ key=lambda bbox: (
279
+ sum(_intersection_area(bbox, region.bbox_px) for region in protected_regions),
280
+ abs((bbox[0] + bbox[2] / 2) - (chart_x + chart_w * 0.73)),
281
+ ),
282
+ )
283
+
284
+
285
+ def _build_slots(
286
+ canvas_w: int,
287
+ canvas_h: int,
288
+ chart_bbox: tuple[int, int, int, int],
289
+ data: dict[str, Any],
290
+ allow_chart_overlap: bool,
291
+ protected_regions: list[ProtectedRegion] | None = None,
292
+ ) -> list[SlotPlanItem]:
293
+ margin = max(56, int(canvas_w * 0.047))
294
+ chart_x, chart_y, chart_w, chart_h = chart_bbox
295
+ title_hint = _title_hint(data)
296
+ protected_regions = protected_regions or []
297
+
298
+ def slot(
299
+ slot_id: str,
300
+ kind: str,
301
+ bbox: tuple[int, int, int, int],
302
+ element: str,
303
+ anchor: str,
304
+ overlap: bool = False,
305
+ policy: str = "image2_generated",
306
+ requirements: list[str] | None = None,
307
+ ) -> SlotPlanItem:
308
+ return SlotPlanItem(
309
+ id=slot_id,
310
+ kind=kind,
311
+ bbox_px=bbox,
312
+ may_overlap_chart=bool(overlap and allow_chart_overlap),
313
+ element=element,
314
+ anchor=anchor,
315
+ collision_rule=(
316
+ "may overlap and visually integrate with chart content"
317
+ if overlap and allow_chart_overlap
318
+ else "stay visually coherent with the chart without requiring pixel preservation"
319
+ ),
320
+ asset_source_policy=policy,
321
+ slot_requirements=requirements or [],
322
+ )
323
+
324
+ overlay = _choose_overlay_slot(chart_bbox, canvas_w, canvas_h, protected_regions)
325
+ visual = _clamp_slot(canvas_w - margin - 360, margin + 26, 360, 260, canvas_w, canvas_h)
326
+ title = _clamp_slot(margin, margin, canvas_w - 2 * margin - 250, 210, canvas_w, canvas_h)
327
+ subtitle = _clamp_slot(margin, margin + 232, canvas_w * 0.60, 100, canvas_w, canvas_h)
328
+ bottom_y = min(canvas_h - margin - 180, chart_y + chart_h + 42)
329
+ bottom = _clamp_slot(margin, bottom_y, canvas_w - 2 * margin, 170, canvas_w, canvas_h)
330
+
331
+ return [
332
+ slot(
333
+ "hero_title",
334
+ "title",
335
+ title,
336
+ f"Generate a polished editorial headline. Title hint: {title_hint}",
337
+ "top-left headline area",
338
+ policy="llm_generated_text",
339
+ requirements=[
340
+ "text may be rewritten by the image model",
341
+ "keep the headline legible and data-aware",
342
+ ],
343
+ ),
344
+ slot(
345
+ "subtitle_context",
346
+ "subtitle",
347
+ subtitle,
348
+ "Generate a short explanatory subtitle or deck based on the chart data.",
349
+ "under the main headline",
350
+ policy="llm_generated_text",
351
+ requirements=["one or two concise lines", "avoid invented numeric values"],
352
+ ),
353
+ slot(
354
+ "topic_visual",
355
+ "image",
356
+ visual,
357
+ "Generate a thematic editorial image or icon cluster that matches the data topic.",
358
+ "upper-right visual support area",
359
+ policy="image2_generated",
360
+ requirements=["no logos", "no watermarks", "avoid small unreadable text"],
361
+ ),
362
+ slot(
363
+ "chart_overlay_callout",
364
+ "callout",
365
+ overlay,
366
+ "Generate a concise visual callout overlay highlighting the most important visible trend.",
367
+ "inside or near the chart plot area",
368
+ overlap=True,
369
+ policy="llm_generated_text_and_shape",
370
+ requirements=[
371
+ "may overlap chart if useful",
372
+ "do not fabricate exact values unless they are visible in the chart",
373
+ ],
374
+ ),
375
+ slot(
376
+ "bottom_context_band",
377
+ "context",
378
+ bottom,
379
+ "Generate supporting context, iconography, or a short narrative footer for the infographic.",
380
+ "below the chart",
381
+ policy="llm_generated_text_and_image",
382
+ requirements=["keep text brief", "do not add source logos or watermarks"],
383
+ ),
384
+ ]
385
+
386
+
387
+ def _safe_slot_id(raw_id: Any, kind: str, index: int) -> str:
388
+ text = str(raw_id or "").strip().lower()
389
+ text = re.sub(r"[^a-z0-9]+", "_", text).strip("_")
390
+ if not text:
391
+ text = f"{kind}_{index:02d}"
392
+ if not re.match(r"^[a-z]", text):
393
+ text = f"slot_{text}"
394
+ return text[:64]
395
+
396
+
397
+ def _slot_kind(raw_kind: Any) -> str:
398
+ kind = re.sub(r"[^a-z0-9_]+", "_", str(raw_kind or "context").strip().lower()).strip("_")
399
+ return kind or "context"
400
+
401
+
402
+ def _coerce_requirements(value: Any) -> list[str]:
403
+ if not isinstance(value, list):
404
+ return []
405
+ requirements = []
406
+ for item in value[:8]:
407
+ text = str(item).strip()
408
+ if text:
409
+ requirements.append(text[:220])
410
+ return requirements
411
+
412
+
413
+ def _slot_overlap_area(
414
+ bbox: tuple[int, int, int, int],
415
+ regions: list[ProtectedRegion],
416
+ ) -> int:
417
+ return sum(_intersection_area(bbox, region.bbox_px) for region in regions)
418
+
419
+
420
+ def _nudge_slot_clear_of_regions(
421
+ bbox: tuple[int, int, int, int],
422
+ regions: list[ProtectedRegion],
423
+ canvas_w: int,
424
+ canvas_h: int,
425
+ ) -> tuple[int, int, int, int] | None:
426
+ if not regions or _slot_overlap_area(bbox, regions) == 0:
427
+ return bbox
428
+
429
+ x, y, w, h = bbox
430
+ candidates = [bbox]
431
+ for region in regions:
432
+ rx, ry, rw, rh = region.bbox_px
433
+ candidates.extend(
434
+ [
435
+ _clamp_slot(x, ry - h - 12, w, h, canvas_w, canvas_h),
436
+ _clamp_slot(x, ry + rh + 12, w, h, canvas_w, canvas_h),
437
+ _clamp_slot(rx - w - 12, y, w, h, canvas_w, canvas_h),
438
+ _clamp_slot(rx + rw + 12, y, w, h, canvas_w, canvas_h),
439
+ ]
440
+ )
441
+
442
+ clear_candidates = [candidate for candidate in candidates if _slot_overlap_area(candidate, regions) == 0]
443
+ if clear_candidates:
444
+ return min(
445
+ clear_candidates,
446
+ key=lambda candidate: abs(candidate[0] - x) + abs(candidate[1] - y),
447
+ )
448
+ return None
449
+
450
+
451
+ def _slots_from_layout_payload(
452
+ data: dict[str, Any],
453
+ *,
454
+ canvas_w: int,
455
+ canvas_h: int,
456
+ chart_bbox: tuple[int, int, int, int],
457
+ allow_chart_overlap: bool,
458
+ chart_internal_slots: list[ProtectedRegion],
459
+ ) -> tuple[list[SlotPlanItem], str, dict[str, Any]]:
460
+ raw_slots = data.get("slots")
461
+ if not isinstance(raw_slots, list):
462
+ raise RuntimeError("GPT layout response is missing a slots array.")
463
+
464
+ slots: list[SlotPlanItem] = []
465
+ seen_ids: set[str] = set()
466
+ validation_notes: list[dict[str, Any]] = []
467
+ for index, raw in enumerate(raw_slots, 1):
468
+ if not isinstance(raw, dict):
469
+ validation_notes.append({"index": index, "status": "skipped", "reason": "slot is not an object"})
470
+ continue
471
+
472
+ raw_bbox = raw.get("bbox_px")
473
+ if not isinstance(raw_bbox, (list, tuple)) or len(raw_bbox) != 4:
474
+ validation_notes.append({"index": index, "status": "skipped", "reason": "invalid bbox_px"})
475
+ continue
476
+ try:
477
+ bbox = _clamp_slot(
478
+ float(raw_bbox[0]),
479
+ float(raw_bbox[1]),
480
+ float(raw_bbox[2]),
481
+ float(raw_bbox[3]),
482
+ canvas_w,
483
+ canvas_h,
484
+ )
485
+ except (TypeError, ValueError):
486
+ validation_notes.append({"index": index, "status": "skipped", "reason": "non-numeric bbox_px"})
487
+ continue
488
+
489
+ if bbox[2] < 40 or bbox[3] < 32:
490
+ validation_notes.append({"index": index, "status": "skipped", "reason": "bbox too small"})
491
+ continue
492
+
493
+ nudged_bbox = _nudge_slot_clear_of_regions(bbox, chart_internal_slots, canvas_w, canvas_h)
494
+ if nudged_bbox is None:
495
+ validation_notes.append(
496
+ {
497
+ "index": index,
498
+ "status": "skipped",
499
+ "reason": "overlaps chart_internal_slots",
500
+ "bbox_px": list(bbox),
501
+ }
502
+ )
503
+ continue
504
+ if nudged_bbox != bbox:
505
+ validation_notes.append(
506
+ {
507
+ "index": index,
508
+ "status": "adjusted",
509
+ "reason": "nudged clear of chart_internal_slots",
510
+ "from_bbox_px": list(bbox),
511
+ "to_bbox_px": list(nudged_bbox),
512
+ }
513
+ )
514
+ bbox = nudged_bbox
515
+
516
+ requested_overlap = bool(raw.get("may_overlap_chart"))
517
+ may_overlap_chart = requested_overlap and allow_chart_overlap
518
+ if not may_overlap_chart and _intersection_area(bbox, chart_bbox) > 0:
519
+ validation_notes.append(
520
+ {
521
+ "index": index,
522
+ "status": "skipped",
523
+ "reason": "overlaps chart while may_overlap_chart is false",
524
+ "bbox_px": list(bbox),
525
+ }
526
+ )
527
+ continue
528
+
529
+ kind = _slot_kind(raw.get("kind"))
530
+ slot_id = _safe_slot_id(raw.get("id"), kind, index)
531
+ if slot_id in seen_ids:
532
+ slot_id = f"{slot_id}_{index:02d}"
533
+ seen_ids.add(slot_id)
534
+
535
+ policy = str(raw.get("asset_source_policy") or "").strip()
536
+ if policy not in ALLOWED_SLOT_POLICIES:
537
+ policy = "llm_generated_text_and_image" if kind in {"context", "callout", "annotation"} else "image2_generated"
538
+
539
+ collision_rule = str(raw.get("collision_rule") or "").strip()
540
+ if not collision_rule:
541
+ collision_rule = (
542
+ "may overlap chart but must preserve all chart labels, marks, icons, and numeric values"
543
+ if may_overlap_chart
544
+ else "avoid chart content and chart-internal icon slots"
545
+ )
546
+
547
+ slots.append(
548
+ SlotPlanItem(
549
+ id=slot_id,
550
+ kind=kind,
551
+ bbox_px=bbox,
552
+ may_overlap_chart=may_overlap_chart,
553
+ element=str(raw.get("element") or f"Generate {kind} content for this infographic.").strip(),
554
+ anchor=str(raw.get("anchor") or "").strip(),
555
+ collision_rule=collision_rule,
556
+ asset_source_policy=policy,
557
+ slot_requirements=_coerce_requirements(raw.get("slot_requirements")),
558
+ )
559
+ )
560
+
561
+ if not slots:
562
+ raise RuntimeError("GPT layout response did not produce any valid editable slots.")
563
+
564
+ background = str(data.get("background") or "#F7F8F4").strip()
565
+ if not re.match(r"^#[0-9a-fA-F]{6}$", background):
566
+ background = "#F7F8F4"
567
+ meta = {
568
+ "design_rationale": str(data.get("design_rationale") or "").strip(),
569
+ "validation_notes": validation_notes,
570
+ }
571
+ return slots, background, meta
572
+
573
+
574
+ def _build_gpt_slots(
575
+ *,
576
+ output_dir: Path,
577
+ chart_png: Path,
578
+ canvas_w: int,
579
+ canvas_h: int,
580
+ chart_bbox: tuple[int, int, int, int],
581
+ data: dict[str, Any],
582
+ allow_chart_overlap: bool,
583
+ chart_internal_slots: list[ProtectedRegion],
584
+ model: str,
585
+ api_key: str | None,
586
+ api_key_env: str,
587
+ base_url: str | None,
588
+ timeout_seconds: float,
589
+ max_retries: int,
590
+ max_output_tokens: int,
591
+ ) -> tuple[list[SlotPlanItem], str, dict[str, Any]]:
592
+ request_path = output_dir / "layout_request.json"
593
+ response_path = output_dir / "layout_response.json"
594
+ payload = {
595
+ "canvas": {"width": canvas_w, "height": canvas_h},
596
+ "chart_bbox_px": list(chart_bbox),
597
+ "allow_chart_overlap": allow_chart_overlap,
598
+ "title_hint": _title_hint(data),
599
+ "data_summary": _summarize_data_for_layout(data),
600
+ "chart_internal_slots": [
601
+ {
602
+ "id": region.id,
603
+ "source_slot": region.source_slot,
604
+ "bbox_px": list(region.bbox_px),
605
+ "anchor": region.anchor,
606
+ "collision_rule": region.collision_rule,
607
+ }
608
+ for region in chart_internal_slots
609
+ ],
610
+ "required_output_shape": {
611
+ "background": "#RRGGBB",
612
+ "design_rationale": "short string",
613
+ "slots": [
614
+ {
615
+ "id": "short_snake_case_id",
616
+ "kind": "title|subtitle|image|callout|context|annotation|badge",
617
+ "bbox_px": [0, 0, 100, 100],
618
+ "may_overlap_chart": False,
619
+ "element": "generation instruction",
620
+ "anchor": "layout anchor",
621
+ "collision_rule": "preservation rule",
622
+ "asset_source_policy": "llm_generated_text|image2_generated|llm_generated_text_and_shape|llm_generated_text_and_image",
623
+ "slot_requirements": ["requirement"],
624
+ }
625
+ ],
626
+ },
627
+ }
628
+ request_log = {
629
+ "agent": "slot_layout_planner",
630
+ "model": model,
631
+ "instructions": GPT_LAYOUT_INSTRUCTIONS,
632
+ "payload": payload,
633
+ "images": [{"label": "sanitized_chart", "path": str(chart_png)}],
634
+ }
635
+ request_path.write_text(json.dumps(request_log, indent=2, ensure_ascii=False), encoding="utf-8")
636
+
637
+ resolved_key, resolved_base_url = _load_openai_config(
638
+ api_key=api_key,
639
+ api_key_env=api_key_env,
640
+ base_url=base_url,
641
+ )
642
+ if not resolved_key:
643
+ raise RuntimeError(
644
+ f"{api_key_env} is not set and config.py does not define api_key/client_key; GPT layout cannot run."
645
+ )
646
+ try:
647
+ from openai import OpenAI
648
+ except ImportError as exc:
649
+ raise RuntimeError("The openai package is required for GPT layout planning.") from exc
650
+
651
+ client_kwargs: dict[str, Any] = {
652
+ "api_key": resolved_key,
653
+ "timeout": timeout_seconds,
654
+ "max_retries": max_retries,
655
+ }
656
+ if resolved_base_url:
657
+ client_kwargs["base_url"] = resolved_base_url
658
+ client = OpenAI(**client_kwargs)
659
+
660
+ content: list[dict[str, Any]] = [
661
+ {
662
+ "type": "input_text",
663
+ "text": (
664
+ "Return valid JSON only. No markdown fences.\n\n"
665
+ f"Payload:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
666
+ ),
667
+ },
668
+ {"type": "input_text", "text": "Image: sanitized_chart"},
669
+ {"type": "input_image", "image_url": _image_data_uri(chart_png)},
670
+ ]
671
+ kwargs: dict[str, Any] = {
672
+ "model": model,
673
+ "instructions": GPT_LAYOUT_INSTRUCTIONS,
674
+ "input": [{"role": "user", "content": content}],
675
+ "max_output_tokens": max_output_tokens,
676
+ "text": {"format": {"type": "json_object"}},
677
+ }
678
+ response = client.responses.create(**kwargs)
679
+ response_meta = {
680
+ "model": getattr(response, "model", model),
681
+ "response_id": getattr(response, "id", None),
682
+ }
683
+ text = _extract_response_text(response)
684
+ response_log: dict[str, Any] = {
685
+ "metadata": response_meta,
686
+ "output_text": text,
687
+ }
688
+ try:
689
+ parsed = _parse_json_object(text)
690
+ slots, background, validation_meta = _slots_from_layout_payload(
691
+ parsed,
692
+ canvas_w=canvas_w,
693
+ canvas_h=canvas_h,
694
+ chart_bbox=chart_bbox,
695
+ allow_chart_overlap=allow_chart_overlap,
696
+ chart_internal_slots=chart_internal_slots,
697
+ )
698
+ except Exception as exc:
699
+ response_log["parse_or_validation_error"] = f"{type(exc).__name__}: {exc}"
700
+ response_path.write_text(json.dumps(response_log, indent=2, ensure_ascii=False), encoding="utf-8")
701
+ raise
702
+
703
+ response_log["parsed"] = parsed
704
+ response_log["validated"] = {
705
+ "background": background,
706
+ "reserved_slots": [asdict(slot) for slot in slots],
707
+ **validation_meta,
708
+ }
709
+ response_path.write_text(json.dumps(response_log, indent=2, ensure_ascii=False), encoding="utf-8")
710
+ meta = {
711
+ "engine": "gpt",
712
+ "model": response_meta["model"],
713
+ "request_path": str(request_path),
714
+ "response_path": str(response_path),
715
+ **validation_meta,
716
+ }
717
+ return slots, background, meta
718
+
719
+
720
+ def _image_data_uri(path: Path) -> str:
721
+ payload = base64.b64encode(path.read_bytes()).decode("ascii")
722
+ return f"data:image/png;base64,{payload}"
723
+
724
+
725
+ def _slot_svg(slot: SlotPlanItem) -> str:
726
+ x, y, w, h = slot.bbox_px
727
+ cls = f"planned-slot planned-{slot.kind}-slot"
728
+ marker = html.escape(slot.id, quote=True)
729
+ element = html.escape(slot.element, quote=True)
730
+ anchor = html.escape(slot.anchor, quote=True)
731
+ collision = html.escape(slot.collision_rule, quote=True)
732
+ policy = html.escape(slot.asset_source_policy, quote=True)
733
+ return (
734
+ f'<g class="{cls}" data-asset-slot="{marker}" data-reserved-slot="{marker}" '
735
+ f'data-slot-kind="{html.escape(slot.kind, quote=True)}" '
736
+ f'data-slot-policy="{policy}" data-asset-source-policy="{policy}" '
737
+ f'data-bbox="{x},{y},{w},{h}" data-anchor="{anchor}" '
738
+ f'data-collision-rule="{collision}" data-planned-element="{element}" '
739
+ f'data-may-overlap-chart="{str(slot.may_overlap_chart).lower()}">'
740
+ f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="10" '
741
+ f'fill="#E8EEF6" fill-opacity="0.62" stroke="#65758B" '
742
+ f'stroke-width="2" stroke-dasharray="10 8"/>'
743
+ "</g>"
744
+ )
745
+
746
+
747
+ def _write_planned_svg(
748
+ output_svg: Path,
749
+ chart_png: Path,
750
+ canvas_size: tuple[int, int],
751
+ chart_bbox: tuple[int, int, int, int],
752
+ slots: list[SlotPlanItem],
753
+ background: str,
754
+ ) -> None:
755
+ canvas_w, canvas_h = canvas_size
756
+ chart_x, chart_y, chart_w, chart_h = chart_bbox
757
+ chart_href = _image_data_uri(chart_png)
758
+ slot_markup = "\n".join(_slot_svg(slot) for slot in slots)
759
+ svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{canvas_w}" height="{canvas_h}" viewBox="0 0 {canvas_w} {canvas_h}" data-planned-slot-canvas="true">
760
+ <rect x="0" y="0" width="{canvas_w}" height="{canvas_h}" fill="{html.escape(background, quote=True)}"/>
761
+ <image class="sanitized-chart-raster" data-chart-source="sanitized-template-render" href="{chart_href}" x="{chart_x}" y="{chart_y}" width="{chart_w}" height="{chart_h}" preserveAspectRatio="xMidYMid meet"/>
762
+ {slot_markup}
763
+ </svg>'''
764
+ output_svg.parent.mkdir(parents=True, exist_ok=True)
765
+ output_svg.write_text(svg, encoding="utf-8")
766
+
767
+
768
+ def _render_planned_png(
769
+ output_png: Path,
770
+ chart_png: Path,
771
+ canvas_size: tuple[int, int],
772
+ chart_bbox: tuple[int, int, int, int],
773
+ slots: list[SlotPlanItem],
774
+ background_rgb: tuple[int, int, int] = (247, 248, 244),
775
+ ) -> None:
776
+ canvas_w, canvas_h = canvas_size
777
+ output_png.parent.mkdir(parents=True, exist_ok=True)
778
+ canvas = Image.new("RGB", (canvas_w, canvas_h), background_rgb)
779
+ with Image.open(chart_png) as chart:
780
+ chart = chart.convert("RGBA")
781
+ x, y, w, h = chart_bbox
782
+ fitted = chart.copy()
783
+ fitted.thumbnail((w, h), Image.Resampling.LANCZOS)
784
+ paste_x = x + (w - fitted.width) // 2
785
+ paste_y = y + (h - fitted.height) // 2
786
+ canvas.paste(fitted.convert("RGB"), (paste_x, paste_y), fitted.getchannel("A"))
787
+
788
+ overlay = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
789
+ draw = ImageDraw.Draw(overlay)
790
+ colors = [
791
+ (93, 117, 150, 72),
792
+ (35, 150, 120, 68),
793
+ (208, 132, 48, 70),
794
+ (135, 88, 178, 70),
795
+ (60, 128, 190, 68),
796
+ ]
797
+ for index, slot in enumerate(slots):
798
+ x, y, w, h = slot.bbox_px
799
+ fill = colors[index % len(colors)]
800
+ outline = (54, 66, 82, 155)
801
+ draw.rounded_rectangle((x, y, x + w, y + h), radius=10, fill=fill, outline=outline, width=2)
802
+ canvas = Image.alpha_composite(canvas.convert("RGBA"), overlay).convert("RGB")
803
+ canvas.save(output_png)
804
+
805
+
806
+ def _write_reference_map(
807
+ path: Path,
808
+ slots: list[SlotPlanItem],
809
+ chart_internal_slots: list[ProtectedRegion],
810
+ ) -> None:
811
+ path.parent.mkdir(parents=True, exist_ok=True)
812
+ payload = {
813
+ "reserved_slots": [],
814
+ "chart_internal_slots": [
815
+ {
816
+ "id": region.id,
817
+ "reason": region.reason,
818
+ "bbox_px": list(region.bbox_px),
819
+ "source_slot": region.source_slot,
820
+ "anchor": region.anchor,
821
+ "collision_rule": region.collision_rule,
822
+ "semantic": region.semantic,
823
+ }
824
+ for region in chart_internal_slots
825
+ ],
826
+ "protected_regions": [],
827
+ }
828
+ for slot in slots:
829
+ payload["reserved_slots"].append(
830
+ {
831
+ "id": slot.id,
832
+ "element": slot.element,
833
+ "reason": f"planned {slot.kind} slot for image-edit generation",
834
+ "slot_requirements": slot.slot_requirements,
835
+ "semantic_slot": {
836
+ "class_or_data_marker": f'data-asset-slot="{slot.id}"',
837
+ "anchor": slot.anchor,
838
+ "collision_rule": slot.collision_rule,
839
+ "asset_source_policy": slot.asset_source_policy,
840
+ "may_overlap_chart": slot.may_overlap_chart,
841
+ },
842
+ }
843
+ )
844
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
845
+
846
+
847
+ def _parse_bbox_text(text: str | None) -> tuple[float, float, float, float] | None:
848
+ if not text:
849
+ return None
850
+ try:
851
+ parts = [float(part) for part in str(text).replace(",", " ").split() if part]
852
+ except ValueError:
853
+ return None
854
+ if len(parts) != 4:
855
+ return None
856
+ x, y, w, h = parts
857
+ if w <= 0 or h <= 0:
858
+ return None
859
+ return x, y, w, h
860
+
861
+
862
+ def _bbox_with_matrix(
863
+ bbox: tuple[float, float, float, float],
864
+ matrix: tuple[float, float, float, float, float, float],
865
+ ) -> tuple[float, float, float, float]:
866
+ x, y, w, h = bbox
867
+ a, b, c, d, e, f = matrix
868
+ points = [
869
+ (a * x + c * y + e, b * x + d * y + f),
870
+ (a * (x + w) + c * y + e, b * (x + w) + d * y + f),
871
+ (a * x + c * (y + h) + e, b * x + d * (y + h) + f),
872
+ (a * (x + w) + c * (y + h) + e, b * (x + w) + d * (y + h) + f),
873
+ ]
874
+ xs = [point[0] for point in points]
875
+ ys = [point[1] for point in points]
876
+ return min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys)
877
+
878
+
879
+ def _float_attr(elem: Any, name: str, default: float | None = None) -> float | None:
880
+ value = elem.get(name)
881
+ if value is None:
882
+ return default
883
+ try:
884
+ return float(str(value).strip())
885
+ except ValueError:
886
+ return default
887
+
888
+
889
+ def _element_local_bbox(elem: Any) -> tuple[float, float, float, float] | None:
890
+ tag = etree_local_name(elem)
891
+ if tag in {"rect", "image"}:
892
+ x = _float_attr(elem, "x", 0.0)
893
+ y = _float_attr(elem, "y", 0.0)
894
+ w = _float_attr(elem, "width")
895
+ h = _float_attr(elem, "height")
896
+ if x is not None and y is not None and w and h and w > 0 and h > 0:
897
+ return x, y, w, h
898
+ if tag == "circle":
899
+ cx = _float_attr(elem, "cx", 0.0)
900
+ cy = _float_attr(elem, "cy", 0.0)
901
+ r = _float_attr(elem, "r")
902
+ if cx is not None and cy is not None and r and r > 0:
903
+ return cx - r, cy - r, r * 2, r * 2
904
+ if tag == "ellipse":
905
+ cx = _float_attr(elem, "cx", 0.0)
906
+ cy = _float_attr(elem, "cy", 0.0)
907
+ rx = _float_attr(elem, "rx")
908
+ ry = _float_attr(elem, "ry")
909
+ if cx is not None and cy is not None and rx and ry and rx > 0 and ry > 0:
910
+ return cx - rx, cy - ry, rx * 2, ry * 2
911
+ return None
912
+
913
+
914
+ def etree_local_name(elem: Any) -> str:
915
+ try:
916
+ return elem.tag.rsplit("}", 1)[-1] if isinstance(elem.tag, str) else ""
917
+ except Exception:
918
+ return ""
919
+
920
+
921
+ def _collect_protected_regions(
922
+ chart_svg: Path,
923
+ source_chart_png: Path,
924
+ chart_bbox: tuple[int, int, int, int],
925
+ padding_px: int = 10,
926
+ ) -> list[ProtectedRegion]:
927
+ try:
928
+ tree, root, svg_width, svg_height = _load_svg(chart_svg)
929
+ except Exception:
930
+ return []
931
+ del tree
932
+ with Image.open(source_chart_png) as chart_image:
933
+ png_w, png_h = chart_image.size
934
+
935
+ chart_x, chart_y, chart_w, chart_h = chart_bbox
936
+ svg_to_png_x = png_w / svg_width
937
+ svg_to_png_y = png_h / svg_height
938
+ png_to_canvas_x = chart_w / png_w
939
+ png_to_canvas_y = chart_h / png_h
940
+ nodes = root.xpath(
941
+ ".//*[@data-asset-slot or @data-reserved-slot or @data-slot-id or @data-slot-kind]"
942
+ )
943
+ regions: list[ProtectedRegion] = []
944
+ for index, elem in enumerate(nodes, 1):
945
+ if not is_inside_chart_internal_slot(elem):
946
+ continue
947
+ raw_bbox = (
948
+ _parse_bbox_text(elem.get("data-bbox"))
949
+ or _parse_bbox_text(elem.get("data-asset-bbox"))
950
+ or _parse_bbox_text(elem.get("data-slot-bbox"))
951
+ )
952
+ if raw_bbox:
953
+ bbox_svg = _bbox_with_matrix(raw_bbox, _element_transform(elem, root))
954
+ else:
955
+ local_bbox = _element_local_bbox(elem)
956
+ if local_bbox is not None:
957
+ bbox_svg = _bbox_with_matrix(local_bbox, _element_transform(elem, root))
958
+ else:
959
+ abs_bbox = _absolute_bbox(elem, root)
960
+ if abs_bbox is None:
961
+ continue
962
+ bbox_svg = (abs_bbox.x, abs_bbox.y, abs_bbox.width, abs_bbox.height)
963
+ if bbox_svg is None:
964
+ continue
965
+ x, y, w, h = bbox_svg
966
+ if w <= 0 or h <= 0:
967
+ continue
968
+ px0 = chart_x + int(round(x * svg_to_png_x * png_to_canvas_x)) - padding_px
969
+ py0 = chart_y + int(round(y * svg_to_png_y * png_to_canvas_y)) - padding_px
970
+ px1 = chart_x + int(round((x + w) * svg_to_png_x * png_to_canvas_x)) + padding_px
971
+ py1 = chart_y + int(round((y + h) * svg_to_png_y * png_to_canvas_y)) + padding_px
972
+ px0 = max(0, px0)
973
+ py0 = max(0, py0)
974
+ px1 = min(chart_x + chart_w, px1)
975
+ py1 = min(chart_y + chart_h, py1)
976
+ if px1 <= px0 or py1 <= py0:
977
+ continue
978
+ slot_name = (
979
+ elem.get("data-asset-slot")
980
+ or elem.get("data-reserved-slot")
981
+ or elem.get("data-slot-id")
982
+ or elem.get("data-slot-kind")
983
+ or f"chart_internal_slot_{index}"
984
+ )
985
+ anchor = elem.get("data-asset-anchor") or elem.get("data-anchor") or elem.get("data-slot-anchor") or ""
986
+ collision_rule = elem.get("data-asset-collision") or elem.get("data-collision-rule") or ""
987
+ regions.append(
988
+ ProtectedRegion(
989
+ id=f"CHART_ICON_{len(regions) + 1:02d}",
990
+ reason="chart-internal icon/asset slot",
991
+ bbox_px=(px0, py0, px1 - px0, py1 - py0),
992
+ source_slot=str(slot_name),
993
+ anchor=str(anchor),
994
+ collision_rule=str(collision_rule),
995
+ semantic=(
996
+ "Generate or polish only the icon inside this chart slot; "
997
+ "preserve nearby chart labels, ranks, lines, points, and numeric values exactly."
998
+ ),
999
+ )
1000
+ )
1001
+ return regions
1002
+
1003
+
1004
+ def build_planned_slot_package(
1005
+ chart_svg: Path,
1006
+ output_dir: Path,
1007
+ *,
1008
+ chart_png: Path | None = None,
1009
+ data_json: Path | None = None,
1010
+ canvas_width: int = 1536,
1011
+ canvas_height: int = 2048,
1012
+ allow_chart_overlap: bool = True,
1013
+ render_longest_side: int | None = None,
1014
+ layout_model: str | None = "gpt-5.5",
1015
+ layout_api_key: str | None = None,
1016
+ layout_api_key_env: str = "OPENAI_API_KEY",
1017
+ layout_base_url: str | None = None,
1018
+ layout_timeout_seconds: float = 120.0,
1019
+ layout_max_retries: int = 0,
1020
+ layout_max_output_tokens: int = 2500,
1021
+ deterministic_layout: bool = False,
1022
+ ) -> PlannedSlotPackage:
1023
+ output_dir.mkdir(parents=True, exist_ok=True)
1024
+ sanitized_svg = output_dir / "sanitized_chart.svg"
1025
+ sanitized_png = output_dir / "sanitized_chart.png"
1026
+ sanitizer_report = output_dir / "sanitized_chart.report.json"
1027
+ sanitize_chart_svg(chart_svg, sanitized_svg, report_path=sanitizer_report)
1028
+
1029
+ if render_longest_side is not None:
1030
+ import os
1031
+
1032
+ previous = os.environ.get("RENDER_LONGEST_SIDE")
1033
+ os.environ["RENDER_LONGEST_SIDE"] = str(render_longest_side)
1034
+ try:
1035
+ render_svg_to_png(sanitized_svg, sanitized_png)
1036
+ finally:
1037
+ if previous is None:
1038
+ os.environ.pop("RENDER_LONGEST_SIDE", None)
1039
+ else:
1040
+ os.environ["RENDER_LONGEST_SIDE"] = previous
1041
+ else:
1042
+ render_svg_to_png(sanitized_svg, sanitized_png)
1043
+
1044
+ source_chart_png = sanitized_png if sanitized_png.is_file() else chart_png
1045
+ if source_chart_png is None or not source_chart_png.is_file():
1046
+ raise FileNotFoundError("planned slot package requires a chart PNG")
1047
+
1048
+ with Image.open(source_chart_png) as chart_image:
1049
+ chart_w, chart_h = chart_image.size
1050
+ max_w = int(canvas_width * 0.84)
1051
+ max_h = int(canvas_height * 0.58)
1052
+ scale = min(max_w / chart_w, max_h / chart_h, 1.0)
1053
+ placed_w = max(1, int(chart_w * scale))
1054
+ placed_h = max(1, int(chart_h * scale))
1055
+ chart_x = (canvas_width - placed_w) // 2
1056
+ chart_y = int(canvas_height * 0.30)
1057
+ chart_bbox = (chart_x, chart_y, placed_w, placed_h)
1058
+
1059
+ chart_internal_slots = _collect_protected_regions(chart_svg, source_chart_png, chart_bbox)
1060
+ data = _read_json(data_json)
1061
+ background = "#F7F8F4"
1062
+ if deterministic_layout or not layout_model:
1063
+ slots = _build_slots(
1064
+ canvas_width,
1065
+ canvas_height,
1066
+ chart_bbox,
1067
+ data,
1068
+ allow_chart_overlap,
1069
+ chart_internal_slots,
1070
+ )
1071
+ layout_engine: dict[str, Any] = {
1072
+ "engine": "deterministic",
1073
+ "model": None,
1074
+ "reason": "deterministic_layout option enabled" if deterministic_layout else "layout_model is empty",
1075
+ }
1076
+ else:
1077
+ slots, background, layout_engine = _build_gpt_slots(
1078
+ output_dir=output_dir,
1079
+ chart_png=source_chart_png,
1080
+ canvas_w=canvas_width,
1081
+ canvas_h=canvas_height,
1082
+ chart_bbox=chart_bbox,
1083
+ data=data,
1084
+ allow_chart_overlap=allow_chart_overlap,
1085
+ chart_internal_slots=chart_internal_slots,
1086
+ model=layout_model,
1087
+ api_key=layout_api_key,
1088
+ api_key_env=layout_api_key_env,
1089
+ base_url=layout_base_url,
1090
+ timeout_seconds=layout_timeout_seconds,
1091
+ max_retries=layout_max_retries,
1092
+ max_output_tokens=layout_max_output_tokens,
1093
+ )
1094
+
1095
+ slot_plan_path = output_dir / "slot_plan.json"
1096
+ planned_svg = output_dir / "planned_slots.svg"
1097
+ planned_png = output_dir / "planned_slots.png"
1098
+ reference_map = output_dir / "reference_element_map.json"
1099
+ slot_plan = {
1100
+ "canvas": {"width": canvas_width, "height": canvas_height, "background": background},
1101
+ "chart": {
1102
+ "sanitized_svg": str(sanitized_svg),
1103
+ "sanitized_png": str(source_chart_png),
1104
+ "bbox_px": list(chart_bbox),
1105
+ "overlap_policy": "editable_overlap_allowed" if allow_chart_overlap else "avoid_chart_overlap",
1106
+ },
1107
+ "layout_engine": layout_engine,
1108
+ "text_policy": "llm_generated",
1109
+ "chart_internal_slots": [asdict(region) for region in chart_internal_slots],
1110
+ "protected_regions": [],
1111
+ "reserved_slots": [asdict(slot) for slot in slots],
1112
+ }
1113
+ slot_plan_path.write_text(json.dumps(slot_plan, indent=2, ensure_ascii=False), encoding="utf-8")
1114
+ _write_reference_map(reference_map, slots, chart_internal_slots)
1115
+ _write_planned_svg(planned_svg, source_chart_png, (canvas_width, canvas_height), chart_bbox, slots, background)
1116
+ _render_planned_png(planned_png, source_chart_png, (canvas_width, canvas_height), chart_bbox, slots)
1117
+
1118
+ return PlannedSlotPackage(
1119
+ slot_plan=slot_plan_path,
1120
+ planned_svg=planned_svg,
1121
+ planned_png=planned_png,
1122
+ reference_map=reference_map,
1123
+ sanitized_svg=sanitized_svg,
1124
+ sanitized_png=sanitized_png,
1125
+ sanitizer_report=sanitizer_report,
1126
+ )
1127
+
1128
+
1129
+ def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace:
1130
+ parser = argparse.ArgumentParser(description="Build a planned slot canvas from a chart-only SVG.")
1131
+ parser.add_argument("--chart-svg", type=Path, required=True)
1132
+ parser.add_argument("--chart-png", type=Path, default=None)
1133
+ parser.add_argument("--data-json", type=Path, default=None)
1134
+ parser.add_argument("--output-dir", type=Path, required=True)
1135
+ parser.add_argument("--canvas-width", type=int, default=1536)
1136
+ parser.add_argument("--canvas-height", type=int, default=2048)
1137
+ parser.add_argument("--disallow-chart-overlap", action="store_true")
1138
+ parser.add_argument("--render-longest-side", type=int, default=None)
1139
+ parser.add_argument("--layout-model", default="gpt-5.5")
1140
+ parser.add_argument("--layout-api-key-env", default="OPENAI_API_KEY")
1141
+ parser.add_argument("--layout-base-url", default=None)
1142
+ parser.add_argument("--layout-timeout", type=float, default=120.0)
1143
+ parser.add_argument("--layout-max-retries", type=int, default=0)
1144
+ parser.add_argument("--layout-max-output-tokens", type=int, default=2500)
1145
+ parser.add_argument(
1146
+ "--deterministic-layout",
1147
+ action="store_true",
1148
+ help="Use the old deterministic slot planner instead of GPT layout design.",
1149
+ )
1150
+ return parser.parse_args(argv)
1151
+
1152
+
1153
+ def main(argv: Iterable[str] | None = None) -> int:
1154
+ args = parse_args(argv)
1155
+ package = build_planned_slot_package(
1156
+ chart_svg=args.chart_svg,
1157
+ chart_png=args.chart_png,
1158
+ data_json=args.data_json,
1159
+ output_dir=args.output_dir,
1160
+ canvas_width=args.canvas_width,
1161
+ canvas_height=args.canvas_height,
1162
+ allow_chart_overlap=not args.disallow_chart_overlap,
1163
+ render_longest_side=args.render_longest_side,
1164
+ layout_model=args.layout_model,
1165
+ layout_api_key_env=args.layout_api_key_env,
1166
+ layout_base_url=args.layout_base_url,
1167
+ layout_timeout_seconds=args.layout_timeout,
1168
+ layout_max_retries=args.layout_max_retries,
1169
+ layout_max_output_tokens=args.layout_max_output_tokens,
1170
+ deterministic_layout=args.deterministic_layout,
1171
+ )
1172
+ print(json.dumps({k: str(v) for k, v in asdict(package).items()}, ensure_ascii=False))
1173
+ return 0
1174
+
1175
+
1176
+ if __name__ == "__main__":
1177
+ raise SystemExit(main())