返回 ppt-master
edit_safety.py
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
295 lines PYTHON