返回 ppt-master
narration.py
1 """Narration audio discovery and PPTX XML helpers."""
2
3 from __future__ import annotations
4
5 import base64
6 import json
7 import re
8 import subprocess
9 from collections.abc import Iterable
10 from pathlib import Path
11 from xml.etree import ElementTree as ET
12
13 from pptx_transitions import (
14 AdvanceUpdate,
15 EnterUpdate,
16 MAX_OOXML_UNSIGNED_INT,
17 P14_NS,
18 PML_NS,
19 apply_slide_motion_xml,
20 parse_source_xml,
21 read_slide_transition_xml,
22 serialize_source_xml,
23 )
24
25
26 DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
27 RELATIONSHIPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
28 MARKUP_COMPATIBILITY_NS = (
29 "http://schemas.openxmlformats.org/markup-compatibility/2006"
30 )
31
32 MEDIA_REL_TYPE = "http://schemas.microsoft.com/office/2007/relationships/media"
33 AUDIO_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
34 IMAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
35
36 AUDIO_CONTENT_TYPES = {
37 ".m4a": "audio/mp4",
38 ".mp3": "audio/mpeg",
39 ".wav": "audio/wav",
40 }
41
42 NARRATION_EXTENSIONS = tuple(AUDIO_CONTENT_TYPES.keys())
43
44 AUDIO_MARKER_SIZE_EMU = 457200 # 48 SVG px
45 AUDIO_MARKER_OFF_CANVAS_EMU = -AUDIO_MARKER_SIZE_EMU
46 AUDIO_MARKER_PNG_BYTES = base64.b64decode(
47 "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAABsUlEQVR4nO2aQZaDIAyG"
48 "cd4cQRdzgHqxeiy9mB5gFvUO7Qon0gAhJEDf+G/a92rJ9ycEqNaYS3XVSQ94uz+esWu"
49 "2ZRCLKzIQBdqnXDPsL+dA+8Qx88UJpAHPHTfJsRY4Jmo1yBUoCZ8Sj2SgNHxK3KiBWv"
50 "DU+EEDteGtQhxeA63AW/l4WMuolNa5zx4DNaCd/XXuWfAY15sBTXguOJTL9501GlG50"
51 "Ovc/xpjzDjtP+5nqj0gkXEL7763OhmQmj4S4FZY1iGnaAUkwQMxTlWouoz65CYBq4LV"
52 "0cTU6VMgw8frOO3e6273x3Nbhq7JCmCCVYDTqDkDMOuUajdnIFWXgdq6DEgLNm5oGbV"
53 "qzoBPcOmES+qxkW3L0FE2s1BWJDa5cdqjm5gxf7ddmqyAC4+dQq1EDYzTTpq3mTFO56"
54 "KTAam7xpJGsOxDTtUpJGEEZhw7lRb5SWlNcJs8dJx+q4DkwwcEJLsiLh86hTRNGMM3g"
55 "nFVXUYlGt1rQLsKqfLxBCvQiokQR3QK1TYRi0/qgVomKHHJTVzaBDUeC0rzBnBqoljL"
56 "qFY1OOP+3yf1PpX+r8TH6wW14c3/7xdFRAAAAABJRU5ErkJggg=="
57 )
58
59
60 for _prefix, _uri in (
61 ("p", PML_NS),
62 ("a", DRAWINGML_NS),
63 ("r", RELATIONSHIPS_NS),
64 ("p14", P14_NS),
65 ):
66 try:
67 ET.register_namespace(_prefix, _uri)
68 except (AttributeError, ValueError):
69 pass
70
71
72 def _qn(namespace: str, tag: str) -> str:
73 return f"{{{namespace}}}{tag}"
74
75
76 def _normalize_title(title: str) -> str:
77 text = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff]+", "_", title.strip())
78 return re.sub(r"_+", "_", text).strip("_").lower()
79
80
81 def _leading_number(text: str) -> int | None:
82 match = re.match(r"^(\d{1,3})", text.strip())
83 return int(match.group(1)) if match else None
84
85
86 def find_narration_files(audio_dir: Path, svg_files: list[Path]) -> dict[str, Path]:
87 """Return `{svg_stem: audio_path}` matched by exact stem, normalized stem, or index."""
88 if not audio_dir.exists() or not audio_dir.is_dir():
89 return {}
90
91 audio_files = [
92 path for path in sorted(audio_dir.iterdir())
93 if path.is_file() and path.suffix.lower() in NARRATION_EXTENSIONS
94 ]
95 exact: dict[str, list[Path]] = {}
96 normalized: dict[str, list[Path]] = {}
97 numbered: dict[int, list[Path]] = {}
98 for path in audio_files:
99 exact.setdefault(path.stem, []).append(path)
100 normalized.setdefault(_normalize_title(path.stem), []).append(path)
101 number = _leading_number(path.stem)
102 if number is not None:
103 numbered.setdefault(number, []).append(path)
104
105 matched: dict[str, Path] = {}
106 claimed_by: dict[Path, str] = {}
107 for index, svg in enumerate(svg_files, 1):
108 stem = svg.stem
109 candidates = exact.get(stem)
110 if not candidates:
111 candidates = normalized.get(_normalize_title(stem))
112 if not candidates:
113 candidates = numbered.get(index)
114 if not candidates:
115 continue
116 if len(candidates) > 1:
117 names = ", ".join(path.name for path in candidates)
118 raise ValueError(
119 f"multiple narration audio files match slide {stem!r}: "
120 f"{names}; keep exactly one supported file for this slide"
121 )
122 candidate = candidates[0]
123 previous_stem = claimed_by.get(candidate)
124 if previous_stem is not None:
125 raise ValueError(
126 f"narration audio file {candidate.name!r} matches multiple slides: "
127 f"{previous_stem!r}, {stem!r}; provide one distinct audio file "
128 "per slide"
129 )
130 matched[stem] = candidate
131 claimed_by[candidate] = stem
132 return matched
133
134
135 def probe_audio_duration(audio_path: Path) -> float | None:
136 """Return duration in seconds using ffprobe when available."""
137 try:
138 result = subprocess.run(
139 [
140 "ffprobe", "-v", "error",
141 "-show_entries", "format=duration",
142 "-of", "json",
143 str(audio_path),
144 ],
145 check=True,
146 capture_output=True,
147 text=True,
148 encoding="utf-8",
149 )
150 data = json.loads(result.stdout or "{}")
151 duration = float(data.get("format", {}).get("duration", 0))
152 return duration if duration > 0 else None
153 except Exception:
154 return None
155
156
157 def next_shape_id(slide_xml: str) -> int:
158 """Return the next slide-local non-visual shape id."""
159 root = parse_source_xml(slide_xml)
160 if root.tag != _qn(PML_NS, "sld"):
161 raise ValueError("narration source XML root must be p:sld")
162 ids = _numeric_ids(
163 root.iter(_qn(PML_NS, "cNvPr")),
164 "shape",
165 minimum=1,
166 )
167 next_id = max(ids, default=1) + 1
168 if next_id > MAX_OOXML_UNSIGNED_INT:
169 raise ValueError("narration source has no available shape identifiers")
170 return next_id
171
172
173 def _create_audio_pic_element(
174 shape_id: int,
175 shape_name: str,
176 audio_rid: str,
177 media_rid: str,
178 poster_rid: str,
179 ) -> ET.Element:
180 pic = ET.Element(_qn(PML_NS, "pic"))
181 nv_pic_pr = ET.SubElement(pic, _qn(PML_NS, "nvPicPr"))
182 c_nv_pr = ET.SubElement(
183 nv_pic_pr,
184 _qn(PML_NS, "cNvPr"),
185 {"id": str(shape_id), "name": shape_name},
186 )
187 ET.SubElement(
188 c_nv_pr,
189 _qn(DRAWINGML_NS, "hlinkClick"),
190 {
191 _qn(RELATIONSHIPS_NS, "id"): "",
192 "action": "ppaction://media",
193 },
194 )
195 c_nv_pic_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "cNvPicPr"))
196 ET.SubElement(
197 c_nv_pic_pr,
198 _qn(DRAWINGML_NS, "picLocks"),
199 {"noChangeAspect": "1"},
200 )
201 nv_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "nvPr"))
202 ET.SubElement(
203 nv_pr,
204 _qn(DRAWINGML_NS, "audioFile"),
205 {_qn(RELATIONSHIPS_NS, "link"): audio_rid},
206 )
207 ext_list = ET.SubElement(nv_pr, _qn(PML_NS, "extLst"))
208 extension = ET.SubElement(
209 ext_list,
210 _qn(PML_NS, "ext"),
211 {"uri": "{DAA4B4D4-6D71-4841-9C94-3DE7FCFB9230}"},
212 )
213 ET.SubElement(
214 extension,
215 _qn(P14_NS, "media"),
216 {_qn(RELATIONSHIPS_NS, "embed"): media_rid},
217 )
218
219 blip_fill = ET.SubElement(pic, _qn(PML_NS, "blipFill"))
220 ET.SubElement(
221 blip_fill,
222 _qn(DRAWINGML_NS, "blip"),
223 {_qn(RELATIONSHIPS_NS, "embed"): poster_rid},
224 )
225 stretch = ET.SubElement(blip_fill, _qn(DRAWINGML_NS, "stretch"))
226 ET.SubElement(stretch, _qn(DRAWINGML_NS, "fillRect"))
227
228 shape_properties = ET.SubElement(pic, _qn(PML_NS, "spPr"))
229 transform = ET.SubElement(shape_properties, _qn(DRAWINGML_NS, "xfrm"))
230 ET.SubElement(
231 transform,
232 _qn(DRAWINGML_NS, "off"),
233 {
234 "x": str(AUDIO_MARKER_OFF_CANVAS_EMU),
235 "y": str(AUDIO_MARKER_OFF_CANVAS_EMU),
236 },
237 )
238 ET.SubElement(
239 transform,
240 _qn(DRAWINGML_NS, "ext"),
241 {
242 "cx": str(AUDIO_MARKER_SIZE_EMU),
243 "cy": str(AUDIO_MARKER_SIZE_EMU),
244 },
245 )
246 geometry = ET.SubElement(
247 shape_properties,
248 _qn(DRAWINGML_NS, "prstGeom"),
249 {"prst": "rect"},
250 )
251 ET.SubElement(geometry, _qn(DRAWINGML_NS, "avLst"))
252 return pic
253
254
255 def create_audio_pic_xml(
256 shape_id: int,
257 shape_name: str,
258 audio_rid: str,
259 media_rid: str,
260 poster_rid: str,
261 ) -> str:
262 """Create an off-canvas audio picture shape carrying narration media."""
263 element = _create_audio_pic_element(
264 shape_id,
265 shape_name,
266 audio_rid,
267 media_rid,
268 poster_rid,
269 )
270 return ET.tostring(element, encoding="unicode")
271
272
273 def _numeric_ids(
274 elements: Iterable[ET.Element],
275 label: str,
276 *,
277 minimum: int = 0,
278 maximum: int = MAX_OOXML_UNSIGNED_INT,
279 ) -> list[int]:
280 ids: list[int] = []
281 seen: set[int] = set()
282 for element in elements:
283 raw_id = element.get("id")
284 try:
285 numeric_id = int(raw_id)
286 except (TypeError, ValueError) as exc:
287 raise ValueError(f"narration source has invalid {label} id: {raw_id!r}") from exc
288 if numeric_id < minimum:
289 raise ValueError(
290 f"narration source has {label} id below {minimum}: {numeric_id}"
291 )
292 if numeric_id > maximum:
293 raise ValueError(
294 f"narration source has {label} id above {maximum}: {numeric_id}"
295 )
296 if numeric_id in seen:
297 raise ValueError(f"narration source has duplicate {label} id: {numeric_id}")
298 ids.append(numeric_id)
299 seen.add(numeric_id)
300 return ids
301
302
303 def _create_audio_timing_element(shape_id: int, ctn_id: int) -> ET.Element:
304 audio = ET.Element(_qn(PML_NS, "audio"))
305 media_node = ET.SubElement(
306 audio,
307 _qn(PML_NS, "cMediaNode"),
308 {"vol": "80000"},
309 )
310 time_node = ET.SubElement(
311 media_node,
312 _qn(PML_NS, "cTn"),
313 {"id": str(ctn_id), "fill": "hold", "display": "0"},
314 )
315 start_conditions = ET.SubElement(time_node, _qn(PML_NS, "stCondLst"))
316 ET.SubElement(start_conditions, _qn(PML_NS, "cond"), {"delay": "0"})
317 target = ET.SubElement(media_node, _qn(PML_NS, "tgtEl"))
318 ET.SubElement(target, _qn(PML_NS, "spTgt"), {"spid": str(shape_id)})
319 return audio
320
321
322 def _direct_child(parent: ET.Element, tag: str, label: str) -> ET.Element:
323 children = [child for child in parent if child.tag == tag]
324 if len(children) != 1:
325 raise ValueError(
326 f"narration source must contain exactly one direct {label}; found {len(children)}"
327 )
328 return children[0]
329
330
331 def _existing_timing_root(timing: ET.Element) -> ET.Element:
332 children = list(timing)
333 for tag, label in (
334 (_qn(PML_NS, "tnLst"), "p:tnLst"),
335 (_qn(PML_NS, "bldLst"), "p:bldLst"),
336 (_qn(PML_NS, "extLst"), "p:extLst"),
337 ):
338 if sum(child.tag == tag for child in children) > 1:
339 raise ValueError(f"narration source timing has multiple {label} elements")
340 node_list = _direct_child(timing, _qn(PML_NS, "tnLst"), "p:timing/p:tnLst")
341 node_index = children.index(node_list)
342 for tag, label in (
343 (_qn(PML_NS, "bldLst"), "p:bldLst"),
344 (_qn(PML_NS, "extLst"), "p:extLst"),
345 ):
346 sibling = next((child for child in children if child.tag == tag), None)
347 if sibling is not None and node_index > children.index(sibling):
348 raise ValueError(f"narration source p:tnLst must precede {label}")
349 timing_roots = [
350 element
351 for element in node_list.iter(_qn(PML_NS, "cTn"))
352 if element.get("nodeType") == "tmRoot"
353 ]
354 if len(timing_roots) != 1:
355 raise ValueError(
356 "narration source timing must contain exactly one tmRoot; "
357 f"found {len(timing_roots)}"
358 )
359 return timing_roots[0]
360
361
362 def _new_timing(audio_timing: ET.Element, root_id: int) -> ET.Element:
363 timing = ET.Element(_qn(PML_NS, "timing"))
364 node_list = ET.SubElement(timing, _qn(PML_NS, "tnLst"))
365 parallel = ET.SubElement(node_list, _qn(PML_NS, "par"))
366 timing_root = ET.SubElement(
367 parallel,
368 _qn(PML_NS, "cTn"),
369 {
370 "id": str(root_id),
371 "dur": "indefinite",
372 "restart": "never",
373 "nodeType": "tmRoot",
374 },
375 )
376 child_nodes = ET.SubElement(timing_root, _qn(PML_NS, "childTnLst"))
377 child_nodes.append(audio_timing)
378 return timing
379
380
381 def _root_extension_index(slide: ET.Element) -> int | None:
382 extension_lists = [
383 index
384 for index, child in enumerate(slide)
385 if child.tag == _qn(PML_NS, "extLst")
386 ]
387 if len(extension_lists) > 1:
388 raise ValueError("narration source has multiple root p:extLst elements")
389 if extension_lists and extension_lists[0] != len(slide) - 1:
390 raise ValueError("narration source root p:extLst is not the last slide child")
391 return extension_lists[0] if extension_lists else None
392
393
394 def _validate_root_timing_position(slide: ET.Element, timing: ET.Element) -> None:
395 children = list(slide)
396 timing_index = children.index(timing)
397 for tag, label in (
398 (_qn(PML_NS, "cSld"), "p:cSld"),
399 (_qn(PML_NS, "clrMapOvr"), "p:clrMapOvr"),
400 ):
401 siblings = [index for index, child in enumerate(children) if child.tag == tag]
402 if len(siblings) > 1:
403 raise ValueError(f"narration source has multiple root {label} elements")
404 if siblings and siblings[0] > timing_index:
405 raise ValueError(f"narration source root p:timing must follow {label}")
406 extension_index = _root_extension_index(slide)
407 if extension_index is not None and timing_index > extension_index:
408 raise ValueError("narration source root p:timing must precede p:extLst")
409
410
411 def _insert_root_timing(slide: ET.Element, timing: ET.Element) -> None:
412 extension_index = _root_extension_index(slide)
413 insert_at = extension_index if extension_index is not None else len(slide)
414 slide.insert(insert_at, timing)
415
416
417 def _animation_timing_branches(
418 slide: ET.Element,
419 ) -> tuple[ET.Element | None, list[ET.Element]]:
420 """Return the root timing anchor and every active/fallback timing branch."""
421 direct = [
422 child for child in slide
423 if child.tag == _qn(PML_NS, "timing")
424 ]
425 alternates: list[tuple[ET.Element, list[ET.Element]]] = []
426 for child in slide:
427 if child.tag != _qn(MARKUP_COMPATIBILITY_NS, "AlternateContent"):
428 continue
429 timings = [
430 timing
431 for branch in list(child)
432 for timing in list(branch)
433 if timing.tag == _qn(PML_NS, "timing")
434 ]
435 if timings:
436 alternates.append((child, timings))
437 if direct and alternates:
438 raise ValueError(
439 "narration source contains both direct and AlternateContent timing"
440 )
441 if len(direct) > 1 or len(alternates) > 1:
442 raise ValueError("narration source has multiple root animation timings")
443 if direct:
444 return direct[0], direct
445 if alternates:
446 anchor, timings = alternates[0]
447 if len(timings) != 2:
448 raise ValueError(
449 "narration source animation AlternateContent must contain "
450 "one Choice and one Fallback timing"
451 )
452 return anchor, timings
453 nested = list(slide.iter(_qn(PML_NS, "timing")))
454 if nested:
455 raise ValueError(
456 "narration source contains unsupported non-root p:timing"
457 )
458 return None, []
459
460
461 def inject_narration(
462 slide_xml: str,
463 *,
464 shape_id: int,
465 shape_name: str,
466 audio_rid: str,
467 media_rid: str,
468 poster_rid: str,
469 ) -> str:
470 """Inject a hidden narration media shape and slide-entry autoplay timing."""
471 if isinstance(shape_id, bool) or not isinstance(shape_id, int) or shape_id <= 0:
472 raise ValueError("narration shape_id must be a positive integer")
473 if shape_id > MAX_OOXML_UNSIGNED_INT:
474 raise ValueError(
475 "narration shape_id exceeds the OOXML unsigned-integer limit: "
476 f"{shape_id}"
477 )
478
479 root = parse_source_xml(slide_xml)
480 if root.tag != _qn(PML_NS, "sld"):
481 raise ValueError("narration source XML root must be p:sld")
482 common_slide_data = _direct_child(root, _qn(PML_NS, "cSld"), "p:sld/p:cSld")
483 shape_tree = _direct_child(
484 common_slide_data,
485 _qn(PML_NS, "spTree"),
486 "p:cSld/p:spTree",
487 )
488
489 shape_ids = _numeric_ids(
490 root.iter(_qn(PML_NS, "cNvPr")),
491 "shape",
492 minimum=1,
493 )
494 if shape_id in shape_ids:
495 raise ValueError(f"narration shape id already exists on slide: {shape_id}")
496 timing_anchor, timing_branches = _animation_timing_branches(root)
497 timing_id_sets = [
498 _numeric_ids(timing.iter(_qn(PML_NS, "cTn")), "timing node")
499 for timing in timing_branches
500 ]
501 timing_ids = [
502 timing_id
503 for timing_set in timing_id_sets
504 for timing_id in timing_set
505 ]
506 next_timing_id = max(timing_ids, default=0) + 1
507 if next_timing_id > MAX_OOXML_UNSIGNED_INT:
508 raise ValueError("narration source has no available timing node identifiers")
509
510 if not timing_branches and next_timing_id + 1 > MAX_OOXML_UNSIGNED_INT:
511 raise ValueError(
512 "narration source has no identifiers available for a new timing root"
513 )
514
515 audio_picture = _create_audio_pic_element(
516 shape_id,
517 shape_name,
518 audio_rid,
519 media_rid,
520 poster_rid,
521 )
522 shape_tree.append(audio_picture)
523
524 if timing_branches:
525 if timing_anchor is None:
526 raise AssertionError("timing branches lost their root anchor")
527 _validate_root_timing_position(root, timing_anchor)
528 for timing in timing_branches:
529 timing_root = _existing_timing_root(timing)
530 child_nodes = _direct_child(
531 timing_root,
532 _qn(PML_NS, "childTnLst"),
533 "tmRoot/p:childTnLst",
534 )
535 child_nodes.append(
536 _create_audio_timing_element(shape_id, next_timing_id)
537 )
538 else:
539 audio_timing = _create_audio_timing_element(shape_id, next_timing_id + 1)
540 _insert_root_timing(root, _new_timing(audio_timing, next_timing_id))
541
542 return serialize_source_xml(root, slide_xml).decode("utf-8")
543
544
545 def apply_recorded_timing(
546 slide_xml: str,
547 *,
548 advance_after: float,
549 transition_duration: float,
550 transition_effect: str | None = "fade",
551 ) -> str:
552 """Set slide auto-advance timing so exported video follows narration length."""
553 summary = read_slide_transition_xml(slide_xml)
554 if summary.logical_count:
555 enter = EnterUpdate(policy="preserve")
556 elif transition_effect is None or transition_effect == "none":
557 enter = EnterUpdate(policy="none")
558 else:
559 enter = EnterUpdate(
560 policy="replace",
561 effect=transition_effect,
562 duration=transition_duration,
563 )
564 updated, _uses_timings = apply_slide_motion_xml(
565 slide_xml,
566 enter=enter,
567 advance=AdvanceUpdate(mode="narration", after=advance_after),
568 )
569 return updated
570
570 lines PYTHON