"""apply: clone selected source slides into a new PPTX and write replacements. Orchestrates the per-stage helpers (text / table / chart / transition / notes) and rebuilds the presentation slide list, relationships, and content types. """ from __future__ import annotations import tempfile import zipfile from pathlib import Path from typing import Any from xml.etree import ElementTree as ET from pptx_animations import ( object_animation_fingerprint, validate_pptx_animation_package, ) from pptx_transitions import ( parse_source_xml, serialize_source_xml, set_package_use_timings, validate_pptx_transition_package, ) from .chart_fill import ( _apply_chart_edits_to_slide_package, _max_chart_part_number, _max_embedding_part_number, ) from .clone import _make_part_allocator, deep_clone_slide_private_parts from .notes import _find_notes_master_target, _slide_rels_with_notes from .ooxml import NS, REL_NS, SLIDE_REL_TYPE, _parse_slide_refs, _qn, _xml_bytes from .package import ( _add_notes_override, _add_slide_override, _content_type_root, _empty_relationships_root, _max_numeric_rid, _max_slide_id, _max_slide_part_number, _prune_unreferenced_parts, ) from .table_fill import _apply_table_edits_to_slide from .text_fill import _apply_replacements_to_slide from .transitions import ( DEFAULT_TRANSITION, DEFAULT_TRANSITION_DURATION, _resolve_slide_transition, _set_slide_transition, ) def apply_plan( pptx_path: Path, plan: dict[str, Any], output_path: Path, *, transition: str | None = DEFAULT_TRANSITION, transition_duration: float = DEFAULT_TRANSITION_DURATION, ) -> None: """Create a filled PPTX by cloning selected source slides and replacing text.""" plan_slides = plan.get("slides") if not isinstance(plan_slides, list) or not plan_slides: raise RuntimeError("Plan must contain a non-empty 'slides' list") with zipfile.ZipFile(pptx_path) as zf: entries = {info.filename: zf.read(info.filename) for info in zf.infolist() if not info.is_dir()} slide_refs = {slide.index: slide for slide in _parse_slide_refs(zf)} notes_master_target = _find_notes_master_target(entries) pres_root = ET.fromstring(entries["ppt/presentation.xml"]) pres_rels_root = ET.fromstring(entries["ppt/_rels/presentation.xml.rels"]) content_root = _content_type_root(ET.fromstring(entries["[Content_Types].xml"])) sld_id_lst = pres_root.find("p:sldIdLst", NS) if sld_id_lst is None: sld_id_lst = ET.SubElement(pres_root, _qn(NS["p"], "sldIdLst")) for child in list(sld_id_lst): sld_id_lst.remove(child) for rel in list(pres_rels_root.findall(_qn(REL_NS, "Relationship"))): if rel.attrib.get("Type") == SLIDE_REL_TYPE: pres_rels_root.remove(rel) next_slide_number = _max_slide_part_number(entries) + 1 next_slide_id = _max_slide_id(sld_id_lst) + 1 next_rel_number = _max_numeric_rid(pres_rels_root) + 1 next_chart_number = _max_chart_part_number(entries) next_embedding_number = _max_embedding_part_number(entries) allocate_part = _make_part_allocator(entries) wrote_auto_advance = False for offset, item in enumerate(plan_slides): source_slide = int(item.get("source_slide", 0)) if source_slide not in slide_refs: raise RuntimeError(f"Plan references a missing source slide: {source_slide}") source_ref = slide_refs[source_slide] new_slide_number = next_slide_number + offset new_part = f"ppt/slides/slide{new_slide_number}.xml" new_rels = f"ppt/slides/_rels/slide{new_slide_number}.xml.rels" new_rid = f"rId{next_rel_number + offset}" source_slide_xml = entries[source_ref.part_name] source_animation_fingerprint = object_animation_fingerprint( source_slide_xml ) slide_root = parse_source_xml(source_slide_xml) replacements = item.get("replacements", []) if not isinstance(replacements, list): raise RuntimeError(f"Slide {source_slide} replacements must be a list") _apply_replacements_to_slide( slide_root, source_slide=source_slide, replacements=replacements, ) table_edits = item.get("table_edits", []) if not isinstance(table_edits, list): raise RuntimeError(f"Slide {source_slide} table_edits must be a list") _apply_table_edits_to_slide( slide_root, source_slide=source_slide, table_edits=table_edits, ) ( slide_effect, slide_effect_options, slide_duration, slide_advance, ) = _resolve_slide_transition( item, default_effect=transition, default_duration=transition_duration, ) slide_has_auto_advance = _set_slide_transition( slide_root, effect=slide_effect, effect_options=slide_effect_options, duration=slide_duration, advance_after=slide_advance, ) if slide_advance is not None and slide_has_auto_advance: wrote_auto_advance = True source_rels = entries.get(source_ref.rels_name) slide_rels_root = ET.fromstring(source_rels) if source_rels else _empty_relationships_root() deep_clone_slide_private_parts( slide_rels_root, new_slide_part=new_part, entries=entries, content_root=content_root, allocate=allocate_part, ) chart_edits = item.get("chart_edits", []) if not isinstance(chart_edits, list): raise RuntimeError(f"Slide {source_slide} chart_edits must be a list") next_chart_number, next_embedding_number = _apply_chart_edits_to_slide_package( slide_root, slide_rels_root, entries, content_root, source_slide=source_slide, new_slide_part=new_part, chart_edits=chart_edits, next_chart_number=next_chart_number, next_embedding_number=next_embedding_number, ) try: serialized_slide = serialize_source_xml( slide_root, source_slide_xml, ) except ValueError as exc: raise RuntimeError(str(exc)) from exc if ( object_animation_fingerprint(serialized_slide) != source_animation_fingerprint ): raise RuntimeError( f'Slide {source_slide} object animations changed during template fill' ) entries[new_part] = serialized_slide notes_text = str(item.get("notes") or item.get("speaker_notes") or "") entries[new_rels], note_entries = _slide_rels_with_notes( _xml_bytes(slide_rels_root), slide_number=new_slide_number, notes_text=notes_text, notes_master_target=notes_master_target, ) entries.update(note_entries) _add_slide_override(content_root, new_part) if note_entries: _add_notes_override(content_root, f"ppt/notesSlides/notesSlide{new_slide_number}.xml") ET.SubElement( pres_rels_root, _qn(REL_NS, "Relationship"), { "Id": new_rid, "Type": SLIDE_REL_TYPE, "Target": f"slides/slide{new_slide_number}.xml", }, ) ET.SubElement( sld_id_lst, _qn(NS["p"], "sldId"), {"id": str(next_slide_id + offset), _qn(NS["r"], "id"): new_rid}, ) entries["ppt/presentation.xml"] = _xml_bytes(pres_root) entries["ppt/_rels/presentation.xml.rels"] = _xml_bytes(pres_rels_root) _prune_unreferenced_parts(entries, content_root) entries["[Content_Types].xml"] = _xml_bytes(content_root) if wrote_auto_advance: try: set_package_use_timings(entries) except ValueError as exc: raise RuntimeError(str(exc)) from exc output_path.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory( prefix="template-fill-pptx-", dir=output_path.parent, ) as temp_dir: candidate_path = Path(temp_dir) / output_path.name with zipfile.ZipFile( candidate_path, "w", compression=zipfile.ZIP_DEFLATED, ) as out: for name, data in entries.items(): out.writestr(name, data) try: validate_pptx_transition_package( candidate_path, require_use_timings=wrote_auto_advance, ) except ValueError as exc: raise RuntimeError( f"PPTX transition package validation failed: {exc}" ) from exc try: validate_pptx_animation_package( candidate_path, require_supported_effects=False, ) except ValueError as exc: raise RuntimeError( f"PPTX animation/timing package validation failed: {exc}" ) from exc candidate_path.replace(output_path)