| 1 | """Classify template-fill chart and table edits before package mutation.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from typing import Any |
| 6 | from xml.etree import ElementTree as ET |
| 7 | |
| 8 | from .ooxml import NS, _qn |
| 9 | |
| 10 | _XY_PLOTS = frozenset({"bubbleChart", "scatterChart"}) |
| 11 | |
| 12 | |
| 13 | def _local_name(tag: str) -> str: |
| 14 | return tag.rsplit("}", 1)[-1] |
| 15 | |
| 16 | |
| 17 | def _tag_namespace(tag: str) -> str: |
| 18 | if not tag.startswith("{") or "}" not in tag: |
| 19 | return "" |
| 20 | return tag[1:].split("}", 1)[0] |
| 21 | |
| 22 | |
| 23 | def _chart_reference(frame: ET.Element) -> tuple[str, str]: |
| 24 | """Return ``(classic|chartex|unknown, relationship id)`` for a chart frame.""" |
| 25 | graphic_data = frame.find(".//a:graphicData", NS) |
| 26 | if graphic_data is None: |
| 27 | return "", "" |
| 28 | |
| 29 | for node in graphic_data.iter(): |
| 30 | if _local_name(node.tag) != "chart": |
| 31 | continue |
| 32 | namespace = _tag_namespace(node.tag) |
| 33 | rel_id = node.attrib.get(_qn(NS["r"], "id"), "") |
| 34 | if namespace == NS["c"]: |
| 35 | return "classic", rel_id |
| 36 | if "chartex" in namespace.lower(): |
| 37 | return "chartex", rel_id |
| 38 | return "unknown", rel_id |
| 39 | |
| 40 | uri = graphic_data.attrib.get("uri", "").lower() |
| 41 | if "chartex" in uri: |
| 42 | return "chartex", "" |
| 43 | if "chart" in uri: |
| 44 | return "unknown", "" |
| 45 | return "", "" |
| 46 | |
| 47 | |
| 48 | def _chart_frames(slide_root: ET.Element) -> list[ET.Element]: |
| 49 | """Return classic, ChartEx, and unknown chart graphic frames.""" |
| 50 | return [ |
| 51 | frame |
| 52 | for frame in slide_root.findall(".//p:graphicFrame", NS) |
| 53 | if _chart_reference(frame)[0] |
| 54 | ] |
| 55 | |
| 56 | |
| 57 | def _unsupported_chart_capability( |
| 58 | code: str, |
| 59 | message: str, |
| 60 | *, |
| 61 | plot_type: str | None = None, |
| 62 | plot_count: int = 0, |
| 63 | data_model: str = "unknown", |
| 64 | ) -> dict[str, Any]: |
| 65 | return { |
| 66 | "supported": False, |
| 67 | "code": code, |
| 68 | "message": message, |
| 69 | "plot_type": plot_type, |
| 70 | "plot_count": plot_count, |
| 71 | "data_model": data_model, |
| 72 | } |
| 73 | |
| 74 | |
| 75 | def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]: |
| 76 | """Classify whether the category cache writer can safely edit a chart part.""" |
| 77 | root_namespace = _tag_namespace(chart_root.tag) |
| 78 | if "chartex" in root_namespace.lower(): |
| 79 | return _unsupported_chart_capability( |
| 80 | "chart_edit_chartex_unsupported", |
| 81 | "template-fill chart edits do not support ChartEx", |
| 82 | ) |
| 83 | if root_namespace != NS["c"]: |
| 84 | return _unsupported_chart_capability( |
| 85 | "chart_edit_plot_type_unsupported", |
| 86 | "template-fill chart edits require a classic DrawingML chart part", |
| 87 | ) |
| 88 | |
| 89 | plot_area = chart_root.find(".//c:plotArea", NS) |
| 90 | if plot_area is None: |
| 91 | return _unsupported_chart_capability( |
| 92 | "chart_edit_plot_type_unsupported", |
| 93 | "template-fill chart edits require a classic chart plotArea", |
| 94 | ) |
| 95 | |
| 96 | plot_nodes = [ |
| 97 | child |
| 98 | for child in list(plot_area) |
| 99 | if _tag_namespace(child.tag) == NS["c"] and _local_name(child.tag).endswith("Chart") |
| 100 | ] |
| 101 | if len(plot_nodes) > 1: |
| 102 | return _unsupported_chart_capability( |
| 103 | "chart_edit_multi_plot_unsupported", |
| 104 | "template-fill chart edits do not support multi-plot or combination charts", |
| 105 | plot_count=len(plot_nodes), |
| 106 | ) |
| 107 | if not plot_nodes: |
| 108 | return _unsupported_chart_capability( |
| 109 | "chart_edit_plot_type_unsupported", |
| 110 | "template-fill chart edits require exactly one recognized chart plot", |
| 111 | ) |
| 112 | |
| 113 | plot = plot_nodes[0] |
| 114 | plot_type = _local_name(plot.tag) |
| 115 | if plot_type == "scatterChart": |
| 116 | return _unsupported_chart_capability( |
| 117 | "chart_edit_scatter_unsupported", |
| 118 | "template-fill chart edits do not support scatter xVal/yVal data", |
| 119 | plot_type=plot_type, |
| 120 | plot_count=1, |
| 121 | data_model="xy", |
| 122 | ) |
| 123 | if plot_type == "bubbleChart": |
| 124 | return _unsupported_chart_capability( |
| 125 | "chart_edit_bubble_unsupported", |
| 126 | "template-fill chart edits do not support bubble xVal/yVal/bubbleSize data", |
| 127 | plot_type=plot_type, |
| 128 | plot_count=1, |
| 129 | data_model="bubble", |
| 130 | ) |
| 131 | series_nodes = plot.findall("c:ser", NS) |
| 132 | if not series_nodes: |
| 133 | return _unsupported_chart_capability( |
| 134 | "chart_edit_no_series", |
| 135 | "template-fill chart edits require at least one editable series", |
| 136 | plot_type=plot_type, |
| 137 | plot_count=1, |
| 138 | data_model="category", |
| 139 | ) |
| 140 | for series in series_nodes: |
| 141 | if any( |
| 142 | series.find(f"c:{tag}", NS) is not None |
| 143 | for tag in ("xVal", "yVal", "bubbleSize") |
| 144 | ): |
| 145 | return _unsupported_chart_capability( |
| 146 | "chart_edit_data_model_unsupported", |
| 147 | "template-fill chart edits require c:cat/c:val series", |
| 148 | plot_type=plot_type, |
| 149 | plot_count=1, |
| 150 | ) |
| 151 | category = series.find("c:cat", NS) |
| 152 | values = series.find("c:val", NS) |
| 153 | if category is None or values is None: |
| 154 | return _unsupported_chart_capability( |
| 155 | "chart_edit_data_model_unsupported", |
| 156 | "template-fill chart edits require c:cat/c:val on every series", |
| 157 | plot_type=plot_type, |
| 158 | plot_count=1, |
| 159 | ) |
| 160 | capability_warnings: list[dict[str, str]] = [] |
| 161 | if plot_area.find("c:dateAx", NS) is not None: |
| 162 | capability_warnings.append( |
| 163 | { |
| 164 | "code": "chart_edit_date_axis_flattened", |
| 165 | "message": ( |
| 166 | "template-fill will flatten date-axis categories to the " |
| 167 | "replacement single-level category cache" |
| 168 | ), |
| 169 | } |
| 170 | ) |
| 171 | if any( |
| 172 | series.find("c:cat/c:multiLvlStrRef", NS) is not None |
| 173 | for series in series_nodes |
| 174 | ): |
| 175 | capability_warnings.append( |
| 176 | { |
| 177 | "code": "chart_edit_multilevel_categories_flattened", |
| 178 | "message": ( |
| 179 | "template-fill will flatten multi-level categories to the " |
| 180 | "replacement single-level category cache" |
| 181 | ), |
| 182 | } |
| 183 | ) |
| 184 | |
| 185 | return { |
| 186 | "supported": True, |
| 187 | "code": "chart_edit_category_single_plot", |
| 188 | "message": "single classic plot uses c:cat/c:val series", |
| 189 | "plot_type": plot_type, |
| 190 | "plot_count": 1, |
| 191 | "data_model": "category", |
| 192 | "warnings": capability_warnings, |
| 193 | } |
| 194 | |
| 195 | |
| 196 | def _is_verified_category_capability(capability: Any) -> bool: |
| 197 | """Return whether an analyzer capability matches the runtime structural gate.""" |
| 198 | return bool( |
| 199 | isinstance(capability, dict) |
| 200 | and capability.get("supported") is True |
| 201 | and capability.get("code") == "chart_edit_category_single_plot" |
| 202 | and capability.get("data_model") == "category" |
| 203 | and capability.get("plot_count") == 1 |
| 204 | and isinstance(capability.get("plot_type"), str) |
| 205 | and bool(capability.get("plot_type")) |
| 206 | and capability.get("plot_type") not in _XY_PLOTS |
| 207 | ) |
| 208 | |
| 209 | |
| 210 | def _require_supported_chart_edit(chart_root: ET.Element) -> dict[str, Any]: |
| 211 | """Raise before mutation unless ``chart_root`` uses the verified category model.""" |
| 212 | capability = _chart_edit_capability(chart_root) |
| 213 | if not _is_verified_category_capability(capability): |
| 214 | code = capability.get("code") or "chart_edit_capability_unknown" |
| 215 | message = capability.get("message") or "chart edit capability is unknown" |
| 216 | raise RuntimeError(f"{message} [{code}]") |
| 217 | return capability |
| 218 | |
| 219 | |
| 220 | def _ooxml_bool(value: str | None) -> bool: |
| 221 | return str(value or "").strip().lower() in {"1", "on", "true"} |
| 222 | |
| 223 | |
| 224 | def _positive_span(value: str | None) -> int: |
| 225 | try: |
| 226 | return max(int(value or "1"), 1) |
| 227 | except ValueError: |
| 228 | return 1 |
| 229 | |
| 230 | |
| 231 | def _table_cell_merge_info(cell: ET.Element) -> dict[str, Any]: |
| 232 | """Return the merge role encoded directly on one physical ``a:tc`` cell.""" |
| 233 | h_merge = _ooxml_bool(cell.attrib.get("hMerge")) |
| 234 | v_merge = _ooxml_bool(cell.attrib.get("vMerge")) |
| 235 | row_span = _positive_span(cell.attrib.get("rowSpan")) |
| 236 | col_span = _positive_span(cell.attrib.get("gridSpan")) |
| 237 | is_merge_slave = h_merge or v_merge |
| 238 | is_merge_anchor = not is_merge_slave and (row_span > 1 or col_span > 1) |
| 239 | merge_role = "slave" if is_merge_slave else "anchor" if is_merge_anchor else "none" |
| 240 | return { |
| 241 | "merge_role": merge_role, |
| 242 | "is_merge_anchor": is_merge_anchor, |
| 243 | "is_merge_slave": is_merge_slave, |
| 244 | "row_span": row_span, |
| 245 | "col_span": col_span, |
| 246 | "h_merge": h_merge, |
| 247 | "v_merge": v_merge, |
| 248 | } |
| 249 | |
| 250 | |
| 251 | def _table_merge_topology(table: ET.Element) -> dict[str, Any]: |
| 252 | """Describe merge anchors and physical slave cells in a DrawingML table.""" |
| 253 | cell_states: list[tuple[int, int, dict[str, Any]]] = [] |
| 254 | anchors: list[dict[str, int]] = [] |
| 255 | for row_index, row in enumerate(table.findall("a:tr", NS)): |
| 256 | for col_index, cell in enumerate(row.findall("a:tc", NS)): |
| 257 | info = _table_cell_merge_info(cell) |
| 258 | cell_states.append((row_index, col_index, info)) |
| 259 | if info["is_merge_anchor"]: |
| 260 | anchors.append( |
| 261 | { |
| 262 | "row": row_index, |
| 263 | "col": col_index, |
| 264 | "row_span": info["row_span"], |
| 265 | "col_span": info["col_span"], |
| 266 | } |
| 267 | ) |
| 268 | |
| 269 | covered_by: dict[tuple[int, int], dict[str, int]] = {} |
| 270 | for anchor in anchors: |
| 271 | for row_index in range(anchor["row"], anchor["row"] + anchor["row_span"]): |
| 272 | for col_index in range(anchor["col"], anchor["col"] + anchor["col_span"]): |
| 273 | if (row_index, col_index) == (anchor["row"], anchor["col"]): |
| 274 | continue |
| 275 | covered_by.setdefault( |
| 276 | (row_index, col_index), |
| 277 | {"row": anchor["row"], "col": anchor["col"]}, |
| 278 | ) |
| 279 | |
| 280 | slave_cells: list[dict[str, Any]] = [] |
| 281 | for row_index, col_index, info in cell_states: |
| 282 | if not info["is_merge_slave"]: |
| 283 | continue |
| 284 | slave: dict[str, Any] = {"row": row_index, "col": col_index} |
| 285 | anchor = covered_by.get((row_index, col_index)) |
| 286 | if anchor is not None: |
| 287 | slave["anchor"] = anchor |
| 288 | slave_cells.append(slave) |
| 289 | |
| 290 | return { |
| 291 | "has_merges": bool(anchors or slave_cells), |
| 292 | "anchors": anchors, |
| 293 | "slave_cells": slave_cells, |
| 294 | } |
| 295 |