返回 ppt-master
chart_data.py
1 """Native chart metadata normalization."""
2
3 from __future__ import annotations
4
5 import math
6 from typing import Any
7
8 from .marker_common import (
9 _chart_bool,
10 _clean_hex,
11 _compact_key,
12 _first_present,
13 _hex_or_none,
14 _number,
15 _powerpoint_emu,
16 _powerpoint_line_width_emu,
17 )
18
19
20 def _chart_number(value: Any) -> int | float:
21 if isinstance(value, bool):
22 raise RuntimeError("Native PPTX chart values must be numeric")
23 try:
24 number = float(value)
25 except (TypeError, ValueError, OverflowError) as exc:
26 raise RuntimeError("Native PPTX chart value is not numeric") from exc
27 if not math.isfinite(number):
28 raise RuntimeError(f"Native PPTX chart value must be finite: {value}")
29 return int(number) if number.is_integer() else number
30
31
32 def _chart_list(value: Any, field_name: str) -> list[Any]:
33 if value is None:
34 return []
35 if not isinstance(value, list):
36 raise RuntimeError(f"Native PPTX chart {field_name} must be a list")
37 return value
38
39
40 def _data_labels_config(payload: dict[str, Any]) -> dict[str, Any] | None:
41 raw = _first_present(payload.get("data_labels"), payload.get("dataLabels"))
42 if raw is None:
43 return None
44 if isinstance(raw, bool):
45 return {} if raw else None
46 if not isinstance(raw, dict):
47 raise RuntimeError("Native PPTX chart data_labels must be a boolean or object")
48 return raw
49
50
51 def _data_label_position(value: Any, chart_type: str, grouping: str | None) -> str | None:
52 """Normalize and validate a label position for its chart plot."""
53 if chart_type == "area":
54 if value is not None:
55 raise RuntimeError("Native PPTX area data labels do not support label position")
56 return None
57 is_stacked = chart_type in {"bar", "column"} and grouping in {
58 "percentStacked", "stacked",
59 }
60 default = "ctr" if is_stacked else (
61 "outEnd" if chart_type in {"bar", "column"} else "t"
62 )
63 if value is None:
64 return default
65 aliases = {
66 "above": "t",
67 "bestfit": "bestFit",
68 "center": "ctr",
69 "inbase": "inBase",
70 "insidebase": "inBase",
71 "insideend": "inEnd",
72 "inend": "inEnd",
73 "outend": "outEnd",
74 "outsideend": "outEnd",
75 }
76 position = aliases.get(_compact_key(value))
77 if not position:
78 raise RuntimeError(
79 "Native PPTX chart data label position must be one of: "
80 "above, best_fit, center, inside_base, inside_end, outside_end"
81 )
82 if chart_type in {"bar", "column"}:
83 if position not in {"ctr", "inBase", "inEnd", "outEnd"}:
84 raise RuntimeError(
85 "Native PPTX bar/column data label position must be one of: "
86 "center, inside_base, inside_end, outside_end"
87 )
88 if is_stacked and position == "outEnd":
89 raise RuntimeError(
90 "Native PPTX stacked bar/column data labels do not support outside_end"
91 )
92 elif chart_type == "line" and position not in {"bestFit", "ctr", "t"}:
93 raise RuntimeError(
94 "Native PPTX line data label position must be one of: above, best_fit, center"
95 )
96 return position
97
98
99 def _chart_data_labels(
100 payload: dict[str, Any],
101 chart_type: str,
102 grouping: str | None,
103 point_count: int,
104 ) -> dict[str, Any] | None:
105 config = _data_labels_config(payload)
106 if config is None:
107 return None
108 if chart_type not in {"area", "bar", "column", "line"}:
109 raise RuntimeError(
110 f"Native PPTX {chart_type} chart data labels are outside current support"
111 )
112 _data_label_position(config.get("position"), chart_type, grouping)
113 if config.get("color") is not None and _hex_or_none(config["color"]) is None:
114 raise RuntimeError("Native PPTX chart data_labels.color must be a color")
115 point_items = _data_label_point_items(
116 config,
117 chart_type,
118 grouping,
119 point_count,
120 )
121 for item in point_items:
122 if item.get("color") is not None and _hex_or_none(item["color"]) is None:
123 raise RuntimeError(
124 "Native PPTX chart data_labels.points color must be a color"
125 )
126 colors = _chart_list(
127 _first_present(
128 config.get("colors"),
129 config.get("label_colors"),
130 config.get("labelColors"),
131 ),
132 "data_labels.colors",
133 )
134 if colors and len(colors) != point_count:
135 raise RuntimeError("Native PPTX chart data_labels.colors must match point count")
136 if any(_hex_or_none(color) is None for color in colors):
137 raise RuntimeError("Native PPTX chart data_labels.colors entries must be colors")
138 return config
139
140
141 def _data_label_point_items(
142 config: dict[str, Any],
143 chart_type: str,
144 grouping: str | None,
145 point_count: int,
146 ) -> list[dict[str, Any]]:
147 """Normalize selected point labels and validate their plot semantics."""
148 raw_points = config.get("points")
149 if raw_points is None:
150 return []
151 items: list[dict[str, Any]] = []
152 seen: set[int] = set()
153 for item in _chart_list(raw_points, "data_labels.points"):
154 if isinstance(item, dict):
155 raw_index = item.get("idx")
156 data = dict(item)
157 else:
158 raw_index = item
159 data = {}
160 if isinstance(raw_index, bool):
161 raise RuntimeError("Native PPTX chart data_labels.points idx must be an integer")
162 index_value = _number(raw_index, "data_labels.points idx")
163 if not index_value.is_integer():
164 raise RuntimeError("Native PPTX chart data_labels.points idx must be an integer")
165 index = int(index_value)
166 if index < 0 or index >= point_count:
167 raise RuntimeError("Native PPTX chart data_labels.points idx is outside point range")
168 if index in seen:
169 raise RuntimeError("Native PPTX chart data_labels.points idx values must be unique")
170 _data_label_position(
171 _first_present(data.get("position"), config.get("position")),
172 chart_type,
173 grouping,
174 )
175 seen.add(index)
176 data["idx"] = index
177 items.append(data)
178 return items
179
180
181 _CATEGORY_CHART_TYPES = {
182 "area",
183 "bar",
184 "column",
185 "doughnut",
186 "line",
187 "of_pie",
188 "pie",
189 "radar",
190 }
191 _XY_CHART_TYPES = {"scatter", "bubble"}
192 _CHARTEX_CHART_TYPES = {
193 "box_whisker",
194 "funnel",
195 "histogram",
196 "pareto",
197 "sunburst",
198 "treemap",
199 "waterfall",
200 }
201 _DEFERRED_CHART_TYPES = {
202 "bullet",
203 "gantt",
204 "heatmap",
205 "map",
206 }
207 _UNSUPPORTED_3D_CHART_TYPES = {
208 "area3d",
209 "bar3d",
210 "column3d",
211 "line3d",
212 "pie3d",
213 "surface",
214 }
215 _DEFAULT_CHART_COLORS = [
216 "4472C4",
217 "ED7D31",
218 "A5A5A5",
219 "FFC000",
220 "5B9BD5",
221 "70AD47",
222 "264478",
223 "9E480E",
224 ]
225
226 _AXIS_ROLE_DEFAULTS = {
227 "category": ("text", "bottom"),
228 "secondary_category": ("text", "bottom"),
229 "secondary_value": ("value", "right"),
230 "value": ("value", "left"),
231 "x": ("value", "bottom"),
232 "y": ("value", "left"),
233 }
234
235
236 def _chart_axes(
237 payload: dict[str, Any],
238 allowed_roles: set[str],
239 *,
240 bar_orientation: bool = False,
241 ) -> dict[str, dict[str, Any]]:
242 """Normalize the narrow classic-chart axis contract."""
243 raw_axes = payload.get("axes")
244 if raw_axes is None:
245 return {}
246 if not isinstance(raw_axes, dict):
247 raise RuntimeError("Native PPTX chart axes must be an object")
248
249 unknown_roles = set(raw_axes) - allowed_roles
250 if unknown_roles:
251 roles = ", ".join(sorted(unknown_roles))
252 raise RuntimeError(f"Native PPTX chart axes contains unsupported role(s): {roles}")
253
254 axes: dict[str, dict[str, Any]] = {}
255 for role, raw_config in raw_axes.items():
256 if not isinstance(raw_config, dict):
257 raise RuntimeError(f"Native PPTX chart axes.{role} must be an object")
258 allowed_fields = {
259 "kind", "label_position", "major_gridlines", "major_unit",
260 "maximum", "minimum", "number_format", "position", "reverse",
261 "visible",
262 }
263 unknown_fields = set(raw_config) - allowed_fields
264 if unknown_fields:
265 fields = ", ".join(sorted(unknown_fields))
266 raise RuntimeError(
267 f"Native PPTX chart axes.{role} contains unsupported field(s): {fields}"
268 )
269 default_kind, default_position = _AXIS_ROLE_DEFAULTS[role]
270 if bar_orientation and role == "category":
271 default_position = "left"
272 elif bar_orientation and role == "value":
273 default_position = "bottom"
274 kind = _compact_key(raw_config.get("kind") or default_kind)
275 if kind not in {"date", "text", "value"}:
276 raise RuntimeError(
277 f"Native PPTX chart axes.{role}.kind must be date, text, or value"
278 )
279 if role in {"category", "secondary_category"} and kind not in {"date", "text"}:
280 raise RuntimeError(f"Native PPTX chart axes.{role}.kind must be date or text")
281 if role in {"value", "secondary_value", "x", "y"} and kind != "value":
282 raise RuntimeError(f"Native PPTX chart axes.{role}.kind must be value")
283
284 position_aliases = {
285 "b": "bottom",
286 "bottom": "bottom",
287 "l": "left",
288 "left": "left",
289 "r": "right",
290 "right": "right",
291 "t": "top",
292 "top": "top",
293 }
294 position = position_aliases.get(
295 _compact_key(raw_config.get("position") or default_position)
296 )
297 if position is None:
298 raise RuntimeError(
299 f"Native PPTX chart axes.{role}.position must be bottom, left, right, or top"
300 )
301 if bar_orientation and role == "category":
302 allowed_positions = {"left", "right"}
303 elif bar_orientation and role == "value":
304 allowed_positions = {"bottom", "top"}
305 else:
306 allowed_positions = (
307 {"bottom", "top"}
308 if role in {"category", "secondary_category", "x"}
309 else {"left", "right"}
310 )
311 if position not in allowed_positions:
312 choices = ", ".join(sorted(allowed_positions))
313 raise RuntimeError(
314 f"Native PPTX chart axes.{role}.position must be one of: {choices}"
315 )
316
317 config: dict[str, Any] = {"kind": kind, "position": position}
318 for field in ("visible", "reverse", "major_gridlines"):
319 value = raw_config.get(field)
320 if value is None:
321 continue
322 if not isinstance(value, bool):
323 raise RuntimeError(
324 f"Native PPTX chart axes.{role}.{field} must be a boolean"
325 )
326 config[field] = value
327
328 raw_label_position = raw_config.get("label_position")
329 if raw_label_position is not None:
330 label_aliases = {
331 "high": "high",
332 "low": "low",
333 "nextto": "next_to",
334 "none": "none",
335 }
336 label_position = label_aliases.get(_compact_key(raw_label_position))
337 if label_position is None:
338 raise RuntimeError(
339 f"Native PPTX chart axes.{role}.label_position must be one of: "
340 "high, low, next_to, none"
341 )
342 config["label_position"] = label_position
343
344 raw_number_format = raw_config.get("number_format")
345 if raw_number_format is not None:
346 if not isinstance(raw_number_format, str):
347 raise RuntimeError(
348 f"Native PPTX chart axes.{role}.number_format must be a string"
349 )
350 if not raw_number_format.strip():
351 raise RuntimeError(
352 f"Native PPTX chart axes.{role}.number_format must be non-empty"
353 )
354 config["number_format"] = raw_number_format
355
356 for field in ("minimum", "maximum", "major_unit"):
357 value = raw_config.get(field)
358 if value is None:
359 continue
360 number = _chart_number(value)
361 if field == "major_unit":
362 if role not in {"value", "secondary_value", "x", "y"}:
363 raise RuntimeError(
364 f"Native PPTX chart axes.{role}.major_unit is unsupported"
365 )
366 if number <= 0:
367 raise RuntimeError(
368 f"Native PPTX chart axes.{role}.major_unit must be positive"
369 )
370 config[field] = number
371 if (
372 config.get("minimum") is not None
373 and config.get("maximum") is not None
374 and config["minimum"] >= config["maximum"]
375 ):
376 raise RuntimeError(
377 f"Native PPTX chart axes.{role}.minimum must be less than maximum"
378 )
379 axes[role] = config
380 return axes
381
382
383 def _category_axis_is_date(axes: dict[str, dict[str, Any]]) -> bool:
384 return axes.get("category", {}).get("kind") == "date"
385
386
387 def _chart_plot_area(payload: dict[str, Any]) -> dict[str, float] | None:
388 """Normalize an optional absolute slide-local plot-area box."""
389 raw = payload.get("plot_area")
390 if raw is None:
391 return None
392 if not isinstance(raw, dict):
393 raise RuntimeError("Native PPTX chart plot_area must be an object")
394
395 box_keys = {"x", "y", "width", "height"}
396 unknown_keys = set(raw) - box_keys
397 if unknown_keys:
398 fields = ", ".join(sorted(unknown_keys))
399 raise RuntimeError(
400 f"Native PPTX chart plot_area contains unsupported field(s): {fields}"
401 )
402 missing_keys = box_keys - set(raw)
403 if missing_keys:
404 fields = ", ".join(sorted(missing_keys))
405 raise RuntimeError(
406 "Native PPTX chart plot_area requires x/y/width/height together; "
407 f"missing: {fields}"
408 )
409
410 plot_area = {
411 key: _number(raw[key], f"chart plot_area.{key}")
412 for key in ("x", "y", "width", "height")
413 }
414 if plot_area["width"] <= 0 or plot_area["height"] <= 0:
415 raise RuntimeError("Native PPTX chart plot_area width/height must be positive")
416 return plot_area
417
418
419 def _chart_plot_area_layout(
420 chart_data: dict[str, Any],
421 chart_bounds: tuple[int, int, int, int],
422 ) -> tuple[float, float, float, float] | None:
423 """Resolve an absolute plot-area box to chart-relative manual-layout factors."""
424 plot_area = chart_data.get("plot_area")
425 if plot_area is None:
426 return None
427
428 chart_x, chart_y, chart_width, chart_height = chart_bounds
429 plot_x = _powerpoint_emu(plot_area["x"], "chart plot_area.x")
430 plot_y = _powerpoint_emu(plot_area["y"], "chart plot_area.y")
431 plot_width = _powerpoint_emu(
432 plot_area["width"],
433 "chart plot_area.width",
434 positive=True,
435 )
436 plot_height = _powerpoint_emu(
437 plot_area["height"],
438 "chart plot_area.height",
439 positive=True,
440 )
441 if (
442 plot_x < chart_x
443 or plot_y < chart_y
444 or plot_x + plot_width > chart_x + chart_width
445 or plot_y + plot_height > chart_y + chart_height
446 ):
447 raise RuntimeError(
448 "Native PPTX chart plot_area must be fully contained within the chart frame"
449 )
450 return (
451 (plot_x - chart_x) / chart_width,
452 (plot_y - chart_y) / chart_height,
453 plot_width / chart_width,
454 plot_height / chart_height,
455 )
456
457
458 def _doughnut_hole_size(payload: dict[str, Any], chart_type: str) -> int | None:
459 """Normalize the closed doughnut-hole percentage contract."""
460 raw = payload.get("hole_size")
461 if chart_type != "doughnut":
462 if raw is not None:
463 raise RuntimeError(
464 "Native PPTX chart hole_size is supported for doughnut charts only"
465 )
466 return None
467 if raw is None:
468 return 75
469 if not isinstance(raw, (int, float)) or isinstance(raw, bool):
470 raise RuntimeError("Native PPTX doughnut hole_size must be a numeric integer")
471 value = _chart_number(raw)
472 if not float(value).is_integer():
473 raise RuntimeError("Native PPTX doughnut hole_size must be an integer")
474 hole_size = int(value)
475 if not 10 <= hole_size <= 90:
476 raise RuntimeError(
477 "Native PPTX doughnut hole_size must be between 10 and 90"
478 )
479 return hole_size
480
481
482 def _chart_kind(payload: dict[str, Any]) -> tuple[str, str | None, str | None]:
483 raw_type = payload.get("type") or payload.get("chart_type") or "column"
484 key = _compact_key(raw_type)
485 aliases: dict[str, tuple[str, str | None, str | None]] = {
486 "area": ("area", "standard", None),
487 "areastacked": ("area", "stacked", None),
488 "areastacked100": ("area", "percentStacked", None),
489 "area100": ("area", "percentStacked", None),
490 "bar": ("bar", "clustered", None),
491 "barofpie": ("of_pie", None, "bar"),
492 "barclustered": ("bar", "clustered", None),
493 "barstacked": ("bar", "stacked", None),
494 "barstacked100": ("bar", "percentStacked", None),
495 "boxandwhisker": ("box_whisker", None, None),
496 "boxplot": ("box_whisker", None, None),
497 "boxwhisker": ("box_whisker", None, None),
498 "bubble": ("bubble", None, None),
499 "bullet": ("bullet", None, None),
500 "bulletchart": ("bullet", None, None),
501 "combo": ("combo", None, None),
502 "combochart": ("combo", None, None),
503 "choropleth": ("map", None, None),
504 "conebarclustered": ("bar3d", "clustered", "cone"),
505 "conebarstacked": ("bar3d", "stacked", "cone"),
506 "conebarstacked100": ("bar3d", "percentStacked", "cone"),
507 "conecol": ("column3d", "clustered", "cone"),
508 "conecolclustered": ("column3d", "clustered", "cone"),
509 "conecolstacked": ("column3d", "stacked", "cone"),
510 "conecolstacked100": ("column3d", "percentStacked", "cone"),
511 "col": ("column", "clustered", None),
512 "column": ("column", "clustered", None),
513 "columnclustered": ("column", "clustered", None),
514 "columnstacked": ("column", "stacked", None),
515 "columnstacked100": ("column", "percentStacked", None),
516 "contour": ("surface", None, "topView"),
517 "contourwireframe": ("surface", None, "topViewWireframe"),
518 "cylinderbarclustered": ("bar3d", "clustered", "cylinder"),
519 "cylinderbarstacked": ("bar3d", "stacked", "cylinder"),
520 "cylinderbarstacked100": ("bar3d", "percentStacked", "cylinder"),
521 "cylindercol": ("column3d", "clustered", "cylinder"),
522 "cylindercolclustered": ("column3d", "clustered", "cylinder"),
523 "cylindercolstacked": ("column3d", "stacked", "cylinder"),
524 "cylindercolstacked100": ("column3d", "percentStacked", "cylinder"),
525 "doughnut": ("doughnut", None, None),
526 "doughnutexploded": ("doughnut", None, "exploded"),
527 "donut": ("doughnut", None, None),
528 "donutexploded": ("doughnut", None, "exploded"),
529 "filledmap": ("map", None, None),
530 "funnel": ("funnel", None, None),
531 "funnelchart": ("funnel", None, None),
532 "gantt": ("gantt", None, None),
533 "ganttchart": ("gantt", None, None),
534 "geo": ("map", None, None),
535 "geomap": ("map", None, None),
536 "heatmap": ("heatmap", None, None),
537 "heatmapchart": ("heatmap", None, None),
538 "histogram": ("histogram", None, None),
539 "histogramchart": ("histogram", None, None),
540 "line": ("line", "standard", "line"),
541 "linemarkers": ("line", "standard", "lineMarker"),
542 "linemarkersstacked": ("line", "stacked", "lineMarker"),
543 "linemarkersstacked100": ("line", "percentStacked", "lineMarker"),
544 "linestacked": ("line", "stacked", "line"),
545 "linestacked100": ("line", "percentStacked", "line"),
546 "linestackedmarkers": ("line", "stacked", "lineMarker"),
547 "linestackedmarkers100": ("line", "percentStacked", "lineMarker"),
548 "pie": ("pie", None, None),
549 "pieexploded": ("pie", None, "exploded"),
550 "ofpie": ("of_pie", None, "pie"),
551 "pieofpie": ("of_pie", None, "pie"),
552 "pareto": ("pareto", None, None),
553 "paretochart": ("pareto", None, None),
554 "pyramidbarclustered": ("bar3d", "clustered", "pyramid"),
555 "pyramidbarstacked": ("bar3d", "stacked", "pyramid"),
556 "pyramidbarstacked100": ("bar3d", "percentStacked", "pyramid"),
557 "pyramidcol": ("column3d", "clustered", "pyramid"),
558 "pyramidcolclustered": ("column3d", "clustered", "pyramid"),
559 "pyramidcolstacked": ("column3d", "stacked", "pyramid"),
560 "pyramidcolstacked100": ("column3d", "percentStacked", "pyramid"),
561 "radar": ("radar", None, "line"),
562 "radarfilled": ("radar", None, "filled"),
563 "radarmarkers": ("radar", None, "lineMarker"),
564 "scatter": ("scatter", None, "marker"),
565 "stock": ("stock", None, "hlc"),
566 "stockhlc": ("stock", None, "hlc"),
567 "stockohlc": ("stock", None, "ohlc"),
568 "stockvhlc": ("stock", None, "vhlc"),
569 "stockvohlc": ("stock", None, "vohlc"),
570 "surface": ("surface", None, "surface3D"),
571 "surface3d": ("surface", None, "surface3D"),
572 "surfacewireframe": ("surface", None, "surface3DWireframe"),
573 "surfacetopview": ("surface", None, "topView"),
574 "surfacetopviewwireframe": ("surface", None, "topViewWireframe"),
575 "sunburst": ("sunburst", None, None),
576 "sunburstchart": ("sunburst", None, None),
577 "map": ("map", None, None),
578 "mapchart": ("map", None, None),
579 "threedarea": ("area3d", "standard", None),
580 "threedareastacked": ("area3d", "stacked", None),
581 "threedareastacked100": ("area3d", "percentStacked", None),
582 "threedbar": ("bar3d", "clustered", "box"),
583 "threedbarclustered": ("bar3d", "clustered", "box"),
584 "threedbarstacked": ("bar3d", "stacked", "box"),
585 "threedbarstacked100": ("bar3d", "percentStacked", "box"),
586 "threedcolumn": ("column3d", "clustered", "box"),
587 "threedcolumnclustered": ("column3d", "clustered", "box"),
588 "threedcolumnstacked": ("column3d", "stacked", "box"),
589 "threedcolumnstacked100": ("column3d", "percentStacked", "box"),
590 "threedline": ("line3d", "standard", None),
591 "threedpie": ("pie3d", None, None),
592 "threedpieexploded": ("pie3d", None, "exploded"),
593 "treemap": ("treemap", None, None),
594 "treemapchart": ("treemap", None, None),
595 "waterfall": ("waterfall", None, None),
596 "waterfallchart": ("waterfall", None, None),
597 "xy": ("scatter", None, "marker"),
598 "xyscatter": ("scatter", None, "marker"),
599 "xyscatterlines": ("scatter", None, "lineMarker"),
600 "xyscatterlinesnomarkers": ("scatter", None, "line"),
601 "xyscattersmooth": ("scatter", None, "smoothMarker"),
602 "xyscattersmoothnomarkers": ("scatter", None, "smooth"),
603 }
604 if key.startswith("100percentstacked"):
605 key = key.replace("100percentstacked", "", 1) + "stacked100"
606 if key.startswith("percentstacked"):
607 key = key.replace("percentstacked", "", 1) + "stacked100"
608 if key.startswith("3d"):
609 key = "threed" + key[2:]
610 chart_type, grouping, style = aliases.get(key, (key, None, None))
611 if chart_type in _UNSUPPORTED_3D_CHART_TYPES:
612 raise RuntimeError("Native PPTX 3D charts are intentionally unsupported")
613 if chart_type in _DEFERRED_CHART_TYPES:
614 raise RuntimeError(
615 f"Native PPTX {chart_type} chart is outside current basic chart support"
616 )
617
618 supported = sorted(_CATEGORY_CHART_TYPES | _XY_CHART_TYPES | _CHARTEX_CHART_TYPES | {"combo", "stock"})
619 if chart_type not in supported:
620 raise RuntimeError(f"Native PPTX chart type must be one of: {', '.join(supported)}")
621 return chart_type, grouping, style
622
623
624 def _chart_grouping(
625 chart_type: str,
626 payload: dict[str, Any],
627 alias_grouping: str | None,
628 ) -> str | None:
629 grouping = payload.get("grouping") or payload.get("chart_grouping") or alias_grouping
630 if not grouping and payload.get("stacked"):
631 grouping = "stacked"
632 if not grouping:
633 return "clustered" if chart_type in {"bar", "column"} else "standard"
634
635 aliases = {
636 "100": "percentStacked",
637 "100percent": "percentStacked",
638 "100percentstacked": "percentStacked",
639 "clustered": "clustered",
640 "percent": "percentStacked",
641 "percentstacked": "percentStacked",
642 "stacked": "stacked",
643 "standard": "standard",
644 }
645 normalized = aliases.get(_compact_key(grouping))
646 if chart_type in {"bar", "column"}:
647 allowed = {"clustered", "stacked", "percentStacked"}
648 elif chart_type in {"area", "line"}:
649 allowed = {"standard", "stacked", "percentStacked"}
650 else:
651 allowed = {"standard"}
652 if normalized not in allowed:
653 if normalized in {"clustered", "standard"}:
654 allowed_text = ", ".join(sorted(allowed))
655 raise RuntimeError(f"Native PPTX {chart_type} chart grouping must be one of: {allowed_text}")
656 raise RuntimeError(
657 f"Native PPTX {grouping} grouping is outside current basic chart support"
658 )
659 return normalized
660
661
662 def _line_style(payload: dict[str, Any], alias_style: str | None) -> str:
663 raw_style = payload.get("line_style") or payload.get("lineStyle") or alias_style
664 if raw_style is None:
665 raw_style = "lineMarker" if payload.get("markers") else "line"
666 aliases = {
667 "line": "line",
668 "linemarker": "lineMarker",
669 "marker": "lineMarker",
670 "markers": "lineMarker",
671 "none": "line",
672 "nomarker": "line",
673 "nomarkers": "line",
674 }
675 style = aliases.get(_compact_key(raw_style))
676 if not style:
677 raise RuntimeError("Native PPTX line_style must be one of: line, lineMarker")
678 return style
679
680
681 def _radar_style(payload: dict[str, Any], alias_style: str | None) -> tuple[str, str | None]:
682 raw_style = payload.get("radar_style") or payload.get("radarStyle") or alias_style or "line"
683 aliases = {
684 "filled": ("filled", None),
685 "line": ("marker", "none"),
686 "linemarker": ("marker", "circle"),
687 "marker": ("marker", "none"),
688 "markers": ("marker", "circle"),
689 "standard": ("marker", "none"),
690 }
691 style = aliases.get(_compact_key(raw_style))
692 if not style:
693 raise RuntimeError(
694 f"Native PPTX radar_style {raw_style} is outside current basic chart support"
695 )
696 return style
697
698
699 def _category_series(payload: dict[str, Any], categories: list[Any]) -> list[dict[str, Any]]:
700 raw_series = payload.get("series", [])
701 if not categories or not isinstance(raw_series, list) or not raw_series:
702 raise RuntimeError("Native PPTX chart requires non-empty categories and series")
703 root_point_colors = _first_present(
704 payload.get("point_colors"),
705 payload.get("pointColors"),
706 )
707 if root_point_colors is not None and len(raw_series) != 1:
708 raise RuntimeError("Native PPTX chart root point_colors is only valid for one series")
709
710 series: list[dict[str, Any]] = []
711 for idx, item in enumerate(raw_series, start=1):
712 if not isinstance(item, dict):
713 raise RuntimeError("Native PPTX chart series entries must be objects")
714 values = [
715 _chart_number(value)
716 for value in _chart_list(item.get("values", []), "series[].values")
717 ]
718 if len(values) != len(categories):
719 raise RuntimeError("Native PPTX chart series values must match categories length")
720 raw_point_colors = _first_present(
721 item.get("point_colors"),
722 item.get("pointColors"),
723 root_point_colors if idx == 1 else None,
724 )
725 point_colors = [
726 _clean_hex(color, "#4472C4")
727 for color in _chart_list(raw_point_colors, "series[].point_colors")
728 ]
729 if point_colors and len(point_colors) != len(values):
730 raise RuntimeError("Native PPTX chart series point_colors must match values length")
731 series_item = {"name": str(item.get("name") or f"Series {idx}"), "values": values}
732 if point_colors:
733 series_item["point_colors"] = point_colors
734 fill_opacity = _first_present(
735 item.get("fill_opacity"),
736 item.get("fillOpacity"),
737 )
738 if fill_opacity is not None:
739 fill_opacity = _number(fill_opacity, "series fill_opacity")
740 if not 0 <= fill_opacity <= 1:
741 raise RuntimeError(
742 "Native PPTX chart series fill_opacity must be between 0 and 1"
743 )
744 series_item["fill_opacity"] = fill_opacity
745 line_width = _first_present(
746 item.get("line_width"),
747 item.get("lineWidth"),
748 )
749 if line_width is not None:
750 line_width = _number(line_width, "series line_width")
751 if line_width <= 0:
752 raise RuntimeError("Native PPTX chart series line_width must be positive")
753 _powerpoint_line_width_emu(line_width, "series line_width")
754 series_item["line_width"] = line_width
755 series.append(series_item)
756 return series
757
758
759 def _category_chart_data(
760 payload: dict[str, Any],
761 chart_type: str,
762 alias_grouping: str | None,
763 alias_style: str | None,
764 ) -> dict[str, Any]:
765 axes = _chart_axes(
766 payload,
767 {"category", "value"},
768 bar_orientation=chart_type == "bar",
769 )
770 if axes and chart_type in {"doughnut", "of_pie", "pie"}:
771 raise RuntimeError(
772 f"Native PPTX {chart_type} chart axes are outside current support"
773 )
774 if _category_axis_is_date(axes) and chart_type != "area":
775 raise RuntimeError(
776 "Native PPTX date category axes are currently supported for area charts only"
777 )
778 raw_categories = _chart_list(payload.get("categories", []), "categories")
779 categories = (
780 [_chart_number(item) for item in raw_categories]
781 if _category_axis_is_date(axes)
782 else [str(item) for item in raw_categories]
783 )
784 style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
785
786 series = _category_series(payload, categories)
787 if chart_type in {"doughnut", "of_pie", "pie"}:
788 if len(series) != 1:
789 raise RuntimeError("Native PPTX pie-family charts support exactly one series")
790
791 of_pie_type = None
792 if chart_type == "of_pie":
793 raw_of_pie_type = (
794 payload.get("of_pie_type")
795 or payload.get("ofPieType")
796 or payload.get("secondary_type")
797 or alias_style
798 or "pie"
799 )
800 of_pie_aliases = {
801 "bar": "bar",
802 "barofpie": "bar",
803 "pie": "pie",
804 "pieofpie": "pie",
805 }
806 of_pie_type = of_pie_aliases.get(_compact_key(raw_of_pie_type))
807 if not of_pie_type:
808 raise RuntimeError("Native PPTX of_pie_type must be one of: bar, pie")
809
810 line_style = _line_style(payload, alias_style) if chart_type == "line" else None
811 radar_style = None
812 radar_marker_style = None
813 if chart_type == "radar":
814 radar_style, radar_marker_style = _radar_style(payload, alias_style)
815
816 if alias_style == "exploded" or payload.get("exploded"):
817 raise RuntimeError("Native PPTX exploded pie/doughnut is outside current basic chart support")
818
819 grouping = (
820 _chart_grouping(chart_type, payload, alias_grouping)
821 if chart_type in {"bar", "column", "line", "area"}
822 else None
823 )
824 return {
825 "kind": "category",
826 "type": chart_type,
827 "categories": categories,
828 "grouping": grouping,
829 "of_pie_type": of_pie_type,
830 "hole_size": _doughnut_hole_size(payload, chart_type),
831 "line_style": line_style,
832 "radar_marker_style": radar_marker_style,
833 "radar_style": radar_style,
834 "show_value_axis_labels": _chart_bool(
835 _first_present(
836 payload.get("show_value_axis_labels"),
837 payload.get("showValueAxisLabels"),
838 style.get("show_value_axis_labels"),
839 style.get("showValueAxisLabels"),
840 ),
841 True,
842 ),
843 "data_labels": _chart_data_labels(
844 payload,
845 chart_type,
846 grouping,
847 len(categories),
848 ),
849 "axes": axes,
850 "series": series,
851 }
852
853
854 def _combo_axis_name(plot_payload: dict[str, Any]) -> str:
855 axis = plot_payload.get("axis") or plot_payload.get("value_axis")
856 if axis is None and plot_payload.get("secondary_axis"):
857 axis = "secondary"
858 axis_key = _compact_key(axis or "primary")
859 aliases = {
860 "left": "primary",
861 "primary": "primary",
862 "right": "secondary",
863 "secondary": "secondary",
864 "secondaryaxis": "secondary",
865 }
866 normalized = aliases.get(axis_key)
867 if not normalized:
868 raise RuntimeError("Native PPTX combo plot axis must be primary or secondary")
869 return normalized
870
871
872 def _combo_plot_type(plot_payload: dict[str, Any]) -> tuple[str, str | None, str | None]:
873 chart_type, alias_grouping, alias_style = _chart_kind(plot_payload)
874 if chart_type not in {"area", "column", "line"}:
875 raise RuntimeError("Native PPTX combo plots support column, line, and area only")
876 has_area_fill = bool(_first_present(plot_payload.get("area_fill"), plot_payload.get("areaFill")))
877 if chart_type == "line" and has_area_fill:
878 chart_type = "area"
879 return chart_type, alias_grouping, alias_style
880
881
882 def _plot_series_area_style(plot_payload: dict[str, Any]) -> bool:
883 for item in _chart_list(plot_payload.get("series", []), "series"):
884 if not isinstance(item, dict):
885 continue
886 if _first_present(
887 item.get("fill_opacity"),
888 item.get("fillOpacity"),
889 ) is not None:
890 return True
891 return False
892
893
894 def _combo_series_indices(
895 plot_payload: dict[str, Any],
896 series_count: int,
897 ) -> list[int] | None:
898 raw_indices = plot_payload.get("series_indices")
899 if raw_indices is None:
900 return None
901 indices: list[int] = []
902 for value in _chart_list(raw_indices, "plots[].series_indices"):
903 if isinstance(value, bool) or not isinstance(value, int) or value < 0:
904 raise RuntimeError(
905 "Native PPTX combo series_indices must contain non-negative integers"
906 )
907 indices.append(value)
908 if len(indices) != series_count or len(set(indices)) != len(indices):
909 raise RuntimeError(
910 "Native PPTX combo series_indices must be unique and match series length"
911 )
912 return indices
913
914
915 def _combo_plot_entry(
916 plot_payload: dict[str, Any],
917 categories: list[Any],
918 *,
919 category_is_numeric: bool,
920 axes: dict[str, dict[str, Any]],
921 fallback_series: list[dict[str, Any]] | None = None,
922 ) -> dict[str, Any]:
923 chart_type, alias_grouping, alias_style = _combo_plot_type(plot_payload)
924 if chart_type == "line" and _plot_series_area_style(plot_payload):
925 raise RuntimeError(
926 "Native PPTX combo line plot with series fill_opacity requires area_fill: true"
927 )
928 axis = _combo_axis_name(plot_payload)
929 category_role = "secondary_category" if axis == "secondary" else "category"
930 axis_is_date = axes.get(category_role, {}).get("kind") == "date"
931 raw_numeric = plot_payload.get("category_numeric")
932 if raw_numeric is not None and not isinstance(raw_numeric, bool):
933 raise RuntimeError(
934 "Native PPTX combo plot category_numeric must be a boolean"
935 )
936 if axis_is_date and raw_numeric is False:
937 raise RuntimeError(
938 "Native PPTX combo date-axis categories must remain numeric"
939 )
940 plot_category_is_numeric = axis_is_date or (
941 raw_numeric if raw_numeric is not None else category_is_numeric
942 )
943 raw_plot_categories = plot_payload.get("categories")
944 category_items = (
945 categories
946 if raw_plot_categories is None
947 else _chart_list(raw_plot_categories, "plots[].categories")
948 )
949 plot_categories = (
950 [_chart_number(item) for item in category_items]
951 if plot_category_is_numeric
952 else [str(item) for item in category_items]
953 )
954 if not plot_categories:
955 raise RuntimeError("Native PPTX combo plot categories must be non-empty")
956 plot_series = fallback_series or _category_series(plot_payload, plot_categories)
957 grouping = (
958 _chart_grouping(chart_type, plot_payload, alias_grouping)
959 if chart_type in {"area", "column", "line"}
960 else None
961 )
962 entry: dict[str, Any] = {
963 "axis": axis,
964 "categories": plot_categories,
965 "category_is_numeric": plot_category_is_numeric,
966 "data_labels": _chart_data_labels(
967 plot_payload,
968 chart_type,
969 grouping,
970 len(plot_categories),
971 ),
972 "grouping": grouping,
973 "series": plot_series,
974 "type": chart_type,
975 }
976 series_indices = _combo_series_indices(plot_payload, len(plot_series))
977 if series_indices is not None:
978 entry["series_indices"] = series_indices
979 if chart_type == "line":
980 entry["line_style"] = _line_style(plot_payload, alias_style)
981 return entry
982
983
984 def _combo_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
985 axes = _chart_axes(
986 payload,
987 {"category", "secondary_category", "secondary_value", "value"},
988 )
989 raw_category_numeric = payload.get("category_numeric")
990 if raw_category_numeric is not None and not isinstance(raw_category_numeric, bool):
991 raise RuntimeError("Native PPTX combo category_numeric must be a boolean")
992 primary_axis_is_date = _category_axis_is_date(axes)
993 if primary_axis_is_date and raw_category_numeric is False:
994 raise RuntimeError("Native PPTX combo date-axis categories must remain numeric")
995 category_is_numeric = primary_axis_is_date or raw_category_numeric is True
996 raw_categories = _chart_list(payload.get("categories", []), "categories")
997 categories = (
998 [_chart_number(item) for item in raw_categories]
999 if category_is_numeric
1000 else [str(item) for item in raw_categories]
1001 )
1002 if not categories:
1003 raise RuntimeError("Native PPTX combo chart categories must be non-empty")
1004 raw_plots = payload.get("plots", payload.get("chart_plots"))
1005 plots: list[dict[str, Any]] = []
1006
1007 if raw_plots is not None:
1008 for item in _chart_list(raw_plots, "plots"):
1009 if not isinstance(item, dict):
1010 raise RuntimeError("Native PPTX combo plots must be objects")
1011 plots.append(_combo_plot_entry(
1012 item,
1013 categories,
1014 category_is_numeric=category_is_numeric,
1015 axes=axes,
1016 ))
1017 else:
1018 raw_series = _chart_list(payload.get("series", []), "series")
1019 if not raw_series:
1020 raise RuntimeError("Native PPTX combo chart requires plots or typed series")
1021 for idx, item in enumerate(raw_series, start=1):
1022 if not isinstance(item, dict):
1023 raise RuntimeError("Native PPTX chart series entries must be objects")
1024 if not (item.get("type") or item.get("chart_type")):
1025 raise RuntimeError("Native PPTX combo series entries require type")
1026 if any(
1027 field in item
1028 for field in ("categories", "category_numeric", "series_indices")
1029 ):
1030 raise RuntimeError(
1031 "Native PPTX combo typed series with plot-scoped metadata "
1032 "must use plots"
1033 )
1034 one_series = _category_series({"series": [item]}, categories)
1035 plot = _combo_plot_entry(
1036 item,
1037 categories,
1038 category_is_numeric=category_is_numeric,
1039 axes=axes,
1040 fallback_series=one_series,
1041 )
1042 signature = (
1043 plot["axis"],
1044 plot.get("grouping"),
1045 plot.get("line_style"),
1046 plot["type"],
1047 )
1048 previous = plots[-1] if plots else None
1049 previous_signature = (
1050 previous.get("axis"),
1051 previous.get("grouping"),
1052 previous.get("line_style"),
1053 previous.get("type"),
1054 ) if previous else None
1055 if (
1056 previous is not None
1057 and signature == previous_signature
1058 and plot.get("data_labels") == previous.get("data_labels")
1059 ):
1060 previous["series"].extend(plot["series"])
1061 else:
1062 plots.append(plot)
1063
1064 if not plots:
1065 raise RuntimeError("Native PPTX combo chart requires at least one plot")
1066 if not any(plot["axis"] == "primary" for plot in plots):
1067 raise RuntimeError("Native PPTX combo chart requires a primary-axis plot")
1068 has_secondary_plot = any(plot["axis"] == "secondary" for plot in plots)
1069 if not has_secondary_plot and {
1070 "secondary_category", "secondary_value",
1071 }.intersection(axes):
1072 raise RuntimeError(
1073 "Native PPTX combo secondary axes require a secondary-axis plot"
1074 )
1075 series_index_groups = [plot.get("series_indices") for plot in plots]
1076 if any(group is not None for group in series_index_groups):
1077 if any(group is None for group in series_index_groups):
1078 raise RuntimeError(
1079 "Native PPTX combo series_indices must cover every plot"
1080 )
1081 flat_indices = [
1082 index
1083 for group in series_index_groups
1084 for index in group
1085 ]
1086 if sorted(flat_indices) != list(range(len(flat_indices))):
1087 raise RuntimeError(
1088 "Native PPTX combo series_indices must form one contiguous range"
1089 )
1090 flat_series: list[dict[str, Any]] = []
1091 independent_categories = any(
1092 plot["categories"] != categories
1093 or plot["category_is_numeric"] != category_is_numeric
1094 for plot in plots
1095 )
1096 next_column = 1
1097 for plot in plots:
1098 plot["start_index"] = len(flat_series)
1099 if independent_categories:
1100 plot["category_column"] = next_column
1101 plot["start_column"] = next_column + 1
1102 next_column += len(plot["series"]) + 1
1103 flat_series.extend(plot["series"])
1104 if not flat_series:
1105 raise RuntimeError("Native PPTX combo chart requires at least one series")
1106
1107 return {
1108 "axes": axes,
1109 "categories": categories,
1110 "category_is_numeric": category_is_numeric,
1111 "independent_categories": independent_categories,
1112 "kind": "combo",
1113 "plots": plots,
1114 "series": flat_series,
1115 "type": "combo",
1116 }
1117
1118
1119 def _chart_values(payload: dict[str, Any], field_name: str = "values") -> list[int | float]:
1120 raw_values = payload.get(field_name)
1121 if raw_values is None and isinstance(payload.get("series"), list) and payload["series"]:
1122 first_series = payload["series"][0]
1123 if isinstance(first_series, dict):
1124 raw_values = first_series.get("values")
1125 values = [_chart_number(value) for value in _chart_list(raw_values, field_name)]
1126 if not values:
1127 raise RuntimeError(f"Native PPTX chart {field_name} must be a non-empty list")
1128 return values
1129
1130
1131 def _chart_categories(payload: dict[str, Any], count: int | None = None) -> list[str]:
1132 raw_categories = payload.get("categories", payload.get("labels", []))
1133 categories = [str(item) for item in _chart_list(raw_categories, "categories")]
1134 if count is not None:
1135 if not categories:
1136 categories = [f"Category {idx + 1}" for idx in range(count)]
1137 if len(categories) != count:
1138 raise RuntimeError("Native PPTX chart categories length must match values length")
1139 elif not categories:
1140 raise RuntimeError("Native PPTX chart requires non-empty categories")
1141 return categories
1142
1143
1144 def _hierarchy_levels(payload: dict[str, Any], count: int) -> list[list[str]]:
1145 raw_levels = payload.get("levels")
1146 if raw_levels is not None:
1147 levels = [
1148 [str(value) for value in _chart_list(level, "levels[]")]
1149 for level in _chart_list(raw_levels, "levels")
1150 ]
1151 else:
1152 raw_categories = _chart_list(payload.get("categories", []), "categories")
1153 if raw_categories and all(isinstance(item, list) for item in raw_categories):
1154 path_rows = [[str(value) for value in item] for item in raw_categories]
1155 else:
1156 path_rows = [[str(item)] for item in raw_categories]
1157 if len(path_rows) != count:
1158 raise RuntimeError("Native PPTX hierarchical chart categories length must match values length")
1159 max_depth = max((len(row) for row in path_rows), default=0)
1160 levels = [
1161 [row[depth] if depth < len(row) else "" for row in path_rows]
1162 for depth in range(max_depth)
1163 ]
1164
1165 if not levels:
1166 raise RuntimeError("Native PPTX hierarchical charts require levels or path categories")
1167 for level in levels:
1168 if len(level) != count:
1169 raise RuntimeError("Native PPTX hierarchical chart levels must match values length")
1170 return levels
1171
1172
1173 def _treemap_parent_labels(payload: dict[str, Any]) -> str:
1174 raw = payload.get("parent_label_layout", payload.get("parent_labels", "overlapping"))
1175 aliases = {
1176 "banner": "banner",
1177 "none": "none",
1178 "overlapping": "overlapping",
1179 }
1180 layout = aliases.get(_compact_key(raw))
1181 if not layout:
1182 raise RuntimeError(
1183 "Native PPTX treemap parent_label_layout must be one of: banner, none, overlapping"
1184 )
1185 return layout
1186
1187
1188 def _chartex_chart_data(payload: dict[str, Any], chart_type: str) -> dict[str, Any]:
1189 if chart_type in {"sunburst", "treemap"}:
1190 values = _chart_values(payload)
1191 levels = _hierarchy_levels(payload, len(values))
1192 data = {
1193 "kind": "chartex",
1194 "levels": levels,
1195 "type": chart_type,
1196 "values": values,
1197 }
1198 if chart_type == "treemap":
1199 data["parent_labels"] = _treemap_parent_labels(payload)
1200 return data
1201
1202 if chart_type == "histogram":
1203 return {
1204 "kind": "chartex",
1205 "type": chart_type,
1206 "values": _chart_values(payload),
1207 }
1208
1209 if chart_type in {"funnel", "pareto", "waterfall"}:
1210 values = _chart_values(payload)
1211 data = {
1212 "categories": _chart_categories(payload, len(values)),
1213 "kind": "chartex",
1214 "type": chart_type,
1215 "values": values,
1216 }
1217 if chart_type == "waterfall":
1218 raw_subtotals = payload.get(
1219 "subtotals",
1220 payload.get("subtotal_indices", []),
1221 )
1222 subtotals: list[int] = []
1223 seen_subtotals: set[int] = set()
1224 for value in _chart_list(raw_subtotals, "subtotals"):
1225 index = _chart_number(value)
1226 if not isinstance(index, int):
1227 raise RuntimeError("Native PPTX waterfall subtotal indices must be integers")
1228 if index < 0 or index >= len(values):
1229 raise RuntimeError(
1230 "Native PPTX waterfall subtotal index is outside point range"
1231 )
1232 if index in seen_subtotals:
1233 raise RuntimeError(
1234 "Native PPTX waterfall subtotal indices must be unique"
1235 )
1236 seen_subtotals.add(index)
1237 subtotals.append(index)
1238 data["subtotals"] = subtotals
1239 return data
1240
1241 if chart_type == "box_whisker":
1242 raw_series = _chart_list(payload.get("series", []), "series")
1243 if not raw_series:
1244 raise RuntimeError("Native PPTX boxWhisker chart requires non-empty series")
1245 series: list[dict[str, Any]] = []
1246 for idx, item in enumerate(raw_series, start=1):
1247 if not isinstance(item, dict):
1248 raise RuntimeError("Native PPTX chart series entries must be objects")
1249 values = [_chart_number(value) for value in _chart_list(item.get("values", []), "series[].values")]
1250 if not values:
1251 raise RuntimeError("Native PPTX boxWhisker series values must be non-empty")
1252 categories = item.get("categories")
1253 if categories is None:
1254 categories = [str(item.get("name") or f"Series {idx}")] * len(values)
1255 categories_list = [str(value) for value in _chart_list(categories, "series[].categories")]
1256 if len(categories_list) != len(values):
1257 raise RuntimeError("Native PPTX boxWhisker series categories must match values length")
1258 series.append({
1259 "categories": categories_list,
1260 "name": str(item.get("name") or f"Series {idx}"),
1261 "values": values,
1262 })
1263 return {
1264 "kind": "chartex",
1265 "series": series,
1266 "type": chart_type,
1267 }
1268
1269 raise RuntimeError(f"Native PPTX {chart_type} chart is outside current basic chart support")
1270
1271
1272 def _stock_chart_data(payload: dict[str, Any]) -> dict[str, Any]:
1273 if _data_labels_config(payload) is not None:
1274 raise RuntimeError("Native PPTX stock chart data labels are outside current support")
1275 axes = _chart_axes(payload, {"category", "value"})
1276 if "category" in axes and not _category_axis_is_date(axes):
1277 raise RuntimeError("Native PPTX stock chart category axis must be date")
1278 categories = [
1279 _chart_number(item)
1280 for item in _chart_list(payload.get("categories", payload.get("dates", [])), "categories")
1281 ]
1282 if not categories:
1283 raise RuntimeError("Native PPTX stock chart requires non-empty categories or dates")
1284
1285 raw_series = payload.get("series")
1286 if raw_series is None:
1287 field_names = [("open", "Open"), ("high", "High"), ("low", "Low"), ("close", "Close")]
1288 raw_series = [
1289 {"name": default_name, "values": payload.get(field_name, [])}
1290 for field_name, default_name in field_names
1291 ]
1292 series = _category_series({"series": raw_series}, categories)
1293 if len(series) != 4:
1294 raise RuntimeError("Native PPTX stock chart requires exactly four series: open, high, low, close")
1295 return {
1296 "axes": axes,
1297 "categories": categories,
1298 "kind": "category",
1299 "series": series,
1300 "type": "stock",
1301 }
1302
1303
1304 def _point_values(point: Any, *, chart_type: str) -> tuple[Any, Any, Any | None]:
1305 if isinstance(point, dict):
1306 return point.get("x"), point.get("y"), point.get("size", point.get("bubble_size"))
1307 if isinstance(point, (list, tuple)):
1308 if len(point) < 2:
1309 raise RuntimeError("Native PPTX XY chart points require x and y")
1310 size = point[2] if len(point) > 2 else None
1311 return point[0], point[1], size
1312 raise RuntimeError("Native PPTX XY chart points must be objects or arrays")
1313
1314
1315 def _xy_chart_data(
1316 payload: dict[str, Any],
1317 chart_type: str,
1318 alias_style: str | None,
1319 ) -> dict[str, Any]:
1320 if _data_labels_config(payload) is not None:
1321 raise RuntimeError(
1322 f"Native PPTX {chart_type} chart data labels are outside current support"
1323 )
1324 axes = _chart_axes(payload, {"x", "y"})
1325 raw_series = payload.get("series", [])
1326 if not isinstance(raw_series, list) or not raw_series:
1327 raise RuntimeError("Native PPTX XY chart requires non-empty series")
1328
1329 series: list[dict[str, Any]] = []
1330 for idx, item in enumerate(raw_series, start=1):
1331 if not isinstance(item, dict):
1332 raise RuntimeError("Native PPTX chart series entries must be objects")
1333
1334 if item.get("points") is not None:
1335 points = [
1336 _point_values(point, chart_type=chart_type)
1337 for point in _chart_list(item.get("points"), "series[].points")
1338 ]
1339 x_values = [_chart_number(point[0]) for point in points]
1340 y_values = [_chart_number(point[1]) for point in points]
1341 size_values = [_chart_number(point[2]) for point in points if point[2] is not None]
1342 else:
1343 x_raw = _chart_list(item.get("x", item.get("xs", [])), "series[].x")
1344 y_raw = _chart_list(
1345 item.get("y", item.get("ys", item.get("values", []))),
1346 "series[].y",
1347 )
1348 size_raw = _chart_list(
1349 item.get("size", item.get("sizes", item.get("bubble_size", []))),
1350 "series[].size",
1351 )
1352 x_values = [_chart_number(value) for value in x_raw]
1353 y_values = [_chart_number(value) for value in y_raw]
1354 size_values = [_chart_number(value) for value in size_raw]
1355
1356 if not x_values or len(x_values) != len(y_values):
1357 raise RuntimeError("Native PPTX XY chart x/y values must be non-empty and same length")
1358 if chart_type == "bubble" and len(size_values) != len(x_values):
1359 raise RuntimeError("Native PPTX bubble chart requires one size per x/y value")
1360
1361 series.append({
1362 "name": str(item.get("name") or f"Series {idx}"),
1363 "sizes": size_values,
1364 "x": x_values,
1365 "y": y_values,
1366 })
1367
1368 scatter_style = _compact_key(payload.get("scatter_style") or alias_style or "marker")
1369 style_aliases = {
1370 "line": "line",
1371 "linemarker": "lineMarker",
1372 "markers": "marker",
1373 "marker": "marker",
1374 "smooth": "smooth",
1375 "smoothmarker": "smoothMarker",
1376 }
1377 if chart_type == "scatter" and scatter_style not in style_aliases:
1378 raise RuntimeError("Native PPTX scatter_style is unsupported")
1379 return {
1380 "axes": axes,
1381 "kind": "xy",
1382 "type": chart_type,
1383 "scatter_style": style_aliases.get(scatter_style, "marker"),
1384 "series": series,
1385 }
1386
1387
1388 def _chart_data(payload: dict[str, Any]) -> dict[str, Any]:
1389 chart_type, alias_grouping, alias_style = _chart_kind(payload)
1390 if payload.get("hole_size") is not None and chart_type != "doughnut":
1391 raise RuntimeError(
1392 "Native PPTX chart hole_size is supported for doughnut charts only"
1393 )
1394 plot_area = _chart_plot_area(payload)
1395 if plot_area is not None and chart_type in _CHARTEX_CHART_TYPES:
1396 raise RuntimeError(
1397 "Native PPTX chart plot_area is supported for classic charts only"
1398 )
1399 if (
1400 chart_type not in _CATEGORY_CHART_TYPES | {"combo", "stock"} | _XY_CHART_TYPES
1401 and _data_labels_config(payload) is not None
1402 ):
1403 raise RuntimeError(
1404 f"Native PPTX {chart_type} chart data labels are outside current support"
1405 )
1406 if chart_type == "combo":
1407 chart_data = _combo_chart_data(payload)
1408 chart_data["plot_area"] = plot_area
1409 return chart_data
1410 if chart_type in _CHARTEX_CHART_TYPES:
1411 return _chartex_chart_data(payload, chart_type)
1412 if chart_type == "stock":
1413 chart_data = _stock_chart_data(payload)
1414 chart_data["plot_area"] = plot_area
1415 return chart_data
1416 if chart_type in _XY_CHART_TYPES:
1417 chart_data = _xy_chart_data(payload, chart_type, alias_style)
1418 chart_data["plot_area"] = plot_area
1419 return chart_data
1420 chart_data = _category_chart_data(
1421 payload,
1422 chart_type,
1423 alias_grouping,
1424 alias_style,
1425 )
1426 chart_data["plot_area"] = plot_area
1427 return chart_data
1428
1429
1430 def validate_chart_payload(payload: dict[str, Any]) -> None:
1431 """Check a native chart payload against the export schema.
1432
1433 Public contract for the pptx_to_svg importer: raises RuntimeError on any
1434 payload the native chart exporter cannot represent.
1435 """
1436 _chart_data(payload)
1437
1438
1439 def validate_data_label_position(
1440 value: Any,
1441 chart_type: str,
1442 grouping: str | None,
1443 ) -> None:
1444 """Check a data-label position against the export schema.
1445
1446 Public contract for the pptx_to_svg importer: raises RuntimeError when the
1447 position is not representable for the given plot.
1448 """
1449 _data_label_position(value, chart_type, grouping)
1450
1450 lines PYTHON