| 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 dataclasses import dataclass |
| 12 | from pathlib import Path |
| 13 | from typing import Any |
| 14 | from xml.etree import ElementTree as ET |
| 15 | |
| 16 | from hyperlink_contract import SLIDE_JUMP_ACTION |
| 17 | from pptx_animations import ( |
| 18 | object_animation_fingerprint, |
| 19 | validate_pptx_animation_package, |
| 20 | ) |
| 21 | from pptx_transitions import ( |
| 22 | parse_source_xml, |
| 23 | serialize_source_xml, |
| 24 | set_package_use_timings, |
| 25 | validate_pptx_transition_package, |
| 26 | ) |
| 27 | |
| 28 | from .chart_fill import ( |
| 29 | _apply_chart_edits_to_slide_package, |
| 30 | _max_chart_part_number, |
| 31 | _max_embedding_part_number, |
| 32 | ) |
| 33 | from .clone import _make_part_allocator, deep_clone_slide_private_parts |
| 34 | from .notes import _find_notes_master_target, _slide_rels_with_notes |
| 35 | from .ooxml import ( |
| 36 | NS, |
| 37 | REL_NS, |
| 38 | SLIDE_REL_TYPE, |
| 39 | SlideRef, |
| 40 | _normalize_part, |
| 41 | _parse_slide_refs, |
| 42 | _qn, |
| 43 | _rels_name_for_part, |
| 44 | _xml_bytes, |
| 45 | ) |
| 46 | from .package import ( |
| 47 | _add_notes_override, |
| 48 | _add_slide_override, |
| 49 | _content_type_root, |
| 50 | _empty_relationships_root, |
| 51 | _max_numeric_rid, |
| 52 | _max_slide_id, |
| 53 | _max_slide_part_number, |
| 54 | _prune_unreferenced_parts, |
| 55 | _relative_target, |
| 56 | ) |
| 57 | from .table_fill import _apply_table_edits_to_slide |
| 58 | from .text_fill import _apply_replacements_to_slide |
| 59 | from .transitions import ( |
| 60 | DEFAULT_TRANSITION, |
| 61 | DEFAULT_TRANSITION_DURATION, |
| 62 | _resolve_slide_transition, |
| 63 | _set_slide_transition, |
| 64 | ) |
| 65 | |
| 66 | |
| 67 | @dataclass(frozen=True) |
| 68 | class _PlannedSlideClone: |
| 69 | """One output slide resolved before any source relationship is rewritten.""" |
| 70 | |
| 71 | offset: int |
| 72 | item: dict[str, Any] |
| 73 | source_slide: int |
| 74 | source_ref: SlideRef |
| 75 | slide_number: int |
| 76 | part_name: str |
| 77 | rels_name: str |
| 78 | presentation_rid: str |
| 79 | |
| 80 | |
| 81 | def _build_clone_roster( |
| 82 | plan_slides: list[Any], |
| 83 | slide_refs: dict[int, SlideRef], |
| 84 | *, |
| 85 | next_slide_number: int, |
| 86 | next_rel_number: int, |
| 87 | ) -> list[_PlannedSlideClone]: |
| 88 | """Resolve every planned output slide before cloning begins.""" |
| 89 | roster: list[_PlannedSlideClone] = [] |
| 90 | for offset, raw_item in enumerate(plan_slides): |
| 91 | if not isinstance(raw_item, dict): |
| 92 | raise RuntimeError(f"Plan slide {offset + 1} must be an object") |
| 93 | source_slide = int(raw_item.get("source_slide", 0)) |
| 94 | if source_slide not in slide_refs: |
| 95 | raise RuntimeError(f"Plan references a missing source slide: {source_slide}") |
| 96 | slide_number = next_slide_number + offset |
| 97 | roster.append( |
| 98 | _PlannedSlideClone( |
| 99 | offset=offset, |
| 100 | item=raw_item, |
| 101 | source_slide=source_slide, |
| 102 | source_ref=slide_refs[source_slide], |
| 103 | slide_number=slide_number, |
| 104 | part_name=f"ppt/slides/slide{slide_number}.xml", |
| 105 | rels_name=f"ppt/slides/_rels/slide{slide_number}.xml.rels", |
| 106 | presentation_rid=f"rId{next_rel_number + offset}", |
| 107 | ) |
| 108 | ) |
| 109 | return roster |
| 110 | |
| 111 | |
| 112 | _SLIDE_LAYOUT_REL_TYPE = ( |
| 113 | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" |
| 114 | ) |
| 115 | _SLIDE_MASTER_REL_TYPE = ( |
| 116 | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" |
| 117 | ) |
| 118 | |
| 119 | |
| 120 | def _slide_jump_relationship_ids(part_root: ET.Element) -> set[str]: |
| 121 | """Return relationship ids used by actual same-deck click actions.""" |
| 122 | relationship_attr = _qn(NS["r"], "id") |
| 123 | return { |
| 124 | relationship_id |
| 125 | for link in part_root.iter(_qn(NS["a"], "hlinkClick")) |
| 126 | if (link.attrib.get("action") or "").strip() == SLIDE_JUMP_ACTION |
| 127 | if (relationship_id := (link.attrib.get(relationship_attr) or "").strip()) |
| 128 | } |
| 129 | |
| 130 | |
| 131 | def _remap_slide_jump_relationships( |
| 132 | part_root: ET.Element, |
| 133 | relationships_root: ET.Element, |
| 134 | *, |
| 135 | source_owner_part: str, |
| 136 | output_owner_part: str, |
| 137 | owner_label: str, |
| 138 | outputs_by_source_part: dict[str, list[str]], |
| 139 | source_ref_by_part: dict[str, SlideRef], |
| 140 | self_source_part: str | None = None, |
| 141 | self_output_part: str | None = None, |
| 142 | ) -> bool: |
| 143 | """Point referenced slide-jump relationships at final output slides. |
| 144 | |
| 145 | Unreferenced ``/slide`` relationships are left untouched. A cloned slide's |
| 146 | self-link follows that clone; shared layout/master links require one unique |
| 147 | output for the referenced source slide. |
| 148 | """ |
| 149 | relationship_ids = _slide_jump_relationship_ids(part_root) |
| 150 | relationships = { |
| 151 | rel.attrib.get("Id", ""): rel |
| 152 | for rel in relationships_root.findall(_qn(REL_NS, "Relationship")) |
| 153 | } |
| 154 | changed = False |
| 155 | for relationship_id in sorted(relationship_ids): |
| 156 | rel = relationships.get(relationship_id) |
| 157 | if rel is None: |
| 158 | raise RuntimeError( |
| 159 | f"{owner_label} has a slide jump with missing relationship " |
| 160 | f"{relationship_id!r}" |
| 161 | ) |
| 162 | if rel.attrib.get("Type") != SLIDE_REL_TYPE: |
| 163 | raise RuntimeError( |
| 164 | f"{owner_label} slide jump {relationship_id!r} does not use " |
| 165 | "a slide relationship" |
| 166 | ) |
| 167 | if rel.attrib.get("TargetMode") == "External": |
| 168 | raise RuntimeError( |
| 169 | f"{owner_label} has an external slide relationship" |
| 170 | ) |
| 171 | target = rel.attrib.get("Target") |
| 172 | if not target: |
| 173 | raise RuntimeError( |
| 174 | f"{owner_label} has a slide relationship without a target" |
| 175 | ) |
| 176 | source_target_part = _normalize_part(target, source_owner_part) |
| 177 | if ( |
| 178 | self_source_part is not None |
| 179 | and self_output_part is not None |
| 180 | and source_target_part == self_source_part |
| 181 | ): |
| 182 | output_target_part = self_output_part |
| 183 | else: |
| 184 | output_targets = outputs_by_source_part.get(source_target_part, []) |
| 185 | source_target_ref = source_ref_by_part.get(source_target_part) |
| 186 | target_label = ( |
| 187 | f"source slide {source_target_ref.index}" |
| 188 | if source_target_ref is not None |
| 189 | else source_target_part |
| 190 | ) |
| 191 | if not output_targets: |
| 192 | raise RuntimeError( |
| 193 | f"{owner_label} links to omitted {target_label}; " |
| 194 | "include the target exactly once or remove the link" |
| 195 | ) |
| 196 | if len(output_targets) > 1: |
| 197 | raise RuntimeError( |
| 198 | f"{owner_label} links to repeated {target_label}; " |
| 199 | "the output target is ambiguous" |
| 200 | ) |
| 201 | output_target_part = output_targets[0] |
| 202 | output_target = _relative_target(output_owner_part, output_target_part) |
| 203 | if rel.attrib.get("Target") != output_target: |
| 204 | rel.set("Target", output_target) |
| 205 | changed = True |
| 206 | return changed |
| 207 | |
| 208 | |
| 209 | def _remap_reachable_shared_layer_slide_jumps( |
| 210 | entries: dict[str, bytes], |
| 211 | clone_roster: list[_PlannedSlideClone], |
| 212 | *, |
| 213 | outputs_by_source_part: dict[str, list[str]], |
| 214 | source_ref_by_part: dict[str, SlideRef], |
| 215 | ) -> None: |
| 216 | """Remap links inherited from layouts and masters used by output slides.""" |
| 217 | pending: list[str] = [] |
| 218 | for clone in clone_roster: |
| 219 | rels_data = entries.get(clone.rels_name) |
| 220 | if not rels_data: |
| 221 | continue |
| 222 | rels_root = ET.fromstring(rels_data) |
| 223 | for rel in rels_root.findall(_qn(REL_NS, "Relationship")): |
| 224 | if rel.attrib.get("Type") != _SLIDE_LAYOUT_REL_TYPE: |
| 225 | continue |
| 226 | target = rel.attrib.get("Target") |
| 227 | if target: |
| 228 | pending.append(_normalize_part(target, clone.part_name)) |
| 229 | |
| 230 | visited: set[str] = set() |
| 231 | while pending: |
| 232 | part_name = pending.pop() |
| 233 | if part_name in visited: |
| 234 | continue |
| 235 | visited.add(part_name) |
| 236 | part_data = entries.get(part_name) |
| 237 | rels_name = _rels_name_for_part(part_name) |
| 238 | rels_data = entries.get(rels_name) |
| 239 | if not part_data or not rels_data: |
| 240 | continue |
| 241 | part_root = ET.fromstring(part_data) |
| 242 | rels_root = ET.fromstring(rels_data) |
| 243 | changed = _remap_slide_jump_relationships( |
| 244 | part_root, |
| 245 | rels_root, |
| 246 | source_owner_part=part_name, |
| 247 | output_owner_part=part_name, |
| 248 | owner_label=part_name, |
| 249 | outputs_by_source_part=outputs_by_source_part, |
| 250 | source_ref_by_part=source_ref_by_part, |
| 251 | ) |
| 252 | if changed: |
| 253 | entries[rels_name] = _xml_bytes(rels_root) |
| 254 | for rel in rels_root.findall(_qn(REL_NS, "Relationship")): |
| 255 | if rel.attrib.get("Type") != _SLIDE_MASTER_REL_TYPE: |
| 256 | continue |
| 257 | target = rel.attrib.get("Target") |
| 258 | if target: |
| 259 | pending.append(_normalize_part(target, part_name)) |
| 260 | |
| 261 | |
| 262 | def apply_plan( |
| 263 | pptx_path: Path, |
| 264 | plan: dict[str, Any], |
| 265 | output_path: Path, |
| 266 | *, |
| 267 | transition: str | None = DEFAULT_TRANSITION, |
| 268 | transition_duration: float = DEFAULT_TRANSITION_DURATION, |
| 269 | ) -> None: |
| 270 | """Create a filled PPTX by cloning selected source slides and replacing text.""" |
| 271 | plan_slides = plan.get("slides") |
| 272 | if not isinstance(plan_slides, list) or not plan_slides: |
| 273 | raise RuntimeError("Plan must contain a non-empty 'slides' list") |
| 274 | |
| 275 | with zipfile.ZipFile(pptx_path) as zf: |
| 276 | entries = {info.filename: zf.read(info.filename) for info in zf.infolist() if not info.is_dir()} |
| 277 | slide_refs = {slide.index: slide for slide in _parse_slide_refs(zf)} |
| 278 | notes_master_target = _find_notes_master_target(entries) |
| 279 | |
| 280 | pres_root = ET.fromstring(entries["ppt/presentation.xml"]) |
| 281 | pres_rels_root = ET.fromstring(entries["ppt/_rels/presentation.xml.rels"]) |
| 282 | content_root = _content_type_root(ET.fromstring(entries["[Content_Types].xml"])) |
| 283 | sld_id_lst = pres_root.find("p:sldIdLst", NS) |
| 284 | if sld_id_lst is None: |
| 285 | sld_id_lst = ET.SubElement(pres_root, _qn(NS["p"], "sldIdLst")) |
| 286 | |
| 287 | for child in list(sld_id_lst): |
| 288 | sld_id_lst.remove(child) |
| 289 | for rel in list(pres_rels_root.findall(_qn(REL_NS, "Relationship"))): |
| 290 | if rel.attrib.get("Type") == SLIDE_REL_TYPE: |
| 291 | pres_rels_root.remove(rel) |
| 292 | |
| 293 | next_slide_number = _max_slide_part_number(entries) + 1 |
| 294 | next_slide_id = _max_slide_id(sld_id_lst) + 1 |
| 295 | next_rel_number = _max_numeric_rid(pres_rels_root) + 1 |
| 296 | next_chart_number = _max_chart_part_number(entries) |
| 297 | next_embedding_number = _max_embedding_part_number(entries) |
| 298 | allocate_part = _make_part_allocator(entries) |
| 299 | wrote_auto_advance = False |
| 300 | |
| 301 | clone_roster = _build_clone_roster( |
| 302 | plan_slides, |
| 303 | slide_refs, |
| 304 | next_slide_number=next_slide_number, |
| 305 | next_rel_number=next_rel_number, |
| 306 | ) |
| 307 | source_ref_by_part = {ref.part_name: ref for ref in slide_refs.values()} |
| 308 | outputs_by_source_part: dict[str, list[str]] = {} |
| 309 | for clone in clone_roster: |
| 310 | outputs_by_source_part.setdefault(clone.source_ref.part_name, []).append(clone.part_name) |
| 311 | |
| 312 | for clone in clone_roster: |
| 313 | item = clone.item |
| 314 | source_slide = clone.source_slide |
| 315 | source_ref = clone.source_ref |
| 316 | new_slide_number = clone.slide_number |
| 317 | new_part = clone.part_name |
| 318 | new_rels = clone.rels_name |
| 319 | new_rid = clone.presentation_rid |
| 320 | |
| 321 | source_slide_xml = entries[source_ref.part_name] |
| 322 | source_animation_fingerprint = object_animation_fingerprint( |
| 323 | source_slide_xml |
| 324 | ) |
| 325 | slide_root = parse_source_xml(source_slide_xml) |
| 326 | replacements = item.get("replacements", []) |
| 327 | if not isinstance(replacements, list): |
| 328 | raise RuntimeError(f"Slide {source_slide} replacements must be a list") |
| 329 | _apply_replacements_to_slide( |
| 330 | slide_root, |
| 331 | source_slide=source_slide, |
| 332 | replacements=replacements, |
| 333 | ) |
| 334 | table_edits = item.get("table_edits", []) |
| 335 | if not isinstance(table_edits, list): |
| 336 | raise RuntimeError(f"Slide {source_slide} table_edits must be a list") |
| 337 | _apply_table_edits_to_slide( |
| 338 | slide_root, |
| 339 | source_slide=source_slide, |
| 340 | table_edits=table_edits, |
| 341 | ) |
| 342 | ( |
| 343 | slide_effect, |
| 344 | slide_effect_options, |
| 345 | slide_duration, |
| 346 | slide_advance, |
| 347 | ) = _resolve_slide_transition( |
| 348 | item, |
| 349 | default_effect=transition, |
| 350 | default_duration=transition_duration, |
| 351 | ) |
| 352 | slide_has_auto_advance = _set_slide_transition( |
| 353 | slide_root, |
| 354 | effect=slide_effect, |
| 355 | effect_options=slide_effect_options, |
| 356 | duration=slide_duration, |
| 357 | advance_after=slide_advance, |
| 358 | ) |
| 359 | if slide_advance is not None and slide_has_auto_advance: |
| 360 | wrote_auto_advance = True |
| 361 | |
| 362 | source_rels = entries.get(source_ref.rels_name) |
| 363 | slide_rels_root = ET.fromstring(source_rels) if source_rels else _empty_relationships_root() |
| 364 | _remap_slide_jump_relationships( |
| 365 | slide_root, |
| 366 | slide_rels_root, |
| 367 | source_owner_part=source_ref.part_name, |
| 368 | output_owner_part=new_part, |
| 369 | owner_label=f"Source slide {source_slide}", |
| 370 | outputs_by_source_part=outputs_by_source_part, |
| 371 | source_ref_by_part=source_ref_by_part, |
| 372 | self_source_part=source_ref.part_name, |
| 373 | self_output_part=new_part, |
| 374 | ) |
| 375 | deep_clone_slide_private_parts( |
| 376 | slide_rels_root, |
| 377 | new_slide_part=new_part, |
| 378 | entries=entries, |
| 379 | content_root=content_root, |
| 380 | allocate=allocate_part, |
| 381 | ) |
| 382 | chart_edits = item.get("chart_edits", []) |
| 383 | if not isinstance(chart_edits, list): |
| 384 | raise RuntimeError(f"Slide {source_slide} chart_edits must be a list") |
| 385 | next_chart_number, next_embedding_number = _apply_chart_edits_to_slide_package( |
| 386 | slide_root, |
| 387 | slide_rels_root, |
| 388 | entries, |
| 389 | content_root, |
| 390 | source_slide=source_slide, |
| 391 | new_slide_part=new_part, |
| 392 | chart_edits=chart_edits, |
| 393 | next_chart_number=next_chart_number, |
| 394 | next_embedding_number=next_embedding_number, |
| 395 | ) |
| 396 | try: |
| 397 | serialized_slide = serialize_source_xml( |
| 398 | slide_root, |
| 399 | source_slide_xml, |
| 400 | ) |
| 401 | except ValueError as exc: |
| 402 | raise RuntimeError(str(exc)) from exc |
| 403 | if ( |
| 404 | object_animation_fingerprint(serialized_slide) |
| 405 | != source_animation_fingerprint |
| 406 | ): |
| 407 | raise RuntimeError( |
| 408 | f'Slide {source_slide} object animations changed during template fill' |
| 409 | ) |
| 410 | entries[new_part] = serialized_slide |
| 411 | notes_text = str(item.get("notes") or item.get("speaker_notes") or "") |
| 412 | entries[new_rels], note_entries = _slide_rels_with_notes( |
| 413 | _xml_bytes(slide_rels_root), |
| 414 | slide_number=new_slide_number, |
| 415 | notes_text=notes_text, |
| 416 | notes_master_target=notes_master_target, |
| 417 | ) |
| 418 | entries.update(note_entries) |
| 419 | _add_slide_override(content_root, new_part) |
| 420 | if note_entries: |
| 421 | _add_notes_override(content_root, f"ppt/notesSlides/notesSlide{new_slide_number}.xml") |
| 422 | |
| 423 | ET.SubElement( |
| 424 | pres_rels_root, |
| 425 | _qn(REL_NS, "Relationship"), |
| 426 | { |
| 427 | "Id": new_rid, |
| 428 | "Type": SLIDE_REL_TYPE, |
| 429 | "Target": f"slides/slide{new_slide_number}.xml", |
| 430 | }, |
| 431 | ) |
| 432 | ET.SubElement( |
| 433 | sld_id_lst, |
| 434 | _qn(NS["p"], "sldId"), |
| 435 | { |
| 436 | "id": str(next_slide_id + clone.offset), |
| 437 | _qn(NS["r"], "id"): new_rid, |
| 438 | }, |
| 439 | ) |
| 440 | |
| 441 | _remap_reachable_shared_layer_slide_jumps( |
| 442 | entries, |
| 443 | clone_roster, |
| 444 | outputs_by_source_part=outputs_by_source_part, |
| 445 | source_ref_by_part=source_ref_by_part, |
| 446 | ) |
| 447 | |
| 448 | entries["ppt/presentation.xml"] = _xml_bytes(pres_root) |
| 449 | entries["ppt/_rels/presentation.xml.rels"] = _xml_bytes(pres_rels_root) |
| 450 | _prune_unreferenced_parts(entries, content_root) |
| 451 | entries["[Content_Types].xml"] = _xml_bytes(content_root) |
| 452 | if wrote_auto_advance: |
| 453 | try: |
| 454 | set_package_use_timings(entries) |
| 455 | except ValueError as exc: |
| 456 | raise RuntimeError(str(exc)) from exc |
| 457 | |
| 458 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 459 | with tempfile.TemporaryDirectory( |
| 460 | prefix="template-fill-pptx-", |
| 461 | dir=output_path.parent, |
| 462 | ) as temp_dir: |
| 463 | candidate_path = Path(temp_dir) / output_path.name |
| 464 | with zipfile.ZipFile( |
| 465 | candidate_path, |
| 466 | "w", |
| 467 | compression=zipfile.ZIP_DEFLATED, |
| 468 | ) as out: |
| 469 | for name, data in entries.items(): |
| 470 | out.writestr(name, data) |
| 471 | try: |
| 472 | validate_pptx_transition_package( |
| 473 | candidate_path, |
| 474 | require_use_timings=wrote_auto_advance, |
| 475 | ) |
| 476 | except ValueError as exc: |
| 477 | raise RuntimeError( |
| 478 | f"PPTX transition package validation failed: {exc}" |
| 479 | ) from exc |
| 480 | try: |
| 481 | validate_pptx_animation_package( |
| 482 | candidate_path, |
| 483 | require_supported_effects=False, |
| 484 | ) |
| 485 | except ValueError as exc: |
| 486 | raise RuntimeError( |
| 487 | f"PPTX animation/timing package validation failed: {exc}" |
| 488 | ) from exc |
| 489 | candidate_path.replace(output_path) |
| 490 |