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