"""Extract native ChartEx payloads from PPTX chart parts. The parser is deliberately closed around the seven ChartEx data models that the native writer already emits. Data topology and caches must be complete; unmodeled chart chrome, labels, axes, binning options, and style details are allowed to normalize during editable native reconstruction. """ from __future__ import annotations import math import re from typing import Any from xml.etree import ElementTree as ET from svg_to_pptx.native_objects.chart_data import validate_chart_payload from svg_to_pptx.native_objects.marker_common import CHART_COLOR_STYLE_REL_TYPE from .color_resolver import COLOR_TAGS, ColorPalette, resolve_color from .emu_units import NS, Xfrm from .ooxml_loader import OoxmlPackage, PartRef CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex" CX_NS = { **NS, "cx": CHARTEX_URI, } _INT_TOKEN_RE = re.compile(r"[0-9]+") _HIERARCHY_LAYOUTS = {"sunburst", "treemap"} _FLAT_LAYOUTS = {"funnel", "waterfall"} _LEGEND_POSITIONS = {"b", "l", "r", "t"} class UnsupportedChartEx(RuntimeError): """A ChartEx part cannot map to the current native authoring schema.""" def __init__(self, status: str) -> None: super().__init__(status) self.status = status def extract_native_chartex_payload( graphic_data: ET.Element | None, xfrm: Xfrm, slide_part: PartRef, pkg: OoxmlPackage, palette: ColorPalette | None = None, ) -> dict[str, Any]: """Return a writer-valid payload for one supported ChartEx reference. ``UnsupportedChartEx.status`` is suitable for ``data-pptx-replacement-status`` when the reference, relationship, data topology, or cache is unusable. Style-part failures never reject an otherwise valid data payload. """ if graphic_data is None: raise UnsupportedChartEx("unsupported-chart-reference") if xfrm.rot or xfrm.flip_h or xfrm.flip_v: raise UnsupportedChartEx("unsupported-native-transform") chart_ref = graphic_data.find("cx:chart", CX_NS) if chart_ref is None: raise UnsupportedChartEx("unsupported-chart-reference") rid = chart_ref.attrib.get(f"{{{NS['r']}}}id") if not rid: raise UnsupportedChartEx("unsupported-chart-reference") chart_path = slide_part.resolve_rel(rid) if not chart_path: raise UnsupportedChartEx("unsupported-chart-relationship") chart_part = pkg.load_part(chart_path) if chart_part is None: raise UnsupportedChartEx("unsupported-chart-part") try: payload = _payload_from_chartex_xml(chart_part.xml, xfrm) colors = _resolved_chart_colors(chart_part, pkg, palette) if colors: payload["style"] = {"colors": colors} validate_chart_payload(payload) except UnsupportedChartEx: raise except RuntimeError as exc: raise UnsupportedChartEx("unsupported-chartex-schema") from exc except (AttributeError, OverflowError, TypeError, ValueError) as exc: raise UnsupportedChartEx("unsupported-chartex-parse") from exc return payload def _payload_from_chartex_xml( chart_root: ET.Element, xfrm: Xfrm, ) -> dict[str, Any]: if chart_root.tag != f"{{{CHARTEX_URI}}}chartSpace": raise UnsupportedChartEx("unsupported-chartex-part") chart_data = _one_child(chart_root, "chartData", "unsupported-chartex-structure") chart = _one_child(chart_root, "chart", "unsupported-chartex-structure") plot_area = _one_child(chart, "plotArea", "unsupported-chartex-structure") region = _one_child(plot_area, "plotAreaRegion", "unsupported-chartex-structure") data_by_id = _data_parts(chart_data) series_nodes = _children(region, "series") if not series_nodes: raise UnsupportedChartEx("unsupported-chartex-series") chart_type = _chart_type(series_nodes) payload: dict[str, Any] = { **_bounds_payload(xfrm), "type": chart_type, } referenced_data_ids: list[int] if chart_type in _HIERARCHY_LAYOUTS: data_id = _series_data_id(series_nodes[0]) data = _require_data(data_by_id, data_id) raw_levels = _string_dimension(data, expected_levels=None) values = _numeric_dimension(data, "size") if not raw_levels or any(len(level) != len(values) for level in raw_levels): raise UnsupportedChartEx("unsupported-chartex-cache") # ChartEx serializes the innermost level first; the authoring schema # and workbook writer use the natural outermost-to-innermost order. payload["levels"] = list(reversed(raw_levels)) payload["values"] = values if chart_type == "treemap": parent_labels = _treemap_parent_labels(series_nodes[0]) if parent_labels is not None: payload["parent_label_layout"] = parent_labels referenced_data_ids = [data_id] elif chart_type == "histogram": data_id = _series_data_id(series_nodes[0]) data = _require_data(data_by_id, data_id) _reject_dimensions(data, allowed={"numDim"}) payload["values"] = _numeric_dimension(data, "val") referenced_data_ids = [data_id] elif chart_type in {"funnel", "pareto", "waterfall"}: data_id = _series_data_id(series_nodes[0]) data = _require_data(data_by_id, data_id) categories = _string_dimension(data, expected_levels=1)[0] values = _numeric_dimension(data, "val") if len(categories) != len(values): raise UnsupportedChartEx("unsupported-chartex-cache") payload["categories"] = categories payload["values"] = values if chart_type == "waterfall": payload["subtotals"] = _waterfall_subtotals(series_nodes[0]) referenced_data_ids = [data_id] elif chart_type == "box_whisker": items: list[dict[str, Any]] = [] referenced_data_ids = [] for index, series in enumerate(series_nodes, start=1): data_id = _series_data_id(series) if data_id in referenced_data_ids: raise UnsupportedChartEx("unsupported-chartex-data-id") data = _require_data(data_by_id, data_id) categories = _string_dimension(data, expected_levels=1)[0] values = _numeric_dimension(data, "val") if len(categories) != len(values): raise UnsupportedChartEx("unsupported-chartex-cache") items.append({ "categories": categories, "name": _series_name(series, index), "values": values, }) referenced_data_ids.append(data_id) payload["series"] = items else: # pragma: no cover - _chart_type is closed, keep the invariant explicit. raise UnsupportedChartEx("unsupported-chartex-type") if set(referenced_data_ids) != set(data_by_id): raise UnsupportedChartEx("unsupported-chartex-data-id") legend = chart.find("cx:legend", CX_NS) if legend is not None: payload["show_legend"] = True position = legend.attrib.get("pos") if position in _LEGEND_POSITIONS: payload["legend_position"] = position return payload def _chart_type(series_nodes: list[ET.Element]) -> str: layouts = [series.attrib.get("layoutId", "") for series in series_nodes] if len(series_nodes) == 1 and layouts[0] in _HIERARCHY_LAYOUTS | _FLAT_LAYOUTS: return layouts[0] if layouts and all(layout == "boxWhisker" for layout in layouts): return "box_whisker" if len(series_nodes) == 1 and layouts == ["clusteredColumn"]: if series_nodes[0].find("cx:layoutPr/cx:binning", CX_NS) is None: raise UnsupportedChartEx("unsupported-chartex-series") return "histogram" if len(series_nodes) == 2 and layouts == ["clusteredColumn", "paretoLine"]: primary, line = series_nodes if ( primary.find("cx:layoutPr/cx:aggregation", CX_NS) is None or line.attrib.get("ownerIdx") != "0" or line.find("cx:dataId", CX_NS) is not None ): raise UnsupportedChartEx("unsupported-chartex-series") return "pareto" raise UnsupportedChartEx("unsupported-chartex-type") def _data_parts(chart_data: ET.Element) -> dict[int, ET.Element]: result: dict[int, ET.Element] = {} for data in _children(chart_data, "data"): data_id = _nonnegative_int(data.attrib.get("id"), "unsupported-chartex-data-id") if data_id in result: raise UnsupportedChartEx("unsupported-chartex-data-id") result[data_id] = data if not result: raise UnsupportedChartEx("unsupported-chartex-data-id") return result def _series_data_id(series: ET.Element) -> int: data_ids = _children(series, "dataId") if len(data_ids) != 1: raise UnsupportedChartEx("unsupported-chartex-data-id") return _nonnegative_int( data_ids[0].attrib.get("val"), "unsupported-chartex-data-id", ) def _require_data(data_by_id: dict[int, ET.Element], data_id: int) -> ET.Element: data = data_by_id.get(data_id) if data is None: raise UnsupportedChartEx("unsupported-chartex-data-id") return data def _string_dimension( data: ET.Element, *, expected_levels: int | None, ) -> list[list[str]]: _reject_dimensions(data, allowed={"strDim", "numDim"}) dimensions = [ child for child in _children(data, "strDim") if child.attrib.get("type") == "cat" ] if len(dimensions) != 1 or len(_children(data, "strDim")) != 1: raise UnsupportedChartEx("unsupported-chartex-dimension") levels = _children(dimensions[0], "lvl") if not levels or (expected_levels is not None and len(levels) != expected_levels): raise UnsupportedChartEx("unsupported-chartex-dimension") return [_level_values(level, numeric=False) for level in levels] def _numeric_dimension(data: ET.Element, dim_type: str) -> list[int | float]: dimensions = [ child for child in _children(data, "numDim") if child.attrib.get("type") == dim_type ] if len(dimensions) != 1 or len(_children(data, "numDim")) != 1: raise UnsupportedChartEx("unsupported-chartex-dimension") levels = _children(dimensions[0], "lvl") if len(levels) != 1: raise UnsupportedChartEx("unsupported-chartex-dimension") return _level_values(levels[0], numeric=True) def _reject_dimensions(data: ET.Element, *, allowed: set[str]) -> None: for child in data: name = _local_name(child.tag) if name.endswith("Dim") and name not in allowed: raise UnsupportedChartEx("unsupported-chartex-dimension") def _level_values( level: ET.Element, *, numeric: bool, ) -> list[Any]: point_count = _nonnegative_int( level.attrib.get("ptCount"), "unsupported-chartex-cache", ) points: dict[int, Any] = {} for point in _children(level, "pt"): point_index = _nonnegative_int( point.attrib.get("idx"), "unsupported-chartex-cache", ) if point_index >= point_count or point_index in points or list(point): raise UnsupportedChartEx("unsupported-chartex-cache") raw_value = point.text or "" points[point_index] = ( _numeric_value(raw_value) if numeric else raw_value ) if ( len(points) != point_count or any(index not in points for index in range(point_count)) ): raise UnsupportedChartEx("unsupported-chartex-cache") return [points[index] for index in range(point_count)] def _numeric_value(raw_value: str) -> int | float: if not raw_value.strip(): raise UnsupportedChartEx("unsupported-chartex-cache") try: number = float(raw_value) except (OverflowError, ValueError): raise UnsupportedChartEx("unsupported-chartex-cache") from None if not math.isfinite(number): raise UnsupportedChartEx("unsupported-chartex-cache") return int(number) if number.is_integer() else number def _waterfall_subtotals(series: ET.Element) -> list[int]: subtotals: list[int] = [] for item in series.findall("cx:layoutPr/cx:subtotals/cx:idx", CX_NS): subtotals.append( _nonnegative_int(item.attrib.get("val"), "unsupported-chartex-cache") ) return subtotals def _treemap_parent_labels(series: ET.Element) -> str | None: parent = series.find("cx:layoutPr/cx:parentLabelLayout", CX_NS) if parent is None: return None value = parent.attrib.get("val") return value if value in {"banner", "none", "overlapping"} else None def _series_name(series: ET.Element, index: int) -> str: value = series.findtext("cx:tx/cx:txData/cx:v", default="", namespaces=CX_NS) return value or f"Series {index}" def _resolved_chart_colors( chart_part: PartRef, pkg: OoxmlPackage, palette: ColorPalette | None, ) -> list[str]: """Resolve the base color cycle; any style failure normalizes silently.""" try: targets = [ info.get("target") for info in chart_part.rels.values() if info.get("type") == CHART_COLOR_STYLE_REL_TYPE and not info.get("external") and info.get("target") ] if len(targets) != 1: return [] colors_part = pkg.load_part(str(targets[0])) if colors_part is None: return [] color_nodes = [ child for child in colors_part.xml if _namespace(child.tag) == NS["a"] and _local_name(child.tag) in COLOR_TAGS ] if not color_nodes: return [] colors: list[str] = [] for color_node in color_nodes: color, _alpha = resolve_color(color_node, palette) if color is None: return [] colors.append(color) return colors except (AttributeError, OverflowError, RuntimeError, TypeError, ValueError): return [] def _one_child(parent: ET.Element, name: str, status: str) -> ET.Element: children = _children(parent, name) if len(children) != 1: raise UnsupportedChartEx(status) return children[0] def _children(parent: ET.Element, name: str) -> list[ET.Element]: return [ child for child in parent if child.tag == f"{{{CHARTEX_URI}}}{name}" ] def _nonnegative_int(raw_value: str | None, status: str) -> int: if raw_value is None or _INT_TOKEN_RE.fullmatch(raw_value) is None: raise UnsupportedChartEx(status) value = int(raw_value) if value < 0: raise UnsupportedChartEx(status) return value def _bounds_payload(xfrm: Xfrm) -> dict[str, int | float]: return { "height": _round_payload_number(xfrm.h), "width": _round_payload_number(xfrm.w), "x": _round_payload_number(xfrm.x), "y": _round_payload_number(xfrm.y), } def _round_payload_number(value: float) -> int | float: rounded = round(float(value), 3) return int(rounded) if rounded.is_integer() else rounded def _namespace(tag: str) -> str: return tag[1:].split("}", 1)[0] if tag.startswith("{") else "" def _local_name(tag: str) -> str: return tag.rsplit("}", 1)[-1] if "}" in tag else tag