| 1 | """Read native PowerPoint chart display caches for slide-library analysis. |
| 2 | |
| 3 | The template-fill workflow edits chart data from explicit fill plans. This |
| 4 | module only reads the data currently visible in a chart XML part, keeping |
| 5 | workbook parsing out of the analyzer. |
| 6 | """ |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | from typing import Any |
| 11 | from xml.etree import ElementTree as ET |
| 12 | |
| 13 | from .ooxml import NS |
| 14 | |
| 15 | |
| 16 | def empty_chart_data() -> dict[str, Any]: |
| 17 | return { |
| 18 | "chart_type": None, |
| 19 | "category_count": 0, |
| 20 | "series_count": 0, |
| 21 | "categories": [], |
| 22 | "series": [], |
| 23 | } |
| 24 | |
| 25 | |
| 26 | def _local_name(tag: str) -> str: |
| 27 | return tag.rsplit("}", 1)[-1] |
| 28 | |
| 29 | |
| 30 | def _chart_types_with_series(chart_root: ET.Element) -> list[ET.Element]: |
| 31 | plot_area = chart_root.find(".//c:plotArea", NS) |
| 32 | if plot_area is None: |
| 33 | return [] |
| 34 | chart_types: list[ET.Element] = [] |
| 35 | for child in list(plot_area): |
| 36 | if _local_name(child.tag).endswith("Chart") and child.findall("c:ser", NS): |
| 37 | chart_types.append(child) |
| 38 | return chart_types |
| 39 | |
| 40 | |
| 41 | def _coerce_number(value: str) -> int | float | str: |
| 42 | try: |
| 43 | number = float(value) |
| 44 | except ValueError: |
| 45 | return value |
| 46 | if number.is_integer(): |
| 47 | return int(number) |
| 48 | return number |
| 49 | |
| 50 | |
| 51 | def _cache_values(parent: ET.Element | None, *, numeric: bool = False) -> list[Any]: |
| 52 | if parent is None: |
| 53 | return [] |
| 54 | cache = parent.find(".//c:strCache", NS) |
| 55 | if cache is None: |
| 56 | cache = parent.find(".//c:numCache", NS) |
| 57 | if cache is None: |
| 58 | return [] |
| 59 | values: list[Any] = [] |
| 60 | for point in cache.findall("c:pt", NS): |
| 61 | value = point.findtext("c:v", default="", namespaces=NS) |
| 62 | values.append(_coerce_number(value) if numeric else value) |
| 63 | return values |
| 64 | |
| 65 | |
| 66 | def _series_name(series: ET.Element, fallback: str) -> str: |
| 67 | tx = series.find("c:tx", NS) |
| 68 | if tx is None: |
| 69 | return fallback |
| 70 | values = _cache_values(tx) |
| 71 | if values: |
| 72 | return str(values[0]) |
| 73 | direct = tx.findtext("c:v", default="", namespaces=NS) |
| 74 | return direct or fallback |
| 75 | |
| 76 | |
| 77 | def read_chart_data(chart_root: ET.Element) -> dict[str, Any]: |
| 78 | """Return a compact summary of chart type, categories, series, and values.""" |
| 79 | chart_types = _chart_types_with_series(chart_root) |
| 80 | if not chart_types: |
| 81 | return empty_chart_data() |
| 82 | |
| 83 | first_series = chart_types[0].find("c:ser", NS) |
| 84 | categories = _cache_values(first_series.find("c:cat", NS)) if first_series is not None else [] |
| 85 | series_payload: list[dict[str, Any]] = [] |
| 86 | plot_types: list[str] = [] |
| 87 | for chart_type in chart_types: |
| 88 | plot_name = _local_name(chart_type.tag) |
| 89 | plot_types.append(plot_name) |
| 90 | for series in chart_type.findall("c:ser", NS): |
| 91 | index = len(series_payload) + 1 |
| 92 | series_categories = _cache_values(series.find("c:cat", NS)) |
| 93 | if not categories and series_categories: |
| 94 | categories = series_categories |
| 95 | series_payload.append( |
| 96 | { |
| 97 | "name": _series_name(series, f"系列{index}"), |
| 98 | "values": _cache_values(series.find("c:val", NS), numeric=True), |
| 99 | "chart_type": plot_name, |
| 100 | } |
| 101 | ) |
| 102 | |
| 103 | return { |
| 104 | "chart_type": plot_types[0] if len(set(plot_types)) == 1 else "comboChart", |
| 105 | "plot_types": plot_types, |
| 106 | "category_count": len(categories), |
| 107 | "series_count": len(series_payload), |
| 108 | "categories": categories, |
| 109 | "series": series_payload, |
| 110 | } |
| 111 |