返回 ppt-master
applier.py
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
490 lines PYTHON