| 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 |