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