返回 ppt-master
chart_to_svg.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / chart_to_svg.py
1 """Extract native Chart replacement metadata from PPTX chart parts.
2
3 The visual chart preview still comes from the existing graphicFrame fallback.
4 This module only builds a conservative ``data-pptx-replace-with="chart"``
5 payload when the chart XML cache can be mapped to the current chart schema.
6 """
7
8 from __future__ import annotations
9
10 import math
11 import re
12 from dataclasses import dataclass
13 from typing import Any
14 from xml.etree import ElementTree as ET
15
16 from svg_to_pptx.drawingml.utils import parse_font_family
17 from svg_to_pptx.native_objects.chart_data import (
18 validate_chart_payload,
19 validate_data_label_position,
20 )
21
22 from .chartex_to_svg import UnsupportedChartEx, extract_native_chartex_payload
23 from .color_resolver import ColorPalette, find_color_elem, resolve_color
24 from .emu_units import NS, Xfrm, ooxml_bool
25 from .normalized_chart_svg import SeriesVisualStyle, render_normalized_chart_svg
26 from .ooxml_loader import OoxmlPackage, PartRef
27
28
29 CHART_URI = "http://schemas.openxmlformats.org/drawingml/2006/chart"
30 CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex"
31
32 C_NS = {
33 **NS,
34 "c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
35 "cx": CHARTEX_URI,
36 }
37
38
39 @dataclass
40 class ChartResult:
41 """Native chart marker payload or a transparent unsupported status."""
42
43 native_payload: dict[str, Any] | None = None
44 native_status: str | None = None
45 normalized_svg: str | None = None
46
47
48 class _UnsupportedChart(RuntimeError):
49 """Raised when a chart should keep its visual fallback only."""
50
51 def __init__(self, status: str) -> None:
52 super().__init__(status)
53 self.status = status
54
55
56 def extract_native_chart_payload(
57 graphic_data: ET.Element | None,
58 xfrm: Xfrm,
59 slide_part: PartRef,
60 pkg: OoxmlPackage,
61 palette: ColorPalette | None = None,
62 ) -> ChartResult:
63 """Return native chart metadata for a supported classic or ChartEx chart."""
64 if graphic_data is None:
65 return ChartResult(native_status="unsupported-chart-reference")
66
67 uri = graphic_data.attrib.get("uri", "")
68 if uri == CHARTEX_URI or graphic_data.find("cx:chart", C_NS) is not None:
69 try:
70 payload = extract_native_chartex_payload(
71 graphic_data,
72 xfrm,
73 slide_part,
74 pkg,
75 palette,
76 )
77 except UnsupportedChartEx as exc:
78 return ChartResult(native_status=exc.status)
79 except RuntimeError:
80 return ChartResult(native_status="unsupported-chartex-parse")
81 return ChartResult(native_payload=payload)
82 if uri != CHART_URI:
83 return ChartResult(native_status="unsupported-chart-uri")
84 if xfrm.rot or xfrm.flip_h or xfrm.flip_v:
85 return ChartResult(native_status="unsupported-native-transform")
86
87 chart_ref = graphic_data.find("c:chart", C_NS)
88 if chart_ref is None:
89 return ChartResult(native_status="unsupported-chart-reference")
90 rid = chart_ref.attrib.get(f"{{{NS['r']}}}id")
91 if not rid:
92 return ChartResult(native_status="unsupported-chart-reference")
93
94 chart_path = slide_part.resolve_rel(rid)
95 if not chart_path:
96 return ChartResult(native_status="unsupported-chart-relationship")
97 chart_part = pkg.load_part(chart_path)
98 if chart_part is None:
99 return ChartResult(native_status="unsupported-chart-part")
100
101 try:
102 payload, visual_styles = _payload_from_chart_xml(
103 chart_part.xml,
104 xfrm,
105 palette=palette,
106 )
107 validate_chart_payload(payload)
108 except _UnsupportedChart as exc:
109 return ChartResult(native_status=exc.status)
110 except RuntimeError:
111 return ChartResult(native_status="unsupported-chart-schema")
112 except (TypeError, ValueError, AttributeError):
113 return ChartResult(native_status="unsupported-chart-parse")
114 # The visual fallback is deliberately best-effort and isolated from the
115 # active native payload. A renderer defect must never downgrade a chart
116 # that the native schema has already validated.
117 try:
118 normalized_svg = render_normalized_chart_svg(payload, visual_styles)
119 except (KeyError, TypeError, ValueError, OverflowError, ArithmeticError):
120 normalized_svg = None
121 return ChartResult(native_payload=payload, normalized_svg=normalized_svg)
122
123
124 def _payload_from_chart_xml(
125 chart_root: ET.Element,
126 xfrm: Xfrm,
127 *,
128 palette: ColorPalette | None = None,
129 ) -> tuple[dict[str, Any], list[SeriesVisualStyle]]:
130 plot_area = chart_root.find(".//c:plotArea", C_NS)
131 if plot_area is None:
132 raise _UnsupportedChart("unsupported-chart-plot")
133
134 chart_nodes = [
135 child
136 for child in list(plot_area)
137 if _local_name(child.tag).endswith("Chart")
138 ]
139 if not chart_nodes:
140 raise _UnsupportedChart("unsupported-chart-plot")
141 date_system = chart_root.find("c:date1904", C_NS)
142 if date_system is not None and (
143 not set(date_system.attrib).issubset({"val"}) or list(date_system)
144 ):
145 raise _UnsupportedChart("unsupported-date-system")
146 uses_1904_dates = _strict_axis_bool(
147 date_system,
148 date_system is not None,
149 )
150 if len(chart_nodes) > 1:
151 if uses_1904_dates and any(
152 _category_cache_is_numeric(chart) for chart in chart_nodes
153 ):
154 raise _UnsupportedChart("unsupported-date-system")
155 payload, visual_styles = _combo_payload(
156 chart_root,
157 plot_area,
158 chart_nodes,
159 xfrm,
160 palette=palette,
161 )
162 return payload, visual_styles
163
164 chart = chart_nodes[0]
165 chart_tag = _local_name(chart.tag)
166 has_date_axis = plot_area.find("c:dateAx", C_NS) is not None
167 if uses_1904_dates and has_date_axis:
168 raise _UnsupportedChart("unsupported-date-system")
169 if has_date_axis:
170 _validate_canonical_series_order(
171 [chart],
172 "unsupported-chart-series-order",
173 )
174 if chart_tag in {"area3DChart", "bar3DChart", "line3DChart", "pie3DChart", "surface3DChart"}:
175 raise _UnsupportedChart("unsupported-3d-chart")
176 if has_date_axis and chart_tag not in {"areaChart", "stockChart"}:
177 raise _UnsupportedChart("unsupported-date-axis")
178 if chart_tag == "barChart":
179 payload = _category_payload(chart, _bar_chart_type(chart), xfrm)
180 elif chart_tag in {
181 "areaChart",
182 "doughnutChart",
183 "lineChart",
184 "ofPieChart",
185 "pieChart",
186 "radarChart",
187 }:
188 chart_type = {
189 "areaChart": "area",
190 "doughnutChart": "doughnut",
191 "lineChart": "line",
192 "ofPieChart": "of_pie",
193 "pieChart": "pie",
194 "radarChart": "radar",
195 }[chart_tag]
196 category_kind = "date" if has_date_axis else "text"
197 if chart_tag == "radarChart" and _category_cache_is_numeric(chart):
198 category_kind = "numeric"
199 payload = _category_payload(
200 chart,
201 chart_type,
202 xfrm,
203 category_kind=category_kind,
204 )
205 if chart_tag == "radarChart":
206 if category_kind == "numeric":
207 payload["categories"] = [
208 str(value) for value in payload["categories"]
209 ]
210 payload["radar_style"] = _effective_radar_style(chart)
211 elif chart_tag == "scatterChart":
212 payload = _xy_payload(chart, "scatter", xfrm)
213 payload["axes"] = _xy_axis_contract(plot_area, chart)
214 elif chart_tag == "bubbleChart":
215 payload = _xy_payload(chart, "bubble", xfrm)
216 payload["axes"] = _xy_axis_contract(plot_area, chart)
217 elif chart_tag == "stockChart":
218 payload, visual_styles = _stock_payload(
219 chart_root,
220 plot_area,
221 chart,
222 xfrm,
223 palette=palette,
224 )
225 return payload, visual_styles
226 else:
227 raise _UnsupportedChart("unsupported-chart-type")
228
229 if has_date_axis:
230 payload["axes"] = _single_axis_contract(
231 plot_area,
232 chart,
233 category_kind="date",
234 cross_between="midCat",
235 )
236 expected_category_format = payload["axes"]["category"].get(
237 "number_format",
238 "m/d/yyyy",
239 )
240 if _numeric_category_cache_format(chart) != expected_category_format:
241 raise _UnsupportedChart("unsupported-chart-category-format")
242
243 visual_styles = _validate_chart_semantics(
244 payload,
245 plot_area,
246 chart,
247 palette=palette,
248 validate_axes=not (
249 has_date_axis or chart_tag in {"bubbleChart", "scatterChart"}
250 ),
251 )
252 _apply_chart_metadata(payload, chart_root, plot_area, chart)
253 return payload, visual_styles
254
255
256 def _combo_payload(
257 chart_root: ET.Element,
258 plot_area: ET.Element,
259 chart_nodes: list[ET.Element],
260 xfrm: Xfrm,
261 *,
262 palette: ColorPalette | None,
263 ) -> tuple[dict[str, Any], list[SeriesVisualStyle]]:
264 series_indices_by_plot = [
265 _plot_series_indices(chart, "unsupported-combo-series-order")
266 for chart in chart_nodes
267 ]
268 flat_series_indices = [
269 index
270 for series_indices in series_indices_by_plot
271 for index in series_indices
272 ]
273 if sorted(flat_series_indices) != list(range(len(flat_series_indices))):
274 raise _UnsupportedChart("unsupported-combo-series-order")
275 axes_by_id = _axis_nodes_by_id(plot_area)
276 if not axes_by_id:
277 raise _UnsupportedChart("unsupported-combo-chart")
278 axes: dict[str, dict[str, Any]] = {}
279 axis_pairs: dict[str, tuple[str, str]] = {}
280 primary_categories: list[Any] | None = None
281 primary_categories_are_numeric = False
282 plots: list[dict[str, Any]] = []
283 visual_styles: list[SeriesVisualStyle] = []
284 colors: list[str] = []
285 referenced_axis_ids: set[str] = set()
286
287 for chart, series_indices in zip(chart_nodes, series_indices_by_plot):
288 chart_tag = _local_name(chart.tag)
289 if chart_tag == "barChart":
290 if _bar_chart_type(chart) != "column":
291 raise _UnsupportedChart("unsupported-combo-chart")
292 chart_type = "column"
293 elif chart_tag == "lineChart":
294 chart_type = "line"
295 elif chart_tag == "areaChart":
296 chart_type = "area"
297 else:
298 raise _UnsupportedChart("unsupported-combo-chart")
299
300 cat_id, cat_axis, val_id, val_axis = _plot_axis_pair(chart, axes_by_id)
301 if _local_name(cat_axis.tag) != "catAx":
302 raise _UnsupportedChart("unsupported-combo-chart")
303 val_position = _element_val(val_axis.find("c:axPos", C_NS))
304 if val_position == "l":
305 axis_name = "primary"
306 category_role = "category"
307 value_role = "value"
308 allowed_value_crosses = {"autoZero"}
309 elif val_position == "r":
310 axis_name = "secondary"
311 category_role = "secondary_category"
312 value_role = "secondary_value"
313 allowed_value_crosses = {"max"}
314 else:
315 raise _UnsupportedChart("unsupported-combo-chart")
316
317 axis_pair = (cat_id, val_id)
318 previous_pair = axis_pairs.get(axis_name)
319 if previous_pair is not None and previous_pair != axis_pair:
320 raise _UnsupportedChart("unsupported-combo-chart")
321 if any(
322 set(existing_pair).intersection(axis_pair)
323 for name, existing_pair in axis_pairs.items()
324 if name != axis_name
325 ):
326 raise _UnsupportedChart("unsupported-combo-chart")
327 axis_pairs[axis_name] = axis_pair
328
329 category_axis = _axis_config_from_xml(
330 cat_axis,
331 role=category_role,
332 expected_cross_axis_id=val_id,
333 allowed_crosses={"autoZero"},
334 expected_cross_between=None,
335 )
336 value_axis = _axis_config_from_xml(
337 val_axis,
338 role=value_role,
339 expected_cross_axis_id=cat_id,
340 allowed_crosses=allowed_value_crosses,
341 expected_cross_between="between",
342 )
343 for role, config in (
344 (category_role, category_axis),
345 (value_role, value_axis),
346 ):
347 if role in axes and axes[role] != config:
348 raise _UnsupportedChart("unsupported-combo-chart")
349 axes[role] = config
350 referenced_axis_ids.update((cat_id, val_id))
351
352 categories_are_numeric = _category_cache_is_numeric(chart)
353 if categories_are_numeric:
354 expected_category_format = category_axis.get(
355 "number_format",
356 "General",
357 )
358 if _numeric_category_cache_format(chart) != expected_category_format:
359 raise _UnsupportedChart("unsupported-combo-category-format")
360 plot_payload = _category_payload(
361 chart,
362 chart_type,
363 xfrm,
364 category_kind="numeric" if categories_are_numeric else "text",
365 )
366 if axis_name == "primary" and primary_categories is None:
367 primary_categories = list(plot_payload["categories"])
368 primary_categories_are_numeric = categories_are_numeric
369 plot_styles = _validate_chart_semantics(
370 plot_payload,
371 plot_area,
372 chart,
373 palette=palette,
374 validate_axes=False,
375 )
376 visual_styles.extend(plot_styles)
377 colors.extend(
378 str(color)
379 for color in plot_payload.get("style", {}).get("colors", [])
380 )
381 _apply_plot_data_labels(plot_payload, chart)
382 plot_entry: dict[str, Any] = {
383 "axis": axis_name,
384 "categories": list(plot_payload["categories"]),
385 "category_numeric": categories_are_numeric,
386 "series": plot_payload["series"],
387 "series_indices": series_indices,
388 "type": chart_type,
389 }
390 for key in ("data_labels", "grouping", "line_style"):
391 if plot_payload.get(key) is not None:
392 plot_entry[key] = plot_payload[key]
393 plots.append(plot_entry)
394
395 if primary_categories is None or not plots:
396 raise _UnsupportedChart("unsupported-combo-chart")
397 if referenced_axis_ids != set(axes_by_id):
398 raise _UnsupportedChart("unsupported-combo-chart")
399
400 category_layouts = {
401 (
402 bool(plot["category_numeric"]),
403 tuple(plot["categories"]),
404 )
405 for plot in plots
406 }
407 if len(category_layouts) == 1:
408 for plot in plots:
409 plot.pop("categories")
410 plot.pop("category_numeric")
411 payload: dict[str, Any] = {
412 **_bounds_payload(xfrm),
413 "axes": axes,
414 "categories": primary_categories,
415 "plots": plots,
416 "type": "combo",
417 }
418 if primary_categories_are_numeric:
419 payload["category_numeric"] = True
420 if colors:
421 payload["style"] = {"colors": colors}
422 _apply_chart_metadata(
423 payload,
424 chart_root,
425 plot_area,
426 chart_nodes[0],
427 include_plot_labels=False,
428 )
429 return payload, visual_styles
430
431
432 def _validate_stock_semantics(
433 plot_area: ET.Element,
434 chart: ET.Element,
435 ) -> None:
436 allowed_children = {"axId", "dLbls", "hiLowLines", "ser", "upDownBars"}
437 if any(_local_name(child.tag) not in allowed_children for child in chart):
438 raise _UnsupportedChart("unsupported-stock-chart")
439 if plot_area.find("c:dTable", C_NS) is not None:
440 raise _UnsupportedChart("unsupported-chart-data-table")
441 for tag in ("dropLines", "errBars", "trendline"):
442 if chart.find(f".//c:{tag}", C_NS) is not None:
443 raise _UnsupportedChart("unsupported-chart-analysis-features")
444 if _data_labels_payload(chart.find("c:dLbls", C_NS)) is not None:
445 raise _UnsupportedChart("unsupported-chart-data-labels")
446 if len(chart.findall("c:dLbls", C_NS)) > 1:
447 raise _UnsupportedChart("unsupported-stock-chart")
448
449 hi_low_lines = chart.findall("c:hiLowLines", C_NS)
450 up_down_bars = chart.findall("c:upDownBars", C_NS)
451 if len(hi_low_lines) != 1 or len(up_down_bars) != 1:
452 raise _UnsupportedChart("unsupported-stock-chart")
453 hi_low_styles = hi_low_lines[0].findall("c:spPr", C_NS)
454 if (
455 hi_low_lines[0].attrib
456 or len(hi_low_styles) > 1
457 or any(_local_name(child.tag) != "spPr" for child in hi_low_lines[0])
458 ):
459 raise _UnsupportedChart("unsupported-stock-chart")
460 up_down = up_down_bars[0]
461 if up_down.attrib or any(
462 _local_name(child.tag) not in {"downBars", "gapWidth", "upBars"}
463 for child in up_down
464 ):
465 raise _UnsupportedChart("unsupported-stock-chart")
466 gap_widths = up_down.findall("c:gapWidth", C_NS)
467 if (
468 len(gap_widths) != 1
469 or set(gap_widths[0].attrib) != {"val"}
470 or list(gap_widths[0])
471 or _element_val(gap_widths[0]) != "150"
472 ):
473 raise _UnsupportedChart("unsupported-stock-chart")
474 for tag in ("upBars", "downBars"):
475 nodes = up_down.findall(f"c:{tag}", C_NS)
476 styles = nodes[0].findall("c:spPr", C_NS) if nodes else []
477 if (
478 len(nodes) != 1
479 or nodes[0].attrib
480 or len(styles) > 1
481 or any(_local_name(child.tag) != "spPr" for child in nodes[0])
482 ):
483 raise _UnsupportedChart("unsupported-stock-chart")
484
485 allowed_series_children = {
486 "cat", "extLst", "idx", "marker", "order", "smooth", "spPr",
487 "tx", "val",
488 }
489 for series_node in chart.findall("c:ser", C_NS):
490 if any(
491 _local_name(child.tag) not in allowed_series_children
492 for child in series_node
493 ):
494 raise _UnsupportedChart("unsupported-stock-chart")
495 for child_name in allowed_series_children - {"extLst"}:
496 if len(series_node.findall(f"c:{child_name}", C_NS)) > 1:
497 raise _UnsupportedChart("unsupported-stock-chart")
498 marker = series_node.find("c:marker", C_NS)
499 symbol = marker.find("c:symbol", C_NS) if marker is not None else None
500 if (
501 marker is not None
502 and (
503 marker.attrib
504 or len(marker) != 1
505 or symbol is None
506 or set(symbol.attrib) != {"val"}
507 or list(symbol)
508 or _element_val(symbol) != "none"
509 )
510 ):
511 raise _UnsupportedChart("unsupported-stock-chart")
512 smooth = series_node.find("c:smooth", C_NS)
513 if smooth is not None:
514 if set(smooth.attrib) != {"val"} or list(smooth):
515 raise _UnsupportedChart("unsupported-stock-chart")
516 if _strict_axis_bool(smooth, False):
517 raise _UnsupportedChart("unsupported-stock-chart")
518 for child_name in ("idx", "order"):
519 child = series_node.find(f"c:{child_name}", C_NS)
520 if child is None or set(child.attrib) != {"val"} or list(child):
521 raise _UnsupportedChart("unsupported-stock-chart")
522
523
524 def _stock_payload(
525 chart_root: ET.Element,
526 plot_area: ET.Element,
527 chart: ET.Element,
528 xfrm: Xfrm,
529 *,
530 palette: ColorPalette | None,
531 ) -> tuple[dict[str, Any], list[SeriesVisualStyle]]:
532 series_nodes = chart.findall("c:ser", C_NS)
533 if len(series_nodes) != 4:
534 raise _UnsupportedChart("unsupported-stock-chart")
535 categories = _numeric_values(series_nodes[0].find("c:cat", C_NS))
536 if not categories:
537 raise _UnsupportedChart("unsupported-chart-cache")
538 series: list[dict[str, Any]] = []
539 for index, series_node in enumerate(series_nodes, start=1):
540 expected_index = str(index - 1)
541 if (
542 _element_val(series_node.find("c:idx", C_NS)) != expected_index
543 or _element_val(series_node.find("c:order", C_NS)) != expected_index
544 ):
545 raise _UnsupportedChart("unsupported-stock-chart")
546 if _numeric_values(series_node.find("c:cat", C_NS)) != categories:
547 raise _UnsupportedChart("unsupported-chart-cache")
548 values = _numeric_values(series_node.find("c:val", C_NS))
549 if len(values) != len(categories):
550 raise _UnsupportedChart("unsupported-chart-cache")
551 series.append({
552 "name": _series_name(series_node, index),
553 "values": values,
554 })
555 axes = _single_axis_contract(
556 plot_area,
557 chart,
558 category_kind="date",
559 cross_between="between",
560 )
561 expected_category_format = axes["category"].get(
562 "number_format",
563 "m/d/yyyy",
564 )
565 category_cache_format = _numeric_category_cache_format(chart)
566 if category_cache_format not in {"General", expected_category_format}:
567 raise _UnsupportedChart("unsupported-chart-category-format")
568 _validate_stock_semantics(plot_area, chart)
569 payload: dict[str, Any] = {
570 **_bounds_payload(xfrm),
571 "axes": axes,
572 "categories": categories,
573 "series": series,
574 "type": "stock",
575 }
576 visual_styles = _chart_visual_styles(payload, chart, palette)
577 _apply_chart_metadata(payload, chart_root, plot_area, chart)
578 return payload, visual_styles
579
580
581 def _canonical_srgb_color(fill: ET.Element | None) -> str:
582 """Return an exporter-canonical solid RGB fill, or reject the style."""
583 if fill is None or fill.attrib:
584 raise _UnsupportedChart("unsupported-chart-series-style")
585 children = list(fill)
586 if (
587 len(children) != 1
588 or _local_name(children[0].tag) != "srgbClr"
589 or set(children[0].attrib) != {"val"}
590 or list(children[0])
591 ):
592 raise _UnsupportedChart("unsupported-chart-series-style")
593 color = children[0].attrib["val"].strip()
594 if len(color) != 6 or any(char not in "0123456789abcdefABCDEF" for char in color):
595 raise _UnsupportedChart("unsupported-chart-series-style")
596 return color.upper()
597
598
599 @dataclass(frozen=True)
600 class _LinePaint:
601 color: str | None
602 opacity: float
603 width: float
604 cap: str
605 visible: bool
606 automatic: bool
607
608
609 @dataclass(frozen=True)
610 class _ShapePaint:
611 fill: str | None
612 fill_opacity: float
613 fill_explicit: bool
614 line: _LinePaint | None
615
616
617 @dataclass(frozen=True)
618 class _MarkerPaint:
619 symbol: str | None
620 size: float
621 shape: _ShapePaint
622
623
624 _FALLBACK_CHART_COLORS = (
625 "#4472C4", "#ED7D31", "#A5A5A5", "#FFC000",
626 "#5B9BD5", "#70AD47", "#264478", "#9E480E",
627 )
628 _COLOR_MODIFIERS_WITH_VALUE = {
629 "alpha", "alphaMod", "alphaOff", "hueMod", "hueOff", "lumMod",
630 "lumOff", "satMod", "satOff", "shade", "tint",
631 }
632 _COLOR_MODIFIERS_WITHOUT_VALUE = {"comp", "gray", "inv"}
633
634
635 def _resolved_solid_fill(
636 fill: ET.Element,
637 palette: ColorPalette | None,
638 ) -> tuple[str, float]:
639 if fill.attrib or len(fill) != 1:
640 raise _UnsupportedChart("unsupported-chart-series-style")
641 color = find_color_elem(fill)
642 if color is None or color is not fill[0]:
643 raise _UnsupportedChart("unsupported-chart-series-style")
644 tag = _local_name(color.tag)
645 allowed_attrs = {
646 "srgbClr": {"val"},
647 "schemeClr": {"val"},
648 "sysClr": {"val", "lastClr"},
649 "prstClr": {"val"},
650 "hslClr": {"hue", "sat", "lum"},
651 "scrgbClr": {"r", "g", "b"},
652 }.get(tag)
653 if allowed_attrs is None or not set(color.attrib).issubset(allowed_attrs):
654 raise _UnsupportedChart("unsupported-chart-series-style")
655 required = {
656 "srgbClr": {"val"}, "schemeClr": {"val"}, "prstClr": {"val"},
657 "hslClr": {"hue", "sat", "lum"}, "scrgbClr": {"r", "g", "b"},
658 }.get(tag, set())
659 if not required.issubset(color.attrib):
660 raise _UnsupportedChart("unsupported-chart-series-style")
661 for modifier in color:
662 modifier_name = _local_name(modifier.tag)
663 if modifier_name in _COLOR_MODIFIERS_WITH_VALUE:
664 if set(modifier.attrib) != {"val"} or list(modifier):
665 raise _UnsupportedChart("unsupported-chart-series-style")
666 try:
667 modifier_value = float(modifier.attrib["val"])
668 except (TypeError, ValueError, OverflowError):
669 raise _UnsupportedChart("unsupported-chart-series-style") from None
670 if not math.isfinite(modifier_value):
671 raise _UnsupportedChart("unsupported-chart-series-style")
672 elif modifier_name in _COLOR_MODIFIERS_WITHOUT_VALUE:
673 if modifier.attrib or list(modifier):
674 raise _UnsupportedChart("unsupported-chart-series-style")
675 else:
676 raise _UnsupportedChart("unsupported-chart-series-style")
677 try:
678 resolved, alpha = resolve_color(color, palette)
679 except (TypeError, ValueError, OverflowError):
680 raise _UnsupportedChart("unsupported-chart-series-style") from None
681 if resolved is None or not math.isfinite(alpha):
682 raise _UnsupportedChart("unsupported-chart-series-style")
683 return resolved.upper(), max(0.0, min(1.0, alpha))
684
685
686 def _resolved_line(
687 line: ET.Element,
688 palette: ColorPalette | None,
689 ) -> _LinePaint:
690 if not set(line.attrib).issubset({"w", "cap"}):
691 raise _UnsupportedChart("unsupported-chart-series-style")
692 raw_width = line.attrib.get("w")
693 if raw_width is None:
694 width = 1.5
695 else:
696 if re.fullmatch(r"[0-9]+", raw_width) is None:
697 raise _UnsupportedChart("unsupported-chart-series-style")
698 width = int(raw_width) / 9525.0
699 if not 0 <= width <= 1000:
700 raise _UnsupportedChart("unsupported-chart-series-style")
701 cap_aliases = {None: "round", "rnd": "round", "sq": "square", "flat": "butt"}
702 cap = cap_aliases.get(line.attrib.get("cap"))
703 if cap is None:
704 raise _UnsupportedChart("unsupported-chart-series-style")
705
706 fill_nodes: list[ET.Element] = []
707 for child in line:
708 name = _local_name(child.tag)
709 if name in {"solidFill", "noFill"}:
710 fill_nodes.append(child)
711 elif name == "prstDash":
712 if child.attrib != {"val": "solid"} or list(child):
713 raise _UnsupportedChart("unsupported-chart-series-style")
714 elif name in {"round", "bevel"}:
715 if child.attrib or list(child):
716 raise _UnsupportedChart("unsupported-chart-series-style")
717 elif name == "miter":
718 if not set(child.attrib).issubset({"lim"}) or list(child):
719 raise _UnsupportedChart("unsupported-chart-series-style")
720 raw_limit = child.attrib.get("lim")
721 if raw_limit is not None and re.fullmatch(r"[0-9]+", raw_limit) is None:
722 raise _UnsupportedChart("unsupported-chart-series-style")
723 else:
724 raise _UnsupportedChart("unsupported-chart-series-style")
725 if len(fill_nodes) > 1:
726 raise _UnsupportedChart("unsupported-chart-series-style")
727 if not fill_nodes:
728 return _LinePaint(None, 1.0, width, cap, True, True)
729 fill = fill_nodes[0]
730 if _local_name(fill.tag) == "noFill":
731 if fill.attrib or list(fill):
732 raise _UnsupportedChart("unsupported-chart-series-style")
733 return _LinePaint(None, 1.0, width, cap, False, False)
734 color, opacity = _resolved_solid_fill(fill, palette)
735 return _LinePaint(color, opacity, width, cap, True, False)
736
737
738 def _resolved_shape_paint(
739 sp_pr: ET.Element | None,
740 palette: ColorPalette | None,
741 ) -> _ShapePaint:
742 if sp_pr is None:
743 return _ShapePaint(None, 1.0, False, None)
744 if sp_pr.attrib:
745 raise _UnsupportedChart("unsupported-chart-series-style")
746 fill_nodes: list[ET.Element] = []
747 line_nodes: list[ET.Element] = []
748 for child in sp_pr:
749 name = _local_name(child.tag)
750 if name in {"solidFill", "noFill"}:
751 fill_nodes.append(child)
752 elif name == "ln":
753 line_nodes.append(child)
754 elif name == "effectLst":
755 if child.attrib or list(child):
756 raise _UnsupportedChart("unsupported-chart-series-style")
757 else:
758 raise _UnsupportedChart("unsupported-chart-series-style")
759 if len(fill_nodes) > 1 or len(line_nodes) > 1:
760 raise _UnsupportedChart("unsupported-chart-series-style")
761 fill_color: str | None = None
762 fill_opacity = 1.0
763 fill_explicit = bool(fill_nodes)
764 if fill_nodes:
765 fill = fill_nodes[0]
766 if _local_name(fill.tag) == "noFill":
767 if fill.attrib or list(fill):
768 raise _UnsupportedChart("unsupported-chart-series-style")
769 else:
770 fill_color, fill_opacity = _resolved_solid_fill(fill, palette)
771 line = _resolved_line(line_nodes[0], palette) if line_nodes else None
772 return _ShapePaint(fill_color, fill_opacity, fill_explicit, line)
773
774
775 def _resolved_marker(
776 marker: ET.Element | None,
777 palette: ColorPalette | None,
778 ) -> _MarkerPaint:
779 if marker is None:
780 return _MarkerPaint(None, 5.0, _resolved_shape_paint(None, palette))
781 if marker.attrib:
782 raise _UnsupportedChart("unsupported-chart-series-style")
783 allowed = {"symbol", "size", "spPr"}
784 names = [_local_name(child.tag) for child in marker]
785 if any(name not in allowed for name in names) or len(names) != len(set(names)):
786 raise _UnsupportedChart("unsupported-chart-series-style")
787 symbol_node = marker.find("c:symbol", C_NS)
788 symbol = _element_val(symbol_node)
789 if symbol_node is not None and (
790 set(symbol_node.attrib) != {"val"} or list(symbol_node)
791 ):
792 raise _UnsupportedChart("unsupported-chart-series-style")
793 if symbol not in {None, "circle", "none"}:
794 raise _UnsupportedChart("unsupported-chart-series-style")
795 size_node = marker.find("c:size", C_NS)
796 size = 5.0
797 if size_node is not None:
798 raw_size = size_node.attrib.get("val", "")
799 if set(size_node.attrib) != {"val"} or list(size_node) or re.fullmatch(r"[0-9]+", raw_size) is None:
800 raise _UnsupportedChart("unsupported-chart-series-style")
801 size = float(raw_size)
802 if not 2 <= size <= 72:
803 raise _UnsupportedChart("unsupported-chart-series-style")
804 return _MarkerPaint(
805 symbol,
806 size,
807 _resolved_shape_paint(marker.find("c:spPr", C_NS), palette),
808 )
809
810
811 def _automatic_color(palette: ColorPalette | None, index: int) -> str:
812 if palette is not None:
813 resolved = palette.resolve_scheme(f"accent{index % 6 + 1}")
814 if resolved:
815 return f"#{resolved.upper()}"
816 return _FALLBACK_CHART_COLORS[index % len(_FALLBACK_CHART_COLORS)]
817
818
819 def _line_color(line: _LinePaint | None, default: str | None) -> str | None:
820 if line is None:
821 return default
822 if not line.visible:
823 return None
824 return default if line.automatic else line.color
825
826
827 def _series_visual_style(
828 shape: _ShapePaint,
829 marker: _MarkerPaint,
830 *,
831 chart_type: str,
832 auto_color: str,
833 ) -> SeriesVisualStyle:
834 fill_default = auto_color if chart_type in {"area", "bar", "bubble", "column"} else None
835 fill = shape.fill if shape.fill_explicit else fill_default
836 if chart_type in {"line", "scatter"}:
837 line_default = auto_color
838 elif chart_type == "area":
839 line_default = auto_color
840 else:
841 line_default = None
842 stroke = _line_color(shape.line, line_default)
843 marker_fill = marker.shape.fill if marker.shape.fill_explicit else auto_color
844 marker_stroke = _line_color(marker.shape.line, auto_color)
845 line = shape.line
846 marker_line = marker.shape.line
847 return SeriesVisualStyle(
848 fill=fill,
849 fill_opacity=shape.fill_opacity,
850 stroke=stroke,
851 stroke_opacity=line.opacity if line is not None else 1.0,
852 stroke_width=line.width if line is not None else 1.5,
853 line_cap=line.cap if line is not None else "round",
854 marker_fill=marker_fill,
855 marker_fill_opacity=marker.shape.fill_opacity,
856 marker_stroke=marker_stroke,
857 marker_stroke_opacity=marker_line.opacity if marker_line is not None else 1.0,
858 marker_stroke_width=marker_line.width if marker_line is not None else 1.0,
859 marker_size=marker.size,
860 )
861
862
863 def _pie_visual_styles(
864 payload: dict[str, Any],
865 series: ET.Element,
866 palette: ColorPalette | None,
867 ) -> list[SeriesVisualStyle]:
868 chart_type = payload["type"]
869 expected_count = len(payload["categories"]) + (1 if chart_type == "of_pie" else 0)
870 base_shape = _resolved_shape_paint(series.find("c:spPr", C_NS), palette)
871 point_nodes = series.findall("c:dPt", C_NS)
872 points: dict[int, _ShapePaint] = {}
873 for point in point_nodes:
874 if point.attrib:
875 raise _UnsupportedChart("unsupported-chart-series-style")
876 allowed = {"idx", "bubble3D", "explosion", "spPr"}
877 names = [_local_name(child.tag) for child in point]
878 if any(name not in allowed for name in names) or len(names) != len(set(names)):
879 raise _UnsupportedChart("unsupported-chart-series-style")
880 idx = point.find("c:idx", C_NS)
881 if idx is None or set(idx.attrib) != {"val"} or list(idx):
882 raise _UnsupportedChart("unsupported-chart-series-style")
883 try:
884 point_index = int(idx.attrib["val"])
885 except ValueError:
886 raise _UnsupportedChart("unsupported-chart-series-style") from None
887 bubble_3d = point.find("c:bubble3D", C_NS)
888 if bubble_3d is not None and (
889 not set(bubble_3d.attrib).issubset({"val"})
890 or list(bubble_3d)
891 or ooxml_bool(bubble_3d.attrib.get("val"), True)
892 ):
893 raise _UnsupportedChart("unsupported-chart-series-style")
894 if point_index < 0 or point_index >= expected_count or point_index in points:
895 raise _UnsupportedChart("unsupported-chart-series-style")
896 points[point_index] = _resolved_shape_paint(point.find("c:spPr", C_NS), palette)
897 if points and set(points) != set(range(expected_count)):
898 raise _UnsupportedChart("unsupported-chart-series-style")
899
900 styles: list[SeriesVisualStyle] = []
901 for index in range(expected_count):
902 auto = _automatic_color(palette, index)
903 point_shape = points.get(index, base_shape)
904 fill = point_shape.fill if point_shape.fill_explicit else auto
905 stroke = _line_color(point_shape.line, "#FFFFFF")
906 line = point_shape.line
907 styles.append(
908 SeriesVisualStyle(
909 fill=fill,
910 fill_opacity=point_shape.fill_opacity,
911 stroke=stroke,
912 stroke_opacity=line.opacity if line is not None else 1.0,
913 stroke_width=line.width if line is not None else 1.0,
914 line_cap=line.cap if line is not None else "round",
915 marker_fill=fill,
916 marker_stroke=stroke,
917 )
918 )
919 return styles
920
921
922 def _chart_visual_styles(
923 payload: dict[str, Any],
924 plot: ET.Element,
925 palette: ColorPalette | None,
926 ) -> list[SeriesVisualStyle]:
927 chart_type = payload["type"]
928 series_nodes = plot.findall("c:ser", C_NS)
929 if chart_type in {"pie", "doughnut", "of_pie"}:
930 if len(series_nodes) != 1:
931 raise _UnsupportedChart("unsupported-chart-series-style")
932 styles = _pie_visual_styles(payload, series_nodes[0], palette)
933 else:
934 if any(series.find("c:dPt", C_NS) is not None for series in series_nodes):
935 raise _UnsupportedChart("unsupported-chart-series-style")
936 styles = []
937 for index, series in enumerate(series_nodes):
938 auto = _automatic_color(palette, index)
939 shape = _resolved_shape_paint(series.find("c:spPr", C_NS), palette)
940 marker = _resolved_marker(series.find("c:marker", C_NS), palette)
941 styles.append(
942 _series_visual_style(
943 shape,
944 marker,
945 chart_type=chart_type,
946 auto_color=auto,
947 )
948 )
949 colors = [
950 style.fill or style.stroke or style.marker_fill or _automatic_color(palette, index)
951 for index, style in enumerate(styles)
952 ]
953 if colors:
954 payload["style"] = {"colors": colors}
955 return styles
956
957
958 def _strict_axis_bool(elem: ET.Element | None, default: bool) -> bool:
959 if elem is None:
960 return default
961 raw = elem.attrib.get("val")
962 if raw is None:
963 return default
964 key = raw.strip().lower()
965 if key in {"1", "on", "true"}:
966 return True
967 if key in {"0", "false", "off"}:
968 return False
969 raise _UnsupportedChart("unsupported-chart-axis-options")
970
971
972 def _validate_canonical_series_order(
973 plots: list[ET.Element],
974 status: str,
975 ) -> None:
976 expected_index = 0
977 for plot in plots:
978 for series in plot.findall("c:ser", C_NS):
979 expected = str(expected_index)
980 for child_name in ("idx", "order"):
981 child = series.find(f"c:{child_name}", C_NS)
982 if (
983 child is None
984 or set(child.attrib) != {"val"}
985 or list(child)
986 or _element_val(child) != expected
987 ):
988 raise _UnsupportedChart(status)
989 expected_index += 1
990
991
992 def _plot_series_indices(plot: ET.Element, status: str) -> list[int]:
993 indices: list[int] = []
994 for series in plot.findall("c:ser", C_NS):
995 values: list[int] = []
996 for child_name in ("idx", "order"):
997 child = series.find(f"c:{child_name}", C_NS)
998 raw_value = _element_val(child)
999 if (
1000 child is None
1001 or set(child.attrib) != {"val"}
1002 or list(child)
1003 or raw_value is None
1004 or re.fullmatch(r"[0-9]+", raw_value) is None
1005 ):
1006 raise _UnsupportedChart(status)
1007 values.append(int(raw_value))
1008 if values[0] != values[1]:
1009 raise _UnsupportedChart(status)
1010 indices.append(values[0])
1011 if not indices or len(set(indices)) != len(indices):
1012 raise _UnsupportedChart(status)
1013 return indices
1014
1015
1016 def _axis_number(elem: ET.Element | None) -> int | float | None:
1017 if elem is None:
1018 return None
1019 raw = elem.attrib.get("val")
1020 try:
1021 number = float(raw) if raw is not None else math.nan
1022 except (TypeError, ValueError, OverflowError):
1023 raise _UnsupportedChart("unsupported-chart-axis-options") from None
1024 if not math.isfinite(number):
1025 raise _UnsupportedChart("unsupported-chart-axis-options")
1026 return int(number) if number.is_integer() else number
1027
1028
1029 def _axis_nodes_by_id(plot_area: ET.Element) -> dict[str, ET.Element]:
1030 if plot_area.find("c:serAx", C_NS) is not None:
1031 raise _UnsupportedChart("unsupported-chart-axis-options")
1032 axes: dict[str, ET.Element] = {}
1033 for tag in ("catAx", "dateAx", "valAx"):
1034 for axis in plot_area.findall(f"c:{tag}", C_NS):
1035 axis_id = _element_val(axis.find("c:axId", C_NS))
1036 if not axis_id or axis_id in axes:
1037 raise _UnsupportedChart("unsupported-chart-axis-options")
1038 axes[axis_id] = axis
1039 return axes
1040
1041
1042 def _plot_axis_pair(
1043 plot: ET.Element,
1044 axes_by_id: dict[str, ET.Element],
1045 ) -> tuple[str, ET.Element, str, ET.Element]:
1046 axis_ids = [_element_val(elem) for elem in plot.findall("c:axId", C_NS)]
1047 if len(axis_ids) != 2 or any(not axis_id for axis_id in axis_ids):
1048 raise _UnsupportedChart("unsupported-chart-axis-options")
1049 if len(set(axis_ids)) != 2:
1050 raise _UnsupportedChart("unsupported-chart-axis-options")
1051 resolved = [(axis_id, axes_by_id.get(str(axis_id))) for axis_id in axis_ids]
1052 if any(axis is None for _, axis in resolved):
1053 raise _UnsupportedChart("unsupported-chart-axis-options")
1054 category_axes = [
1055 (str(axis_id), axis)
1056 for axis_id, axis in resolved
1057 if axis is not None and _local_name(axis.tag) in {"catAx", "dateAx"}
1058 ]
1059 value_axes = [
1060 (str(axis_id), axis)
1061 for axis_id, axis in resolved
1062 if axis is not None and _local_name(axis.tag) == "valAx"
1063 ]
1064 if len(category_axes) != 1 or len(value_axes) != 1:
1065 raise _UnsupportedChart("unsupported-chart-axis-options")
1066 cat_id, cat_axis = category_axes[0]
1067 val_id, val_axis = value_axes[0]
1068 return cat_id, cat_axis, val_id, val_axis
1069
1070
1071 def _axis_config_from_xml(
1072 axis: ET.Element,
1073 *,
1074 role: str,
1075 expected_cross_axis_id: str,
1076 allowed_crosses: set[str],
1077 expected_cross_between: str | None,
1078 ) -> dict[str, Any]:
1079 axis_kind = _local_name(axis.tag)
1080 allowed_children = {
1081 "auto", "axId", "axPos", "baseTimeUnit", "crossAx", "crossBetween",
1082 "crosses", "delete", "lblAlgn", "lblOffset", "majorGridlines",
1083 "majorTickMark", "majorUnit", "minorTickMark", "noMultiLvlLbl",
1084 "numFmt", "scaling", "spPr", "tickLblPos", "title", "txPr",
1085 }
1086 if any(_local_name(child.tag) not in allowed_children for child in axis):
1087 raise _UnsupportedChart("unsupported-chart-axis-options")
1088 if axis.attrib:
1089 raise _UnsupportedChart("unsupported-chart-axis-options")
1090 for child_name in allowed_children:
1091 if len(axis.findall(f"c:{child_name}", C_NS)) > 1:
1092 raise _UnsupportedChart("unsupported-chart-axis-options")
1093 simple_children = {
1094 "auto", "axId", "axPos", "baseTimeUnit", "crossAx",
1095 "crossBetween", "crosses", "delete", "lblAlgn", "lblOffset",
1096 "majorTickMark", "majorUnit", "minorTickMark", "noMultiLvlLbl",
1097 "tickLblPos",
1098 }
1099 if any(
1100 _local_name(child.tag) in simple_children
1101 and (set(child.attrib) != {"val"} or list(child))
1102 for child in axis
1103 ):
1104 raise _UnsupportedChart("unsupported-chart-axis-options")
1105 if axis.find("c:minorGridlines", C_NS) is not None:
1106 raise _UnsupportedChart("unsupported-chart-axis-options")
1107
1108 scaling = axis.find("c:scaling", C_NS)
1109 config: dict[str, Any] = {}
1110 if scaling is not None:
1111 if scaling.attrib or any(
1112 _local_name(child.tag) not in {"max", "min", "orientation"}
1113 for child in scaling
1114 ):
1115 raise _UnsupportedChart("unsupported-chart-axis-options")
1116 for child_name in ("orientation", "max", "min"):
1117 if len(scaling.findall(f"c:{child_name}", C_NS)) > 1:
1118 raise _UnsupportedChart("unsupported-chart-axis-options")
1119 if any(
1120 set(child.attrib) != {"val"} or list(child)
1121 for child in scaling
1122 ):
1123 raise _UnsupportedChart("unsupported-chart-axis-options")
1124 orientation = _element_val(scaling.find("c:orientation", C_NS)) or "minMax"
1125 if orientation not in {"maxMin", "minMax"}:
1126 raise _UnsupportedChart("unsupported-chart-axis-options")
1127 config["reverse"] = orientation == "maxMin"
1128 minimum = _axis_number(scaling.find("c:min", C_NS))
1129 maximum = _axis_number(scaling.find("c:max", C_NS))
1130 if minimum is not None:
1131 config["minimum"] = minimum
1132 if maximum is not None:
1133 config["maximum"] = maximum
1134 if minimum is not None and maximum is not None and minimum >= maximum:
1135 raise _UnsupportedChart("unsupported-chart-axis-options")
1136 else:
1137 config["reverse"] = False
1138
1139 position_aliases = {"b": "bottom", "l": "left", "r": "right", "t": "top"}
1140 position = position_aliases.get(_element_val(axis.find("c:axPos", C_NS)) or "")
1141 if position is None:
1142 raise _UnsupportedChart("unsupported-chart-axis-options")
1143 if role in {"category", "secondary_category"}:
1144 if position not in {"bottom", "top"}:
1145 raise _UnsupportedChart("unsupported-chart-axis-options")
1146 config["kind"] = "date" if axis_kind == "dateAx" else "text"
1147 elif role == "x":
1148 if position not in {"bottom", "top"} or axis_kind != "valAx":
1149 raise _UnsupportedChart("unsupported-chart-axis-options")
1150 config["kind"] = "value"
1151 else:
1152 if position not in {"left", "right"} or axis_kind != "valAx":
1153 raise _UnsupportedChart("unsupported-chart-axis-options")
1154 config["kind"] = "value"
1155 config["position"] = position
1156 config["visible"] = not _strict_axis_bool(axis.find("c:delete", C_NS), False)
1157
1158 tick_label_aliases = {
1159 "high": "high",
1160 "low": "low",
1161 "nextTo": "next_to",
1162 "none": "none",
1163 }
1164 tick_label_position = _element_val(axis.find("c:tickLblPos", C_NS)) or "nextTo"
1165 if tick_label_position not in tick_label_aliases:
1166 raise _UnsupportedChart("unsupported-chart-axis-options")
1167 config["label_position"] = tick_label_aliases[tick_label_position]
1168 config["major_gridlines"] = axis.find("c:majorGridlines", C_NS) is not None
1169
1170 num_fmt = axis.find("c:numFmt", C_NS)
1171 if num_fmt is not None:
1172 if list(num_fmt) or not set(num_fmt.attrib).issubset({"formatCode", "sourceLinked"}):
1173 raise _UnsupportedChart("unsupported-chart-axis-options")
1174 number_format = num_fmt.attrib.get("formatCode", "")
1175 if not number_format.strip():
1176 raise _UnsupportedChart("unsupported-chart-axis-number-format")
1177 config["number_format"] = number_format
1178
1179 major_unit = _axis_number(axis.find("c:majorUnit", C_NS))
1180 if major_unit is not None:
1181 if role not in {"value", "secondary_value", "x", "y"} or major_unit <= 0:
1182 raise _UnsupportedChart("unsupported-chart-axis-options")
1183 config["major_unit"] = major_unit
1184
1185 if _element_val(axis.find("c:crossAx", C_NS)) != expected_cross_axis_id:
1186 raise _UnsupportedChart("unsupported-chart-axis-options")
1187 crosses = _element_val(axis.find("c:crosses", C_NS)) or "autoZero"
1188 if crosses not in allowed_crosses:
1189 raise _UnsupportedChart("unsupported-chart-axis-options")
1190 cross_between = _element_val(axis.find("c:crossBetween", C_NS))
1191 if cross_between != expected_cross_between:
1192 raise _UnsupportedChart("unsupported-chart-axis-options")
1193 auto = axis.find("c:auto", C_NS)
1194 if auto is not None:
1195 _strict_axis_bool(auto, True)
1196 major_tick = _element_val(axis.find("c:majorTickMark", C_NS))
1197 minor_tick = _element_val(axis.find("c:minorTickMark", C_NS))
1198 if major_tick not in {None, "none", "out"} or minor_tick not in {None, "none"}:
1199 raise _UnsupportedChart("unsupported-chart-axis-options")
1200 label_alignment = _element_val(axis.find("c:lblAlgn", C_NS))
1201 label_offset = _element_val(axis.find("c:lblOffset", C_NS))
1202 no_multi_level = _element_val(axis.find("c:noMultiLvlLbl", C_NS))
1203 if role in {"category", "secondary_category"}:
1204 if label_alignment not in {None, "ctr"}:
1205 raise _UnsupportedChart("unsupported-chart-axis-options")
1206 if label_offset not in {None, "100"}:
1207 raise _UnsupportedChart("unsupported-chart-axis-options")
1208 if no_multi_level not in {None, "0"}:
1209 raise _UnsupportedChart("unsupported-chart-axis-options")
1210 elif any(
1211 value is not None
1212 for value in (label_alignment, label_offset, no_multi_level, auto)
1213 ):
1214 raise _UnsupportedChart("unsupported-chart-axis-options")
1215 if axis_kind == "dateAx":
1216 if _element_val(axis.find("c:baseTimeUnit", C_NS)) not in {None, "days"}:
1217 raise _UnsupportedChart("unsupported-chart-axis-options")
1218 elif axis.find("c:baseTimeUnit", C_NS) is not None:
1219 raise _UnsupportedChart("unsupported-chart-axis-options")
1220 return config
1221
1222
1223 def _single_axis_contract(
1224 plot_area: ET.Element,
1225 plot: ET.Element,
1226 *,
1227 category_kind: str,
1228 cross_between: str,
1229 ) -> dict[str, dict[str, Any]]:
1230 axes_by_id = _axis_nodes_by_id(plot_area)
1231 cat_id, cat_axis, val_id, val_axis = _plot_axis_pair(plot, axes_by_id)
1232 if set(axes_by_id) != {cat_id, val_id}:
1233 raise _UnsupportedChart("unsupported-chart-axis-options")
1234 if _local_name(cat_axis.tag) != ("dateAx" if category_kind == "date" else "catAx"):
1235 raise _UnsupportedChart("unsupported-chart-axis-options")
1236 category = _axis_config_from_xml(
1237 cat_axis,
1238 role="category",
1239 expected_cross_axis_id=val_id,
1240 allowed_crosses={"autoZero"},
1241 expected_cross_between=None,
1242 )
1243 value = _axis_config_from_xml(
1244 val_axis,
1245 role="value",
1246 expected_cross_axis_id=cat_id,
1247 allowed_crosses={"autoZero"},
1248 expected_cross_between=cross_between,
1249 )
1250 return {"category": category, "value": value}
1251
1252
1253 def _xy_axis_contract(
1254 plot_area: ET.Element,
1255 plot: ET.Element,
1256 ) -> dict[str, dict[str, Any]]:
1257 """Return the closed two-value-axis contract used by XY charts."""
1258 axes_by_id = _axis_nodes_by_id(plot_area)
1259 plot_axis_nodes = plot.findall("c:axId", C_NS)
1260 if len(plot_axis_nodes) != 2 or any(
1261 set(node.attrib) != {"val"} or list(node)
1262 for node in plot_axis_nodes
1263 ):
1264 raise _UnsupportedChart("unsupported-chart-axis-options")
1265 axis_ids = [_element_val(node) for node in plot_axis_nodes]
1266 if any(not axis_id for axis_id in axis_ids) or len(set(axis_ids)) != 2:
1267 raise _UnsupportedChart("unsupported-chart-axis-options")
1268 if set(axes_by_id) != set(axis_ids):
1269 raise _UnsupportedChart("unsupported-chart-axis-options")
1270
1271 resolved = [
1272 (str(axis_id), axes_by_id.get(str(axis_id)))
1273 for axis_id in axis_ids
1274 ]
1275 if any(
1276 axis is None or _local_name(axis.tag) != "valAx"
1277 for _, axis in resolved
1278 ):
1279 raise _UnsupportedChart("unsupported-chart-axis-options")
1280 horizontal = [
1281 (axis_id, axis)
1282 for axis_id, axis in resolved
1283 if axis is not None
1284 and _element_val(axis.find("c:axPos", C_NS)) in {"b", "t"}
1285 ]
1286 vertical = [
1287 (axis_id, axis)
1288 for axis_id, axis in resolved
1289 if axis is not None
1290 and _element_val(axis.find("c:axPos", C_NS)) in {"l", "r"}
1291 ]
1292 if len(horizontal) != 1 or len(vertical) != 1:
1293 raise _UnsupportedChart("unsupported-chart-axis-options")
1294
1295 x_id, x_axis = horizontal[0]
1296 y_id, y_axis = vertical[0]
1297 x_config = _axis_config_from_xml(
1298 x_axis,
1299 role="x",
1300 expected_cross_axis_id=y_id,
1301 allowed_crosses={"autoZero"},
1302 expected_cross_between="midCat",
1303 )
1304 y_config = _axis_config_from_xml(
1305 y_axis,
1306 role="y",
1307 expected_cross_axis_id=x_id,
1308 allowed_crosses={"autoZero"},
1309 expected_cross_between="midCat",
1310 )
1311 return {"x": x_config, "y": y_config}
1312
1313
1314 def _validate_legacy_axes(payload: dict[str, Any], plot_area: ET.Element) -> None:
1315 """Keep the original narrow axis gate for payloads without axes metadata."""
1316 grouping = payload.get("grouping")
1317 axes = plot_area.findall("c:catAx", C_NS) + plot_area.findall("c:valAx", C_NS)
1318 for axis in axes:
1319 axis_kind = _local_name(axis.tag)
1320 axis_position = _element_val(axis.find("c:axPos", C_NS))
1321 delete = axis.find("c:delete", C_NS)
1322 if delete is not None and ooxml_bool(delete.attrib.get("val"), True):
1323 raise _UnsupportedChart("unsupported-chart-axis-options")
1324 scaling = axis.find("c:scaling", C_NS)
1325 if scaling is not None:
1326 for tag in ("logBase", "min", "max"):
1327 if scaling.find(f"c:{tag}", C_NS) is not None:
1328 raise _UnsupportedChart("unsupported-chart-axis-options")
1329 orientation = _element_val(scaling.find("c:orientation", C_NS))
1330 if orientation not in {None, "minMax"}:
1331 raise _UnsupportedChart("unsupported-chart-axis-options")
1332 for tag in (
1333 "majorUnit", "minorUnit", "crossesAt", "dispUnits",
1334 "tickLblSkip", "tickMarkSkip",
1335 ):
1336 if axis.find(f"c:{tag}", C_NS) is not None:
1337 raise _UnsupportedChart("unsupported-chart-axis-options")
1338 crosses = _element_val(axis.find("c:crosses", C_NS))
1339 if crosses not in {None, "autoZero"}:
1340 raise _UnsupportedChart("unsupported-chart-axis-options")
1341 auto = axis.find("c:auto", C_NS)
1342 if auto is not None and not ooxml_bool(auto.attrib.get("val"), True):
1343 raise _UnsupportedChart("unsupported-chart-axis-options")
1344 num_fmt = axis.find("c:numFmt", C_NS)
1345 if num_fmt is not None:
1346 format_code = num_fmt.attrib.get("formatCode", "").strip()
1347 allowed_formats = {"", "General"}
1348 if grouping == "percentStacked":
1349 allowed_formats.add("0%")
1350 if format_code not in allowed_formats:
1351 raise _UnsupportedChart("unsupported-chart-axis-number-format")
1352 tick_label_position = _element_val(axis.find("c:tickLblPos", C_NS))
1353 if payload["type"] in {"scatter", "bubble"} or axis_kind == "catAx":
1354 if tick_label_position not in {None, "nextTo"}:
1355 raise _UnsupportedChart("unsupported-chart-axis-options")
1356 elif tick_label_position == "none":
1357 payload["show_value_axis_labels"] = False
1358 elif tick_label_position not in {None, "nextTo"}:
1359 raise _UnsupportedChart("unsupported-chart-axis-options")
1360
1361 has_major_gridlines = axis.find("c:majorGridlines", C_NS) is not None
1362 if payload["type"] in {"scatter", "bubble"}:
1363 expected_major_gridlines = axis_position in {"l", "r"}
1364 else:
1365 expected_major_gridlines = axis_kind == "valAx"
1366 if has_major_gridlines != expected_major_gridlines:
1367 raise _UnsupportedChart("unsupported-chart-axis-options")
1368 if axis.find("c:minorGridlines", C_NS) is not None:
1369 raise _UnsupportedChart("unsupported-chart-axis-options")
1370
1371
1372 def _validate_normalized_axis_topology(
1373 payload: dict[str, Any],
1374 plot_area: ET.Element,
1375 plot: ET.Element,
1376 ) -> None:
1377 """Validate a category/value pair whose presentation will normalize."""
1378 axes_by_id = _axis_nodes_by_id(plot_area)
1379 cat_id, cat_axis, val_id, val_axis = _plot_axis_pair(plot, axes_by_id)
1380 if set(axes_by_id) != {cat_id, val_id}:
1381 raise _UnsupportedChart("unsupported-chart-axis-options")
1382
1383 plot_axis_nodes = plot.findall("c:axId", C_NS)
1384 if any(set(node.attrib) != {"val"} or list(node) for node in plot_axis_nodes):
1385 raise _UnsupportedChart("unsupported-chart-axis-options")
1386 for axis, axis_id, cross_axis_id in (
1387 (cat_axis, cat_id, val_id),
1388 (val_axis, val_id, cat_id),
1389 ):
1390 if axis.attrib:
1391 raise _UnsupportedChart("unsupported-chart-axis-options")
1392 id_nodes = axis.findall("c:axId", C_NS)
1393 cross_nodes = axis.findall("c:crossAx", C_NS)
1394 if (
1395 len(id_nodes) != 1
1396 or len(cross_nodes) != 1
1397 or set(id_nodes[0].attrib) != {"val"}
1398 or set(cross_nodes[0].attrib) != {"val"}
1399 or list(id_nodes[0])
1400 or list(cross_nodes[0])
1401 or _element_val(id_nodes[0]) != axis_id
1402 or _element_val(cross_nodes[0]) != cross_axis_id
1403 ):
1404 raise _UnsupportedChart("unsupported-chart-axis-options")
1405
1406 position_nodes = (
1407 cat_axis.findall("c:axPos", C_NS),
1408 val_axis.findall("c:axPos", C_NS),
1409 )
1410 if any(
1411 len(nodes) != 1
1412 or set(nodes[0].attrib) != {"val"}
1413 or list(nodes[0])
1414 for nodes in position_nodes
1415 ):
1416 raise _UnsupportedChart("unsupported-chart-axis-options")
1417 category_position = _element_val(position_nodes[0][0])
1418 value_position = _element_val(position_nodes[1][0])
1419 if payload["type"] == "bar":
1420 valid_positions = (
1421 category_position in {"l", "r"}
1422 and value_position in {"b", "t"}
1423 )
1424 else:
1425 valid_positions = (
1426 category_position in {"b", "t"}
1427 and value_position in {"l", "r"}
1428 )
1429 if not valid_positions:
1430 raise _UnsupportedChart("unsupported-chart-axis-options")
1431
1432
1433 def _validate_or_normalize_legacy_axes(
1434 payload: dict[str, Any],
1435 plot_area: ET.Element,
1436 plot: ET.Element,
1437 ) -> None:
1438 """Keep exact legacy payloads while allowing presentation normalization."""
1439 try:
1440 _validate_legacy_axes(payload, plot_area)
1441 except _UnsupportedChart as exc:
1442 if exc.status not in {
1443 "unsupported-chart-axis-number-format",
1444 "unsupported-chart-axis-options",
1445 }:
1446 raise
1447 if payload["type"] in {"area", "column", "line"}:
1448 try:
1449 payload["axes"] = _single_axis_contract(
1450 plot_area,
1451 plot,
1452 category_kind="text",
1453 cross_between="between",
1454 )
1455 return
1456 except _UnsupportedChart:
1457 payload.pop("axes", None)
1458 _validate_normalized_axis_topology(payload, plot_area, plot)
1459
1460
1461 def _effective_radar_style(plot: ET.Element) -> str:
1462 """Map Office radar marker variants to the writer's uniform style set."""
1463 style_nodes = plot.findall("c:radarStyle", C_NS)
1464 if len(style_nodes) > 1:
1465 raise _UnsupportedChart("unsupported-chart-radar-style")
1466 style_node = style_nodes[0] if style_nodes else None
1467 if style_node is not None and (
1468 set(style_node.attrib) != {"val"} or list(style_node)
1469 ):
1470 raise _UnsupportedChart("unsupported-chart-radar-style")
1471 style = _element_val(style_node) if style_node is not None else "standard"
1472 if style == "filled":
1473 return "filled"
1474 if style not in {"marker", "standard"}:
1475 raise _UnsupportedChart("unsupported-chart-radar-style")
1476
1477 default_marker = style == "marker"
1478 marker_states: list[bool] = []
1479 for series in plot.findall("c:ser", C_NS):
1480 symbol = _element_val(series.find("c:marker/c:symbol", C_NS))
1481 marker_states.append(
1482 default_marker if symbol is None else symbol != "none"
1483 )
1484 return "lineMarker" if any(marker_states) else "line"
1485
1486
1487 def _effective_scatter_style(
1488 plot: ET.Element,
1489 visual_styles: list[SeriesVisualStyle],
1490 ) -> str:
1491 """Normalize plot-level line intent plus uniform series overrides."""
1492 style_nodes = plot.findall("c:scatterStyle", C_NS)
1493 if len(style_nodes) > 1:
1494 raise _UnsupportedChart("unsupported-chart-scatter-style")
1495 style_node = style_nodes[0] if style_nodes else None
1496 if style_node is not None and (
1497 set(style_node.attrib) != {"val"} or list(style_node)
1498 ):
1499 raise _UnsupportedChart("unsupported-chart-scatter-style")
1500 plot_style = _element_val(style_node) or "marker"
1501 if plot_style not in {"line", "lineMarker", "marker", "smooth", "smoothMarker"}:
1502 raise _UnsupportedChart("unsupported-chart-scatter-style")
1503 plot_has_line = plot_style in {"line", "lineMarker", "smooth", "smoothMarker"}
1504 plot_is_smooth = plot_style in {"smooth", "smoothMarker"}
1505
1506 style_by_state = {
1507 (False, True, False): "marker",
1508 (True, False, False): "line",
1509 (True, True, False): "lineMarker",
1510 (True, False, True): "smooth",
1511 (True, True, True): "smoothMarker",
1512 }
1513 effective_styles: set[str] = set()
1514 series_nodes = plot.findall("c:ser", C_NS)
1515 if len(series_nodes) != len(visual_styles):
1516 raise _UnsupportedChart("unsupported-chart-scatter-style")
1517 for series, visual_style in zip(series_nodes, visual_styles):
1518 line_node = series.find("c:spPr/a:ln", C_NS)
1519 has_line = plot_has_line if line_node is None else (
1520 visual_style.stroke is not None
1521 and visual_style.stroke_opacity > 0
1522 )
1523 markers = series.findall("c:marker", C_NS)
1524 if len(markers) != 1:
1525 raise _UnsupportedChart("unsupported-chart-scatter-style")
1526 marker = markers[0]
1527 symbol = marker.find("c:symbol", C_NS) if marker is not None else None
1528 if (
1529 marker is None
1530 or symbol is None
1531 or set(symbol.attrib) != {"val"}
1532 or list(symbol)
1533 ):
1534 raise _UnsupportedChart("unsupported-chart-scatter-style")
1535 symbol_value = _element_val(symbol)
1536 if symbol_value not in {"circle", "none"}:
1537 raise _UnsupportedChart("unsupported-chart-scatter-style")
1538 has_marker = symbol_value == "circle"
1539
1540 smooth_nodes = series.findall("c:smooth", C_NS)
1541 if len(smooth_nodes) > 1:
1542 raise _UnsupportedChart("unsupported-chart-scatter-style")
1543 smooth = smooth_nodes[0] if smooth_nodes else None
1544 is_smooth = plot_is_smooth
1545 if smooth is not None:
1546 if not set(smooth.attrib).issubset({"val"}) or list(smooth):
1547 raise _UnsupportedChart("unsupported-chart-scatter-style")
1548 raw_smooth = smooth.attrib.get("val")
1549 key = raw_smooth.strip().lower() if raw_smooth is not None else "true"
1550 if key in {"1", "on", "true"}:
1551 is_smooth = True
1552 elif key in {"0", "false", "off"}:
1553 is_smooth = False
1554 else:
1555 raise _UnsupportedChart("unsupported-chart-scatter-style")
1556 if is_smooth and not has_line:
1557 raise _UnsupportedChart("unsupported-chart-scatter-style")
1558
1559 effective_style = style_by_state.get((has_line, has_marker, is_smooth))
1560 if effective_style is None:
1561 raise _UnsupportedChart("unsupported-chart-scatter-style")
1562 effective_styles.add(effective_style)
1563
1564 if len(effective_styles) != 1:
1565 raise _UnsupportedChart("unsupported-chart-scatter-style")
1566 return effective_styles.pop()
1567
1568
1569 def _bounded_plot_integer(
1570 plot: ET.Element,
1571 name: str,
1572 *,
1573 minimum: int,
1574 maximum: int,
1575 status: str,
1576 ) -> int | None:
1577 nodes = plot.findall(f"c:{name}", C_NS)
1578 if len(nodes) > 1:
1579 raise _UnsupportedChart(status)
1580 if not nodes:
1581 return None
1582 node = nodes[0]
1583 raw_value = node.attrib.get("val")
1584 if (
1585 set(node.attrib) != {"val"}
1586 or list(node)
1587 or raw_value is None
1588 or re.fullmatch(r"-?[0-9]+", raw_value) is None
1589 ):
1590 raise _UnsupportedChart(status)
1591 value = int(raw_value)
1592 if not minimum <= value <= maximum:
1593 raise _UnsupportedChart(status)
1594 return value
1595
1596
1597 def _validate_chart_semantics(
1598 payload: dict[str, Any],
1599 plot_area: ET.Element,
1600 plot: ET.Element,
1601 *,
1602 palette: ColorPalette | None = None,
1603 validate_axes: bool = True,
1604 ) -> list[SeriesVisualStyle]:
1605 """Reject valid chart features the compact marker cannot reproduce."""
1606 chart_type = payload["type"]
1607 grouping = payload.get("grouping")
1608 visual_styles = _chart_visual_styles(payload, plot, palette)
1609 for tag in (
1610 "trendline", "errBars", "dropLines", "hiLowLines", "upDownBars",
1611 ):
1612 if plot.find(f".//c:{tag}", C_NS) is not None:
1613 raise _UnsupportedChart("unsupported-chart-analysis-features")
1614 if plot_area.find("c:dTable", C_NS) is not None:
1615 raise _UnsupportedChart("unsupported-chart-data-table")
1616 ser_line_nodes = plot.findall("c:serLines", C_NS)
1617 if len(ser_line_nodes) > 1:
1618 raise _UnsupportedChart("unsupported-chart-analysis-features")
1619 if ser_line_nodes:
1620 ser_lines = ser_line_nodes[0]
1621 if (
1622 chart_type != "of_pie"
1623 or ser_lines.attrib
1624 or len(ser_lines) > 1
1625 or any(_local_name(child.tag) != "spPr" for child in ser_lines)
1626 ):
1627 raise _UnsupportedChart("unsupported-chart-analysis-features")
1628 if validate_axes:
1629 _validate_or_normalize_legacy_axes(payload, plot_area, plot)
1630
1631 if chart_type == "line":
1632 smooth_nodes = [plot.find("c:smooth", C_NS), *plot.findall("c:ser/c:smooth", C_NS)]
1633 if any(
1634 node is not None and ooxml_bool(node.attrib.get("val"), True)
1635 for node in smooth_nodes
1636 ):
1637 raise _UnsupportedChart("unsupported-chart-line-style")
1638 plot_marker = plot.find("c:marker", C_NS)
1639 plot_has_markers = bool(
1640 plot_marker is not None
1641 and ooxml_bool(plot_marker.attrib.get("val"), True)
1642 )
1643 marker_states: set[bool] = set()
1644 for series in plot.findall("c:ser", C_NS):
1645 marker_node = series.find("c:marker", C_NS)
1646 symbol = _element_val(series.find("c:marker/c:symbol", C_NS))
1647 if symbol not in {None, "circle", "none"}:
1648 raise _UnsupportedChart("unsupported-chart-line-style")
1649 marker_states.add(plot_has_markers if symbol is None else symbol != "none")
1650 if len(marker_states) > 1:
1651 raise _UnsupportedChart("unsupported-chart-line-style")
1652
1653 if chart_type in {"bar", "column"}:
1654 _bounded_plot_integer(
1655 plot,
1656 "gapWidth",
1657 minimum=0,
1658 maximum=500,
1659 status="unsupported-chart-bar-options",
1660 )
1661 _bounded_plot_integer(
1662 plot,
1663 "overlap",
1664 minimum=-100,
1665 maximum=100,
1666 status="unsupported-chart-bar-options",
1667 )
1668
1669 if chart_type == "bubble":
1670 bubble_scale = _element_val(plot.find("c:bubbleScale", C_NS))
1671 if bubble_scale not in {None, "100"}:
1672 raise _UnsupportedChart("unsupported-chart-bubble-options")
1673 show_negative = plot.find("c:showNegBubbles", C_NS)
1674 if show_negative is not None and ooxml_bool(
1675 show_negative.attrib.get("val"),
1676 True,
1677 ):
1678 raise _UnsupportedChart("unsupported-chart-bubble-options")
1679 size_represents = _element_val(plot.find("c:sizeRepresents", C_NS))
1680 if size_represents not in {None, "area"}:
1681 raise _UnsupportedChart("unsupported-chart-bubble-options")
1682 bubble_3d_nodes = [
1683 plot.find("c:bubble3D", C_NS),
1684 *plot.findall("c:ser/c:bubble3D", C_NS),
1685 ]
1686 if any(
1687 node is not None and ooxml_bool(node.attrib.get("val"), True)
1688 for node in bubble_3d_nodes
1689 ):
1690 raise _UnsupportedChart("unsupported-chart-bubble-options")
1691
1692 if chart_type == "scatter":
1693 payload["scatter_style"] = _effective_scatter_style(plot, visual_styles)
1694
1695 if chart_type in {"pie", "doughnut", "of_pie"}:
1696 for explosion in plot.findall(".//c:explosion", C_NS):
1697 if _element_val(explosion) not in {None, "0"}:
1698 raise _UnsupportedChart("unsupported-chart-pie-options")
1699 first_slice = _element_val(plot.find("c:firstSliceAng", C_NS))
1700 if first_slice not in {None, "0"}:
1701 raise _UnsupportedChart("unsupported-chart-pie-options")
1702 if chart_type == "doughnut":
1703 hole_size = _element_val(plot.find("c:holeSize", C_NS))
1704 if hole_size != "75":
1705 raise _UnsupportedChart("unsupported-chart-doughnut-options")
1706 if chart_type == "of_pie":
1707 for tag in ("splitType", "splitPos", "custSplit"):
1708 if plot.find(f"c:{tag}", C_NS) is not None:
1709 raise _UnsupportedChart("unsupported-chart-of-pie-options")
1710 gap_width = _element_val(plot.find("c:gapWidth", C_NS))
1711 if gap_width != "100":
1712 raise _UnsupportedChart("unsupported-chart-of-pie-options")
1713 second_size = _element_val(plot.find("c:secondPieSize", C_NS))
1714 if second_size not in {None, "75"}:
1715 raise _UnsupportedChart("unsupported-chart-of-pie-options")
1716 return visual_styles
1717
1718
1719 def _apply_plot_data_labels(payload: dict[str, Any], plot: ET.Element) -> None:
1720 if plot.find("c:ser/c:dLbls", C_NS) is not None:
1721 raise _UnsupportedChart("unsupported-chart-series-data-labels")
1722 data_labels = _data_labels_payload(plot.find("c:dLbls", C_NS))
1723 if not data_labels:
1724 return
1725 if payload["type"] not in {"area", "bar", "column", "line"}:
1726 raise _UnsupportedChart("unsupported-chart-data-labels")
1727 try:
1728 validate_data_label_position(
1729 data_labels.get("position"),
1730 payload["type"],
1731 payload.get("grouping"),
1732 )
1733 except RuntimeError:
1734 raise _UnsupportedChart("unsupported-chart-data-labels") from None
1735 payload["data_labels"] = data_labels
1736
1737
1738 def _apply_chart_metadata(
1739 payload: dict[str, Any],
1740 chart_root: ET.Element,
1741 plot_area: ET.Element,
1742 plot: ET.Element,
1743 *,
1744 include_plot_labels: bool = True,
1745 ) -> None:
1746 """Copy visible classic-chart chrome supported by the native schema."""
1747 chart = chart_root.find("c:chart", C_NS)
1748 if chart is None:
1749 return
1750
1751 title_element = chart.find("c:title", C_NS)
1752 title_entries = _canonical_title_entries(title_element)
1753 title = _chart_text(title_element)
1754 if title:
1755 if title_entries is not None:
1756 payload["title"] = title_entries[0]
1757 if len(title_entries) == 2:
1758 payload["subtitle"] = title_entries[1]
1759 else:
1760 payload["title"] = title
1761
1762 legend = chart.find("c:legend", C_NS)
1763 if legend is not None:
1764 delete = legend.find("c:delete", C_NS)
1765 if delete is None or not ooxml_bool(delete.attrib.get("val"), True):
1766 position = _element_val(legend.find("c:legendPos", C_NS)) or "r"
1767 if position not in {"b", "l", "r", "t"}:
1768 raise _UnsupportedChart("unsupported-chart-legend-position")
1769 payload["show_legend"] = True
1770 payload["legend_position"] = position
1771
1772 if include_plot_labels:
1773 _apply_plot_data_labels(payload, plot)
1774
1775 axis_titles: dict[str, str] = {}
1776 category_axis_nodes = (
1777 plot_area.findall("c:catAx", C_NS)
1778 + plot_area.findall("c:dateAx", C_NS)
1779 )
1780 category_titles = [
1781 text
1782 for axis in category_axis_nodes
1783 if (text := _chart_text(axis.find("c:title", C_NS)))
1784 ]
1785 value_titles = [
1786 text
1787 for axis in plot_area.findall("c:valAx", C_NS)
1788 if (text := _chart_text(axis.find("c:title", C_NS)))
1789 ]
1790 if payload["type"] == "combo":
1791 if len(set(category_titles)) > 1:
1792 raise _UnsupportedChart("unsupported-chart-axis-titles")
1793 if category_titles:
1794 axis_titles["category"] = category_titles[0]
1795 for axis in plot_area.findall("c:valAx", C_NS):
1796 text = _chart_text(axis.find("c:title", C_NS))
1797 if not text:
1798 continue
1799 position = _element_val(axis.find("c:axPos", C_NS))
1800 key = "secondary_value" if position == "r" else "value"
1801 if key in axis_titles:
1802 raise _UnsupportedChart("unsupported-chart-axis-titles")
1803 axis_titles[key] = text
1804 elif payload["type"] in {"scatter", "bubble"}:
1805 if category_titles:
1806 raise _UnsupportedChart("unsupported-chart-axis-titles")
1807 titled_value_axes = [
1808 (
1809 _element_val(axis.find("c:axPos", C_NS)),
1810 _chart_text(axis.find("c:title", C_NS)),
1811 )
1812 for axis in plot_area.findall("c:valAx", C_NS)
1813 ]
1814 for position, text in titled_value_axes:
1815 if not text:
1816 continue
1817 key = "x" if position in {"b", "t"} else "y"
1818 if key in axis_titles:
1819 raise _UnsupportedChart("unsupported-chart-axis-titles")
1820 axis_titles[key] = text
1821 else:
1822 if len(category_titles) > 1 or len(value_titles) > 1:
1823 raise _UnsupportedChart("unsupported-chart-axis-titles")
1824 if category_titles:
1825 axis_titles["category"] = category_titles[0]
1826 if value_titles:
1827 axis_titles["value"] = value_titles[0]
1828 if axis_titles:
1829 payload["axis_titles"] = axis_titles
1830
1831
1832 def _chart_text(container: ET.Element | None) -> str:
1833 if container is None:
1834 return ""
1835 paragraphs: list[str] = []
1836 for paragraph in container.findall(".//a:p", C_NS):
1837 text = "".join(node.text or "" for node in paragraph.findall(".//a:t", C_NS))
1838 if text:
1839 paragraphs.append(text)
1840 if paragraphs:
1841 return "\n".join(paragraphs)
1842 values = [node.text or "" for node in container.findall(".//c:v", C_NS)]
1843 return "".join(values)
1844
1845
1846 def _canonical_title_paragraph(paragraph: ET.Element) -> dict[str, Any] | None:
1847 """Return one exporter-canonical or basic Office title paragraph."""
1848 if paragraph.attrib or [_local_name(child.tag) for child in paragraph] != ["r"]:
1849 return None
1850 run = paragraph.find("a:r", C_NS)
1851 if run is None or run.attrib:
1852 return None
1853 run_child_names = [_local_name(child.tag) for child in run]
1854 if run_child_names == ["t"]:
1855 text = run.find("a:t", C_NS)
1856 if (
1857 text is None
1858 or text.attrib
1859 or list(text)
1860 or not (text.text or "")
1861 or text.text != text.text.strip()
1862 ):
1863 return None
1864 return {"text": text.text}
1865 if run_child_names != ["rPr", "t"]:
1866 return None
1867 run_props = run.find("a:rPr", C_NS)
1868 text = run.find("a:t", C_NS)
1869 if (
1870 run_props is None
1871 or set(run_props.attrib) != {"lang", "sz"}
1872 or text is None
1873 or text.attrib
1874 or list(text)
1875 or not (text.text or "")
1876 or text.text != text.text.strip()
1877 ):
1878 return None
1879 size_token = run_props.attrib["sz"]
1880 if re.fullmatch(r"[0-9]+", size_token) is None:
1881 return None
1882 size = int(size_token)
1883 if size % 10 != 0 or not 100 <= size <= 400000:
1884 return None
1885 child_names = [_local_name(child.tag) for child in run_props]
1886 if child_names not in ([], ["solidFill"], ["latin", "ea"], ["solidFill", "latin", "ea"]):
1887 return None
1888 solid_fill = run_props.find("a:solidFill", C_NS)
1889 color = None
1890 if solid_fill is not None:
1891 try:
1892 color = _canonical_srgb_color(solid_fill)
1893 except _UnsupportedChart:
1894 return None
1895 latin = run_props.find("a:latin", C_NS)
1896 east_asian = run_props.find("a:ea", C_NS)
1897 if (latin is None) != (east_asian is None):
1898 return None
1899 for font in (latin, east_asian):
1900 if font is not None and (
1901 set(font.attrib) != {"typeface"}
1902 or not font.attrib["typeface"].strip()
1903 or list(font)
1904 ):
1905 return None
1906 entry: dict[str, Any] = {
1907 "text": text.text,
1908 "font_size": _round_payload_number(size / 75.0),
1909 }
1910 if color is not None:
1911 entry["color"] = f"#{color}"
1912 if latin is not None and east_asian is not None:
1913 latin_name = latin.attrib["typeface"]
1914 east_asian_name = east_asian.attrib["typeface"]
1915 font_family = (
1916 latin_name
1917 if latin_name == east_asian_name
1918 else f"{latin_name}, {east_asian_name}"
1919 )
1920 resolved_fonts = parse_font_family(font_family)
1921 if (
1922 resolved_fonts["latin"] != latin_name
1923 or resolved_fonts["ea"] != east_asian_name
1924 ):
1925 return None
1926 entry["font_family"] = font_family
1927 return entry
1928
1929
1930 def _canonical_title_entries(title: ET.Element | None) -> list[dict[str, Any]] | None:
1931 """Recognize exporter-canonical or basic Office rich title structure."""
1932 if title is None:
1933 return None
1934 title_child_names = [_local_name(child.tag) for child in title]
1935 if title_child_names not in (["tx", "layout"], ["tx", "layout", "overlay"]):
1936 return None
1937 overlay = title.find("c:overlay", C_NS)
1938 if overlay is not None and (
1939 set(overlay.attrib) != {"val"}
1940 or list(overlay)
1941 or ooxml_bool(overlay.attrib.get("val"), True)
1942 ):
1943 return None
1944 tx = title.find("c:tx", C_NS)
1945 layout = title.find("c:layout", C_NS)
1946 rich = title.find("c:tx/c:rich", C_NS)
1947 if (
1948 tx is None
1949 or tx.attrib
1950 or [_local_name(child.tag) for child in tx] != ["rich"]
1951 or layout is None
1952 or layout.attrib
1953 or list(layout)
1954 or rich is None
1955 or rich.attrib
1956 ):
1957 return None
1958 children = list(rich)
1959 child_names = [_local_name(child.tag) for child in children]
1960 if child_names not in (
1961 ["bodyPr", "lstStyle", "p"],
1962 ["bodyPr", "lstStyle", "p", "p"],
1963 ):
1964 return None
1965 if children[0].attrib or list(children[0]) or children[1].attrib or list(children[1]):
1966 return None
1967 entries = [_canonical_title_paragraph(paragraph) for paragraph in children[2:]]
1968 if any(entry is None for entry in entries):
1969 return None
1970 return [entry for entry in entries if entry is not None]
1971
1972
1973 def _data_label_text_style(tx_pr: ET.Element) -> dict[str, Any]:
1974 """Extract the subset of label text properties emitted by this exporter."""
1975 body_pr = tx_pr.find("a:bodyPr", C_NS)
1976 list_style = tx_pr.find("a:lstStyle", C_NS)
1977 if body_pr is None or body_pr.attrib or list(body_pr):
1978 raise _UnsupportedChart("unsupported-chart-data-labels")
1979 if list_style is None or list_style.attrib or list(list_style):
1980 raise _UnsupportedChart("unsupported-chart-data-labels")
1981 paragraphs = tx_pr.findall("a:p", C_NS)
1982 if len(paragraphs) != 1:
1983 raise _UnsupportedChart("unsupported-chart-data-labels")
1984 paragraph = paragraphs[0]
1985 if any(
1986 child.tag.rsplit("}", 1)[-1] not in {"pPr", "endParaRPr"}
1987 for child in paragraph
1988 ):
1989 raise _UnsupportedChart("unsupported-chart-data-labels")
1990 p_pr = paragraph.find("a:pPr", C_NS)
1991 if p_pr is None or p_pr.attrib:
1992 raise _UnsupportedChart("unsupported-chart-data-labels")
1993 if any(
1994 child.tag.rsplit("}", 1)[-1] != "defRPr"
1995 for child in p_pr
1996 ):
1997 raise _UnsupportedChart("unsupported-chart-data-labels")
1998 end_r_pr = paragraph.find("a:endParaRPr", C_NS)
1999 if end_r_pr is not None and (
2000 any(name not in {"lang", "altLang"} for name in end_r_pr.attrib)
2001 or list(end_r_pr)
2002 ):
2003 raise _UnsupportedChart("unsupported-chart-data-labels")
2004
2005 r_pr = p_pr.find("a:defRPr", C_NS)
2006 if r_pr is None:
2007 return {}
2008 allowed_attrs = {"sz", "b"}
2009 if any(name not in allowed_attrs for name in r_pr.attrib):
2010 raise _UnsupportedChart("unsupported-chart-data-labels")
2011 allowed_children = {"solidFill", "latin", "ea"}
2012 if any(
2013 child.tag.rsplit("}", 1)[-1] not in allowed_children
2014 for child in r_pr
2015 ):
2016 raise _UnsupportedChart("unsupported-chart-data-labels")
2017
2018 style: dict[str, Any] = {}
2019 raw_size = r_pr.attrib.get("sz")
2020 if raw_size is not None:
2021 try:
2022 size_px = float(raw_size) / 75.0
2023 except ValueError:
2024 raise _UnsupportedChart("unsupported-chart-data-labels") from None
2025 if size_px <= 0 or not math.isfinite(size_px):
2026 raise _UnsupportedChart("unsupported-chart-data-labels")
2027 style["font_size"] = int(size_px) if size_px.is_integer() else round(size_px, 3)
2028 if r_pr.attrib.get("b") is not None:
2029 style["bold"] = ooxml_bool(r_pr.attrib.get("b"), True)
2030
2031 solid_fill = r_pr.find("a:solidFill", C_NS)
2032 if solid_fill is not None:
2033 color_children = list(solid_fill)
2034 if (
2035 len(color_children) != 1
2036 or color_children[0].tag.rsplit("}", 1)[-1] != "srgbClr"
2037 or list(color_children[0])
2038 ):
2039 raise _UnsupportedChart("unsupported-chart-data-labels")
2040 color = color_children[0].attrib.get("val", "").strip()
2041 if len(color) != 6 or any(char not in "0123456789abcdefABCDEF" for char in color):
2042 raise _UnsupportedChart("unsupported-chart-data-labels")
2043 style["color"] = f"#{color.upper()}"
2044
2045 latin = r_pr.find("a:latin", C_NS)
2046 east_asian = r_pr.find("a:ea", C_NS)
2047 latin_face = latin.attrib.get("typeface", "").strip() if latin is not None else ""
2048 east_asian_face = (
2049 east_asian.attrib.get("typeface", "").strip()
2050 if east_asian is not None else ""
2051 )
2052 font_face = (
2053 f"{latin_face}, {east_asian_face}"
2054 if latin_face and east_asian_face and latin_face != east_asian_face
2055 else latin_face or east_asian_face
2056 )
2057 if font_face:
2058 style["font_family"] = font_face
2059 return style
2060
2061
2062 def _data_labels_payload(dlabels: ET.Element | None) -> dict[str, Any] | None:
2063 if dlabels is None:
2064 return None
2065 if dlabels.find("c:dLbl", C_NS) is not None:
2066 raise _UnsupportedChart("unsupported-chart-point-labels")
2067 allowed_children = {
2068 "numFmt", "txPr", "dLblPos", "showLegendKey", "showVal",
2069 "showCatName", "showSerName", "showPercent", "showBubbleSize",
2070 "showLeaderLines",
2071 }
2072 if any(
2073 child.tag.rsplit("}", 1)[-1] not in allowed_children
2074 for child in dlabels
2075 ):
2076 raise _UnsupportedChart("unsupported-chart-data-labels")
2077 for tag in ("showLegendKey", "showBubbleSize"):
2078 elem = dlabels.find(f"c:{tag}", C_NS)
2079 if elem is not None and ooxml_bool(elem.attrib.get("val"), True):
2080 raise _UnsupportedChart("unsupported-chart-data-labels")
2081
2082 config: dict[str, Any] = {}
2083 for tag, field in (
2084 ("showVal", "show_value"),
2085 ("showCatName", "show_category"),
2086 ("showSerName", "show_series"),
2087 ("showPercent", "show_percent"),
2088 ):
2089 elem = dlabels.find(f"c:{tag}", C_NS)
2090 config[field] = (
2091 ooxml_bool(elem.attrib.get("val"), True)
2092 if elem is not None else False
2093 )
2094 if not any(config.values()):
2095 return None
2096
2097 leader_lines = dlabels.find("c:showLeaderLines", C_NS)
2098 if leader_lines is not None:
2099 config["show_leader_lines"] = ooxml_bool(
2100 leader_lines.attrib.get("val"),
2101 True,
2102 )
2103
2104 position = _element_val(dlabels.find("c:dLblPos", C_NS))
2105 if position:
2106 position_aliases = {
2107 "bestFit": "best_fit",
2108 "ctr": "center",
2109 "inBase": "inside_base",
2110 "inEnd": "inside_end",
2111 "outEnd": "outside_end",
2112 "t": "above",
2113 }
2114 normalized_position = position_aliases.get(position)
2115 if normalized_position is None:
2116 raise _UnsupportedChart("unsupported-chart-data-labels")
2117 config["position"] = normalized_position
2118 num_fmt = dlabels.find("c:numFmt", C_NS)
2119 if num_fmt is not None and num_fmt.attrib.get("formatCode"):
2120 config["number_format"] = num_fmt.attrib["formatCode"]
2121 tx_pr = dlabels.find("c:txPr", C_NS)
2122 if tx_pr is not None:
2123 config.update(_data_label_text_style(tx_pr))
2124 return config
2125
2126
2127 def _category_payload(
2128 chart: ET.Element,
2129 chart_type: str,
2130 xfrm: Xfrm,
2131 *,
2132 category_kind: str = "text",
2133 ) -> dict[str, Any]:
2134 series_nodes = chart.findall("c:ser", C_NS)
2135 if not series_nodes:
2136 raise _UnsupportedChart("unsupported-chart-cache")
2137
2138 category_reader = (
2139 _numeric_values
2140 if category_kind in {"date", "numeric"}
2141 else _category_values
2142 )
2143 categories = category_reader(series_nodes[0].find("c:cat", C_NS))
2144 if not categories:
2145 raise _UnsupportedChart("unsupported-chart-cache")
2146
2147 series: list[dict[str, Any]] = []
2148 for idx, ser in enumerate(series_nodes, start=1):
2149 if category_reader(ser.find("c:cat", C_NS)) != categories:
2150 raise _UnsupportedChart("unsupported-chart-cache")
2151 values = _numeric_values(ser.find("c:val", C_NS))
2152 if not values or len(values) != len(categories):
2153 raise _UnsupportedChart("unsupported-chart-cache")
2154 series.append({
2155 "name": _series_name(ser, idx),
2156 "values": values,
2157 })
2158
2159 payload: dict[str, Any] = {
2160 **_bounds_payload(xfrm),
2161 "categories": categories,
2162 "series": series,
2163 "type": chart_type,
2164 }
2165 grouping = _element_val(chart.find("c:grouping", C_NS))
2166 if grouping and chart_type in {"area", "bar", "column", "line"}:
2167 payload["grouping"] = grouping
2168 if chart_type == "line":
2169 payload["line_style"] = _line_style(chart, series_nodes)
2170 if chart_type == "of_pie":
2171 payload["of_pie_type"] = _element_val(chart.find("c:ofPieType", C_NS)) or "pie"
2172 return payload
2173
2174
2175 def _xy_payload(chart: ET.Element, chart_type: str, xfrm: Xfrm) -> dict[str, Any]:
2176 series_nodes = chart.findall("c:ser", C_NS)
2177 if not series_nodes:
2178 raise _UnsupportedChart("unsupported-chart-cache")
2179
2180 series: list[dict[str, Any]] = []
2181 for idx, ser in enumerate(series_nodes, start=1):
2182 x_values = _numeric_values(ser.find("c:xVal", C_NS))
2183 y_values = _numeric_values(ser.find("c:yVal", C_NS))
2184 if not x_values or len(x_values) != len(y_values):
2185 raise _UnsupportedChart("unsupported-chart-cache")
2186 item: dict[str, Any] = {
2187 "name": _series_name(ser, idx),
2188 "x": x_values,
2189 "y": y_values,
2190 }
2191 if chart_type == "bubble":
2192 sizes = _numeric_values(ser.find("c:bubbleSize", C_NS))
2193 if len(sizes) != len(x_values):
2194 raise _UnsupportedChart("unsupported-chart-cache")
2195 item["sizes"] = sizes
2196 series.append(item)
2197
2198 payload: dict[str, Any] = {
2199 **_bounds_payload(xfrm),
2200 "series": series,
2201 "type": chart_type,
2202 }
2203 return payload
2204
2205
2206 def _bar_chart_type(chart: ET.Element) -> str:
2207 return "bar" if _element_val(chart.find("c:barDir", C_NS)) == "bar" else "column"
2208
2209
2210 def _line_style(chart: ET.Element, series_nodes: list[ET.Element]) -> str:
2211 symbols = [
2212 _element_val(ser.find("c:marker/c:symbol", C_NS))
2213 for ser in series_nodes
2214 ]
2215 if symbols and all(symbol == "none" for symbol in symbols):
2216 return "line"
2217 if any(symbol not in {None, "none"} for symbol in symbols):
2218 return "lineMarker"
2219 marker = chart.find("c:marker", C_NS)
2220 if marker is not None and ooxml_bool(marker.attrib.get("val"), True):
2221 return "lineMarker"
2222 return "line"
2223
2224
2225 def _category_values(cat: ET.Element | None) -> list[str]:
2226 cache = _first_cache(cat, ("strCache", "strLit"))
2227 if cache is not None:
2228 return [str(value) for value in _cache_point_values(cache)]
2229 cache = _first_cache(cat, ("numCache", "numLit"))
2230 if cache is None:
2231 return []
2232 format_code = cache.findtext("c:formatCode", default="", namespaces=C_NS).strip()
2233 if format_code and format_code.lower() != "general":
2234 raise _UnsupportedChart("unsupported-formatted-category-cache")
2235 numbers = _numeric_cache_values(cache)
2236 return [str(value) for value in numbers]
2237
2238
2239 def _category_cache_is_numeric(chart: ET.Element) -> bool:
2240 """Return the cache representation shared by every series in one plot."""
2241 result: bool | None = None
2242 for series in chart.findall("c:ser", C_NS):
2243 category = series.find("c:cat", C_NS)
2244 has_text = _first_cache(category, ("strCache", "strLit")) is not None
2245 has_number = _first_cache(category, ("numCache", "numLit")) is not None
2246 if has_text == has_number:
2247 raise _UnsupportedChart("unsupported-combo-category-layout")
2248 current = has_number
2249 if result is not None and current != result:
2250 raise _UnsupportedChart("unsupported-combo-category-layout")
2251 result = current
2252 if result is None:
2253 raise _UnsupportedChart("unsupported-combo-category-layout")
2254 return result
2255
2256
2257 def _numeric_category_cache_format(chart: ET.Element) -> str:
2258 """Return one exact numeric category-cache format shared by the plot."""
2259 formats: set[str] = set()
2260 for series in chart.findall("c:ser", C_NS):
2261 cache = _first_cache(
2262 series.find("c:cat", C_NS),
2263 ("numCache", "numLit"),
2264 )
2265 if cache is None:
2266 raise _UnsupportedChart("unsupported-chart-category-format")
2267 number_format = cache.findtext(
2268 "c:formatCode",
2269 default="",
2270 namespaces=C_NS,
2271 )
2272 if not number_format.strip():
2273 raise _UnsupportedChart("unsupported-chart-category-format")
2274 formats.add(number_format)
2275 if len(formats) != 1:
2276 raise _UnsupportedChart("unsupported-chart-category-format")
2277 return next(iter(formats))
2278
2279
2280 def _series_name(ser: ET.Element, index: int) -> str:
2281 tx = ser.find("c:tx", C_NS)
2282 values = _text_cache_values(tx)
2283 if values:
2284 return values[0]
2285 direct = tx.findtext("c:v", default="", namespaces=C_NS) if tx is not None else ""
2286 return direct or f"Series {index}"
2287
2288
2289 def _text_cache_values(parent: ET.Element | None) -> list[str]:
2290 cache = _first_cache(parent, ("strCache", "strLit"))
2291 if cache is not None:
2292 return [str(value) for value in _cache_point_values(cache)]
2293 cache = _first_cache(parent, ("numCache", "numLit"))
2294 return [str(value) for value in _cache_point_values(cache)]
2295
2296
2297 def _numeric_values(parent: ET.Element | None) -> list[int | float]:
2298 cache = _first_cache(parent, ("numCache", "numLit"))
2299 if cache is None:
2300 return []
2301 return _numeric_cache_values(cache)
2302
2303
2304 def _numeric_cache_values(cache: ET.Element) -> list[int | float]:
2305 values: list[int | float] = []
2306 for value in _cache_point_values(cache):
2307 number = float(value)
2308 if not math.isfinite(number):
2309 raise _UnsupportedChart("unsupported-chart-cache")
2310 values.append(int(number) if number.is_integer() else number)
2311 return values
2312
2313
2314 def _first_cache(parent: ET.Element | None, names: tuple[str, ...]) -> ET.Element | None:
2315 if parent is None:
2316 return None
2317 for name in names:
2318 found = parent.find(f".//c:{name}", C_NS)
2319 if found is not None:
2320 return found
2321 return None
2322
2323
2324 def _cache_point_values(cache: ET.Element | None) -> list[str]:
2325 if cache is None:
2326 return []
2327 points: dict[int, str] = {}
2328 for idx, point in enumerate(cache.findall("c:pt", C_NS)):
2329 raw_idx = point.attrib.get("idx")
2330 try:
2331 point_idx = int(raw_idx) if raw_idx is not None else idx
2332 except ValueError:
2333 raise _UnsupportedChart("unsupported-chart-cache")
2334 if point_idx < 0 or point_idx in points:
2335 raise _UnsupportedChart("unsupported-chart-cache")
2336 value = point.findtext("c:v", default="", namespaces=C_NS)
2337 points[point_idx] = value
2338
2339 count_elem = cache.find("c:ptCount", C_NS)
2340 if count_elem is not None:
2341 try:
2342 point_count = int(count_elem.attrib.get("val", ""))
2343 except ValueError:
2344 raise _UnsupportedChart("unsupported-chart-cache")
2345 else:
2346 point_count = len(points)
2347 if (
2348 point_count < 0
2349 or point_count != len(points)
2350 or any(idx not in points for idx in range(point_count))
2351 ):
2352 raise _UnsupportedChart("unsupported-chart-cache")
2353 return [points[idx] for idx in range(point_count)]
2354
2355
2356 def _element_val(elem: ET.Element | None) -> str | None:
2357 if elem is None:
2358 return None
2359 return elem.attrib.get("val")
2360
2361
2362 def _bounds_payload(xfrm: Xfrm) -> dict[str, int | float]:
2363 return {
2364 "height": _round_payload_number(xfrm.h),
2365 "width": _round_payload_number(xfrm.w),
2366 "x": _round_payload_number(xfrm.x),
2367 "y": _round_payload_number(xfrm.y),
2368 }
2369
2370
2371 def _round_payload_number(value: float) -> int | float:
2372 rounded = round(float(value), 3)
2373 return int(rounded) if rounded.is_integer() else rounded
2374
2375
2376 def _local_name(tag: str) -> str:
2377 return tag.rsplit("}", 1)[-1] if "}" in tag else tag
2378
2378 lines PYTHON