| 1 | """apply: clone selected source slides into a new PPTX and write replacements. |
| 2 | |
| 3 | Orchestrates the per-stage helpers (text / table / chart / transition / notes) |
| 4 | and rebuilds the presentation slide list, relationships, and content types. |
| 5 | """ |
| 6 | |
| 7 | from __future__ import annotations |
| 8 | |
| 9 | import tempfile |
| 10 | import zipfile |
| 11 | from pathlib import Path |
| 12 | from typing import Any |
| 13 | from xml.etree import ElementTree as ET |
| 14 | |
| 15 | from pptx_animations import ( |
| 16 | object_animation_fingerprint, |
| 17 | validate_pptx_animation_package, |
| 18 | ) |
| 19 | from pptx_transitions import ( |
| 20 | parse_source_xml, |
| 21 | serialize_source_xml, |
| 22 | set_package_use_timings, |
| 23 | validate_pptx_transition_package, |
| 24 | ) |
| 25 | |
| 26 | from .chart_fill import ( |
| 27 | _apply_chart_edits_to_slide_package, |
| 28 | _max_chart_part_number, |
| 29 | _max_embedding_part_number, |
| 30 | ) |
| 31 | from .clone import _make_part_allocator, deep_clone_slide_private_parts |
| 32 | from .notes import _find_notes_master_target, _slide_rels_with_notes |
| 33 | from .ooxml import NS, REL_NS, SLIDE_REL_TYPE, _parse_slide_refs, _qn, _xml_bytes |
| 34 | from .package import ( |
| 35 | _add_notes_override, |
| 36 | _add_slide_override, |
| 37 | _content_type_root, |
| 38 | _empty_relationships_root, |
| 39 | _max_numeric_rid, |
| 40 | _max_slide_id, |
| 41 | _max_slide_part_number, |
| 42 | _prune_unreferenced_parts, |
| 43 | ) |
| 44 | from .table_fill import _apply_table_edits_to_slide |
| 45 | from .text_fill import _apply_replacements_to_slide |
| 46 | from .transitions import ( |
| 47 | DEFAULT_TRANSITION, |
| 48 | DEFAULT_TRANSITION_DURATION, |
| 49 | _resolve_slide_transition, |
| 50 | _set_slide_transition, |
| 51 | ) |
| 52 | |
| 53 | |
| 54 | def apply_plan( |
| 55 | pptx_path: Path, |
| 56 | plan: dict[str, Any], |
| 57 | output_path: Path, |
| 58 | *, |
| 59 | transition: str | None = DEFAULT_TRANSITION, |
| 60 | transition_duration: float = DEFAULT_TRANSITION_DURATION, |
| 61 | ) -> None: |
| 62 | """Create a filled PPTX by cloning selected source slides and replacing text.""" |
| 63 | plan_slides = plan.get("slides") |
| 64 | if not isinstance(plan_slides, list) or not plan_slides: |
| 65 | raise RuntimeError("Plan must contain a non-empty 'slides' list") |
| 66 | |
| 67 | with zipfile.ZipFile(pptx_path) as zf: |
| 68 | entries = {info.filename: zf.read(info.filename) for info in zf.infolist() if not info.is_dir()} |
| 69 | slide_refs = {slide.index: slide for slide in _parse_slide_refs(zf)} |
| 70 | notes_master_target = _find_notes_master_target(entries) |
| 71 | |
| 72 | pres_root = ET.fromstring(entries["ppt/presentation.xml"]) |
| 73 | pres_rels_root = ET.fromstring(entries["ppt/_rels/presentation.xml.rels"]) |
| 74 | content_root = _content_type_root(ET.fromstring(entries["[Content_Types].xml"])) |
| 75 | sld_id_lst = pres_root.find("p:sldIdLst", NS) |
| 76 | if sld_id_lst is None: |
| 77 | sld_id_lst = ET.SubElement(pres_root, _qn(NS["p"], "sldIdLst")) |
| 78 | |
| 79 | for child in list(sld_id_lst): |
| 80 | sld_id_lst.remove(child) |
| 81 | for rel in list(pres_rels_root.findall(_qn(REL_NS, "Relationship"))): |
| 82 | if rel.attrib.get("Type") == SLIDE_REL_TYPE: |
| 83 | pres_rels_root.remove(rel) |
| 84 | |
| 85 | next_slide_number = _max_slide_part_number(entries) + 1 |
| 86 | next_slide_id = _max_slide_id(sld_id_lst) + 1 |
| 87 | next_rel_number = _max_numeric_rid(pres_rels_root) + 1 |
| 88 | next_chart_number = _max_chart_part_number(entries) |
| 89 | next_embedding_number = _max_embedding_part_number(entries) |
| 90 | allocate_part = _make_part_allocator(entries) |
| 91 | wrote_auto_advance = False |
| 92 | |
| 93 | for offset, item in enumerate(plan_slides): |
| 94 | source_slide = int(item.get("source_slide", 0)) |
| 95 | if source_slide not in slide_refs: |
| 96 | raise RuntimeError(f"Plan references a missing source slide: {source_slide}") |
| 97 | source_ref = slide_refs[source_slide] |
| 98 | new_slide_number = next_slide_number + offset |
| 99 | new_part = f"ppt/slides/slide{new_slide_number}.xml" |
| 100 | new_rels = f"ppt/slides/_rels/slide{new_slide_number}.xml.rels" |
| 101 | new_rid = f"rId{next_rel_number + offset}" |
| 102 | |
| 103 | source_slide_xml = entries[source_ref.part_name] |
| 104 | source_animation_fingerprint = object_animation_fingerprint( |
| 105 | source_slide_xml |
| 106 | ) |
| 107 | slide_root = parse_source_xml(source_slide_xml) |
| 108 | replacements = item.get("replacements", []) |
| 109 | if not isinstance(replacements, list): |
| 110 | raise RuntimeError(f"Slide {source_slide} replacements must be a list") |
| 111 | _apply_replacements_to_slide( |
| 112 | slide_root, |
| 113 | source_slide=source_slide, |
| 114 | replacements=replacements, |
| 115 | ) |
| 116 | table_edits = item.get("table_edits", []) |
| 117 | if not isinstance(table_edits, list): |
| 118 | raise RuntimeError(f"Slide {source_slide} table_edits must be a list") |
| 119 | _apply_table_edits_to_slide( |
| 120 | slide_root, |
| 121 | source_slide=source_slide, |
| 122 | table_edits=table_edits, |
| 123 | ) |
| 124 | ( |
| 125 | slide_effect, |
| 126 | slide_effect_options, |
| 127 | slide_duration, |
| 128 | slide_advance, |
| 129 | ) = _resolve_slide_transition( |
| 130 | item, |
| 131 | default_effect=transition, |
| 132 | default_duration=transition_duration, |
| 133 | ) |
| 134 | slide_has_auto_advance = _set_slide_transition( |
| 135 | slide_root, |
| 136 | effect=slide_effect, |
| 137 | effect_options=slide_effect_options, |
| 138 | duration=slide_duration, |
| 139 | advance_after=slide_advance, |
| 140 | ) |
| 141 | if slide_advance is not None and slide_has_auto_advance: |
| 142 | wrote_auto_advance = True |
| 143 | |
| 144 | source_rels = entries.get(source_ref.rels_name) |
| 145 | slide_rels_root = ET.fromstring(source_rels) if source_rels else _empty_relationships_root() |
| 146 | deep_clone_slide_private_parts( |
| 147 | slide_rels_root, |
| 148 | new_slide_part=new_part, |
| 149 | entries=entries, |
| 150 | content_root=content_root, |
| 151 | allocate=allocate_part, |
| 152 | ) |
| 153 | chart_edits = item.get("chart_edits", []) |
| 154 | if not isinstance(chart_edits, list): |
| 155 | raise RuntimeError(f"Slide {source_slide} chart_edits must be a list") |
| 156 | next_chart_number, next_embedding_number = _apply_chart_edits_to_slide_package( |
| 157 | slide_root, |
| 158 | slide_rels_root, |
| 159 | entries, |
| 160 | content_root, |
| 161 | source_slide=source_slide, |
| 162 | new_slide_part=new_part, |
| 163 | chart_edits=chart_edits, |
| 164 | next_chart_number=next_chart_number, |
| 165 | next_embedding_number=next_embedding_number, |
| 166 | ) |
| 167 | try: |
| 168 | serialized_slide = serialize_source_xml( |
| 169 | slide_root, |
| 170 | source_slide_xml, |
| 171 | ) |
| 172 | except ValueError as exc: |
| 173 | raise RuntimeError(str(exc)) from exc |
| 174 | if ( |
| 175 | object_animation_fingerprint(serialized_slide) |
| 176 | != source_animation_fingerprint |
| 177 | ): |
| 178 | raise RuntimeError( |
| 179 | f'Slide {source_slide} object animations changed during template fill' |
| 180 | ) |
| 181 | entries[new_part] = serialized_slide |
| 182 | notes_text = str(item.get("notes") or item.get("speaker_notes") or "") |
| 183 | entries[new_rels], note_entries = _slide_rels_with_notes( |
| 184 | _xml_bytes(slide_rels_root), |
| 185 | slide_number=new_slide_number, |
| 186 | notes_text=notes_text, |
| 187 | notes_master_target=notes_master_target, |
| 188 | ) |
| 189 | entries.update(note_entries) |
| 190 | _add_slide_override(content_root, new_part) |
| 191 | if note_entries: |
| 192 | _add_notes_override(content_root, f"ppt/notesSlides/notesSlide{new_slide_number}.xml") |
| 193 | |
| 194 | ET.SubElement( |
| 195 | pres_rels_root, |
| 196 | _qn(REL_NS, "Relationship"), |
| 197 | { |
| 198 | "Id": new_rid, |
| 199 | "Type": SLIDE_REL_TYPE, |
| 200 | "Target": f"slides/slide{new_slide_number}.xml", |
| 201 | }, |
| 202 | ) |
| 203 | ET.SubElement( |
| 204 | sld_id_lst, |
| 205 | _qn(NS["p"], "sldId"), |
| 206 | {"id": str(next_slide_id + offset), _qn(NS["r"], "id"): new_rid}, |
| 207 | ) |
| 208 | |
| 209 | entries["ppt/presentation.xml"] = _xml_bytes(pres_root) |
| 210 | entries["ppt/_rels/presentation.xml.rels"] = _xml_bytes(pres_rels_root) |
| 211 | _prune_unreferenced_parts(entries, content_root) |
| 212 | entries["[Content_Types].xml"] = _xml_bytes(content_root) |
| 213 | if wrote_auto_advance: |
| 214 | try: |
| 215 | set_package_use_timings(entries) |
| 216 | except ValueError as exc: |
| 217 | raise RuntimeError(str(exc)) from exc |
| 218 | |
| 219 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 220 | with tempfile.TemporaryDirectory( |
| 221 | prefix="template-fill-pptx-", |
| 222 | dir=output_path.parent, |
| 223 | ) as temp_dir: |
| 224 | candidate_path = Path(temp_dir) / output_path.name |
| 225 | with zipfile.ZipFile( |
| 226 | candidate_path, |
| 227 | "w", |
| 228 | compression=zipfile.ZIP_DEFLATED, |
| 229 | ) as out: |
| 230 | for name, data in entries.items(): |
| 231 | out.writestr(name, data) |
| 232 | try: |
| 233 | validate_pptx_transition_package( |
| 234 | candidate_path, |
| 235 | require_use_timings=wrote_auto_advance, |
| 236 | ) |
| 237 | except ValueError as exc: |
| 238 | raise RuntimeError( |
| 239 | f"PPTX transition package validation failed: {exc}" |
| 240 | ) from exc |
| 241 | try: |
| 242 | validate_pptx_animation_package( |
| 243 | candidate_path, |
| 244 | require_supported_effects=False, |
| 245 | ) |
| 246 | except ValueError as exc: |
| 247 | raise RuntimeError( |
| 248 | f"PPTX animation/timing package validation failed: {exc}" |
| 249 | ) from exc |
| 250 | candidate_path.replace(output_path) |
| 251 |