返回 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 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
251 lines PYTHON