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