返回 ppt-master
chart_fill.py
1 """apply: edit native PowerPoint chart data on cloned slides.
2
3 Each referenced chart part is cloned, its ``<c:ser>`` caches are rewritten from
4 the plan's categories / series, and the embedded ``.xlsx`` workbook is rebuilt so
5 PowerPoint's "Edit Data" view stays consistent. Chart styling / axes / legend
6 layout are left untouched.
7 """
8
9 from __future__ import annotations
10
11 import io
12 import re
13 import sys
14 import zipfile
15 from typing import Any
16 from xml.etree import ElementTree as ET
17
18 from .edit_safety import (
19 _chart_frames,
20 _chart_reference,
21 _require_supported_chart_edit,
22 )
23 from .ooxml import (
24 CHART_CONTENT_TYPE,
25 CHART_REL_TYPE,
26 NS,
27 PACKAGE_REL_TYPE,
28 REL_NS,
29 XLSX_CONTENT_TYPE,
30 _normalize_part,
31 _qn,
32 _rels_name_for_part,
33 _shape_identity,
34 _xml_bytes,
35 )
36 from .package import _add_content_type_override, _find_relationship, _relative_target
37 from .selectors import _chart_selectors
38
39
40 def _chart_key_maps(slide_root: ET.Element, source_slide: int) -> dict[str, dict[str, str]]:
41 maps: dict[str, dict[str, str]] = {}
42 for order, container in enumerate(_chart_frames(slide_root), start=1):
43 shape_id, shape_name = _shape_identity(container, order)
44 chart_kind, rel_id = _chart_reference(container)
45 info = {
46 "shape_id": shape_id,
47 "shape_name": shape_name,
48 "rel_id": rel_id,
49 "chart_kind": chart_kind,
50 }
51 maps[f"chart_id:s{source_slide:02d}_ch{shape_id}"] = info
52 maps[f"shape_id:{shape_id}"] = info
53 if shape_name:
54 maps[f"shape_name:{shape_name}"] = info
55 return maps
56
57
58 def _max_chart_part_number(entries: dict[str, bytes]) -> int:
59 max_number = 0
60 pattern = re.compile(r"^ppt/charts/chart(\d+)\.xml$")
61 for name in entries:
62 match = pattern.match(name)
63 if match:
64 max_number = max(max_number, int(match.group(1)))
65 return max_number
66
67
68 def _max_embedding_part_number(entries: dict[str, bytes]) -> int:
69 max_number = 0
70 pattern = re.compile(r"^ppt/embeddings/templateFillChart(\d+)\.xlsx$")
71 for name in entries:
72 match = pattern.match(name)
73 if match:
74 max_number = max(max_number, int(match.group(1)))
75 return max_number
76
77
78 def _chart_part_from_relationship(slide_part: str, rel: ET.Element) -> str:
79 target = rel.attrib.get("Target", "")
80 if rel.attrib.get("Type") != CHART_REL_TYPE or not target:
81 raise RuntimeError("Matched chart shape does not point to a chart relationship")
82 return _normalize_part(target, slide_part)
83
84
85 def _chart_type_with_series(chart_root: ET.Element) -> ET.Element:
86 plot_area = chart_root.find(".//c:plotArea", NS)
87 if plot_area is None:
88 raise RuntimeError("Chart XML has no plotArea")
89 chart_types: list[ET.Element] = []
90 for child in list(plot_area):
91 if child.tag.endswith("Chart") and child.findall("c:ser", NS):
92 chart_types.append(child)
93 if len(chart_types) > 1:
94 raise RuntimeError(
95 "template-fill chart edits do not support multi-plot / combination charts; "
96 "use beautify/main pipeline to redraw the chart, or leave the native chart untouched"
97 )
98 if chart_types:
99 return chart_types[0]
100 raise RuntimeError("Chart XML has no editable series")
101
102
103 def _ensure_child(parent: ET.Element, tag: str) -> ET.Element:
104 child = parent.find(tag, NS)
105 if child is not None:
106 return child
107 return ET.SubElement(parent, _qn(NS["c"], tag.split(":", 1)[1]))
108
109
110 def _set_val_attr(element: ET.Element, value: str | int | float) -> None:
111 element.set("val", str(value))
112
113
114 def _excel_col(index: int) -> str:
115 result = ""
116 while index:
117 index, remainder = divmod(index - 1, 26)
118 result = chr(65 + remainder) + result
119 return result or "A"
120
121
122 def _write_string_cache(cache: ET.Element, values: list[str]) -> None:
123 for child in list(cache):
124 cache.remove(child)
125 pt_count = ET.SubElement(cache, _qn(NS["c"], "ptCount"))
126 _set_val_attr(pt_count, len(values))
127 for index, value in enumerate(values):
128 pt = ET.SubElement(cache, _qn(NS["c"], "pt"), {"idx": str(index)})
129 v = ET.SubElement(pt, _qn(NS["c"], "v"))
130 v.text = str(value)
131
132
133 def _write_number_cache(cache: ET.Element, values: list[Any]) -> None:
134 for child in list(cache):
135 cache.remove(child)
136 fmt = ET.SubElement(cache, _qn(NS["c"], "formatCode"))
137 fmt.text = "General"
138 pt_count = ET.SubElement(cache, _qn(NS["c"], "ptCount"))
139 _set_val_attr(pt_count, len(values))
140 for index, value in enumerate(values):
141 pt = ET.SubElement(cache, _qn(NS["c"], "pt"), {"idx": str(index)})
142 v = ET.SubElement(pt, _qn(NS["c"], "v"))
143 v.text = str(value)
144
145
146 def _set_series_name(series: ET.Element, name: str, column_index: int) -> None:
147 tx = _ensure_child(series, "c:tx")
148 for child in list(tx):
149 tx.remove(child)
150 str_ref = ET.SubElement(tx, _qn(NS["c"], "strRef"))
151 formula = ET.SubElement(str_ref, _qn(NS["c"], "f"))
152 formula.text = f"Sheet1!${_excel_col(column_index)}$1"
153 cache = ET.SubElement(str_ref, _qn(NS["c"], "strCache"))
154 _write_string_cache(cache, [name])
155
156
157 def _set_category_cache(series: ET.Element, categories: list[str]) -> None:
158 cat = _ensure_child(series, "c:cat")
159 for child in list(cat):
160 cat.remove(child)
161 str_ref = ET.SubElement(cat, _qn(NS["c"], "strRef"))
162 formula = ET.SubElement(str_ref, _qn(NS["c"], "f"))
163 formula.text = f"Sheet1!$A$2:$A${len(categories) + 1}"
164 cache = ET.SubElement(str_ref, _qn(NS["c"], "strCache"))
165 _write_string_cache(cache, [str(item) for item in categories])
166
167
168 def _set_value_cache(series: ET.Element, values: list[Any], column_index: int) -> None:
169 val = _ensure_child(series, "c:val")
170 for child in list(val):
171 val.remove(child)
172 num_ref = ET.SubElement(val, _qn(NS["c"], "numRef"))
173 formula = ET.SubElement(num_ref, _qn(NS["c"], "f"))
174 formula.text = f"Sheet1!${_excel_col(column_index)}$2:${_excel_col(column_index)}${len(values) + 1}"
175 cache = ET.SubElement(num_ref, _qn(NS["c"], "numCache"))
176 _write_number_cache(cache, values)
177
178
179 def _apply_chart_edit_to_chart_xml(chart_root: ET.Element, chart_edit: dict[str, Any]) -> None:
180 capability = _require_supported_chart_edit(chart_root)
181 for warning in capability.get("warnings", []):
182 if not isinstance(warning, dict):
183 continue
184 code = warning.get("code") or "chart_edit_category_flattened"
185 message = warning.get("message") or "chart categories will be flattened"
186 print(f" Warning: {message} [{code}]", file=sys.stderr)
187 categories = [str(item) for item in chart_edit.get("categories", [])]
188 series_payload = chart_edit.get("series", [])
189 if not categories or not isinstance(series_payload, list) or not series_payload:
190 raise RuntimeError("Chart edit requires non-empty categories and series")
191 chart_type = _chart_type_with_series(chart_root)
192 series_nodes = chart_type.findall("c:ser", NS)
193 if not series_nodes:
194 raise RuntimeError("Chart XML has no editable series")
195 template_series = series_nodes[-1]
196 while len(series_nodes) < len(series_payload):
197 clone = ET.fromstring(ET.tostring(template_series, encoding="utf-8"))
198 chart_type.append(clone)
199 series_nodes.append(clone)
200 for extra in series_nodes[len(series_payload) :]:
201 chart_type.remove(extra)
202 series_nodes = chart_type.findall("c:ser", NS)
203 for index, (series, payload) in enumerate(zip(series_nodes, series_payload), start=0):
204 values = payload.get("values", [])
205 if len(values) != len(categories):
206 raise RuntimeError("Chart series values must match categories length")
207 idx = _ensure_child(series, "c:idx")
208 order = _ensure_child(series, "c:order")
209 _set_val_attr(idx, index)
210 _set_val_attr(order, index)
211 _set_series_name(series, str(payload.get("name", f"系列{index + 1}")), index + 2)
212 _set_category_cache(series, categories)
213 _set_value_cache(series, values, index + 2)
214
215
216 def _spreadsheet_relationships(xlsx_entries: dict[str, bytes], part_name: str) -> dict[str, str]:
217 rels_name = _rels_name_for_part(part_name)
218 if rels_name not in xlsx_entries:
219 return {}
220 root = ET.fromstring(xlsx_entries[rels_name])
221 rels: dict[str, str] = {}
222 for rel in root.findall(_qn(REL_NS, "Relationship")):
223 rel_id = rel.attrib.get("Id")
224 target = rel.attrib.get("Target")
225 if rel_id and target:
226 rels[rel_id] = _normalize_part(target, part_name)
227 return rels
228
229
230 def _first_workbook_sheet(xlsx_entries: dict[str, bytes]) -> str | None:
231 workbook_part = "xl/workbook.xml"
232 if workbook_part not in xlsx_entries:
233 return None
234 root = ET.fromstring(xlsx_entries[workbook_part])
235 sheets = root.find("{http://schemas.openxmlformats.org/spreadsheetml/2006/main}sheets")
236 if sheets is None:
237 return None
238 first = next(iter(list(sheets)), None)
239 if first is None:
240 return None
241 rel_id = first.attrib.get(_qn(NS["r"], "id"))
242 if not rel_id:
243 return None
244 return _spreadsheet_relationships(xlsx_entries, workbook_part).get(rel_id)
245
246
247 def _spreadsheet_cell_ref(row: int, col: int) -> str:
248 return f"{_excel_col(col)}{row}"
249
250
251 def _spreadsheet_cell(value: Any, row: int, col: int) -> ET.Element:
252 cell = ET.Element(
253 "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}c",
254 {"r": _spreadsheet_cell_ref(row, col)},
255 )
256 if isinstance(value, (int, float)) and not isinstance(value, bool):
257 v = ET.SubElement(cell, "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}v")
258 v.text = str(value)
259 return cell
260 cell.set("t", "inlineStr")
261 inline = ET.SubElement(cell, "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}is")
262 text = ET.SubElement(inline, "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}t")
263 text.text = str(value)
264 return cell
265
266
267 def _rewrite_chart_workbook(xlsx_bytes: bytes, chart_edit: dict[str, Any]) -> bytes:
268 categories = chart_edit.get("categories", [])
269 series_payload = chart_edit.get("series", [])
270 with zipfile.ZipFile(io.BytesIO(xlsx_bytes)) as zin:
271 xlsx_entries = {info.filename: zin.read(info.filename) for info in zin.infolist() if not info.is_dir()}
272 sheet_part = _first_workbook_sheet(xlsx_entries) or "xl/worksheets/sheet1.xml"
273 if sheet_part not in xlsx_entries:
274 return xlsx_bytes
275 sheet_root = ET.fromstring(xlsx_entries[sheet_part])
276 sheet_ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
277 sheet_data = sheet_root.find(_qn(sheet_ns, "sheetData"))
278 if sheet_data is None:
279 sheet_data = ET.SubElement(sheet_root, _qn(sheet_ns, "sheetData"))
280 for child in list(sheet_data):
281 sheet_data.remove(child)
282
283 rows = [["Category"] + [str(item.get("name", f"系列{idx + 1}")) for idx, item in enumerate(series_payload)]]
284 for row_index, category in enumerate(categories):
285 rows.append([category] + [item.get("values", [])[row_index] for item in series_payload])
286 for row_index, values in enumerate(rows, start=1):
287 row = ET.SubElement(sheet_data, _qn(sheet_ns, "row"), {"r": str(row_index)})
288 for col_index, value in enumerate(values, start=1):
289 row.append(_spreadsheet_cell(value, row_index, col_index))
290 xlsx_entries[sheet_part] = _xml_bytes(sheet_root)
291
292 out_buffer = io.BytesIO()
293 with zipfile.ZipFile(out_buffer, "w", compression=zipfile.ZIP_DEFLATED) as zout:
294 for name, data in xlsx_entries.items():
295 zout.writestr(name, data)
296 return out_buffer.getvalue()
297
298
299 def _find_chart_workbook_rel(chart_rels_root: ET.Element) -> ET.Element | None:
300 for rel in chart_rels_root.findall(_qn(REL_NS, "Relationship")):
301 target = rel.attrib.get("Target", "")
302 if rel.attrib.get("Type") == PACKAGE_REL_TYPE or target.lower().endswith(".xlsx"):
303 return rel
304 return None
305
306
307 def _clone_and_update_chart_part(
308 entries: dict[str, bytes],
309 content_root: ET.Element,
310 *,
311 source_chart_part: str,
312 new_chart_part: str,
313 chart_edit: dict[str, Any],
314 next_embedding_number: int,
315 ) -> int:
316 if source_chart_part not in entries:
317 raise RuntimeError(f"Missing chart part: {source_chart_part}")
318 chart_root = ET.fromstring(entries[source_chart_part])
319 _apply_chart_edit_to_chart_xml(chart_root, chart_edit)
320 entries[new_chart_part] = _xml_bytes(chart_root)
321 _add_content_type_override(content_root, new_chart_part, CHART_CONTENT_TYPE)
322
323 source_chart_rels = _rels_name_for_part(source_chart_part)
324 if source_chart_rels not in entries:
325 return next_embedding_number
326 new_chart_rels = _rels_name_for_part(new_chart_part)
327 chart_rels_root = ET.fromstring(entries[source_chart_rels])
328 workbook_rel = _find_chart_workbook_rel(chart_rels_root)
329 if workbook_rel is not None:
330 workbook_target = workbook_rel.attrib.get("Target", "")
331 workbook_part = _normalize_part(workbook_target, source_chart_part)
332 if workbook_part in entries:
333 next_embedding_number += 1
334 new_workbook_part = f"ppt/embeddings/templateFillChart{next_embedding_number}.xlsx"
335 entries[new_workbook_part] = _rewrite_chart_workbook(entries[workbook_part], chart_edit)
336 workbook_rel.set("Target", _relative_target(new_chart_part, new_workbook_part))
337 _add_content_type_override(content_root, new_workbook_part, XLSX_CONTENT_TYPE)
338 entries[new_chart_rels] = _xml_bytes(chart_rels_root)
339 return next_embedding_number
340
341
342 def _apply_chart_edits_to_slide_package(
343 slide_root: ET.Element,
344 rels_root: ET.Element,
345 entries: dict[str, bytes],
346 content_root: ET.Element,
347 *,
348 source_slide: int,
349 new_slide_part: str,
350 chart_edits: list[dict[str, Any]],
351 next_chart_number: int,
352 next_embedding_number: int,
353 ) -> tuple[int, int]:
354 maps = _chart_key_maps(slide_root, source_slide)
355 cloned_by_rel_id: dict[str, str] = {}
356 errors: list[str] = []
357 for chart_edit in chart_edits:
358 selectors = _chart_selectors(chart_edit)
359 chart_info = next((maps[key] for key in selectors if key in maps), None)
360 if chart_info is None:
361 if chart_edit.get("optional"):
362 continue
363 errors.append(", ".join(selectors) or "<missing selector>")
364 continue
365 chart_kind = chart_info.get("chart_kind", "")
366 if chart_kind != "classic":
367 code = (
368 "chart_edit_chartex_unsupported"
369 if chart_kind == "chartex"
370 else "chart_edit_plot_type_unsupported"
371 )
372 raise RuntimeError(
373 "template-fill chart edits require a supported classic chart "
374 f"[{code}]"
375 )
376 rel_id = chart_info.get("rel_id", "")
377 rel = _find_relationship(rels_root, rel_id)
378 if rel is None:
379 errors.append(f"{selectors[0] if selectors else '<chart>'} relationship={rel_id}")
380 continue
381 if rel_id not in cloned_by_rel_id:
382 next_chart_number += 1
383 source_chart_part = _chart_part_from_relationship(new_slide_part, rel)
384 new_chart_part = f"ppt/charts/chart{next_chart_number}.xml"
385 next_embedding_number = _clone_and_update_chart_part(
386 entries,
387 content_root,
388 source_chart_part=source_chart_part,
389 new_chart_part=new_chart_part,
390 chart_edit=chart_edit,
391 next_embedding_number=next_embedding_number,
392 )
393 rel.set("Target", _relative_target(new_slide_part, new_chart_part))
394 cloned_by_rel_id[rel_id] = new_chart_part
395 continue
396 chart_root = ET.fromstring(entries[cloned_by_rel_id[rel_id]])
397 _apply_chart_edit_to_chart_xml(chart_root, chart_edit)
398 entries[cloned_by_rel_id[rel_id]] = _xml_bytes(chart_root)
399 if errors:
400 raise RuntimeError(f"Missing chart edit target(s) on slide {source_slide}: {'; '.join(errors)}")
401 return next_chart_number, next_embedding_number
402
402 lines PYTHON