"""apply: edit native PowerPoint chart data on cloned slides. Each referenced chart part is cloned, its ```` caches are rewritten from the plan's categories / series, and the embedded ``.xlsx`` workbook is rebuilt so PowerPoint's "Edit Data" view stays consistent. Chart styling / axes / legend layout are left untouched. """ from __future__ import annotations import io import re import sys import zipfile from typing import Any from xml.etree import ElementTree as ET from .edit_safety import ( _chart_frames, _chart_reference, _require_supported_chart_edit, ) from .ooxml import ( CHART_CONTENT_TYPE, CHART_REL_TYPE, NS, PACKAGE_REL_TYPE, REL_NS, XLSX_CONTENT_TYPE, _normalize_part, _qn, _rels_name_for_part, _shape_identity, _xml_bytes, ) from .package import _add_content_type_override, _find_relationship, _relative_target from .selectors import _chart_selectors def _chart_key_maps(slide_root: ET.Element, source_slide: int) -> dict[str, dict[str, str]]: maps: dict[str, dict[str, str]] = {} for order, container in enumerate(_chart_frames(slide_root), start=1): shape_id, shape_name = _shape_identity(container, order) chart_kind, rel_id = _chart_reference(container) info = { "shape_id": shape_id, "shape_name": shape_name, "rel_id": rel_id, "chart_kind": chart_kind, } maps[f"chart_id:s{source_slide:02d}_ch{shape_id}"] = info maps[f"shape_id:{shape_id}"] = info if shape_name: maps[f"shape_name:{shape_name}"] = info return maps def _max_chart_part_number(entries: dict[str, bytes]) -> int: max_number = 0 pattern = re.compile(r"^ppt/charts/chart(\d+)\.xml$") for name in entries: match = pattern.match(name) if match: max_number = max(max_number, int(match.group(1))) return max_number def _max_embedding_part_number(entries: dict[str, bytes]) -> int: max_number = 0 pattern = re.compile(r"^ppt/embeddings/templateFillChart(\d+)\.xlsx$") for name in entries: match = pattern.match(name) if match: max_number = max(max_number, int(match.group(1))) return max_number def _chart_part_from_relationship(slide_part: str, rel: ET.Element) -> str: target = rel.attrib.get("Target", "") if rel.attrib.get("Type") != CHART_REL_TYPE or not target: raise RuntimeError("Matched chart shape does not point to a chart relationship") return _normalize_part(target, slide_part) def _chart_type_with_series(chart_root: ET.Element) -> ET.Element: plot_area = chart_root.find(".//c:plotArea", NS) if plot_area is None: raise RuntimeError("Chart XML has no plotArea") chart_types: list[ET.Element] = [] for child in list(plot_area): if child.tag.endswith("Chart") and child.findall("c:ser", NS): chart_types.append(child) if len(chart_types) > 1: raise RuntimeError( "template-fill chart edits do not support multi-plot / combination charts; " "use beautify/main pipeline to redraw the chart, or leave the native chart untouched" ) if chart_types: return chart_types[0] raise RuntimeError("Chart XML has no editable series") def _ensure_child(parent: ET.Element, tag: str) -> ET.Element: child = parent.find(tag, NS) if child is not None: return child return ET.SubElement(parent, _qn(NS["c"], tag.split(":", 1)[1])) def _set_val_attr(element: ET.Element, value: str | int | float) -> None: element.set("val", str(value)) def _excel_col(index: int) -> str: result = "" while index: index, remainder = divmod(index - 1, 26) result = chr(65 + remainder) + result return result or "A" def _write_string_cache(cache: ET.Element, values: list[str]) -> None: for child in list(cache): cache.remove(child) pt_count = ET.SubElement(cache, _qn(NS["c"], "ptCount")) _set_val_attr(pt_count, len(values)) for index, value in enumerate(values): pt = ET.SubElement(cache, _qn(NS["c"], "pt"), {"idx": str(index)}) v = ET.SubElement(pt, _qn(NS["c"], "v")) v.text = str(value) def _write_number_cache(cache: ET.Element, values: list[Any]) -> None: for child in list(cache): cache.remove(child) fmt = ET.SubElement(cache, _qn(NS["c"], "formatCode")) fmt.text = "General" pt_count = ET.SubElement(cache, _qn(NS["c"], "ptCount")) _set_val_attr(pt_count, len(values)) for index, value in enumerate(values): pt = ET.SubElement(cache, _qn(NS["c"], "pt"), {"idx": str(index)}) v = ET.SubElement(pt, _qn(NS["c"], "v")) v.text = str(value) def _set_series_name(series: ET.Element, name: str, column_index: int) -> None: tx = _ensure_child(series, "c:tx") for child in list(tx): tx.remove(child) str_ref = ET.SubElement(tx, _qn(NS["c"], "strRef")) formula = ET.SubElement(str_ref, _qn(NS["c"], "f")) formula.text = f"Sheet1!${_excel_col(column_index)}$1" cache = ET.SubElement(str_ref, _qn(NS["c"], "strCache")) _write_string_cache(cache, [name]) def _set_category_cache(series: ET.Element, categories: list[str]) -> None: cat = _ensure_child(series, "c:cat") for child in list(cat): cat.remove(child) str_ref = ET.SubElement(cat, _qn(NS["c"], "strRef")) formula = ET.SubElement(str_ref, _qn(NS["c"], "f")) formula.text = f"Sheet1!$A$2:$A${len(categories) + 1}" cache = ET.SubElement(str_ref, _qn(NS["c"], "strCache")) _write_string_cache(cache, [str(item) for item in categories]) def _set_value_cache(series: ET.Element, values: list[Any], column_index: int) -> None: val = _ensure_child(series, "c:val") for child in list(val): val.remove(child) num_ref = ET.SubElement(val, _qn(NS["c"], "numRef")) formula = ET.SubElement(num_ref, _qn(NS["c"], "f")) formula.text = f"Sheet1!${_excel_col(column_index)}$2:${_excel_col(column_index)}${len(values) + 1}" cache = ET.SubElement(num_ref, _qn(NS["c"], "numCache")) _write_number_cache(cache, values) def _apply_chart_edit_to_chart_xml(chart_root: ET.Element, chart_edit: dict[str, Any]) -> None: capability = _require_supported_chart_edit(chart_root) for warning in capability.get("warnings", []): if not isinstance(warning, dict): continue code = warning.get("code") or "chart_edit_category_flattened" message = warning.get("message") or "chart categories will be flattened" print(f" Warning: {message} [{code}]", file=sys.stderr) categories = [str(item) for item in chart_edit.get("categories", [])] series_payload = chart_edit.get("series", []) if not categories or not isinstance(series_payload, list) or not series_payload: raise RuntimeError("Chart edit requires non-empty categories and series") chart_type = _chart_type_with_series(chart_root) series_nodes = chart_type.findall("c:ser", NS) if not series_nodes: raise RuntimeError("Chart XML has no editable series") template_series = series_nodes[-1] while len(series_nodes) < len(series_payload): clone = ET.fromstring(ET.tostring(template_series, encoding="utf-8")) chart_type.append(clone) series_nodes.append(clone) for extra in series_nodes[len(series_payload) :]: chart_type.remove(extra) series_nodes = chart_type.findall("c:ser", NS) for index, (series, payload) in enumerate(zip(series_nodes, series_payload), start=0): values = payload.get("values", []) if len(values) != len(categories): raise RuntimeError("Chart series values must match categories length") idx = _ensure_child(series, "c:idx") order = _ensure_child(series, "c:order") _set_val_attr(idx, index) _set_val_attr(order, index) _set_series_name(series, str(payload.get("name", f"系列{index + 1}")), index + 2) _set_category_cache(series, categories) _set_value_cache(series, values, index + 2) def _spreadsheet_relationships(xlsx_entries: dict[str, bytes], part_name: str) -> dict[str, str]: rels_name = _rels_name_for_part(part_name) if rels_name not in xlsx_entries: return {} root = ET.fromstring(xlsx_entries[rels_name]) rels: dict[str, str] = {} for rel in root.findall(_qn(REL_NS, "Relationship")): rel_id = rel.attrib.get("Id") target = rel.attrib.get("Target") if rel_id and target: rels[rel_id] = _normalize_part(target, part_name) return rels def _first_workbook_sheet(xlsx_entries: dict[str, bytes]) -> str | None: workbook_part = "xl/workbook.xml" if workbook_part not in xlsx_entries: return None root = ET.fromstring(xlsx_entries[workbook_part]) sheets = root.find("{http://schemas.openxmlformats.org/spreadsheetml/2006/main}sheets") if sheets is None: return None first = next(iter(list(sheets)), None) if first is None: return None rel_id = first.attrib.get(_qn(NS["r"], "id")) if not rel_id: return None return _spreadsheet_relationships(xlsx_entries, workbook_part).get(rel_id) def _spreadsheet_cell_ref(row: int, col: int) -> str: return f"{_excel_col(col)}{row}" def _spreadsheet_cell(value: Any, row: int, col: int) -> ET.Element: cell = ET.Element( "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}c", {"r": _spreadsheet_cell_ref(row, col)}, ) if isinstance(value, (int, float)) and not isinstance(value, bool): v = ET.SubElement(cell, "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}v") v.text = str(value) return cell cell.set("t", "inlineStr") inline = ET.SubElement(cell, "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}is") text = ET.SubElement(inline, "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}t") text.text = str(value) return cell def _rewrite_chart_workbook(xlsx_bytes: bytes, chart_edit: dict[str, Any]) -> bytes: categories = chart_edit.get("categories", []) series_payload = chart_edit.get("series", []) with zipfile.ZipFile(io.BytesIO(xlsx_bytes)) as zin: xlsx_entries = {info.filename: zin.read(info.filename) for info in zin.infolist() if not info.is_dir()} sheet_part = _first_workbook_sheet(xlsx_entries) or "xl/worksheets/sheet1.xml" if sheet_part not in xlsx_entries: return xlsx_bytes sheet_root = ET.fromstring(xlsx_entries[sheet_part]) sheet_ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" sheet_data = sheet_root.find(_qn(sheet_ns, "sheetData")) if sheet_data is None: sheet_data = ET.SubElement(sheet_root, _qn(sheet_ns, "sheetData")) for child in list(sheet_data): sheet_data.remove(child) rows = [["Category"] + [str(item.get("name", f"系列{idx + 1}")) for idx, item in enumerate(series_payload)]] for row_index, category in enumerate(categories): rows.append([category] + [item.get("values", [])[row_index] for item in series_payload]) for row_index, values in enumerate(rows, start=1): row = ET.SubElement(sheet_data, _qn(sheet_ns, "row"), {"r": str(row_index)}) for col_index, value in enumerate(values, start=1): row.append(_spreadsheet_cell(value, row_index, col_index)) xlsx_entries[sheet_part] = _xml_bytes(sheet_root) out_buffer = io.BytesIO() with zipfile.ZipFile(out_buffer, "w", compression=zipfile.ZIP_DEFLATED) as zout: for name, data in xlsx_entries.items(): zout.writestr(name, data) return out_buffer.getvalue() def _find_chart_workbook_rel(chart_rels_root: ET.Element) -> ET.Element | None: for rel in chart_rels_root.findall(_qn(REL_NS, "Relationship")): target = rel.attrib.get("Target", "") if rel.attrib.get("Type") == PACKAGE_REL_TYPE or target.lower().endswith(".xlsx"): return rel return None def _clone_and_update_chart_part( entries: dict[str, bytes], content_root: ET.Element, *, source_chart_part: str, new_chart_part: str, chart_edit: dict[str, Any], next_embedding_number: int, ) -> int: if source_chart_part not in entries: raise RuntimeError(f"Missing chart part: {source_chart_part}") chart_root = ET.fromstring(entries[source_chart_part]) _apply_chart_edit_to_chart_xml(chart_root, chart_edit) entries[new_chart_part] = _xml_bytes(chart_root) _add_content_type_override(content_root, new_chart_part, CHART_CONTENT_TYPE) source_chart_rels = _rels_name_for_part(source_chart_part) if source_chart_rels not in entries: return next_embedding_number new_chart_rels = _rels_name_for_part(new_chart_part) chart_rels_root = ET.fromstring(entries[source_chart_rels]) workbook_rel = _find_chart_workbook_rel(chart_rels_root) if workbook_rel is not None: workbook_target = workbook_rel.attrib.get("Target", "") workbook_part = _normalize_part(workbook_target, source_chart_part) if workbook_part in entries: next_embedding_number += 1 new_workbook_part = f"ppt/embeddings/templateFillChart{next_embedding_number}.xlsx" entries[new_workbook_part] = _rewrite_chart_workbook(entries[workbook_part], chart_edit) workbook_rel.set("Target", _relative_target(new_chart_part, new_workbook_part)) _add_content_type_override(content_root, new_workbook_part, XLSX_CONTENT_TYPE) entries[new_chart_rels] = _xml_bytes(chart_rels_root) return next_embedding_number def _apply_chart_edits_to_slide_package( slide_root: ET.Element, rels_root: ET.Element, entries: dict[str, bytes], content_root: ET.Element, *, source_slide: int, new_slide_part: str, chart_edits: list[dict[str, Any]], next_chart_number: int, next_embedding_number: int, ) -> tuple[int, int]: maps = _chart_key_maps(slide_root, source_slide) cloned_by_rel_id: dict[str, str] = {} errors: list[str] = [] for chart_edit in chart_edits: selectors = _chart_selectors(chart_edit) chart_info = next((maps[key] for key in selectors if key in maps), None) if chart_info is None: if chart_edit.get("optional"): continue errors.append(", ".join(selectors) or "") continue chart_kind = chart_info.get("chart_kind", "") if chart_kind != "classic": code = ( "chart_edit_chartex_unsupported" if chart_kind == "chartex" else "chart_edit_plot_type_unsupported" ) raise RuntimeError( "template-fill chart edits require a supported classic chart " f"[{code}]" ) rel_id = chart_info.get("rel_id", "") rel = _find_relationship(rels_root, rel_id) if rel is None: errors.append(f"{selectors[0] if selectors else ''} relationship={rel_id}") continue if rel_id not in cloned_by_rel_id: next_chart_number += 1 source_chart_part = _chart_part_from_relationship(new_slide_part, rel) new_chart_part = f"ppt/charts/chart{next_chart_number}.xml" next_embedding_number = _clone_and_update_chart_part( entries, content_root, source_chart_part=source_chart_part, new_chart_part=new_chart_part, chart_edit=chart_edit, next_embedding_number=next_embedding_number, ) rel.set("Target", _relative_target(new_slide_part, new_chart_part)) cloned_by_rel_id[rel_id] = new_chart_part continue chart_root = ET.fromstring(entries[cloned_by_rel_id[rel_id]]) _apply_chart_edit_to_chart_xml(chart_root, chart_edit) entries[cloned_by_rel_id[rel_id]] = _xml_bytes(chart_root) if errors: raise RuntimeError(f"Missing chart edit target(s) on slide {source_slide}: {'; '.join(errors)}") return next_chart_number, next_embedding_number