| 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 |