返回 ppt-master
pptx_transitions.py
根目录 / skills / ppt-master / scripts / pptx_transitions.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - PPTX Transition Core
4
5 Provide one strict PowerPoint-native transition registry, a compatibility input
6 map, and shared OOXML read/write helpers for generated slides, template-filled
7 PPTX files, and native PPTX enhancement.
8 See references/animations.md for the public workflow and
9 scripts/docs/pptx-transitions.md for the OOXML contract.
10
11 Usage:
12 Import from PPT Master PPTX builders and direct-package workflows.
13
14 Examples:
15 from pptx_transitions import AdvanceUpdate, EnterUpdate, apply_slide_motion
16
17 Dependencies:
18 lxml (preferred for prefix-preserving source-PPTX mutation)
19 """
20
21 from __future__ import annotations
22
23 import io
24 import math
25 import posixpath
26 import re
27 import zipfile
28 from dataclasses import dataclass, field
29 from pathlib import Path
30 from typing import Any, Iterable, Mapping, MutableMapping
31 from xml.etree import ElementTree as ET
32 from xml.sax.saxutils import quoteattr
33
34 try:
35 from lxml import etree as LET
36 except ImportError:
37 LET = None
38
39
40 PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
41 P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main"
42 P15_NS = "http://schemas.microsoft.com/office/powerpoint/2012/main"
43 P159_NS = "http://schemas.microsoft.com/office/powerpoint/2015/09/main"
44 MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
45 PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
46 CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
47 RELATIONSHIPS_NS = (
48 "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
49 )
50
51 PRESENTATION_PROPS_PART = "ppt/presProps.xml"
52 PRESENTATION_RELS_PART = "ppt/_rels/presentation.xml.rels"
53 CONTENT_TYPES_PART = "[Content_Types].xml"
54 PRESENTATION_PROPS_REL_TYPE = (
55 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps"
56 )
57 PRESENTATION_PROPS_CONTENT_TYPE = (
58 "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"
59 )
60 AUDIO_REL_TYPE = (
61 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
62 )
63 WAV_CONTENT_TYPES = frozenset({"audio/wav", "audio/x-wav"})
64
65 DEFAULT_TRANSITION = "fade"
66 DEFAULT_TRANSITION_DURATION = 0.4
67 MAX_OOXML_MILLISECONDS = 4_294_967_295
68 # OOXML stores millisecond values and identifiers (shape ids, time-node ids,
69 # preset codes) as xsd:unsignedInt, so both ceilings share one value.
70 MAX_OOXML_UNSIGNED_INT = MAX_OOXML_MILLISECONDS
71
72
73 _TRANSITION_SPECS: dict[str, dict[str, Any]] = {
74 # PowerPoint-native public ordering is assembled by gallery category below.
75 "fade": {
76 "name": "Fade",
77 "element": "fade",
78 "attrs": {},
79 },
80 "push": {
81 "name": "Push",
82 "element": "push",
83 "attrs": {"dir": "r"},
84 },
85 "wipe": {
86 "name": "Wipe",
87 "element": "wipe",
88 "attrs": {"dir": "r"},
89 },
90 "split": {
91 "name": "Split",
92 "element": "split",
93 "attrs": {"orient": "horz", "dir": "out"},
94 },
95 "cover": {
96 "name": "Cover",
97 "element": "cover",
98 "attrs": {"dir": "r"},
99 },
100 "random": {
101 "name": "Random",
102 "element": "random",
103 "attrs": {},
104 },
105 "blinds": {
106 "name": "Blinds",
107 "element": "blinds",
108 "attrs": {"dir": "vert"},
109 },
110 "checkerboard": {
111 "name": "Checkerboard",
112 "element": "checker",
113 "attrs": {"dir": "horz"},
114 },
115 "comb": {
116 "name": "Comb",
117 "element": "comb",
118 "attrs": {"dir": "horz"},
119 },
120 "cut": {
121 "name": "Cut",
122 "element": "cut",
123 "attrs": {"thruBlk": "0"},
124 },
125 "dissolve": {
126 "name": "Dissolve",
127 "element": "dissolve",
128 "attrs": {},
129 },
130 "random_bars": {
131 "name": "Random Bars",
132 "element": "randomBar",
133 "attrs": {"dir": "vert"},
134 },
135 "zoom": {
136 "name": "Zoom",
137 "prefix": "p14",
138 "element": "warp",
139 "attrs": {"dir": "in"},
140 "fallback": "fade",
141 },
142 # Current PowerPoint transition gallery: Subtle.
143 "morph": {
144 "name": "Morph",
145 "prefix": "p159",
146 "element": "morph",
147 "attrs": {"option": "byObject"},
148 "fallback": "fade",
149 },
150 "reveal": {
151 "name": "Reveal",
152 "prefix": "p14",
153 "element": "reveal",
154 "attrs": {"dir": "r"},
155 "fallback": "fade",
156 },
157 "shape": {
158 "name": "Shape",
159 "element": "circle",
160 "attrs": {},
161 },
162 "uncover": {
163 "name": "Uncover",
164 "element": "pull",
165 "attrs": {"dir": "r"},
166 },
167 "flash": {
168 "name": "Flash",
169 "prefix": "p14",
170 "element": "flash",
171 "attrs": {},
172 "fallback": "fade",
173 },
174 # Current PowerPoint transition gallery: Exciting.
175 "fall_over": {
176 "name": "Fall Over",
177 "prefix": "p15",
178 "element": "prstTrans",
179 "attrs": {"prst": "fallOver", "invX": "1"},
180 "fallback": "fade",
181 },
182 "drape": {
183 "name": "Drape",
184 "prefix": "p15",
185 "element": "prstTrans",
186 "attrs": {"prst": "drape", "invX": "1"},
187 "fallback": "fade",
188 },
189 "curtains": {
190 "name": "Curtains",
191 "prefix": "p15",
192 "element": "prstTrans",
193 "attrs": {"prst": "curtains"},
194 "fallback": "fade",
195 },
196 "wind": {
197 "name": "Wind",
198 "prefix": "p15",
199 "element": "prstTrans",
200 "attrs": {"prst": "wind"},
201 "fallback": "fade",
202 },
203 "prestige": {
204 "name": "Prestige",
205 "prefix": "p15",
206 "element": "prstTrans",
207 "attrs": {"prst": "prestige"},
208 "fallback": "fade",
209 },
210 "fracture": {
211 "name": "Fracture",
212 "prefix": "p15",
213 "element": "prstTrans",
214 "attrs": {"prst": "fracture"},
215 "fallback": "fade",
216 },
217 "crush": {
218 "name": "Crush",
219 "prefix": "p15",
220 "element": "prstTrans",
221 "attrs": {"prst": "crush"},
222 "fallback": "fade",
223 },
224 "peel_off": {
225 "name": "Peel Off",
226 "prefix": "p15",
227 "element": "prstTrans",
228 "attrs": {"prst": "peelOff", "invX": "1"},
229 "fallback": "fade",
230 },
231 "page_curl": {
232 "name": "Page Curl",
233 "prefix": "p15",
234 "element": "prstTrans",
235 "attrs": {"prst": "pageCurlSingle", "invX": "1"},
236 "fallback": "fade",
237 },
238 "airplane": {
239 "name": "Airplane",
240 "prefix": "p15",
241 "element": "prstTrans",
242 "attrs": {"prst": "airplane"},
243 "fallback": "fade",
244 },
245 "origami": {
246 "name": "Origami",
247 "prefix": "p15",
248 "element": "prstTrans",
249 "attrs": {"prst": "origami"},
250 "fallback": "fade",
251 },
252 "clock": {
253 "name": "Clock",
254 "element": "wheel",
255 "attrs": {"spokes": "1"},
256 },
257 "ripple": {
258 "name": "Ripple",
259 "prefix": "p14",
260 "element": "ripple",
261 "attrs": {},
262 "fallback": "fade",
263 },
264 "honeycomb": {
265 "name": "Honeycomb",
266 "prefix": "p14",
267 "element": "honeycomb",
268 "attrs": {},
269 "fallback": "fade",
270 },
271 "glitter": {
272 "name": "Glitter",
273 "prefix": "p14",
274 "element": "glitter",
275 "attrs": {},
276 "fallback": "fade",
277 },
278 "vortex": {
279 "name": "Vortex",
280 "prefix": "p14",
281 "element": "vortex",
282 "attrs": {"dir": "r"},
283 "fallback": "fade",
284 },
285 "shred": {
286 "name": "Shred",
287 "prefix": "p14",
288 "element": "shred",
289 "attrs": {"dir": "out"},
290 "fallback": "fade",
291 },
292 "switch": {
293 "name": "Switch",
294 "prefix": "p14",
295 "element": "switch",
296 "attrs": {"dir": "r"},
297 "fallback": "fade",
298 },
299 "flip": {
300 "name": "Flip",
301 "prefix": "p14",
302 "element": "flip",
303 "attrs": {"dir": "r"},
304 "fallback": "fade",
305 },
306 "gallery": {
307 "name": "Gallery",
308 "prefix": "p14",
309 "element": "gallery",
310 "attrs": {"dir": "r"},
311 "fallback": "fade",
312 },
313 "cube": {
314 "name": "Cube",
315 "prefix": "p14",
316 "element": "prism",
317 "attrs": {"dir": "r"},
318 "fallback": "fade",
319 },
320 "doors": {
321 "name": "Doors",
322 "prefix": "p14",
323 "element": "doors",
324 "attrs": {"dir": "vert"},
325 "fallback": "fade",
326 },
327 "box": {
328 "name": "Box",
329 "element": "zoom",
330 "attrs": {},
331 },
332 # Current PowerPoint transition gallery: Dynamic Content.
333 "pan": {
334 "name": "Pan",
335 "prefix": "p14",
336 "element": "pan",
337 "attrs": {"dir": "r"},
338 "fallback": "fade",
339 },
340 "ferris_wheel": {
341 "name": "Ferris Wheel",
342 "prefix": "p14",
343 "element": "ferris",
344 "attrs": {"dir": "r"},
345 "fallback": "fade",
346 },
347 "conveyor": {
348 "name": "Conveyor",
349 "prefix": "p14",
350 "element": "conveyor",
351 "attrs": {"dir": "r"},
352 "fallback": "fade",
353 },
354 "rotate": {
355 "name": "Rotate",
356 "prefix": "p14",
357 "element": "prism",
358 "attrs": {"dir": "r", "isContent": "1"},
359 "fallback": "fade",
360 },
361 "window": {
362 "name": "Window",
363 "prefix": "p14",
364 "element": "window",
365 "attrs": {},
366 "fallback": "fade",
367 },
368 "orbit": {
369 "name": "Orbit",
370 "prefix": "p14",
371 "element": "prism",
372 "attrs": {"dir": "r", "isContent": "1", "isInverted": "1"},
373 "fallback": "fade",
374 },
375 "fly_through": {
376 "name": "Fly Through",
377 "prefix": "p14",
378 "element": "flythrough",
379 "attrs": {},
380 "fallback": "fade",
381 },
382 }
383
384 TRANSITION_CATEGORIES = ("subtle", "exciting", "dynamic_content")
385 _TRANSITION_KEYS_BY_CATEGORY = {
386 "subtle": (
387 "morph",
388 "fade",
389 "push",
390 "wipe",
391 "split",
392 "reveal",
393 "cut",
394 "random_bars",
395 "shape",
396 "uncover",
397 "cover",
398 "flash",
399 ),
400 "exciting": (
401 "fall_over",
402 "drape",
403 "curtains",
404 "wind",
405 "prestige",
406 "fracture",
407 "crush",
408 "peel_off",
409 "page_curl",
410 "airplane",
411 "origami",
412 "dissolve",
413 "checkerboard",
414 "blinds",
415 "clock",
416 "ripple",
417 "honeycomb",
418 "glitter",
419 "vortex",
420 "shred",
421 "switch",
422 "flip",
423 "gallery",
424 "cube",
425 "doors",
426 "box",
427 "comb",
428 "zoom",
429 "random",
430 ),
431 "dynamic_content": (
432 "pan",
433 "ferris_wheel",
434 "conveyor",
435 "rotate",
436 "window",
437 "orbit",
438 "fly_through",
439 ),
440 }
441
442 TRANSITION_EFFECT_OPTION_FIELDS = (
443 "direction",
444 "orientation",
445 "style",
446 "shape",
447 "pattern",
448 "origin",
449 "pages",
450 "morph_by",
451 "through_black",
452 "bounce",
453 )
454
455
456 def _enum_option(
457 default: str,
458 values: Mapping[str, Mapping[str, Any]],
459 ) -> dict[str, Any]:
460 return {
461 "type": "enum",
462 "default": default,
463 "values": dict(values),
464 }
465
466
467 def _attribute_enum(
468 default: str,
469 attribute: str,
470 values: Mapping[str, str | None],
471 ) -> dict[str, Any]:
472 overrides: dict[str, dict[str, Any]] = {}
473 for name, value in values.items():
474 if value is None:
475 overrides[name] = {"remove_attrs": (attribute,)}
476 else:
477 overrides[name] = {"attrs": {attribute: value}}
478 return _enum_option(default, overrides)
479
480
481 def _boolean_option(
482 default: bool,
483 attribute: str,
484 ) -> dict[str, Any]:
485 return {
486 "type": "boolean",
487 "default": default,
488 "values": {
489 False: {"remove_attrs": (attribute,)},
490 True: {"attrs": {attribute: "1"}},
491 },
492 }
493
494
495 _CARDINAL_DIRECTIONS = {
496 "left": None,
497 "right": "r",
498 "up": "u",
499 "down": "d",
500 }
501 _CORNER_DIRECTIONS = {
502 **_CARDINAL_DIRECTIONS,
503 "up_left": "lu",
504 "up_right": "ru",
505 "down_left": "ld",
506 "down_right": "rd",
507 }
508 _TRANSITION_EFFECT_OPTIONS: dict[str, dict[str, dict[str, Any]]] = {
509 "morph": {
510 "morph_by": _attribute_enum(
511 "object",
512 "option",
513 {
514 "object": "byObject",
515 "word": "byWord",
516 "character": "byChar",
517 },
518 ),
519 },
520 "fade": {
521 "style": _attribute_enum(
522 "smoothly",
523 "thruBlk",
524 {"smoothly": None, "through_black": "1"},
525 ),
526 },
527 "push": {
528 "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
529 },
530 "wipe": {
531 "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
532 },
533 "split": {
534 "orientation": _attribute_enum(
535 "horizontal",
536 "orient",
537 {"horizontal": None, "vertical": "vert"},
538 ),
539 "direction": _attribute_enum(
540 "out",
541 "dir",
542 {"in": "in", "out": None},
543 ),
544 },
545 "reveal": {
546 "direction": _attribute_enum(
547 "right",
548 "dir",
549 {"left": None, "right": "r"},
550 ),
551 "through_black": _boolean_option(False, "thruBlk"),
552 },
553 "cut": {
554 "through_black": {
555 "type": "boolean",
556 "default": False,
557 "values": {
558 False: {"remove_attrs": ("thruBlk",)},
559 True: {"attrs": {"thruBlk": "1"}},
560 },
561 },
562 },
563 "random_bars": {
564 "orientation": _attribute_enum(
565 "vertical",
566 "dir",
567 {"horizontal": None, "vertical": "vert"},
568 ),
569 },
570 "shape": {
571 "shape": _enum_option(
572 "circle",
573 {
574 "circle": {"element": "circle"},
575 "diamond": {"element": "diamond"},
576 "plus": {"element": "plus"},
577 },
578 ),
579 },
580 "uncover": {
581 "direction": _attribute_enum("right", "dir", _CORNER_DIRECTIONS),
582 },
583 "cover": {
584 "direction": _attribute_enum("right", "dir", _CORNER_DIRECTIONS),
585 },
586 "fall_over": {
587 "direction": _attribute_enum(
588 "right",
589 "invX",
590 {"left": None, "right": "1"},
591 ),
592 },
593 "drape": {
594 "direction": _attribute_enum(
595 "right",
596 "invX",
597 {"left": None, "right": "1"},
598 ),
599 },
600 "wind": {
601 "direction": _attribute_enum(
602 "right",
603 "invX",
604 {"left": "1", "right": None},
605 ),
606 },
607 "peel_off": {
608 "direction": _attribute_enum(
609 "right",
610 "invX",
611 {"left": None, "right": "1"},
612 ),
613 },
614 "page_curl": {
615 "direction": _attribute_enum(
616 "right",
617 "invX",
618 {"left": None, "right": "1"},
619 ),
620 "pages": _attribute_enum(
621 "single",
622 "prst",
623 {"single": "pageCurlSingle", "double": "pageCurlDouble"},
624 ),
625 },
626 "airplane": {
627 "direction": _attribute_enum(
628 "right",
629 "invX",
630 {"left": "1", "right": None},
631 ),
632 },
633 "origami": {
634 "direction": _attribute_enum(
635 "right",
636 "invX",
637 {"left": "1", "right": None},
638 ),
639 },
640 "checkerboard": {
641 "direction": _attribute_enum(
642 "across",
643 "dir",
644 {"across": None, "down": "vert"},
645 ),
646 },
647 "blinds": {
648 "orientation": _attribute_enum(
649 "vertical",
650 "dir",
651 {"horizontal": None, "vertical": "vert"},
652 ),
653 },
654 "clock": {
655 "style": _enum_option(
656 "clockwise",
657 {
658 "clockwise": {
659 "element": "wheel",
660 "attrs": {"spokes": "1"},
661 },
662 "counterclockwise": {
663 "prefix": "p14",
664 "element": "wheelReverse",
665 "attrs": {"spokes": "1"},
666 "fallback": "fade",
667 },
668 "wedge": {
669 "element": "wedge",
670 "remove_attrs": ("spokes",),
671 },
672 },
673 ),
674 },
675 "ripple": {
676 "origin": _attribute_enum(
677 "center",
678 "dir",
679 {
680 "center": None,
681 "up_left": "lu",
682 "up_right": "ru",
683 "down_left": "ld",
684 "down_right": "rd",
685 },
686 ),
687 },
688 "glitter": {
689 "shape": _attribute_enum(
690 "diamond",
691 "pattern",
692 {"diamond": None, "hexagon": "hexagon"},
693 ),
694 "direction": _attribute_enum(
695 "right",
696 "dir",
697 {
698 "left": "r",
699 "right": None,
700 "up": "d",
701 "down": "u",
702 },
703 ),
704 },
705 "vortex": {
706 "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
707 },
708 "shred": {
709 "pattern": _attribute_enum(
710 "strips",
711 "pattern",
712 {"strips": None, "rectangle": "rectangle"},
713 ),
714 "direction": _attribute_enum(
715 "out",
716 "dir",
717 {"in": None, "out": "out"},
718 ),
719 },
720 "switch": {
721 "direction": _attribute_enum(
722 "right",
723 "dir",
724 {"left": "l", "right": "r"},
725 ),
726 },
727 "flip": {
728 "direction": _attribute_enum(
729 "right",
730 "dir",
731 {"left": "l", "right": "r"},
732 ),
733 },
734 "gallery": {
735 "direction": _attribute_enum(
736 "right",
737 "dir",
738 {"left": "l", "right": "r"},
739 ),
740 },
741 "cube": {
742 "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
743 },
744 "doors": {
745 "orientation": _attribute_enum(
746 "vertical",
747 "dir",
748 {"horizontal": None, "vertical": "vert"},
749 ),
750 },
751 "box": {
752 "direction": _attribute_enum(
753 "out",
754 "dir",
755 {"in": "in", "out": None},
756 ),
757 },
758 "comb": {
759 "orientation": _attribute_enum(
760 "horizontal",
761 "dir",
762 {"horizontal": None, "vertical": "vert"},
763 ),
764 },
765 "zoom": {
766 "direction": _attribute_enum(
767 "in",
768 "dir",
769 {"in": "in", "out": None},
770 ),
771 },
772 "pan": {
773 "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
774 },
775 "ferris_wheel": {
776 "direction": _attribute_enum(
777 "right",
778 "dir",
779 {"left": "l", "right": "r"},
780 ),
781 },
782 "conveyor": {
783 "direction": _attribute_enum(
784 "right",
785 "dir",
786 {"left": "l", "right": "r"},
787 ),
788 },
789 "rotate": {
790 "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
791 },
792 "window": {
793 "orientation": _attribute_enum(
794 "horizontal",
795 "dir",
796 {"horizontal": None, "vertical": "vert"},
797 ),
798 },
799 "orbit": {
800 "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
801 },
802 "fly_through": {
803 "direction": _attribute_enum(
804 "in",
805 "dir",
806 {"in": None, "out": "out"},
807 ),
808 "bounce": _boolean_option(False, "hasBounce"),
809 },
810 }
811
812 NATIVE_TRANSITIONS: dict[str, dict[str, Any]] = {}
813 for _category in TRANSITION_CATEGORIES:
814 for _key in _TRANSITION_KEYS_BY_CATEGORY[_category]:
815 if _key in NATIVE_TRANSITIONS:
816 raise RuntimeError(f"duplicate native transition key: {_key}")
817 _spec = dict(_TRANSITION_SPECS[_key])
818 _spec["category"] = _category
819 _spec["effectOptions"] = _TRANSITION_EFFECT_OPTIONS.get(_key, {})
820 NATIVE_TRANSITIONS[_key] = _spec
821 if set(NATIVE_TRANSITIONS) != set(_TRANSITION_SPECS):
822 raise RuntimeError("native transition gallery categories are incomplete")
823 if len(NATIVE_TRANSITIONS) != 48:
824 raise RuntimeError(
825 f"native transition gallery count changed: {len(NATIVE_TRANSITIONS)}"
826 )
827 for _key, _spec in NATIVE_TRANSITIONS.items():
828 _options = _spec["effectOptions"]
829 _unknown_options = set(_options) - set(TRANSITION_EFFECT_OPTION_FIELDS)
830 if _unknown_options:
831 raise RuntimeError(
832 f"native transition {_key!r} has unknown effect option(s): "
833 + ", ".join(sorted(_unknown_options))
834 )
835 for _option_name, _option_spec in _options.items():
836 if _option_spec.get("type") not in {"enum", "boolean"}:
837 raise RuntimeError(
838 f"native transition {_key!r} option {_option_name!r} "
839 "must be enum or boolean"
840 )
841 _values = _option_spec.get("values")
842 if not isinstance(_values, dict) or not _values:
843 raise RuntimeError(
844 f"native transition {_key!r} option {_option_name!r} "
845 "must define values"
846 )
847 if _option_spec.get("default") not in _values:
848 raise RuntimeError(
849 f"native transition {_key!r} option {_option_name!r} "
850 "has an unknown default"
851 )
852
853 NATIVE_TRANSITION_KEYS = tuple(NATIVE_TRANSITIONS)
854 # Retained as a module-level compatibility name for existing imports. New code
855 # should use ``NATIVE_TRANSITIONS`` to distinguish the native registry from
856 # accepted legacy input names.
857 CANONICAL_TRANSITIONS = NATIVE_TRANSITIONS
858
859 TRANSITION_ALIASES: dict[str, str] = {
860 "strips": "wipe",
861 "circle": "shape",
862 "diamond": "shape",
863 "newsflash": "flash",
864 "plus": "shape",
865 "pull": "uncover",
866 "wedge": "clock",
867 "wheel": "clock",
868 }
869 LEGACY_TRANSITION_KEYS = tuple(TRANSITION_ALIASES)
870 TRANSITION_ALIAS_OPTIONS: dict[str, dict[str, object]] = {
871 "strips": {"direction": "right"},
872 "circle": {"shape": "circle"},
873 "diamond": {"shape": "diamond"},
874 "plus": {"shape": "plus"},
875 "wedge": {"style": "wedge"},
876 "wheel": {"style": "clockwise"},
877 }
878 TRANSITIONS: dict[str, dict[str, Any]] = dict(NATIVE_TRANSITIONS)
879 for _alias, _canonical_key in TRANSITION_ALIASES.items():
880 TRANSITIONS[_alias] = NATIVE_TRANSITIONS[_canonical_key]
881
882 TRANSITION_NAMESPACES = {
883 "p": PML_NS,
884 "p14": P14_NS,
885 "p15": P15_NS,
886 "p159": P159_NS,
887 }
888
889 for _prefix, _uri in (
890 *TRANSITION_NAMESPACES.items(),
891 ("mc", MC_NS),
892 ("r", RELATIONSHIPS_NS),
893 ):
894 try:
895 ET.register_namespace(_prefix, _uri)
896 except (AttributeError, ValueError):
897 pass
898
899
900 @dataclass(frozen=True)
901 class EnterUpdate:
902 """Describe how the current slide's visual transition enters."""
903
904 policy: str = "replace"
905 effect: str | None = DEFAULT_TRANSITION
906 duration: float = DEFAULT_TRANSITION_DURATION
907 effect_options: Mapping[str, object] | None = None
908
909
910 @dataclass(frozen=True)
911 class AdvanceUpdate:
912 """Describe how the current slide advances to the next slide."""
913
914 mode: str = "preserve"
915 after: float | None = None
916
917
918 @dataclass(frozen=True)
919 class TransitionSummary:
920 """Read-back summary of one slide's logical transition slot."""
921
922 carrier: str
923 logical_count: int
924 effect: str | None = None
925 effect_namespace: str | None = None
926 fallback_effect: str | None = None
927 fallback_effect_namespace: str | None = None
928 duration_ms: int | None = None
929 speed: str | None = None
930 advance_on_click: bool | None = None
931 advance_after_ms: int | None = None
932 effect_attributes: Mapping[str, str] = field(default_factory=dict)
933 canonical_effect: str | None = None
934 effect_options: Mapping[str, object] = field(default_factory=dict)
935 sound_relationship_id: str | None = None
936 sound_name: str | None = None
937 fallback_sound_relationship_id: str | None = None
938 fallback_sound_name: str | None = None
939
940
941 @dataclass(frozen=True)
942 class MorphPairExpectation:
943 """One forced-Morph name expected on two adjacent generated slides."""
944
945 source_slide_number: int
946 destination_slide_number: int
947 key: str
948
949 @property
950 def shape_name(self) -> str:
951 return f"!!{self.key}"
952
953
954 def _qn(namespace: str, tag: str) -> str:
955 return f"{{{namespace}}}{tag}"
956
957
958 def _local_name(tag: str) -> str:
959 return tag.rsplit("}", 1)[-1]
960
961
962 def _namespace_name(tag: str) -> str | None:
963 if tag.startswith("{") and "}" in tag:
964 return tag[1:].split("}", 1)[0]
965 return None
966
967
968 def _value_repr(value: object) -> str:
969 try:
970 return repr(value)
971 except Exception:
972 return f"<{type(value).__name__}>"
973
974
975 def validate_seconds(
976 value: object,
977 field: str,
978 *,
979 allow_zero: bool,
980 ) -> float:
981 """Return a finite seconds value or raise a field-specific error."""
982 display_value = _value_repr(value)
983 if isinstance(value, bool):
984 raise ValueError(f"{field} must be a finite number, not {display_value}")
985 try:
986 number = float(value)
987 except (TypeError, ValueError, OverflowError) as exc:
988 raise ValueError(
989 f"{field} must be a finite number: {display_value}"
990 ) from exc
991 if not math.isfinite(number):
992 raise ValueError(f"{field} must be finite: {display_value}")
993 if allow_zero:
994 if number < 0:
995 raise ValueError(f"{field} must be non-negative: {display_value}")
996 elif number <= 0:
997 raise ValueError(f"{field} must be greater than zero: {display_value}")
998 return number
999
1000
1001 def normalize_transition_effect(effect: object, *, allow_none: bool = True) -> str | None:
1002 """Return one canonical PowerPoint transition or explicit no-effect."""
1003 if effect is None or effect == "none":
1004 if allow_none:
1005 return None
1006 raise ValueError("transition effect is required")
1007 if not isinstance(effect, str):
1008 raise ValueError(f"transition effect must be a string: {effect!r}")
1009 if effect in TRANSITION_ALIASES:
1010 return TRANSITION_ALIASES[effect]
1011 if effect in NATIVE_TRANSITIONS:
1012 return effect
1013 valid = ", ".join((*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS))
1014 raise ValueError(
1015 f"unknown transition effect {effect!r}; valid effects: {valid}, none"
1016 )
1017
1018
1019 def normalize_transition_effect_options(
1020 effect: str,
1021 options: object = None,
1022 ) -> dict[str, object]:
1023 """Validate PowerPoint Effect Options for one native transition."""
1024 if effect not in NATIVE_TRANSITIONS:
1025 if options in (None, {}):
1026 return {}
1027 raise ValueError(
1028 "transition effect_options require one explicit native effect; "
1029 f"found {effect!r}"
1030 )
1031 if options is None:
1032 options = {}
1033 if not isinstance(options, Mapping):
1034 raise ValueError(
1035 f"transition effect_options must be an object: {options!r}"
1036 )
1037
1038 option_specs = NATIVE_TRANSITIONS[effect]["effectOptions"]
1039 unknown = set(options) - set(option_specs)
1040 if unknown:
1041 unsupported = ", ".join(sorted(unknown))
1042 supported = ", ".join(option_specs) or "(none)"
1043 raise ValueError(
1044 f"transition effect {effect!r} does not support effect option(s): "
1045 f"{unsupported}; supported options: {supported}"
1046 )
1047
1048 normalized: dict[str, object] = {}
1049 for name, value in options.items():
1050 spec = option_specs[name]
1051 field = f"transition effect_options.{name}"
1052 if spec["type"] == "enum":
1053 if not isinstance(value, str) or value not in spec["values"]:
1054 valid = ", ".join(spec["values"])
1055 raise ValueError(
1056 f"{field} for {effect!r} must be one of {valid}: {value!r}"
1057 )
1058 normalized[name] = value
1059 elif spec["type"] == "boolean":
1060 if not isinstance(value, bool):
1061 raise ValueError(f"{field} must be a boolean: {value!r}")
1062 normalized[name] = value
1063 else:
1064 raise AssertionError(
1065 f"unhandled transition option type: {spec['type']!r}"
1066 )
1067 return normalized
1068
1069
1070 def normalize_transition_effect_request(
1071 effect: object,
1072 options: object = None,
1073 *,
1074 allow_none: bool = True,
1075 ) -> tuple[str | None, dict[str, object]]:
1076 """Normalize one native effect plus options and legacy semantic aliases."""
1077 raw_effect = effect
1078 canonical = normalize_transition_effect(effect, allow_none=allow_none)
1079 alias_options = (
1080 TRANSITION_ALIAS_OPTIONS.get(raw_effect, {})
1081 if isinstance(raw_effect, str)
1082 else {}
1083 )
1084 explicit_options: Mapping[str, object]
1085 if options is None:
1086 explicit_options = {}
1087 elif isinstance(options, Mapping):
1088 explicit_options = options
1089 else:
1090 raise ValueError(
1091 f"transition effect_options must be an object: {options!r}"
1092 )
1093 for name, alias_value in alias_options.items():
1094 if name in explicit_options and explicit_options[name] != alias_value:
1095 raise ValueError(
1096 f"legacy transition effect {raw_effect!r} implies "
1097 f"effect_options.{name}={alias_value!r}, which conflicts with "
1098 f"{explicit_options[name]!r}"
1099 )
1100 merged = {**alias_options, **explicit_options}
1101 if canonical is None:
1102 if merged:
1103 raise ValueError(
1104 "transition effect_options require one explicit native effect; "
1105 "found 'none'"
1106 )
1107 return None, {}
1108 return canonical, normalize_transition_effect_options(canonical, merged)
1109
1110
1111 def describe_transition_effect(effect: object) -> dict[str, Any]:
1112 """Return the author-facing parameter contract for one transition."""
1113 canonical, implied_options = normalize_transition_effect_request(
1114 effect,
1115 allow_none=False,
1116 )
1117 option_contract: dict[str, Any] = {}
1118 for name, raw_spec in NATIVE_TRANSITIONS[canonical]["effectOptions"].items():
1119 option_contract[name] = {
1120 "type": raw_spec["type"],
1121 "default": raw_spec["default"],
1122 "values": list(raw_spec["values"]),
1123 }
1124 return {
1125 "input": effect,
1126 "effect": canonical,
1127 "name": NATIVE_TRANSITIONS[canonical]["name"],
1128 "category": NATIVE_TRANSITIONS[canonical]["category"],
1129 "compatibility_alias": (
1130 effect
1131 if isinstance(effect, str) and effect in TRANSITION_ALIASES
1132 else None
1133 ),
1134 "implied_effect_options": implied_options,
1135 "effect_options": option_contract,
1136 "timing": {
1137 "duration": "positive seconds",
1138 "auto_advance": "non-negative seconds",
1139 },
1140 "sound": "project-relative .wav path or null in animations.json",
1141 }
1142
1143
1144 def _seconds_to_ms(value: object, field: str, *, allow_zero: bool) -> int:
1145 seconds = validate_seconds(value, field, allow_zero=allow_zero)
1146 raw_milliseconds = seconds * 1000
1147 if (
1148 not math.isfinite(raw_milliseconds)
1149 or raw_milliseconds > MAX_OOXML_MILLISECONDS
1150 ):
1151 raise ValueError(
1152 f"{field} exceeds the OOXML millisecond limit: {value!r}"
1153 )
1154 milliseconds = int(raw_milliseconds)
1155 return milliseconds if allow_zero else max(1, milliseconds)
1156
1157
1158 def _effect_spec(
1159 effect: str,
1160 effect_options: object = None,
1161 ) -> tuple[str, str, str, dict[str, Any], str | None]:
1162 info = NATIVE_TRANSITIONS[effect]
1163 prefix = str(info.get("prefix", "p"))
1164 namespace = TRANSITION_NAMESPACES[prefix]
1165 element = str(info["element"])
1166 attrs = dict(info.get("attrs", {}))
1167 fallback = info.get("fallback")
1168 options = {
1169 name: option_spec["default"]
1170 for name, option_spec in info["effectOptions"].items()
1171 }
1172 options.update(
1173 normalize_transition_effect_options(effect, effect_options)
1174 )
1175 option_specs = info["effectOptions"]
1176 for name, value in options.items():
1177 override = option_specs[name]["values"][value]
1178 if "prefix" in override:
1179 prefix = str(override["prefix"])
1180 namespace = TRANSITION_NAMESPACES[prefix]
1181 if "element" in override:
1182 element = str(override["element"])
1183 for attribute in override.get("remove_attrs", ()):
1184 attrs.pop(str(attribute), None)
1185 attrs.update(
1186 {
1187 str(attribute): str(attribute_value)
1188 for attribute, attribute_value in override.get("attrs", {}).items()
1189 }
1190 )
1191 if "fallback" in override:
1192 fallback = override["fallback"]
1193 return prefix, namespace, element, attrs, str(fallback) if fallback else None
1194
1195
1196 def _effect_xml(
1197 effect: str,
1198 effect_options: object = None,
1199 ) -> tuple[str, str, str]:
1200 prefix, _namespace, element, effect_attrs, _fallback = _effect_spec(
1201 effect,
1202 effect_options,
1203 )
1204 attrs = " ".join(
1205 f'{key}="{value}"'
1206 for key, value in effect_attrs.items()
1207 )
1208 suffix = f" {attrs}" if attrs else ""
1209 return prefix, element, suffix
1210
1211
1212 def _transition_attributes(
1213 *,
1214 duration_ms: int | None,
1215 advance_after_ms: int | None,
1216 advance_on_click: bool | None,
1217 declare_p14: bool,
1218 ) -> str:
1219 attrs: list[str] = []
1220 if duration_ms is not None:
1221 attrs.append(f'p14:dur="{duration_ms}"')
1222 if declare_p14:
1223 attrs.append(f'xmlns:p14="{P14_NS}"')
1224 if advance_on_click is not None:
1225 attrs.append(f'advClick="{1 if advance_on_click else 0}"')
1226 if advance_after_ms is not None:
1227 attrs.append(f'advTm="{advance_after_ms}"')
1228 return " " + " ".join(attrs) if attrs else ""
1229
1230
1231 def _normalize_transition_sound(
1232 sound: Mapping[str, object] | None,
1233 ) -> dict[str, str] | None:
1234 """Validate one packaged transition-sound relationship descriptor."""
1235 if sound is None:
1236 return None
1237 if not isinstance(sound, Mapping):
1238 raise ValueError("transition sound must be a relationship descriptor")
1239 unknown = set(sound) - {"relationship_id", "name"}
1240 if unknown:
1241 raise ValueError(
1242 "transition sound has unknown field(s): "
1243 + ", ".join(sorted(str(field) for field in unknown))
1244 )
1245 relationship_id = sound.get("relationship_id")
1246 name = sound.get("name")
1247 if not isinstance(relationship_id, str) or not relationship_id.strip():
1248 raise ValueError(
1249 "transition sound relationship_id must be a non-empty string"
1250 )
1251 if not isinstance(name, str) or not name.strip():
1252 raise ValueError("transition sound name must be a non-empty string")
1253 return {
1254 "relationship_id": relationship_id,
1255 "name": name,
1256 }
1257
1258
1259 def _transition_sound_xml(
1260 sound: Mapping[str, str] | None,
1261 *,
1262 indent: str,
1263 ) -> str:
1264 """Return the optional p:sndAc payload at one indentation level."""
1265 if sound is None:
1266 return ""
1267 relationship_id = quoteattr(sound["relationship_id"])
1268 name = quoteattr(sound["name"])
1269 return (
1270 f"{indent}<p:sndAc>\n"
1271 f"{indent} <p:stSnd>\n"
1272 f"{indent} <p:snd r:embed={relationship_id} name={name}/>\n"
1273 f"{indent} </p:stSnd>\n"
1274 f"{indent}</p:sndAc>\n"
1275 )
1276
1277
1278 def create_transition_xml(
1279 effect: str | None = DEFAULT_TRANSITION,
1280 duration: float = 0.5,
1281 advance_after: float | None = None,
1282 advance_on_click: bool | None = None,
1283 effect_options: Mapping[str, object] | None = None,
1284 sound: Mapping[str, object] | None = None,
1285 ) -> str:
1286 """Build a direct or MCE-backed p:transition XML fragment."""
1287 normalized_effect, normalized_options = normalize_transition_effect_request(
1288 effect,
1289 effect_options,
1290 )
1291 normalized_sound = _normalize_transition_sound(sound)
1292 duration_ms = None
1293 if normalized_effect is not None:
1294 duration_ms = _seconds_to_ms(
1295 duration,
1296 "transition duration",
1297 allow_zero=False,
1298 )
1299 if advance_on_click is not None:
1300 if not isinstance(advance_on_click, bool):
1301 raise ValueError(
1302 "transition advance_on_click must be a boolean or None"
1303 )
1304 advance_ms = None
1305 if advance_after is not None:
1306 advance_ms = _seconds_to_ms(
1307 advance_after,
1308 "transition advance_after",
1309 allow_zero=True,
1310 )
1311
1312 if (
1313 normalized_effect is None
1314 and advance_ms is None
1315 and advance_on_click is None
1316 and normalized_sound is None
1317 ):
1318 return ""
1319
1320 attr_text = _transition_attributes(
1321 duration_ms=duration_ms,
1322 advance_after_ms=advance_ms,
1323 advance_on_click=advance_on_click,
1324 declare_p14=normalized_effect is not None,
1325 )
1326 if normalized_effect is None:
1327 if normalized_sound is None:
1328 return f" <p:transition{attr_text}/>"
1329 return (
1330 f" <p:transition{attr_text}>\n"
1331 + _transition_sound_xml(normalized_sound, indent=" ")
1332 + " </p:transition>"
1333 )
1334
1335 prefix, element_name, effect_attrs = _effect_xml(
1336 normalized_effect,
1337 normalized_options,
1338 )
1339 if prefix != "p":
1340 _effect_prefix, _namespace, _element, _attrs, fallback = _effect_spec(
1341 normalized_effect,
1342 normalized_options,
1343 )
1344 fallback_effect = fallback or "fade"
1345 fallback_prefix, fallback_name, fallback_attrs = _effect_xml(
1346 fallback_effect
1347 )
1348 fallback_attr_text = _transition_attributes(
1349 duration_ms=None,
1350 advance_after_ms=advance_ms,
1351 advance_on_click=advance_on_click,
1352 declare_p14=False,
1353 )
1354 choice_sound_xml = _transition_sound_xml(
1355 normalized_sound,
1356 indent=" ",
1357 )
1358 fallback_sound_xml = _transition_sound_xml(
1359 normalized_sound,
1360 indent=" ",
1361 )
1362 return (
1363 f' <mc:AlternateContent xmlns:mc="{MC_NS}">\n'
1364 f' <mc:Choice xmlns:{prefix}="{TRANSITION_NAMESPACES[prefix]}" '
1365 f'Requires="{prefix}">\n'
1366 f" <p:transition{attr_text}>\n"
1367 f" <{prefix}:{element_name}{effect_attrs}/>\n"
1368 f"{choice_sound_xml}"
1369 " </p:transition>\n"
1370 " </mc:Choice>\n"
1371 " <mc:Fallback>\n"
1372 f" <p:transition{fallback_attr_text}>\n"
1373 f" <{fallback_prefix}:{fallback_name}{fallback_attrs}/>\n"
1374 f"{fallback_sound_xml}"
1375 " </p:transition>\n"
1376 " </mc:Fallback>\n"
1377 " </mc:AlternateContent>"
1378 )
1379 sound_xml = _transition_sound_xml(normalized_sound, indent=" ")
1380 return (
1381 f" <p:transition{attr_text}>\n"
1382 f" <{prefix}:{element_name}{effect_attrs}/>\n"
1383 f"{sound_xml}"
1384 " </p:transition>"
1385 )
1386
1387
1388 def _is_lxml_element(element: object) -> bool:
1389 return LET is not None and isinstance(element, LET._Element)
1390
1391
1392 def _new_element(
1393 context: Any,
1394 tag: str,
1395 *,
1396 nsmap: dict[str, str] | None = None,
1397 ) -> Any:
1398 if _is_lxml_element(context):
1399 return LET.Element(tag, nsmap=nsmap)
1400 return ET.Element(tag)
1401
1402
1403 def _build_transition_element(
1404 context: Any,
1405 *,
1406 effect: str | None,
1407 duration: float,
1408 advance_after: float | None,
1409 advance_on_click: bool | None,
1410 effect_options: Mapping[str, object] | None = None,
1411 ) -> Any | None:
1412 normalized_effect, normalized_options = normalize_transition_effect_request(
1413 effect,
1414 effect_options,
1415 )
1416 if (
1417 normalized_effect is None
1418 and advance_after is None
1419 and advance_on_click is not False
1420 ):
1421 return None
1422
1423 if advance_on_click is not None:
1424 if not isinstance(advance_on_click, bool):
1425 raise ValueError(
1426 "transition advance_on_click must be a boolean or None"
1427 )
1428 duration_ms = (
1429 _seconds_to_ms(
1430 duration,
1431 "transition duration",
1432 allow_zero=False,
1433 )
1434 if normalized_effect is not None
1435 else None
1436 )
1437 advance_ms = (
1438 _seconds_to_ms(
1439 advance_after,
1440 "transition advance_after",
1441 allow_zero=True,
1442 )
1443 if advance_after is not None
1444 else None
1445 )
1446
1447 def build_transition(
1448 effect_name: str | None,
1449 *,
1450 include_duration: bool,
1451 options: Mapping[str, object] | None = None,
1452 ) -> Any:
1453 nsmap = {"p": PML_NS}
1454 if include_duration:
1455 nsmap["p14"] = P14_NS
1456 transition = _new_element(
1457 context,
1458 _qn(PML_NS, "transition"),
1459 nsmap=nsmap,
1460 )
1461 if include_duration and duration_ms is not None:
1462 transition.set(_qn(P14_NS, "dur"), str(duration_ms))
1463 if advance_on_click is not None:
1464 transition.set("advClick", "1" if advance_on_click else "0")
1465 if advance_ms is not None:
1466 transition.set("advTm", str(advance_ms))
1467 if effect_name is not None:
1468 _prefix, namespace, element_name, effect_attrs, _fallback = (
1469 _effect_spec(effect_name, options)
1470 )
1471 child = _new_element(context, _qn(namespace, element_name))
1472 for key, value in effect_attrs.items():
1473 child.set(key, str(value))
1474 transition.append(child)
1475 return transition
1476
1477 if normalized_effect is None:
1478 return build_transition(None, include_duration=False)
1479
1480 prefix, _namespace, _element, _attrs, fallback = _effect_spec(
1481 normalized_effect,
1482 normalized_options,
1483 )
1484 if prefix == "p":
1485 return build_transition(
1486 normalized_effect,
1487 include_duration=True,
1488 options=normalized_options,
1489 )
1490
1491 carrier = _new_element(
1492 context,
1493 _qn(MC_NS, "AlternateContent"),
1494 nsmap={"mc": MC_NS},
1495 )
1496 choice = _new_element(
1497 context,
1498 _qn(MC_NS, "Choice"),
1499 nsmap={prefix: TRANSITION_NAMESPACES[prefix]},
1500 )
1501 choice.set("Requires", prefix)
1502 choice.append(
1503 build_transition(
1504 normalized_effect,
1505 include_duration=True,
1506 options=normalized_options,
1507 )
1508 )
1509 fallback_node = _new_element(context, _qn(MC_NS, "Fallback"))
1510 fallback_node.append(
1511 build_transition(fallback or "fade", include_duration=False)
1512 )
1513 carrier.extend((choice, fallback_node))
1514 return carrier
1515
1516
1517 def _transition_elements(carrier: Any) -> list[Any]:
1518 if carrier.tag == _qn(PML_NS, "transition"):
1519 return [carrier]
1520 return [
1521 element
1522 for element in carrier.iter()
1523 if element.tag == _qn(PML_NS, "transition")
1524 ]
1525
1526
1527 def transition_carriers(slide_root: Any) -> list[Any]:
1528 """Return root-level direct or AlternateContent transition carriers."""
1529 carriers: list[Any] = []
1530 for child in list(slide_root):
1531 if child.tag == _qn(PML_NS, "transition"):
1532 carriers.append(child)
1533 continue
1534 if child.tag != _qn(MC_NS, "AlternateContent"):
1535 continue
1536 if _transition_elements(child):
1537 carriers.append(child)
1538 return carriers
1539
1540
1541 def _primary_and_fallback(carrier: Any) -> tuple[Any | None, Any | None]:
1542 if carrier.tag == _qn(PML_NS, "transition"):
1543 return carrier, None
1544
1545 primary = None
1546 fallback = None
1547 for child in list(carrier):
1548 transitions = _transition_elements(child)
1549 if not transitions:
1550 continue
1551 if child.tag == _qn(MC_NS, "Choice") and primary is None:
1552 primary = transitions[0]
1553 elif child.tag == _qn(MC_NS, "Fallback") and fallback is None:
1554 fallback = transitions[0]
1555 if primary is None:
1556 transitions = _transition_elements(carrier)
1557 primary = transitions[0] if transitions else None
1558 return primary, fallback
1559
1560
1561 def _effect_identity(
1562 transition: Any | None,
1563 ) -> tuple[str | None, str | None, dict[str, str]]:
1564 if transition is None:
1565 return None, None, {}
1566 for child in list(transition):
1567 if child.tag == _qn(PML_NS, "sndAc"):
1568 continue
1569 return (
1570 _local_name(child.tag),
1571 _namespace_name(child.tag),
1572 {str(name): str(value) for name, value in child.attrib.items()},
1573 )
1574 return None, None, {}
1575
1576
1577 def _sound_identity(
1578 transition: Any | None,
1579 ) -> tuple[str | None, str | None]:
1580 """Return one embedded transition sound relationship and display name."""
1581 if transition is None:
1582 return None, None
1583 sound_action = next(
1584 (
1585 child
1586 for child in list(transition)
1587 if child.tag == _qn(PML_NS, "sndAc")
1588 ),
1589 None,
1590 )
1591 if sound_action is None:
1592 return None, None
1593 start_sound = next(
1594 (
1595 child
1596 for child in list(sound_action)
1597 if child.tag == _qn(PML_NS, "stSnd")
1598 ),
1599 None,
1600 )
1601 if start_sound is None:
1602 return None, None
1603 sound = next(
1604 (
1605 child
1606 for child in list(start_sound)
1607 if child.tag == _qn(PML_NS, "snd")
1608 ),
1609 None,
1610 )
1611 if sound is None:
1612 return None, None
1613 return sound.get(_qn(RELATIONSHIPS_NS, "embed")), sound.get("name")
1614
1615
1616 def _effective_transition_options(
1617 effect: str,
1618 options: Mapping[str, object] | None = None,
1619 ) -> dict[str, object]:
1620 effective = {
1621 name: spec["default"]
1622 for name, spec in NATIVE_TRANSITIONS[effect]["effectOptions"].items()
1623 }
1624 effective.update(normalize_transition_effect_options(effect, options))
1625 return effective
1626
1627
1628 def _transition_option_combinations(effect: str) -> list[dict[str, object]]:
1629 combinations: list[dict[str, object]] = [{}]
1630 for name, spec in NATIVE_TRANSITIONS[effect]["effectOptions"].items():
1631 combinations = [
1632 {**combination, name: value}
1633 for combination in combinations
1634 for value in spec["values"]
1635 ]
1636 return combinations
1637
1638
1639 def _identify_native_transition(
1640 element: str | None,
1641 namespace: str | None,
1642 attributes: Mapping[str, str],
1643 ) -> tuple[str | None, dict[str, object]]:
1644 if element is None or namespace is None:
1645 return None, {}
1646 for effect in NATIVE_TRANSITION_KEYS:
1647 for options in _transition_option_combinations(effect):
1648 _prefix, expected_namespace, expected_element, expected_attrs, _fallback = (
1649 _effect_spec(effect, options)
1650 )
1651 if (
1652 namespace == expected_namespace
1653 and element == expected_element
1654 and dict(attributes) == {
1655 str(name): str(value)
1656 for name, value in expected_attrs.items()
1657 }
1658 ):
1659 return effect, options
1660 return None, {}
1661
1662
1663 def _int_attribute(element: Any | None, *names: str) -> int | None:
1664 if element is None:
1665 return None
1666 for name in names:
1667 raw = element.get(name)
1668 if raw is None:
1669 continue
1670 try:
1671 return int(raw)
1672 except (TypeError, ValueError):
1673 return None
1674 return None
1675
1676
1677 def _bool_attribute(element: Any, name: str, default: bool) -> bool:
1678 raw = element.get(name)
1679 if raw is None:
1680 return default
1681 return str(raw).strip().lower() not in {"0", "false", "off", "no"}
1682
1683
1684 def read_slide_transition(slide_root: Any) -> TransitionSummary:
1685 """Read the primary transition without mistaking fallback for success."""
1686 carriers = transition_carriers(slide_root)
1687 if not carriers:
1688 return TransitionSummary(carrier="none", logical_count=0)
1689
1690 carrier = carriers[0]
1691 primary, fallback = _primary_and_fallback(carrier)
1692 effect, effect_namespace, effect_attributes = _effect_identity(primary)
1693 fallback_effect, fallback_namespace, _fallback_attributes = _effect_identity(
1694 fallback
1695 )
1696 sound_relationship_id, sound_name = _sound_identity(primary)
1697 fallback_sound_relationship_id, fallback_sound_name = _sound_identity(
1698 fallback
1699 )
1700 canonical_effect, effect_options = _identify_native_transition(
1701 effect,
1702 effect_namespace,
1703 effect_attributes,
1704 )
1705 carrier_name = (
1706 "alternate-content"
1707 if carrier.tag == _qn(MC_NS, "AlternateContent")
1708 else "direct"
1709 )
1710 duration_ms = _int_attribute(
1711 primary,
1712 _qn(P14_NS, "dur"),
1713 "dur",
1714 )
1715 advance_after_ms = _int_attribute(primary, "advTm")
1716 return TransitionSummary(
1717 carrier=carrier_name if len(carriers) == 1 else "multiple",
1718 logical_count=len(carriers),
1719 effect=effect,
1720 effect_namespace=effect_namespace,
1721 effect_attributes=effect_attributes,
1722 canonical_effect=canonical_effect,
1723 effect_options=effect_options,
1724 fallback_effect=fallback_effect,
1725 fallback_effect_namespace=fallback_namespace,
1726 duration_ms=duration_ms,
1727 speed=primary.get("spd") if primary is not None else None,
1728 advance_on_click=(
1729 _bool_attribute(primary, "advClick", True)
1730 if primary is not None
1731 else None
1732 ),
1733 advance_after_ms=advance_after_ms,
1734 sound_relationship_id=sound_relationship_id,
1735 sound_name=sound_name,
1736 fallback_sound_relationship_id=fallback_sound_relationship_id,
1737 fallback_sound_name=fallback_sound_name,
1738 )
1739
1740
1741 def _captured_advance(carriers: list[Any]) -> tuple[bool | None, float | None]:
1742 if not carriers:
1743 return None, None
1744 primary, _fallback = _primary_and_fallback(carriers[0])
1745 if primary is None:
1746 return None, None
1747 click = (
1748 _bool_attribute(primary, "advClick", True)
1749 if primary.get("advClick") is not None
1750 else None
1751 )
1752 advance_ms = _int_attribute(primary, "advTm")
1753 after = advance_ms / 1000 if advance_ms is not None else None
1754 return click, after
1755
1756
1757 def _resolve_advance(
1758 update: AdvanceUpdate,
1759 *,
1760 preserved_click: bool | None,
1761 preserved_after: float | None,
1762 ) -> tuple[bool | None, float | None]:
1763 valid_modes = {"preserve", "click", "after", "both", "narration"}
1764 if update.mode not in valid_modes:
1765 raise ValueError(
1766 f"unknown slide advance mode {update.mode!r}; "
1767 f"valid modes: {', '.join(sorted(valid_modes))}"
1768 )
1769 if update.mode == "preserve":
1770 return preserved_click, preserved_after
1771 if update.mode == "click":
1772 return True, None
1773 if update.after is None:
1774 raise ValueError(f"slide advance mode {update.mode!r} requires 'after'")
1775 after = validate_seconds(
1776 update.after,
1777 "slide advance after",
1778 allow_zero=True,
1779 )
1780 if update.mode == "both":
1781 return True, after
1782 return False, after
1783
1784
1785 def _set_advance_attributes(
1786 transition: Any,
1787 *,
1788 advance_on_click: bool | None,
1789 advance_after: float | None,
1790 ) -> None:
1791 if advance_on_click is None:
1792 transition.attrib.pop("advClick", None)
1793 else:
1794 transition.set("advClick", "1" if advance_on_click else "0")
1795 if advance_after is None:
1796 transition.attrib.pop("advTm", None)
1797 else:
1798 transition.set(
1799 "advTm",
1800 str(
1801 _seconds_to_ms(
1802 advance_after,
1803 "slide advance after",
1804 allow_zero=True,
1805 )
1806 ),
1807 )
1808
1809
1810 def _insert_transition_carrier(slide_root: Any, carrier: Any) -> None:
1811 children = list(slide_root)
1812 for index, child in enumerate(children):
1813 if child.tag in {
1814 _qn(PML_NS, "timing"),
1815 _qn(PML_NS, "extLst"),
1816 }:
1817 slide_root.insert(index, carrier)
1818 return
1819
1820 clr_map = None
1821 for child in children:
1822 if child.tag == _qn(PML_NS, "clrMapOvr"):
1823 clr_map = child
1824 if clr_map is not None:
1825 slide_root.insert(list(slide_root).index(clr_map) + 1, carrier)
1826 return
1827 slide_root.append(carrier)
1828
1829
1830 def _apply_slide_motion_unchecked(
1831 slide_root: Any,
1832 *,
1833 enter: EnterUpdate,
1834 advance: AdvanceUpdate,
1835 ) -> bool:
1836 """Apply one resolved enter/advance update and return whether advTm remains."""
1837 valid_policies = {"preserve", "replace", "none"}
1838 if enter.policy not in valid_policies:
1839 raise ValueError(
1840 f"unknown transition enter policy {enter.policy!r}; "
1841 f"valid policies: {', '.join(sorted(valid_policies))}"
1842 )
1843 if (
1844 enter.policy != "replace"
1845 and enter.effect_options not in (None, {})
1846 ):
1847 raise ValueError(
1848 "transition effect_options require enter policy 'replace'"
1849 )
1850
1851 carriers = transition_carriers(slide_root)
1852 if len(carriers) > 1:
1853 raise ValueError(
1854 "slide contains multiple logical transition carriers; "
1855 "refusing to preserve or replace an ambiguous source"
1856 )
1857
1858 preserved_click, preserved_after = _captured_advance(carriers)
1859 advance_on_click, advance_after = _resolve_advance(
1860 advance,
1861 preserved_click=preserved_click,
1862 preserved_after=preserved_after,
1863 )
1864
1865 if enter.policy == "preserve":
1866 if advance.mode == "preserve":
1867 return any(
1868 transition.get("advTm") is not None
1869 for carrier in carriers
1870 for transition in _transition_elements(carrier)
1871 )
1872 if carriers:
1873 for transition in _transition_elements(carriers[0]):
1874 _set_advance_attributes(
1875 transition,
1876 advance_on_click=advance_on_click,
1877 advance_after=advance_after,
1878 )
1879 return advance_after is not None
1880
1881 transition = _build_transition_element(
1882 slide_root,
1883 effect=None,
1884 duration=enter.duration,
1885 advance_after=advance_after,
1886 advance_on_click=advance_on_click,
1887 effect_options=None,
1888 )
1889 if transition is not None:
1890 _insert_transition_carrier(slide_root, transition)
1891 return advance_after is not None
1892
1893 effect, effect_options = (
1894 normalize_transition_effect_request(
1895 enter.effect,
1896 enter.effect_options,
1897 allow_none=False,
1898 )
1899 if enter.policy == "replace"
1900 else (None, {})
1901 )
1902 duration = validate_seconds(
1903 enter.duration,
1904 "transition duration",
1905 allow_zero=False,
1906 )
1907
1908 if carriers:
1909 slide_root.remove(carriers[0])
1910
1911 transition = _build_transition_element(
1912 slide_root,
1913 effect=effect,
1914 duration=duration,
1915 advance_after=advance_after,
1916 advance_on_click=advance_on_click,
1917 effect_options=effect_options,
1918 )
1919 if transition is not None:
1920 _insert_transition_carrier(slide_root, transition)
1921 return advance_after is not None
1922
1923
1924 def _visual_identity(summary: TransitionSummary) -> tuple[Any, ...]:
1925 return (
1926 summary.carrier,
1927 summary.logical_count,
1928 summary.effect,
1929 summary.effect_namespace,
1930 tuple(sorted(summary.effect_attributes.items())),
1931 summary.canonical_effect,
1932 tuple(sorted(summary.effect_options.items())),
1933 summary.fallback_effect,
1934 summary.fallback_effect_namespace,
1935 summary.duration_ms,
1936 summary.speed,
1937 summary.sound_relationship_id,
1938 summary.sound_name,
1939 summary.fallback_sound_relationship_id,
1940 summary.fallback_sound_name,
1941 )
1942
1943
1944 def _expected_visual_identity(
1945 effect: str,
1946 effect_options: Mapping[str, object] | None = None,
1947 ) -> tuple[str, str, str, str | None, str | None, dict[str, Any]]:
1948 prefix, namespace, element, attrs, fallback = _effect_spec(
1949 effect,
1950 effect_options,
1951 )
1952 carrier = "direct" if prefix == "p" else "alternate-content"
1953 fallback_element = None
1954 fallback_namespace = None
1955 if carrier == "alternate-content":
1956 (
1957 _fallback_prefix,
1958 fallback_namespace,
1959 fallback_element,
1960 _fallback_attrs,
1961 _nested_fallback,
1962 ) = _effect_spec(fallback or "fade")
1963 return (
1964 carrier,
1965 element,
1966 namespace,
1967 fallback_element,
1968 fallback_namespace,
1969 attrs,
1970 )
1971
1972
1973 def _validate_applied_motion(
1974 slide_root: Any,
1975 *,
1976 before: TransitionSummary,
1977 enter: EnterUpdate,
1978 advance: AdvanceUpdate,
1979 ) -> None:
1980 errors = validate_slide_transition_structure(slide_root)
1981 after = read_slide_transition(slide_root)
1982
1983 if enter.policy == "preserve":
1984 if before.logical_count and _visual_identity(after) != _visual_identity(before):
1985 errors.append("preserve policy changed the source visual transition")
1986 elif not before.logical_count and after.effect is not None:
1987 errors.append("preserve policy added a visual transition")
1988 elif enter.policy == "replace":
1989 effect, effect_options = normalize_transition_effect_request(
1990 enter.effect,
1991 enter.effect_options,
1992 allow_none=False,
1993 )
1994 expected_effect_options = _effective_transition_options(
1995 effect,
1996 effect_options,
1997 )
1998 expected_duration = _seconds_to_ms(
1999 enter.duration,
2000 "transition duration",
2001 allow_zero=False,
2002 )
2003 (
2004 expected_carrier,
2005 expected_effect,
2006 expected_namespace,
2007 expected_fallback,
2008 expected_fallback_namespace,
2009 expected_attrs,
2010 ) = _expected_visual_identity(effect, effect_options)
2011 if (
2012 after.carrier != expected_carrier
2013 or after.logical_count != 1
2014 or after.effect != expected_effect
2015 or after.effect_namespace != expected_namespace
2016 or after.canonical_effect != effect
2017 or dict(after.effect_options) != expected_effect_options
2018 or after.fallback_effect != expected_fallback
2019 or after.fallback_effect_namespace != expected_fallback_namespace
2020 or after.duration_ms != expected_duration
2021 ):
2022 errors.append(
2023 f"replace policy read-back does not match effect {effect!r}"
2024 )
2025 carriers = transition_carriers(slide_root)
2026 if carriers:
2027 primary, _fallback = _primary_and_fallback(carriers[0])
2028 effect_children = [
2029 child
2030 for child in (list(primary) if primary is not None else [])
2031 if child.tag != _qn(PML_NS, "sndAc")
2032 ]
2033 if effect_children:
2034 actual_attrs = effect_children[0].attrib
2035 for name, value in expected_attrs.items():
2036 if actual_attrs.get(name) != str(value):
2037 errors.append(
2038 f"replace policy wrote invalid {effect} {name} attribute"
2039 )
2040 elif enter.policy == "none":
2041 if after.effect is not None or after.fallback_effect is not None:
2042 errors.append("none policy retained a visual transition")
2043 if (
2044 after.sound_relationship_id is not None
2045 or after.fallback_sound_relationship_id is not None
2046 ):
2047 errors.append("none policy retained a transition sound")
2048
2049 preserved_click = (
2050 before.advance_on_click
2051 if before.advance_on_click is not None
2052 else True
2053 )
2054 preserved_after = (
2055 before.advance_after_ms / 1000
2056 if before.advance_after_ms is not None
2057 else None
2058 )
2059 expected_click, expected_after = _resolve_advance(
2060 advance,
2061 preserved_click=preserved_click,
2062 preserved_after=preserved_after,
2063 )
2064 actual_click = (
2065 after.advance_on_click
2066 if after.advance_on_click is not None
2067 else True
2068 )
2069 actual_after_ms = after.advance_after_ms
2070 expected_after_ms = (
2071 _seconds_to_ms(
2072 expected_after,
2073 "transition advance_after",
2074 allow_zero=True,
2075 )
2076 if expected_after is not None
2077 else None
2078 )
2079 if actual_click != expected_click or actual_after_ms != expected_after_ms:
2080 errors.append("transition advance read-back does not match the requested mode")
2081 if advance.mode != "preserve":
2082 for carrier in transition_carriers(slide_root):
2083 for transition in _transition_elements(carrier):
2084 branch_click = _bool_attribute(transition, "advClick", True)
2085 branch_after_ms = _int_attribute(transition, "advTm")
2086 if (
2087 branch_click != expected_click
2088 or branch_after_ms != expected_after_ms
2089 ):
2090 errors.append(
2091 "transition fallback advance does not match the requested mode"
2092 )
2093 break
2094
2095 if errors:
2096 raise ValueError("; ".join(errors))
2097
2098
2099 def apply_slide_motion(
2100 slide_root: Any,
2101 *,
2102 enter: EnterUpdate,
2103 advance: AdvanceUpdate,
2104 ) -> bool:
2105 """Apply and immediately read back one resolved transition update."""
2106 before = read_slide_transition(slide_root)
2107 uses_timings = _apply_slide_motion_unchecked(
2108 slide_root,
2109 enter=enter,
2110 advance=advance,
2111 )
2112 _validate_applied_motion(
2113 slide_root,
2114 before=before,
2115 enter=enter,
2116 advance=advance,
2117 )
2118 return uses_timings
2119
2120
2121 def _xml_declaration(source: str) -> str:
2122 match = re.match(r"\s*(<\?xml[^?]*\?>)", source)
2123 return match.group(1) if match else ""
2124
2125
2126 def _serialize_lxml_like(root: Any, source: str) -> str:
2127 body = LET.tostring(root, encoding="unicode", pretty_print=False)
2128 declaration = _xml_declaration(source)
2129 return f"{declaration}\n{body}" if declaration else body
2130
2131
2132 def namespace_bindings(xml_data: str | bytes) -> dict[str, str]:
2133 """Return source prefix bindings without changing the document."""
2134 data = xml_data.encode("utf-8") if isinstance(xml_data, str) else xml_data
2135 bindings: dict[str, str] = {}
2136 for _event, (prefix, uri) in ET.iterparse(
2137 io.BytesIO(data),
2138 events=("start-ns",),
2139 ):
2140 bindings[prefix or ""] = uri
2141 return bindings
2142
2143
2144 def register_source_namespaces(xml_data: str | bytes) -> dict[str, str]:
2145 """Register source prefixes before stdlib ElementTree re-serialization."""
2146 bindings = namespace_bindings(xml_data)
2147 for prefix, uri in bindings.items():
2148 if prefix == "xml":
2149 continue
2150 try:
2151 ET.register_namespace(prefix, uri)
2152 except (AttributeError, ValueError):
2153 continue
2154 return bindings
2155
2156
2157 def parse_source_xml(xml_data: str | bytes) -> ET.Element:
2158 """Parse XML after registering its original namespace prefixes."""
2159 data = xml_data.encode("utf-8") if isinstance(xml_data, str) else xml_data
2160 register_source_namespaces(data)
2161 return ET.fromstring(data)
2162
2163
2164 def _required_mce_prefixes(root: Any) -> set[str]:
2165 prefixes = set(str(root.get(_qn(MC_NS, "Ignorable")) or "").split())
2166 for element in root.iter():
2167 if element.tag == _qn(MC_NS, "Choice"):
2168 prefixes.update(str(element.get("Requires") or "").split())
2169 return {prefix for prefix in prefixes if prefix}
2170
2171
2172 def _inject_root_namespace_declarations(
2173 xml_data: bytes,
2174 *,
2175 bindings: dict[str, str],
2176 prefixes: set[str],
2177 ) -> bytes:
2178 text = xml_data.decode("utf-8")
2179 declaration_end = text.find("?>")
2180 root_start = text.find("<", declaration_end + 2 if declaration_end >= 0 else 0)
2181 root_end = text.find(">", root_start)
2182 if root_start < 0 or root_end < 0:
2183 raise ValueError("unable to locate serialized XML root element")
2184
2185 opening = text[root_start:root_end]
2186 declarations: list[str] = []
2187 for prefix in sorted(prefixes):
2188 uri = bindings.get(prefix)
2189 if not uri:
2190 raise ValueError(
2191 f"MCE prefix {prefix!r} has no namespace binding in source XML"
2192 )
2193 if re.search(rf"\bxmlns:{re.escape(prefix)}\s*=", opening):
2194 continue
2195 declarations.append(f" xmlns:{prefix}={quoteattr(uri)}")
2196 if declarations:
2197 text = text[:root_end] + "".join(declarations) + text[root_end:]
2198 return text.encode("utf-8")
2199
2200
2201 def validate_mce_prefixes(xml_data: str | bytes) -> list[str]:
2202 """Return unresolved MCE Requires/Ignorable prefix errors."""
2203 if LET is None:
2204 return []
2205 data = xml_data.encode("utf-8") if isinstance(xml_data, str) else xml_data
2206 try:
2207 root = LET.fromstring(data)
2208 except LET.XMLSyntaxError as exc:
2209 return [f"invalid XML: {exc}"]
2210
2211 errors: list[str] = []
2212 ignorable = str(root.get(_qn(MC_NS, "Ignorable")) or "").split()
2213 for prefix in ignorable:
2214 if prefix not in root.nsmap:
2215 errors.append(f"mc:Ignorable prefix is not bound: {prefix}")
2216 for choice in root.iter(_qn(MC_NS, "Choice")):
2217 for prefix in str(choice.get("Requires") or "").split():
2218 if prefix not in choice.nsmap:
2219 errors.append(f"mc:Choice Requires prefix is not bound: {prefix}")
2220 return errors
2221
2222
2223 def serialize_source_xml(root: ET.Element, source_xml: str | bytes) -> bytes:
2224 """Serialize stdlib XML while retaining MCE prefix bindings."""
2225 expected_transition = (
2226 read_slide_transition(root)
2227 if root.tag == _qn(PML_NS, "sld")
2228 else None
2229 )
2230 source = (
2231 source_xml.encode("utf-8")
2232 if isinstance(source_xml, str)
2233 else source_xml
2234 )
2235 bindings = register_source_namespaces(source)
2236 prefixes = _required_mce_prefixes(root)
2237 for prefix in prefixes:
2238 namespace = TRANSITION_NAMESPACES.get(prefix)
2239 if namespace is None:
2240 continue
2241 bindings[prefix] = namespace
2242 ET.register_namespace(prefix, namespace)
2243 serialized = ET.tostring(root, encoding="utf-8", xml_declaration=True)
2244 serialized = _inject_root_namespace_declarations(
2245 serialized,
2246 bindings=bindings,
2247 prefixes=prefixes,
2248 )
2249 errors = validate_mce_prefixes(serialized)
2250 if expected_transition is not None:
2251 errors.extend(validate_slide_transition_xml(serialized))
2252 actual_transition = read_slide_transition_xml(serialized)
2253 if actual_transition != expected_transition:
2254 errors.append("slide transition changed during XML serialization")
2255 if errors:
2256 raise ValueError("; ".join(errors))
2257 return serialized
2258
2259
2260 def apply_slide_motion_xml(
2261 slide_xml: str,
2262 *,
2263 enter: EnterUpdate,
2264 advance: AdvanceUpdate,
2265 ) -> tuple[str, bool]:
2266 """Apply motion to source XML while preserving MCE namespace prefixes."""
2267 source_bytes = slide_xml.encode("utf-8")
2268 if LET is not None:
2269 parser = LET.XMLParser(
2270 remove_blank_text=False,
2271 resolve_entities=False,
2272 no_network=True,
2273 )
2274 root = LET.fromstring(source_bytes, parser)
2275 uses_timings = apply_slide_motion(
2276 root,
2277 enter=enter,
2278 advance=advance,
2279 )
2280 output = _serialize_lxml_like(root, slide_xml)
2281 else:
2282 root = parse_source_xml(source_bytes)
2283 uses_timings = apply_slide_motion(
2284 root,
2285 enter=enter,
2286 advance=advance,
2287 )
2288 output = serialize_source_xml(root, source_bytes).decode("utf-8")
2289
2290 expected_transition = read_slide_transition(root)
2291 errors = validate_slide_transition_xml(output)
2292 if read_slide_transition_xml(output) != expected_transition:
2293 errors.append("slide transition changed during XML serialization")
2294 if errors:
2295 raise ValueError("; ".join(errors))
2296 return output, uses_timings
2297
2298
2299 def read_slide_transition_xml(slide_xml: str | bytes) -> TransitionSummary:
2300 """Read a transition summary from raw slide XML."""
2301 data = (
2302 slide_xml.encode("utf-8")
2303 if isinstance(slide_xml, str)
2304 else slide_xml
2305 )
2306 if LET is not None:
2307 root = LET.fromstring(data)
2308 else:
2309 root = parse_source_xml(data)
2310 return read_slide_transition(root)
2311
2312
2313 def validate_generated_transition_xml(
2314 slide_xml: str | bytes,
2315 *,
2316 effect: str | None,
2317 duration: object,
2318 advance_on_click: bool | None,
2319 advance_after: object | None,
2320 effect_options: Mapping[str, object] | None = None,
2321 sound: Mapping[str, object] | None = None,
2322 ) -> TransitionSummary:
2323 """Validate a generated transition against its resolved settings."""
2324 data = slide_xml.encode("utf-8") if isinstance(slide_xml, str) else slide_xml
2325 root = LET.fromstring(data) if LET is not None else parse_source_xml(data)
2326 errors = validate_slide_transition_structure(root) + validate_mce_prefixes(data)
2327 summary = read_slide_transition(root)
2328 normalized_effect, normalized_options = normalize_transition_effect_request(
2329 effect,
2330 effect_options,
2331 )
2332 normalized_sound = _normalize_transition_sound(sound)
2333 expected_click = True if advance_on_click is None else advance_on_click
2334 if not isinstance(expected_click, bool):
2335 errors.append("transition advance_on_click must be a boolean or None")
2336 expected_after_ms = (
2337 _seconds_to_ms(
2338 advance_after,
2339 "transition advance_after",
2340 allow_zero=True,
2341 )
2342 if advance_after is not None
2343 else None
2344 )
2345 expects_carrier = (
2346 normalized_effect is not None
2347 or expected_after_ms is not None
2348 or expected_click is False
2349 or normalized_sound is not None
2350 )
2351
2352 if not expects_carrier:
2353 if summary.logical_count != 0:
2354 errors.append("generated slide unexpectedly contains a transition carrier")
2355 else:
2356 expected_duration_ms = (
2357 _seconds_to_ms(
2358 duration,
2359 "transition duration",
2360 allow_zero=False,
2361 )
2362 if normalized_effect is not None
2363 else None
2364 )
2365 expected_carrier = "direct"
2366 expected_effect = None
2367 expected_namespace = None
2368 expected_fallback = None
2369 expected_fallback_namespace = None
2370 expected_attrs: dict[str, Any] = {}
2371 expected_sound_relationship_id = (
2372 normalized_sound["relationship_id"]
2373 if normalized_sound is not None
2374 else None
2375 )
2376 expected_sound_name = (
2377 normalized_sound["name"]
2378 if normalized_sound is not None
2379 else None
2380 )
2381 expected_fallback_sound_relationship_id = None
2382 expected_fallback_sound_name = None
2383 if normalized_effect is not None:
2384 (
2385 expected_carrier,
2386 expected_effect,
2387 expected_namespace,
2388 expected_fallback,
2389 expected_fallback_namespace,
2390 expected_attrs,
2391 ) = _expected_visual_identity(
2392 normalized_effect,
2393 normalized_options,
2394 )
2395 expected_effect_options = _effective_transition_options(
2396 normalized_effect,
2397 normalized_options,
2398 )
2399 if expected_carrier == "alternate-content":
2400 expected_fallback_sound_relationship_id = (
2401 expected_sound_relationship_id
2402 )
2403 expected_fallback_sound_name = expected_sound_name
2404 else:
2405 expected_effect_options = {}
2406 if (
2407 summary.carrier != expected_carrier
2408 or summary.logical_count != 1
2409 or summary.effect != expected_effect
2410 or summary.effect_namespace != expected_namespace
2411 or summary.canonical_effect != normalized_effect
2412 or dict(summary.effect_options) != expected_effect_options
2413 or summary.fallback_effect != expected_fallback
2414 or summary.fallback_effect_namespace != expected_fallback_namespace
2415 or summary.duration_ms != expected_duration_ms
2416 or summary.advance_on_click != expected_click
2417 or summary.advance_after_ms != expected_after_ms
2418 or summary.sound_relationship_id != expected_sound_relationship_id
2419 or summary.sound_name != expected_sound_name
2420 or summary.fallback_sound_relationship_id
2421 != expected_fallback_sound_relationship_id
2422 or summary.fallback_sound_name != expected_fallback_sound_name
2423 ):
2424 errors.append("generated transition read-back does not match its settings")
2425 if normalized_effect is not None:
2426 carriers = transition_carriers(root)
2427 if carriers:
2428 primary, _fallback = _primary_and_fallback(carriers[0])
2429 effect_children = [
2430 child
2431 for child in (list(primary) if primary is not None else [])
2432 if child.tag != _qn(PML_NS, "sndAc")
2433 ]
2434 if len(effect_children) != 1:
2435 errors.append(
2436 "generated transition must contain exactly one visual "
2437 f"effect child; found {len(effect_children)}"
2438 )
2439 else:
2440 for name, value in expected_attrs.items():
2441 if effect_children[0].get(name) != str(value):
2442 errors.append(
2443 f"generated {normalized_effect} transition has "
2444 f"invalid {name} attribute"
2445 )
2446
2447 if errors:
2448 raise ValueError("; ".join(errors))
2449 return summary
2450
2451
2452 def _slide_relationships_part(slide_part: str) -> str:
2453 directory = posixpath.dirname(slide_part)
2454 filename = posixpath.basename(slide_part)
2455 return posixpath.join(directory, "_rels", f"{filename}.rels")
2456
2457
2458 def _resolve_slide_relationship_target(
2459 slide_part: str,
2460 target: str,
2461 ) -> str | None:
2462 normalized_target = target.replace("\\", "/")
2463 if not normalized_target:
2464 return None
2465 if normalized_target.startswith("/"):
2466 resolved = posixpath.normpath(normalized_target.lstrip("/"))
2467 else:
2468 resolved = posixpath.normpath(
2469 posixpath.join(posixpath.dirname(slide_part), normalized_target)
2470 )
2471 if resolved == ".." or resolved.startswith("../"):
2472 return None
2473 return resolved
2474
2475
2476 def _transition_sound_elements(slide_root: Any) -> list[Any]:
2477 sounds: list[Any] = []
2478 for carrier in transition_carriers(slide_root):
2479 for transition in _transition_elements(carrier):
2480 for sound_action in list(transition):
2481 if sound_action.tag != _qn(PML_NS, "sndAc"):
2482 continue
2483 for start_sound in list(sound_action):
2484 if start_sound.tag != _qn(PML_NS, "stSnd"):
2485 continue
2486 sounds.extend(
2487 child
2488 for child in list(start_sound)
2489 if child.tag == _qn(PML_NS, "snd")
2490 )
2491 return sounds
2492
2493
2494 def _package_part_content_type(
2495 package: zipfile.ZipFile,
2496 target_part: str,
2497 ) -> str | None:
2498 """Resolve one package part's MIME type from defaults or overrides."""
2499 content_types_root = ET.fromstring(package.read(CONTENT_TYPES_PART))
2500 normalized_target = "/" + target_part.lstrip("/")
2501 for entry in content_types_root:
2502 if (
2503 entry.tag == _qn(CONTENT_TYPES_NS, "Override")
2504 and entry.get("PartName") == normalized_target
2505 ):
2506 return entry.get("ContentType")
2507 extension = Path(target_part).suffix.lstrip(".").lower()
2508 for entry in content_types_root:
2509 if (
2510 entry.tag == _qn(CONTENT_TYPES_NS, "Default")
2511 and str(entry.get("Extension") or "").lower() == extension
2512 ):
2513 return entry.get("ContentType")
2514 return None
2515
2516
2517 def _validate_transition_sound_package_parts(
2518 package: zipfile.ZipFile,
2519 package_names: set[str],
2520 slide_part: str,
2521 slide_xml: bytes,
2522 ) -> list[str]:
2523 """Validate transition-sound relationships and embedded WAV targets."""
2524 errors: list[str] = []
2525 slide_root = (
2526 LET.fromstring(slide_xml)
2527 if LET is not None
2528 else parse_source_xml(slide_xml)
2529 )
2530 sounds = _transition_sound_elements(slide_root)
2531 if not sounds:
2532 return errors
2533
2534 if CONTENT_TYPES_PART not in package_names:
2535 return ["transition sound is missing [Content_Types].xml"]
2536
2537 relationships_part = _slide_relationships_part(slide_part)
2538 if relationships_part not in package_names:
2539 return [
2540 f"transition sound is missing slide relationships: "
2541 f"{relationships_part}"
2542 ]
2543 try:
2544 relationships_root = ET.fromstring(package.read(relationships_part))
2545 except (KeyError, ET.ParseError) as exc:
2546 return [f"unable to read {relationships_part}: {exc}"]
2547
2548 relationships: dict[str, Any] = {}
2549 duplicate_ids: set[str] = set()
2550 for relationship in relationships_root:
2551 relationship_id = str(relationship.get("Id") or "")
2552 if not relationship_id:
2553 continue
2554 if relationship_id in relationships:
2555 duplicate_ids.add(relationship_id)
2556 relationships[relationship_id] = relationship
2557 if duplicate_ids:
2558 errors.append(
2559 f"{relationships_part} contains duplicate relationship id(s): "
2560 + ", ".join(sorted(duplicate_ids))
2561 )
2562
2563 for sound in sounds:
2564 relationship_id = sound.get(_qn(RELATIONSHIPS_NS, "embed"))
2565 if not relationship_id:
2566 errors.append("p:snd must declare a non-empty r:embed")
2567 continue
2568 relationship = relationships.get(relationship_id)
2569 if relationship is None:
2570 errors.append(
2571 f"p:snd references missing slide relationship: "
2572 f"{relationship_id}"
2573 )
2574 continue
2575 if relationship.get("Type") != AUDIO_REL_TYPE:
2576 errors.append(
2577 f"transition sound relationship {relationship_id} must use "
2578 "the OOXML audio relationship type"
2579 )
2580 if relationship.get("TargetMode") == "External":
2581 errors.append(
2582 f"transition sound relationship {relationship_id} must be internal"
2583 )
2584 continue
2585 target = str(relationship.get("Target") or "")
2586 target_part = _resolve_slide_relationship_target(slide_part, target)
2587 if target_part is None:
2588 errors.append(
2589 f"transition sound relationship {relationship_id} has an "
2590 f"invalid target: {target!r}"
2591 )
2592 continue
2593 if Path(target_part).suffix.lower() != ".wav":
2594 errors.append(
2595 f"transition sound relationship {relationship_id} must target "
2596 f"a .wav part: {target_part}"
2597 )
2598 if target_part not in package_names:
2599 errors.append(
2600 f"transition sound relationship {relationship_id} target is "
2601 f"missing: {target_part}"
2602 )
2603 continue
2604 content_type = _package_part_content_type(package, target_part)
2605 if str(content_type or "").lower() not in WAV_CONTENT_TYPES:
2606 errors.append(
2607 f"transition sound target {target_part} must declare a WAV "
2608 f"content type; found {content_type!r}"
2609 )
2610 payload = package.read(target_part)
2611 if not (
2612 len(payload) >= 12
2613 and payload[:4] in {b"RIFF", b"RF64"}
2614 and payload[8:12] == b"WAVE"
2615 ):
2616 errors.append(
2617 f"transition sound target {target_part} is not RIFF/RF64 WAVE"
2618 )
2619
2620 return errors
2621
2622
2623 def validate_pptx_transition_package(
2624 pptx_path: Path,
2625 *,
2626 require_use_timings: bool = False,
2627 ) -> dict[str, TransitionSummary]:
2628 """Read back and validate every slide transition in a written PPTX.
2629
2630 Return summaries keyed by package part name. Raise ``ValueError`` when the
2631 ZIP, slide transition structure, MCE bindings, or required presentation
2632 timing metadata is invalid.
2633 """
2634 errors: list[str] = []
2635 summaries: dict[str, TransitionSummary] = {}
2636 try:
2637 with zipfile.ZipFile(pptx_path, "r") as package:
2638 names = package.namelist()
2639 part_counts: dict[str, int] = {}
2640 for name in names:
2641 part_counts[name] = part_counts.get(name, 0) + 1
2642 duplicate_names = sorted(
2643 name for name, count in part_counts.items() if count > 1
2644 )
2645 if duplicate_names:
2646 errors.append(
2647 "duplicate package parts: " + ", ".join(duplicate_names)
2648 )
2649
2650 package_names = set(names)
2651 slide_names = sorted(
2652 name
2653 for name in names
2654 if name.startswith("ppt/slides/slide")
2655 and name.endswith(".xml")
2656 )
2657 for slide_name in slide_names:
2658 slide_xml = package.read(slide_name)
2659 for problem in validate_slide_transition_xml(slide_xml):
2660 errors.append(f"{slide_name}: {problem}")
2661 try:
2662 summaries[slide_name] = read_slide_transition_xml(slide_xml)
2663 except Exception as exc:
2664 errors.append(f"{slide_name}: transition read-back failed: {exc}")
2665 try:
2666 sound_errors = _validate_transition_sound_package_parts(
2667 package,
2668 package_names,
2669 slide_name,
2670 slide_xml,
2671 )
2672 except Exception as exc:
2673 errors.append(
2674 f"{slide_name}: transition sound read-back failed: {exc}"
2675 )
2676 else:
2677 for problem in sound_errors:
2678 errors.append(f"{slide_name}: {problem}")
2679
2680 if require_use_timings:
2681 errors.extend(_validate_package_use_timings(package, names))
2682 except (OSError, zipfile.BadZipFile, KeyError, ET.ParseError) as exc:
2683 errors.append(f"unable to read PPTX transition package: {exc}")
2684
2685 if errors:
2686 raise ValueError("; ".join(errors))
2687 return summaries
2688
2689
2690 def _top_level_shape_types_by_name(
2691 slide_xml: bytes,
2692 ) -> dict[str, list[str]]:
2693 """Return top-level Selection Pane names and their OOXML container types."""
2694 root = LET.fromstring(slide_xml) if LET is not None else parse_source_xml(slide_xml)
2695 sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
2696 if sp_tree is None:
2697 raise ValueError("slide has no p:cSld/p:spTree")
2698 shapes: dict[str, list[str]] = {}
2699 for child in sp_tree:
2700 c_nv_pr = next(child.iter(_qn(PML_NS, "cNvPr")), None)
2701 name = c_nv_pr.get("name") if c_nv_pr is not None else None
2702 if not name:
2703 continue
2704 shapes.setdefault(name, []).append(_local_name(child.tag))
2705 return shapes
2706
2707
2708 def validate_pptx_morph_pairs(
2709 pptx_path: Path,
2710 expectations: Iterable[MorphPairExpectation],
2711 ) -> None:
2712 """Prove that every requested forced-Morph pair survives final packaging."""
2713 expected_pairs = tuple(expectations)
2714 if not expected_pairs:
2715 return
2716
2717 errors: list[str] = []
2718 slide_shapes: dict[int, dict[str, list[str]]] = {}
2719 slide_transitions: dict[int, TransitionSummary] = {}
2720 involved_slides = {
2721 slide_number
2722 for pair in expected_pairs
2723 for slide_number in (
2724 pair.source_slide_number,
2725 pair.destination_slide_number,
2726 )
2727 }
2728 try:
2729 with zipfile.ZipFile(pptx_path, "r") as package:
2730 names = set(package.namelist())
2731 for slide_number in sorted(involved_slides):
2732 part = f"ppt/slides/slide{slide_number}.xml"
2733 if part not in names:
2734 errors.append(f"{part}: Morph slide part is missing")
2735 continue
2736 slide_xml = package.read(part)
2737 try:
2738 slide_shapes[slide_number] = _top_level_shape_types_by_name(
2739 slide_xml
2740 )
2741 slide_transitions[slide_number] = read_slide_transition_xml(
2742 slide_xml
2743 )
2744 except Exception as exc:
2745 errors.append(f"{part}: Morph read-back failed: {exc}")
2746 except (OSError, zipfile.BadZipFile, KeyError, ET.ParseError) as exc:
2747 errors.append(f"unable to read PPTX Morph package: {exc}")
2748
2749 for slide_number, names_to_types in slide_shapes.items():
2750 duplicate_names = sorted(
2751 name
2752 for name, types in names_to_types.items()
2753 if name.startswith("!!") and len(types) != 1
2754 )
2755 if duplicate_names:
2756 errors.append(
2757 f"ppt/slides/slide{slide_number}.xml: duplicate forced-Morph "
2758 f"name(s): {', '.join(duplicate_names)}"
2759 )
2760
2761 for pair in expected_pairs:
2762 if pair.destination_slide_number != pair.source_slide_number + 1:
2763 errors.append(
2764 f'Morph pair "{pair.key}" must connect adjacent generated slides'
2765 )
2766 continue
2767 source_shapes = slide_shapes.get(pair.source_slide_number)
2768 destination_shapes = slide_shapes.get(pair.destination_slide_number)
2769 if source_shapes is None or destination_shapes is None:
2770 continue
2771
2772 shape_name = pair.shape_name
2773 source_types = source_shapes.get(shape_name, [])
2774 destination_types = destination_shapes.get(shape_name, [])
2775 if len(source_types) != 1:
2776 errors.append(
2777 f'Morph pair "{pair.key}" expected exactly one source object '
2778 f'named "{shape_name}" on slide {pair.source_slide_number}'
2779 )
2780 if len(destination_types) != 1:
2781 errors.append(
2782 f'Morph pair "{pair.key}" expected exactly one destination '
2783 f'object named "{shape_name}" on slide '
2784 f'{pair.destination_slide_number}'
2785 )
2786 if (
2787 len(source_types) == 1
2788 and len(destination_types) == 1
2789 and source_types[0] != destination_types[0]
2790 ):
2791 errors.append(
2792 f'Morph pair "{pair.key}" changes OOXML object type from '
2793 f'{source_types[0]} to {destination_types[0]}'
2794 )
2795
2796 transition = slide_transitions.get(pair.destination_slide_number)
2797 if (
2798 transition is not None
2799 and (
2800 transition.canonical_effect != "morph"
2801 or transition.effect_options.get("morph_by") != "object"
2802 )
2803 ):
2804 errors.append(
2805 f'Morph pair "{pair.key}" destination slide '
2806 f'{pair.destination_slide_number} does not use Morph by object'
2807 )
2808
2809 declared_names_by_edge: dict[tuple[int, int], set[str]] = {}
2810 for pair in expected_pairs:
2811 declared_names_by_edge.setdefault(
2812 (
2813 pair.source_slide_number,
2814 pair.destination_slide_number,
2815 ),
2816 set(),
2817 ).add(pair.shape_name)
2818 for source_slide_number in sorted(slide_shapes):
2819 destination_slide_number = source_slide_number + 1
2820 if destination_slide_number not in slide_shapes:
2821 continue
2822 transition = slide_transitions.get(destination_slide_number)
2823 if (
2824 transition is None
2825 or transition.canonical_effect != "morph"
2826 ):
2827 continue
2828 source_names = {
2829 name
2830 for name in slide_shapes[source_slide_number]
2831 if name.startswith("!!")
2832 }
2833 destination_names = {
2834 name
2835 for name in slide_shapes[destination_slide_number]
2836 if name.startswith("!!")
2837 }
2838 declared_names = declared_names_by_edge.get(
2839 (source_slide_number, destination_slide_number),
2840 set(),
2841 )
2842 unexpected_names = sorted(
2843 (source_names & destination_names) - declared_names
2844 )
2845 if unexpected_names:
2846 errors.append(
2847 f"Morph edge {source_slide_number}->{destination_slide_number} "
2848 "contains undeclared forced name(s): "
2849 + ", ".join(unexpected_names)
2850 )
2851
2852 if errors:
2853 raise ValueError("; ".join(dict.fromkeys(errors)))
2854
2855
2856 def _validate_package_use_timings(
2857 package: zipfile.ZipFile,
2858 names: list[str],
2859 ) -> list[str]:
2860 errors: list[str] = []
2861 required_parts = {
2862 PRESENTATION_RELS_PART,
2863 CONTENT_TYPES_PART,
2864 }
2865 missing = sorted(required_parts - set(names))
2866 if missing:
2867 return ["timed advance is missing package parts: " + ", ".join(missing)]
2868
2869 rels_root = ET.fromstring(package.read(PRESENTATION_RELS_PART))
2870 props_part = _presentation_props_part(rels_root)
2871 if props_part is None:
2872 return ["presentation relationships do not reference presentation properties"]
2873 if props_part not in names:
2874 return [f"presentation properties part is missing: {props_part}"]
2875
2876 props_root = ET.fromstring(package.read(props_part))
2877 show_properties = props_root.find(_qn(PML_NS, "showPr"))
2878 if (
2879 show_properties is None
2880 or show_properties.get("useTimings") not in {"1", "true", "on"}
2881 ):
2882 errors.append(f"{props_part} must set p:showPr@useTimings=1")
2883
2884 content_root = ET.fromstring(package.read(CONTENT_TYPES_PART))
2885 package_part_name = "/" + props_part.lstrip("/")
2886 if not any(
2887 override.get("PartName") == package_part_name
2888 and override.get("ContentType") == PRESENTATION_PROPS_CONTENT_TYPE
2889 for override in content_root
2890 ):
2891 errors.append(f"[Content_Types].xml must declare {props_part}")
2892 return errors
2893
2894
2895 def validate_slide_transition_structure(slide_root: Any) -> list[str]:
2896 """Return logical-carrier and schema-order errors for one slide root."""
2897 errors: list[str] = []
2898 children = list(slide_root)
2899 carriers = transition_carriers(slide_root)
2900 if len(carriers) > 1:
2901 errors.append(
2902 f"slide has {len(carriers)} logical transition carriers; expected at most 1"
2903 )
2904 if carriers:
2905 carrier_index = children.index(carriers[0])
2906 common_slide = next(
2907 (
2908 child
2909 for child in children
2910 if child.tag == _qn(PML_NS, "cSld")
2911 ),
2912 None,
2913 )
2914 if common_slide is None:
2915 errors.append("slide with transition carrier must contain p:cSld")
2916 elif carrier_index < children.index(common_slide):
2917 errors.append("transition carrier must follow p:cSld")
2918 color_map = next(
2919 (
2920 child
2921 for child in children
2922 if child.tag == _qn(PML_NS, "clrMapOvr")
2923 ),
2924 None,
2925 )
2926 if color_map is not None and carrier_index < children.index(color_map):
2927 errors.append("transition carrier must follow p:clrMapOvr")
2928 for tag in ("timing", "extLst"):
2929 element = next(
2930 (
2931 child
2932 for child in children
2933 if child.tag == _qn(PML_NS, tag)
2934 ),
2935 None,
2936 )
2937 if element is not None and carrier_index > children.index(element):
2938 errors.append(f"transition carrier must precede p:{tag}")
2939
2940 for carrier in carriers:
2941 for transition in _transition_elements(carrier):
2942 sound_actions = [
2943 child
2944 for child in list(transition)
2945 if child.tag == _qn(PML_NS, "sndAc")
2946 ]
2947 if len(sound_actions) > 1:
2948 errors.append(
2949 "p:transition must contain at most one p:sndAc; "
2950 f"found {len(sound_actions)}"
2951 )
2952 if sound_actions:
2953 start_sounds = [
2954 child
2955 for child in list(sound_actions[0])
2956 if child.tag == _qn(PML_NS, "stSnd")
2957 ]
2958 if len(start_sounds) > 1:
2959 errors.append(
2960 "p:sndAc must contain at most one p:stSnd; "
2961 f"found {len(start_sounds)}"
2962 )
2963 if start_sounds:
2964 sounds = [
2965 child
2966 for child in list(start_sounds[0])
2967 if child.tag == _qn(PML_NS, "snd")
2968 ]
2969 if len(sounds) != 1:
2970 errors.append(
2971 "p:stSnd must contain exactly one p:snd; "
2972 f"found {len(sounds)}"
2973 )
2974 if carrier.tag != _qn(MC_NS, "AlternateContent"):
2975 continue
2976 choices = [
2977 child
2978 for child in list(carrier)
2979 if child.tag == _qn(MC_NS, "Choice")
2980 ]
2981 if not choices:
2982 errors.append("mc:AlternateContent transition must contain mc:Choice")
2983 for branch_name in ("Choice", "Fallback"):
2984 branches = [
2985 child
2986 for child in list(carrier)
2987 if child.tag == _qn(MC_NS, branch_name)
2988 ]
2989 for branch in branches:
2990 count = len(_transition_elements(branch))
2991 if count != 1:
2992 errors.append(
2993 f"mc:{branch_name} must contain exactly one p:transition; "
2994 f"found {count}"
2995 )
2996 return errors
2997
2998
2999 def validate_slide_transition_xml(slide_xml: str | bytes) -> list[str]:
3000 """Validate raw slide transition structure and MCE prefix bindings."""
3001 data = slide_xml.encode("utf-8") if isinstance(slide_xml, str) else slide_xml
3002 try:
3003 if LET is not None:
3004 root = LET.fromstring(data)
3005 else:
3006 root = parse_source_xml(data)
3007 except Exception as exc:
3008 return [f"invalid slide XML: {exc}"]
3009 return validate_slide_transition_structure(root) + validate_mce_prefixes(data)
3010
3011
3012 def _insert_show_properties(root: Any, element: Any) -> None:
3013 for index, child in enumerate(list(root)):
3014 if child.tag in {
3015 _qn(PML_NS, "clrMru"),
3016 _qn(PML_NS, "extLst"),
3017 }:
3018 root.insert(index, element)
3019 return
3020 root.append(element)
3021
3022
3023 def _next_relationship_id(rels_xml: str) -> str:
3024 root = ET.fromstring(rels_xml)
3025 numbers: list[int] = []
3026 for relationship in root:
3027 match = re.fullmatch(r"rId(\d+)", relationship.get("Id", ""))
3028 if match is not None:
3029 numbers.append(int(match.group(1)))
3030 return f"rId{max(numbers, default=0) + 1}"
3031
3032
3033 def _presentation_props_part(rels_root: Any) -> str | None:
3034 for relationship in rels_root:
3035 if relationship.get("Type") != PRESENTATION_PROPS_REL_TYPE:
3036 continue
3037 if relationship.get("TargetMode") == "External":
3038 raise ValueError("presentation properties relationship must be internal")
3039 target = str(relationship.get("Target") or "").replace("\\", "/")
3040 if not target:
3041 raise ValueError("presentation properties relationship has no target")
3042 if target.startswith("/"):
3043 part_name = target.lstrip("/")
3044 else:
3045 part_name = posixpath.normpath(posixpath.join("ppt", target))
3046 if part_name == ".." or part_name.startswith("../"):
3047 raise ValueError(
3048 "presentation properties relationship escapes the PPTX package"
3049 )
3050 return part_name
3051 return None
3052
3053
3054 def _ensure_presentation_props_references(
3055 parts: MutableMapping[str, bytes],
3056 *,
3057 props_part: str,
3058 ) -> None:
3059 rels_source = parts[PRESENTATION_RELS_PART]
3060 rels_root = parse_source_xml(rels_source)
3061 if _presentation_props_part(rels_root) is None:
3062 ET.SubElement(
3063 rels_root,
3064 _qn(PACKAGE_REL_NS, "Relationship"),
3065 {
3066 "Id": _next_relationship_id(rels_source.decode("utf-8")),
3067 "Type": PRESENTATION_PROPS_REL_TYPE,
3068 "Target": "presProps.xml",
3069 },
3070 )
3071 parts[PRESENTATION_RELS_PART] = serialize_source_xml(
3072 rels_root,
3073 rels_source,
3074 )
3075
3076 content_source = parts[CONTENT_TYPES_PART]
3077 content_root = parse_source_xml(content_source)
3078 part_name = "/" + props_part.lstrip("/")
3079 if not any(
3080 override.get("PartName") == part_name
3081 and override.get("ContentType") == PRESENTATION_PROPS_CONTENT_TYPE
3082 for override in content_root
3083 ):
3084 ET.SubElement(
3085 content_root,
3086 _qn(CONTENT_TYPES_NS, "Override"),
3087 {
3088 "PartName": part_name,
3089 "ContentType": PRESENTATION_PROPS_CONTENT_TYPE,
3090 },
3091 )
3092 parts[CONTENT_TYPES_PART] = serialize_source_xml(
3093 content_root,
3094 content_source,
3095 )
3096
3097
3098 def set_package_use_timings(
3099 parts: MutableMapping[str, bytes],
3100 *,
3101 enabled: bool = True,
3102 ) -> None:
3103 """Set presentation-wide timing playback in ppt/presProps.xml."""
3104 rels_root = parse_source_xml(parts[PRESENTATION_RELS_PART])
3105 existing_props_part = _presentation_props_part(rels_root)
3106 props_part = existing_props_part or PRESENTATION_PROPS_PART
3107 if props_part in parts:
3108 source = parts[props_part]
3109 root = parse_source_xml(source)
3110 else:
3111 if existing_props_part is not None:
3112 raise ValueError(
3113 f"presentation properties part is missing: {props_part}"
3114 )
3115 source = (
3116 f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
3117 f'<p:presentationPr xmlns:p="{PML_NS}"/>'
3118 ).encode("utf-8")
3119 root = parse_source_xml(source)
3120
3121 show_properties = root.find(_qn(PML_NS, "showPr"))
3122 if show_properties is None:
3123 show_properties = ET.Element(_qn(PML_NS, "showPr"))
3124 _insert_show_properties(root, show_properties)
3125 show_properties.set("useTimings", "1" if enabled else "0")
3126 parts[props_part] = serialize_source_xml(root, source)
3127 _ensure_presentation_props_references(parts, props_part=props_part)
3128
3129
3130 def set_directory_use_timings(
3131 extract_dir: Path,
3132 *,
3133 enabled: bool = True,
3134 ) -> None:
3135 """Set presentation timing playback in an extracted PPTX directory."""
3136 part_names = {
3137 PRESENTATION_RELS_PART,
3138 CONTENT_TYPES_PART,
3139 }
3140 rels_source = (extract_dir / PRESENTATION_RELS_PART).read_bytes()
3141 rels_root = parse_source_xml(rels_source)
3142 props_part = _presentation_props_part(rels_root) or PRESENTATION_PROPS_PART
3143 if (extract_dir / props_part).is_file():
3144 part_names.add(props_part)
3145 parts = {
3146 name: (extract_dir / name).read_bytes()
3147 for name in part_names
3148 }
3149 set_package_use_timings(parts, enabled=enabled)
3150 for name, payload in parts.items():
3151 path = extract_dir / name
3152 path.parent.mkdir(parents=True, exist_ok=True)
3153 path.write_bytes(payload)
3154
3154 lines PYTHON