返回 ppt-master
pptx_animations.py
根目录 / skills / ppt-master / scripts / pptx_animations.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - PPTX Animation Module
4
5 Provides one strict object-animation registry plus OOXML read/write helpers.
6
7 Supported transition effects:
8 - Complete current PowerPoint gallery: Subtle, Exciting, Dynamic Content
9 - Compatibility aliases: strips, circle, diamond, newsflash, plus, pull,
10 wedge, wheel
11 - none: no visual transition (handled by the shared transition core)
12
13 PowerPoint-native object animations:
14 - 53 entrance effects (``entrance_*``)
15 - 33 emphasis effects (``emphasis_*``)
16 - 64 motion paths (``path_*``)
17 - 53 exit effects (``exit_*``)
18
19 Legacy compatibility inputs (accepted but never selected for new output):
20 appear, fade, fly, fly_left, fly_right, fly_top, cut, zoom, wipe,
21 wipe_left, wipe_right, wipe_up, wipe_down, split, blinds, checkerboard,
22 dissolve, random_bars, peek, wheel, box, circle, diamond, plus, strips,
23 wedge, stretch, expand, swivel
24
25 The four media commands in MsoAnimEffect are intentionally excluded because
26 they require audio/video shapes or bookmarks rather than generated SVG groups.
27
28 Animation modes used by the builder:
29 - single effect name (one of the above) — apply to every element
30 - 'auto' — pick effect from the group's SVG id. Image-like ids
31 (hero / figure- / image / img- / kpi) cycle through a
32 visual pool (``entrance_zoom`` / ``entrance_dissolve`` /
33 ``entrance_circle`` / ``entrance_box`` /
34 ``entrance_diamond`` / ``entrance_wheel``) so multiple
35 images vary across the deck. Other
36 semantic matches map to a single stable effect
37 (chart→``entrance_wipe``,
38 card-/step-/pillar-→``entrance_fly``,
39 title/takeaway→``entrance_fade``).
40 Unmatched ids cycle through a small modern pool
41 (``entrance_fade`` / ``entrance_wipe`` /
42 ``entrance_fly`` / ``entrance_zoom``).
43 - 'mixed' — compatible mode name: first element fades, the rest cycle
44 through a larger canonical PowerPoint entrance pool.
45 - 'random' — pick a seeded canonical PowerPoint entrance per element
46
47 Generated animation rows are validated against their requested effect, target,
48 duration, order, and Start mode before a PPTX is published. Package validation
49 also checks timing-tree placement, time-node identifiers, and shape references.
50
51 See references/animations.md for the public workflow contract.
52
53 Dependencies: None (standard-library XML generation and validation)
54
55 Usage:
56 python3 scripts/pptx_animations.py --demo
57 python3 scripts/pptx_animations.py --list
58 python3 scripts/pptx_animations.py --describe entrance_fly
59 """
60
61 import argparse
62 import copy
63 import hashlib
64 import json
65 import math
66 import posixpath
67 import random
68 import re
69 import zipfile
70 from dataclasses import dataclass, replace
71 from pathlib import Path
72 from typing import Any, Mapping, Sequence
73 from xml.etree import ElementTree as ET
74
75 from console_encoding import configure_utf8_stdio
76 from pptx_transitions import (
77 LEGACY_TRANSITION_KEYS,
78 MAX_OOXML_MILLISECONDS,
79 MAX_OOXML_UNSIGNED_INT,
80 NATIVE_TRANSITION_KEYS,
81 NATIVE_TRANSITIONS,
82 PML_NS,
83 TRANSITION_ALIAS_OPTIONS,
84 TRANSITION_ALIASES,
85 TRANSITION_CATEGORIES,
86 create_transition_xml,
87 describe_transition_effect,
88 validate_seconds,
89 )
90
91 configure_utf8_stdio()
92
93
94 # ============================================================================
95 # Object animation definitions
96 # ============================================================================
97
98 # Compatibility names normalize to canonical PowerPoint-authored presets.
99 # ``cut`` has no current object-animation preset, so the compatibility name
100 # resolves to the standard instantaneous entrance, ``entrance_appear``.
101 ANIMATION_ALIASES: dict[str, str] = {
102 'appear': 'entrance_appear',
103 'fade': 'entrance_fade',
104 'fly': 'entrance_fly',
105 'fly_left': 'entrance_fly',
106 'fly_right': 'entrance_fly',
107 'fly_top': 'entrance_fly',
108 'cut': 'entrance_appear',
109 'zoom': 'entrance_zoom',
110 'wipe': 'entrance_wipe',
111 'wipe_left': 'entrance_wipe',
112 'wipe_right': 'entrance_wipe',
113 'wipe_up': 'entrance_wipe',
114 'wipe_down': 'entrance_wipe',
115 'split': 'entrance_split',
116 'blinds': 'entrance_blinds',
117 'checkerboard': 'entrance_checkerboard',
118 'dissolve': 'entrance_dissolve',
119 'random_bars': 'entrance_random_bars',
120 'peek': 'entrance_peek',
121 'wheel': 'entrance_wheel',
122 'box': 'entrance_box',
123 'circle': 'entrance_circle',
124 'diamond': 'entrance_diamond',
125 'plus': 'entrance_plus',
126 'strips': 'entrance_strips',
127 'wedge': 'entrance_wedge',
128 'stretch': 'entrance_stretch',
129 'expand': 'entrance_expand',
130 'swivel': 'entrance_swivel',
131 }
132
133 LEGACY_ANIMATION_KEYS = tuple(ANIMATION_ALIASES)
134 ANIMATION_CATEGORIES = ('entrance', 'emphasis', 'path', 'exit')
135 _PRESET_CLASS_BY_CATEGORY = {
136 'entrance': 'entr',
137 'emphasis': 'emph',
138 'path': 'path',
139 'exit': 'exit',
140 }
141 _DML_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'
142 _REL_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
143 _PACKAGE_REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'
144 _AUDIO_REL_TYPE = (
145 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio'
146 )
147 _P14_NS = 'http://schemas.microsoft.com/office/powerpoint/2010/main'
148 _MC_NS = 'http://schemas.openxmlformats.org/markup-compatibility/2006'
149 ET.register_namespace('p', PML_NS)
150 ET.register_namespace('a', _DML_NS)
151 ET.register_namespace('r', _REL_NS)
152 ET.register_namespace('p14', _P14_NS)
153
154 ANIMATION_EFFECT_OPTION_FIELDS = (
155 'direction',
156 'amount',
157 'color',
158 'font_name',
159 'relative',
160 'size',
161 )
162 ANIMATION_TIMING_OPTION_FIELDS = (
163 'repeat_count',
164 'repeat_duration',
165 'auto_reverse',
166 'rewind',
167 'accelerate',
168 'decelerate',
169 'bounce_end',
170 'restart',
171 )
172 ANIMATION_RESTARTS = ('always', 'when-not-active', 'never')
173 ANIMATION_AFTER_EFFECTS = ('none', 'dim', 'hide', 'hide-on-next-click')
174 _INTERPOLATED_BEHAVIOR_TAGS = frozenset({
175 'anim',
176 'animClr',
177 'animEffect',
178 'animMotion',
179 'animRot',
180 'animScale',
181 })
182 _NON_CONCRETE_FONT_NAMES = frozenset({
183 '-apple-system',
184 'blinkmacsystemfont',
185 'cursive',
186 'emoji',
187 'fantasy',
188 'inherit',
189 'initial',
190 'math',
191 'monospace',
192 'revert',
193 'revert-layer',
194 'sans-serif',
195 'serif',
196 'system-ui',
197 'ui-monospace',
198 'ui-rounded',
199 'ui-sans-serif',
200 'ui-serif',
201 'unset',
202 })
203
204 # Legacy directional names retain their historical semantics by desugaring
205 # into one canonical effect plus the matching PowerPoint EffectParameters
206 # value. New plans never select these aliases.
207 ANIMATION_ALIAS_OPTIONS: dict[str, dict[str, object]] = {
208 'fly_left': {'direction': 'left'},
209 'fly_right': {'direction': 'right'},
210 'fly_top': {'direction': 'up'},
211 'wipe_left': {'direction': 'left'},
212 'wipe_right': {'direction': 'right'},
213 'wipe_up': {'direction': 'up'},
214 'wipe_down': {'direction': 'down'},
215 'wheel': {'amount': 4},
216 }
217
218
219 def _load_native_animations() -> dict[str, dict[str, Any]]:
220 """Load the PowerPoint-authored preset rows shipped with this module."""
221 manifest_path = Path(__file__).with_name('pptx_animation_presets.json')
222 try:
223 manifest = json.loads(manifest_path.read_text(encoding='utf-8'))
224 except (OSError, json.JSONDecodeError) as exc:
225 raise RuntimeError(
226 f'unable to load native animation presets from {manifest_path}: {exc}'
227 ) from exc
228 if manifest.get('version') != 2:
229 raise RuntimeError(
230 f'unsupported native animation preset version: {manifest.get("version")!r}'
231 )
232 raw_effects = manifest.get('effects')
233 if not isinstance(raw_effects, list):
234 raise RuntimeError('native animation preset manifest field "effects" must be a list')
235
236 native: dict[str, dict[str, Any]] = {}
237 category_counts = {category: 0 for category in ANIMATION_CATEGORIES}
238 for raw in raw_effects:
239 if not isinstance(raw, dict):
240 raise RuntimeError('native animation preset entries must be objects')
241 key = raw.get('key')
242 category = raw.get('category')
243 if not isinstance(key, str) or not key:
244 raise RuntimeError(f'native animation preset has invalid key: {key!r}')
245 if category not in ANIMATION_CATEGORIES:
246 raise RuntimeError(
247 f'native animation preset {key!r} has invalid category: {category!r}'
248 )
249 if key in native or key in ANIMATION_ALIASES:
250 raise RuntimeError(f'duplicate animation preset key: {key}')
251 row_xml = raw.get('row_xml')
252 if not isinstance(row_xml, str):
253 raise RuntimeError(f'native animation preset {key!r} is missing row_xml')
254 try:
255 row = ET.fromstring(row_xml)
256 except ET.ParseError as exc:
257 raise RuntimeError(
258 f'native animation preset {key!r} contains invalid row_xml: {exc}'
259 ) from exc
260 if row.tag != f'{{{PML_NS}}}cTn':
261 raise RuntimeError(f'native animation preset {key!r} is not a p:cTn row')
262
263 spec = {
264 'name': str(raw.get('name') or key),
265 'filter': raw.get('filter'),
266 'presetID': int(raw.get('preset_id')),
267 'presetSubtype': int(raw.get('preset_subtype')),
268 'presetClass': _PRESET_CLASS_BY_CATEGORY[category],
269 'category': category,
270 'msoEffectId': int(raw.get('mso_effect_id')),
271 'defaultDurationMs': raw.get('default_duration_ms'),
272 'durationScalable': bool(raw.get('duration_scalable')),
273 'rowXml': row_xml,
274 'effectOptions': raw.get('effect_options', {}),
275 }
276 if row.get('presetClass') != spec['presetClass']:
277 raise RuntimeError(f'native animation preset {key!r} changed presetClass')
278 if int(row.get('presetID', '-1')) != spec['presetID']:
279 raise RuntimeError(f'native animation preset {key!r} changed presetID')
280 if int(row.get('presetSubtype', '-1')) != spec['presetSubtype']:
281 raise RuntimeError(f'native animation preset {key!r} changed presetSubtype')
282 effect_options = spec['effectOptions']
283 if not isinstance(effect_options, dict):
284 raise RuntimeError(
285 f'native animation preset {key!r} effect_options must be an object'
286 )
287 unknown_options = set(effect_options) - set(ANIMATION_EFFECT_OPTION_FIELDS)
288 if unknown_options:
289 raise RuntimeError(
290 f'native animation preset {key!r} has unknown effect option(s): '
291 + ', '.join(sorted(unknown_options))
292 )
293 for option_name, option_spec in effect_options.items():
294 if not isinstance(option_spec, dict):
295 raise RuntimeError(
296 f'native animation preset {key!r} option {option_name!r} '
297 'must be an object'
298 )
299 required = option_spec.get('required', False)
300 if not isinstance(required, bool):
301 raise RuntimeError(
302 f'native animation preset {key!r} option {option_name!r} '
303 'required must be a boolean'
304 )
305 if required and 'default' in option_spec:
306 raise RuntimeError(
307 f'native animation preset {key!r} option {option_name!r} '
308 'cannot define both required and default'
309 )
310 option_type = option_spec.get('type')
311 if option_type == 'enum':
312 values = option_spec.get('values')
313 if not isinstance(values, dict) or not values:
314 raise RuntimeError(
315 f'native animation preset {key!r} enum option '
316 f'{option_name!r} must define values'
317 )
318 default = str(option_spec.get('default'))
319 if default not in values:
320 raise RuntimeError(
321 f'native animation preset {key!r} enum option '
322 f'{option_name!r} has an unknown default'
323 )
324 for option_value, variant_xml in values.items():
325 if not isinstance(option_value, str) or not isinstance(
326 variant_xml,
327 str,
328 ):
329 raise RuntimeError(
330 f'native animation preset {key!r} enum option '
331 f'{option_name!r} contains an invalid variant'
332 )
333 try:
334 variant = ET.fromstring(variant_xml)
335 except ET.ParseError as exc:
336 raise RuntimeError(
337 f'native animation preset {key!r} enum option '
338 f'{option_name!r}/{option_value!r} contains invalid XML: '
339 f'{exc}'
340 ) from exc
341 if variant.tag != f'{{{PML_NS}}}cTn':
342 raise RuntimeError(
343 f'native animation preset {key!r} enum option '
344 f'{option_name!r}/{option_value!r} is not a p:cTn row'
345 )
346 if variant.get('presetClass') != spec['presetClass']:
347 raise RuntimeError(
348 f'native animation preset {key!r} enum option '
349 f'{option_name!r}/{option_value!r} changed presetClass'
350 )
351 if int(variant.get('presetID', '-1')) != spec['presetID']:
352 raise RuntimeError(
353 f'native animation preset {key!r} enum option '
354 f'{option_name!r}/{option_value!r} changed presetID'
355 )
356 elif option_type not in {'number', 'string', 'boolean', 'color'}:
357 raise RuntimeError(
358 f'native animation preset {key!r} option {option_name!r} '
359 f'has unknown type: {option_type!r}'
360 )
361 native[key] = spec
362 category_counts[category] += 1
363
364 expected_counts = {'entrance': 53, 'emphasis': 33, 'path': 64, 'exit': 53}
365 if category_counts != expected_counts:
366 raise RuntimeError(
367 'native animation preset category counts changed: '
368 f'{category_counts!r}; expected {expected_counts!r}'
369 )
370 return native
371
372
373 NATIVE_ANIMATIONS = _load_native_animations()
374 NATIVE_ANIMATION_KEYS = tuple(NATIVE_ANIMATIONS)
375 ANIMATIONS = {
376 **NATIVE_ANIMATIONS,
377 **{
378 alias: NATIVE_ANIMATIONS[canonical]
379 for alias, canonical in ANIMATION_ALIASES.items()
380 },
381 }
382
383 ANIMATION_MODES = ('auto', 'mixed', 'random')
384 ANIMATION_TRIGGERS = ('on-click', 'with-previous', 'after-previous')
385
386 _TRIGGER_NODE_TYPES = {
387 'on-click': 'clickEffect',
388 'with-previous': 'withEffect',
389 'after-previous': 'afterEffect',
390 }
391 _NODE_TYPE_TRIGGERS = {
392 value: key for key, value in _TRIGGER_NODE_TYPES.items()
393 }
394
395
396 @dataclass(frozen=True)
397 class AnimationTarget:
398 """Resolved object-animation request for one PowerPoint shape."""
399
400 shape_id: int
401 delay_ms: int
402 effect: str
403 duration_ms: int
404 effect_options: Mapping[str, object]
405 trigger: str = 'after-previous'
406 trigger_shape_id: int | None = None
407 repeat_count: float | None = None
408 repeat_duration_ms: int | None = None
409 auto_reverse: bool | None = None
410 rewind: bool | None = None
411 accelerate: float | None = None
412 decelerate: float | None = None
413 bounce_end: float | None = None
414 restart: str | None = None
415 after_effect: str = 'none'
416 after_effect_color: str | None = None
417 sound_relationship_id: str | None = None
418 sound_name: str | None = None
419
420 @property
421 def playback_duration_ms(self) -> int:
422 """Return the wall-clock duration used by after-previous scheduling."""
423 one_play = self.duration_ms * (2 if self.auto_reverse else 1)
424 if self.repeat_duration_ms is not None:
425 return self.repeat_duration_ms
426 if self.repeat_count is not None:
427 return max(1, round(one_play * self.repeat_count))
428 return one_play
429
430
431 @dataclass(frozen=True)
432 class AnimationRowSummary:
433 """Read-back summary for one object-animation row in the animation pane."""
434
435 shape_id: int
436 effect: str | None
437 supported_effects: tuple[str, ...]
438 preset_class: str
439 trigger: str
440 duration_ms: int | None
441 offset_ms: int
442 preset_id: int
443 preset_subtype: int
444 filter_name: str | None
445 effect_options: Mapping[str, object]
446 trigger_shape_id: int | None
447 repeat_count: float | None
448 repeat_duration_ms: int | None
449 auto_reverse: bool
450 rewind: bool
451 accelerate: float
452 decelerate: float
453 bounce_end: float
454 restart: str
455 after_effect: str
456 after_effect_color: str | None
457 sound_relationship_id: str | None
458 sound_name: str | None
459 playback_duration_ms: int | None
460
461
462 @dataclass(frozen=True)
463 class AnimationSequenceSummary:
464 """Read-back summary for the logical object sequence on one slide."""
465
466 timing_count: int
467 trigger: str | None
468 rows: tuple[AnimationRowSummary, ...]
469 audio_target_ids: tuple[int, ...]
470
471
472 def _qn(namespace: str, tag: str) -> str:
473 return f'{{{namespace}}}{tag}'
474
475
476 def _local_name(tag: str) -> str:
477 return tag.rsplit('}', 1)[-1]
478
479
480 def normalize_animation_effect(
481 effect: object,
482 *,
483 allow_none: bool = True,
484 allow_modes: bool = True,
485 ) -> str | None:
486 """Return a supported effect/mode without silently substituting another."""
487 if effect is None or effect == 'none':
488 if allow_none:
489 return None
490 raise ValueError('animation effect is required')
491 if not isinstance(effect, str):
492 raise ValueError(f'animation effect must be a string: {effect!r}')
493 if effect in ANIMATION_ALIASES:
494 return ANIMATION_ALIASES[effect]
495 if effect in NATIVE_ANIMATIONS:
496 return effect
497 if allow_modes and effect in ANIMATION_MODES:
498 return effect
499 valid = list(ANIMATIONS)
500 if allow_modes:
501 valid.extend(ANIMATION_MODES)
502 if allow_none:
503 valid.append('none')
504 raise ValueError(
505 f'unknown animation effect {effect!r}; valid effects: {", ".join(valid)}'
506 )
507
508
509 def _finite_number(value: object, field: str) -> float:
510 if isinstance(value, bool) or not isinstance(value, (int, float)):
511 raise ValueError(f'{field} must be a finite number: {value!r}')
512 number = float(value)
513 if not math.isfinite(number):
514 raise ValueError(f'{field} must be a finite number: {value!r}')
515 return number
516
517
518 def _normalize_animation_color(value: object, field: str) -> str:
519 if not isinstance(value, str):
520 raise ValueError(
521 f'{field} must be #RRGGBB or theme:<scheme-color>: {value!r}'
522 )
523 if re.fullmatch(r'#[0-9A-Fa-f]{6}', value):
524 return value.upper()
525 if re.fullmatch(
526 r'theme:(?:dk1|lt1|dk2|lt2|tx1|tx2|bg1|bg2|accent[1-6]|'
527 r'hlink|folHlink)',
528 value,
529 ):
530 return value
531 raise ValueError(
532 f'{field} must be #RRGGBB or theme:<scheme-color>: {value!r}'
533 )
534
535
536 def _normalize_powerpoint_font_name(value: object, field: str) -> str:
537 """Return one concrete PowerPoint font name without checking installation."""
538 if not isinstance(value, str) or not value.strip():
539 raise ValueError(
540 f'{field} must be one concrete PowerPoint font name: {value!r}'
541 )
542 normalized = value.strip()
543 if len(normalized) > 255:
544 raise ValueError(f'{field} exceeds 255 characters')
545 if ',' in normalized:
546 raise ValueError(
547 f'{field} must be one concrete PowerPoint font name, '
548 f'not a CSS font stack: {value!r}'
549 )
550 if normalized.casefold() in _NON_CONCRETE_FONT_NAMES:
551 raise ValueError(
552 f'{field} must be one concrete PowerPoint font name, '
553 f'not a generic family or CSS-wide keyword: {value!r}'
554 )
555 return normalized
556
557
558 def normalize_animation_effect_options(
559 effect: str,
560 options: object = None,
561 ) -> dict[str, object]:
562 """Validate effect-specific PowerPoint EffectParameters values."""
563 if effect not in NATIVE_ANIMATIONS:
564 if options in (None, {}):
565 return {}
566 raise ValueError(
567 'animation effect_options require one explicit canonical effect; '
568 f'found {effect!r}'
569 )
570 if options is None:
571 options = {}
572 if not isinstance(options, Mapping):
573 raise ValueError(f'animation effect_options must be an object: {options!r}')
574
575 option_specs = NATIVE_ANIMATIONS[effect]['effectOptions']
576 unknown = set(options) - set(option_specs)
577 if unknown:
578 unsupported = ', '.join(sorted(unknown))
579 supported = ', '.join(option_specs) or '(none)'
580 raise ValueError(
581 f'animation effect {effect!r} does not support effect option(s): '
582 f'{unsupported}; supported options: {supported}'
583 )
584 missing_required = sorted(
585 name
586 for name, spec in option_specs.items()
587 if spec.get('required') and name not in options
588 )
589 if missing_required:
590 required_fields = ', '.join(
591 f'effect_options.{name}' for name in missing_required
592 )
593 raise ValueError(
594 f'animation effect {effect!r} requires {required_fields}'
595 )
596
597 normalized: dict[str, object] = {}
598 for name, value in options.items():
599 spec = option_specs[name]
600 option_type = spec['type']
601 field = f'animation effect_options.{name}'
602 if option_type == 'enum':
603 key = str(value)
604 if isinstance(value, bool) or key not in spec['values']:
605 valid = ', '.join(spec['values'])
606 raise ValueError(
607 f'{field} for {effect!r} must be one of {valid}: {value!r}'
608 )
609 normalized[name] = (
610 int(key)
611 if name == 'amount' and re.fullmatch(r'\d+', key)
612 else key
613 )
614 elif option_type == 'number':
615 number = _finite_number(value, field)
616 minimum = spec.get('minimum')
617 maximum = spec.get('maximum')
618 if minimum is not None and number < float(minimum):
619 raise ValueError(
620 f'{field} for {effect!r} must be at least {minimum}: {value!r}'
621 )
622 if maximum is not None and number > float(maximum):
623 raise ValueError(
624 f'{field} for {effect!r} must be at most {maximum}: {value!r}'
625 )
626 normalized[name] = number
627 elif option_type == 'string':
628 if name == 'font_name':
629 normalized[name] = _normalize_powerpoint_font_name(value, field)
630 else:
631 if not isinstance(value, str) or not value.strip():
632 raise ValueError(
633 f'{field} must be a non-empty string: {value!r}'
634 )
635 normalized_value = value.strip()
636 if len(normalized_value) > 255:
637 raise ValueError(f'{field} exceeds 255 characters')
638 normalized[name] = normalized_value
639 elif option_type == 'boolean':
640 if not isinstance(value, bool):
641 raise ValueError(f'{field} must be a boolean: {value!r}')
642 normalized[name] = value
643 elif option_type == 'color':
644 normalized[name] = _normalize_animation_color(value, field)
645 else:
646 raise AssertionError(f'unhandled animation effect option type: {option_type}')
647 return normalized
648
649
650 def normalize_animation_effect_request(
651 effect: object,
652 options: object = None,
653 *,
654 allow_none: bool = True,
655 allow_modes: bool = True,
656 ) -> tuple[str | None, dict[str, object]]:
657 """Normalize one effect plus options, including legacy semantic aliases."""
658 raw_effect = effect
659 canonical = normalize_animation_effect(
660 effect,
661 allow_none=allow_none,
662 allow_modes=allow_modes,
663 )
664 alias_options = (
665 ANIMATION_ALIAS_OPTIONS.get(raw_effect, {})
666 if isinstance(raw_effect, str)
667 else {}
668 )
669 explicit_options: Mapping[str, object]
670 if options is None:
671 explicit_options = {}
672 elif isinstance(options, Mapping):
673 explicit_options = options
674 else:
675 raise ValueError(f'animation effect_options must be an object: {options!r}')
676 for name, alias_value in alias_options.items():
677 if name in explicit_options and explicit_options[name] != alias_value:
678 raise ValueError(
679 f'legacy animation effect {raw_effect!r} implies '
680 f'effect_options.{name}={alias_value!r}, which conflicts with '
681 f'{explicit_options[name]!r}'
682 )
683 merged = {**alias_options, **explicit_options}
684 if canonical is None or canonical in ANIMATION_MODES:
685 if merged:
686 raise ValueError(
687 'animation effect_options require one explicit canonical effect; '
688 f'found {canonical or "none"!r}'
689 )
690 return canonical, {}
691 return canonical, normalize_animation_effect_options(canonical, merged)
692
693
694 def normalize_animation_trigger(trigger: object) -> str:
695 """Return a supported PowerPoint Start mode or raise a precise error."""
696 if not isinstance(trigger, str):
697 raise ValueError(f'animation trigger must be a string: {trigger!r}')
698 if trigger not in ANIMATION_TRIGGERS:
699 raise ValueError(
700 f'unknown animation trigger {trigger!r}; valid triggers: '
701 f'{", ".join(ANIMATION_TRIGGERS)}'
702 )
703 return trigger
704
705
706 def _seconds_to_ms(value: object, field: str, *, allow_zero: bool) -> int:
707 seconds = validate_seconds(value, field, allow_zero=allow_zero)
708 raw_milliseconds = seconds * 1000
709 if (
710 not math.isfinite(raw_milliseconds)
711 or raw_milliseconds > MAX_OOXML_MILLISECONDS
712 ):
713 raise ValueError(f'{field} exceeds the OOXML millisecond limit: {value!r}')
714 milliseconds = int(raw_milliseconds)
715 return milliseconds if allow_zero else max(1, milliseconds)
716
717
718 def animation_seconds_to_milliseconds(
719 value: object,
720 field: str,
721 *,
722 allow_zero: bool,
723 ) -> int:
724 """Convert validated animation seconds to the OOXML millisecond range."""
725 return _seconds_to_ms(value, field, allow_zero=allow_zero)
726
727
728 def _positive_shape_id(value: object, field: str = 'animation shape_id') -> int:
729 if isinstance(value, bool):
730 raise ValueError(f'{field} must be a positive integer: {value!r}')
731 if isinstance(value, int):
732 shape_id = value
733 elif isinstance(value, str) and re.fullmatch(r'[1-9]\d*', value):
734 shape_id = int(value)
735 else:
736 raise ValueError(f'{field} must be a positive integer: {value!r}')
737 if shape_id <= 0 or shape_id > MAX_OOXML_UNSIGNED_INT:
738 raise ValueError(f'{field} must be a positive integer: {value!r}')
739 return shape_id
740
741
742 def _non_negative_milliseconds(value: object, field: str) -> int:
743 if isinstance(value, bool):
744 raise ValueError(f'{field} must be a non-negative integer: {value!r}')
745 if isinstance(value, int):
746 milliseconds = value
747 elif isinstance(value, str) and re.fullmatch(r'\d+', value):
748 milliseconds = int(value)
749 else:
750 raise ValueError(f'{field} must be a non-negative integer: {value!r}')
751 if milliseconds < 0 or milliseconds > MAX_OOXML_MILLISECONDS:
752 raise ValueError(
753 f'{field} must be between 0 and {MAX_OOXML_MILLISECONDS}: {value!r}'
754 )
755 return milliseconds
756
757
758 def _optional_bool(value: object, field: str) -> bool:
759 if not isinstance(value, bool):
760 raise ValueError(f'{field} must be a boolean: {value!r}')
761 return value
762
763
764 def _optional_ratio(value: object, field: str) -> float:
765 ratio = _finite_number(value, field)
766 if ratio < 0 or ratio > 1:
767 raise ValueError(f'{field} must be between 0 and 1: {value!r}')
768 return ratio
769
770
771 def _normalize_repeat_count(value: object) -> float:
772 count = _finite_number(value, 'animation repeat_count')
773 if count <= 0 or count * 1000 > MAX_OOXML_UNSIGNED_INT:
774 raise ValueError(
775 'animation repeat_count must be positive and fit the OOXML range: '
776 f'{value!r}'
777 )
778 return count
779
780
781 def _normalize_after_effect(value: object) -> tuple[str, str | None]:
782 if value is None:
783 return 'none', None
784 if isinstance(value, str):
785 effect_type = value
786 color = None
787 elif isinstance(value, Mapping):
788 unknown = set(value) - {'type', 'color'}
789 if unknown:
790 raise ValueError(
791 'animation after_effect has unknown field(s): '
792 + ', '.join(sorted(unknown))
793 )
794 effect_type = value.get('type', 'none')
795 color = value.get('color')
796 else:
797 raise ValueError(
798 f'animation after_effect must be a string or object: {value!r}'
799 )
800 if effect_type not in ANIMATION_AFTER_EFFECTS:
801 raise ValueError(
802 f'animation after_effect.type must be one of '
803 f'{", ".join(ANIMATION_AFTER_EFFECTS)}: {effect_type!r}'
804 )
805 if effect_type == 'dim':
806 if color is None:
807 raise ValueError('animation dim after_effect requires color')
808 return effect_type, _normalize_animation_color(
809 color,
810 'animation after_effect.color',
811 )
812 if color is not None:
813 raise ValueError(
814 f'animation after_effect.color is valid only with type "dim": {color!r}'
815 )
816 return effect_type, None
817
818
819 def _normalize_sound(value: object) -> tuple[str | None, str | None]:
820 if value is None:
821 return None, None
822 if not isinstance(value, Mapping):
823 raise ValueError(
824 'low-level animation sound must be an object with '
825 'relationship_id and name'
826 )
827 unknown = set(value) - {'relationship_id', 'name'}
828 if unknown:
829 raise ValueError(
830 'low-level animation sound has unknown field(s): '
831 + ', '.join(sorted(unknown))
832 )
833 relationship_id = value.get('relationship_id')
834 name = value.get('name')
835 if not isinstance(relationship_id, str) or not re.fullmatch(
836 r'rId[1-9]\d*',
837 relationship_id,
838 ):
839 raise ValueError(
840 'low-level animation sound relationship_id must match rIdN: '
841 f'{relationship_id!r}'
842 )
843 if not isinstance(name, str) or not name.strip():
844 raise ValueError(
845 f'low-level animation sound name must be non-empty: {name!r}'
846 )
847 return relationship_id, name
848
849
850 def _normalize_target_mapping(
851 target: Mapping[str, object],
852 default_duration_ms: int,
853 default_trigger: str,
854 ) -> AnimationTarget:
855 allowed = {
856 'shape_id',
857 'delay_ms',
858 'effect',
859 'duration',
860 'effect_options',
861 'trigger',
862 'trigger_shape_id',
863 *ANIMATION_TIMING_OPTION_FIELDS,
864 'after_effect',
865 'sound',
866 }
867 unknown = set(target) - allowed
868 if unknown:
869 raise ValueError(
870 'animation target has unknown field(s): ' + ', '.join(sorted(unknown))
871 )
872 shape_id = _positive_shape_id(target.get('shape_id'))
873 delay_ms = _non_negative_milliseconds(
874 target.get('delay_ms', 0),
875 'animation target delay_ms',
876 )
877 effect, effect_options = normalize_animation_effect_request(
878 target.get('effect'),
879 target.get('effect_options'),
880 allow_none=False,
881 allow_modes=False,
882 )
883 duration_ms = default_duration_ms
884 if target.get('duration') is not None:
885 duration_ms = _seconds_to_ms(
886 target.get('duration'),
887 'animation target duration',
888 allow_zero=False,
889 )
890 trigger_shape_id = (
891 _positive_shape_id(
892 target['trigger_shape_id'],
893 'animation target trigger_shape_id',
894 )
895 if 'trigger_shape_id' in target
896 else None
897 )
898 if trigger_shape_id == shape_id:
899 raise ValueError(
900 'animation trigger_shape_id must target a different shape'
901 )
902 target_trigger = (
903 normalize_animation_trigger(target['trigger'])
904 if 'trigger' in target
905 else default_trigger
906 )
907 if trigger_shape_id is not None:
908 if 'trigger' in target and target_trigger != 'on-click':
909 raise ValueError(
910 'animation target with trigger_shape_id must use '
911 'trigger "on-click"'
912 )
913 target_trigger = 'on-click'
914 repeat_count = (
915 _normalize_repeat_count(target['repeat_count'])
916 if 'repeat_count' in target
917 else None
918 )
919 repeat_duration_ms = (
920 _seconds_to_ms(
921 target['repeat_duration'],
922 'animation repeat_duration',
923 allow_zero=False,
924 )
925 if 'repeat_duration' in target
926 else None
927 )
928 if repeat_count is not None and repeat_duration_ms is not None:
929 raise ValueError(
930 'animation repeat_count and repeat_duration are mutually exclusive'
931 )
932 auto_reverse = (
933 _optional_bool(target['auto_reverse'], 'animation auto_reverse')
934 if 'auto_reverse' in target
935 else None
936 )
937 rewind = (
938 _optional_bool(target['rewind'], 'animation rewind')
939 if 'rewind' in target
940 else None
941 )
942 accelerate = (
943 _optional_ratio(target['accelerate'], 'animation accelerate')
944 if 'accelerate' in target
945 else None
946 )
947 decelerate = (
948 _optional_ratio(target['decelerate'], 'animation decelerate')
949 if 'decelerate' in target
950 else None
951 )
952 if (
953 accelerate is not None
954 and decelerate is not None
955 and accelerate + decelerate > 1
956 ):
957 raise ValueError(
958 'animation accelerate + decelerate must not exceed 1'
959 )
960 bounce_end = (
961 _optional_ratio(target['bounce_end'], 'animation bounce_end')
962 if 'bounce_end' in target
963 else None
964 )
965 if bounce_end and decelerate:
966 raise ValueError(
967 'animation bounce_end and decelerate are mutually exclusive '
968 'in PowerPoint'
969 )
970 restart = target.get('restart')
971 if restart is not None and restart not in ANIMATION_RESTARTS:
972 raise ValueError(
973 f'animation restart must be one of '
974 f'{", ".join(ANIMATION_RESTARTS)}: {restart!r}'
975 )
976 after_effect, after_effect_color = _normalize_after_effect(
977 target.get('after_effect')
978 )
979 sound_relationship_id, sound_name = _normalize_sound(target.get('sound'))
980 return AnimationTarget(
981 shape_id=shape_id,
982 delay_ms=delay_ms,
983 effect=effect,
984 duration_ms=duration_ms,
985 effect_options=effect_options,
986 trigger=target_trigger,
987 trigger_shape_id=trigger_shape_id,
988 repeat_count=repeat_count,
989 repeat_duration_ms=repeat_duration_ms,
990 auto_reverse=auto_reverse,
991 rewind=rewind,
992 accelerate=accelerate,
993 decelerate=decelerate,
994 bounce_end=bounce_end,
995 restart=restart,
996 after_effect=after_effect,
997 after_effect_color=after_effect_color,
998 sound_relationship_id=sound_relationship_id,
999 sound_name=sound_name,
1000 )
1001
1002
1003 def _normalize_target(
1004 target: Sequence[object] | Mapping[str, object],
1005 default_duration_ms: int,
1006 default_trigger: str = 'after-previous',
1007 ) -> AnimationTarget:
1008 if isinstance(target, Mapping):
1009 return _normalize_target_mapping(
1010 target,
1011 default_duration_ms,
1012 default_trigger,
1013 )
1014 if isinstance(target, (str, bytes)) or not isinstance(target, Sequence):
1015 raise ValueError(f'animation target must be a 3- or 4-item sequence: {target!r}')
1016 if len(target) not in (3, 4):
1017 raise ValueError(f'animation target must contain 3 or 4 items: {target!r}')
1018 shape_id = _positive_shape_id(target[0])
1019 delay_ms = _non_negative_milliseconds(target[1], 'animation target delay_ms')
1020 effect, effect_options = normalize_animation_effect_request(
1021 target[2],
1022 allow_none=False,
1023 allow_modes=False,
1024 )
1025 duration_ms = default_duration_ms
1026 if len(target) == 4 and target[3] is not None:
1027 duration_ms = _seconds_to_ms(
1028 target[3],
1029 'animation target duration',
1030 allow_zero=False,
1031 )
1032 return AnimationTarget(
1033 shape_id=shape_id,
1034 delay_ms=delay_ms,
1035 effect=effect,
1036 duration_ms=duration_ms,
1037 effect_options=effect_options,
1038 trigger=default_trigger,
1039 )
1040
1041 # Pool used by 'mixed' / 'random' modes. Every entry is a canonical
1042 # PowerPoint-authored preset; compatibility aliases never enter selection.
1043 _MIXED_POOL = [
1044 'entrance_blinds', 'entrance_checkerboard', 'entrance_dissolve',
1045 'entrance_fly', 'entrance_ascend', 'entrance_random_bars',
1046 'entrance_box', 'entrance_split', 'entrance_strips', 'entrance_wedge',
1047 'entrance_wheel', 'entrance_wipe', 'entrance_expand', 'entrance_fade',
1048 'entrance_swivel', 'entrance_zoom',
1049 ]
1050
1051 # Small modern pool used by 'auto' mode when the group id matches no semantic
1052 # pattern. Restricted to four widely supported, restrained effects so the
1053 # fallback cycle never produces PowerPoint-era visuals.
1054 _AUTO_POOL = [
1055 'entrance_fade',
1056 'entrance_wipe',
1057 'entrance_fly',
1058 'entrance_zoom',
1059 ]
1060
1061 # Image-only diversity pool. Image-like groups (`hero`, `figure-`, `image`,
1062 # `img-`, `kpi`) deliberately cycle through a richer set of visual effects
1063 # rather than mapping to a single effect: images are visual focal points, so
1064 # variation is desirable on them even when surrounding information-dense
1065 # elements (titles, charts, lists) stay reserved. Pool members are chosen for
1066 # image-friendly motion — no PowerPoint-era patterns (``entrance_blinds`` /
1067 # ``entrance_checkerboard`` / ``entrance_random_bars`` / ``entrance_wedge``)
1068 # that would dominate raster content.
1069 _IMAGE_POOL = [
1070 'entrance_zoom',
1071 'entrance_dissolve',
1072 'entrance_circle',
1073 'entrance_box',
1074 'entrance_diamond',
1075 'entrance_wheel',
1076 ]
1077 _IMAGE_KEYWORDS: tuple[str, ...] = ('hero', 'figure-', 'image', 'img-', 'kpi')
1078
1079 # Ordered (substring, effect) patterns consumed by 'auto' mode for non-image
1080 # groups. The first matching substring in the lowercased group id wins;
1081 # ordering matters where substrings could overlap (e.g. 'title' before 'item'
1082 # prevents 'item-title' from being misread as a list item). All substrings are
1083 # lowercase. Image-like ids are handled separately via ``_IMAGE_POOL`` because
1084 # they cycle rather than map to a single effect.
1085 _SEMANTIC_PATTERNS: list[tuple[tuple[str, ...], str]] = [
1086 (
1087 ('title', 'chapter-', 'section-', 'cover-', 'tagline', 'subtitle'),
1088 'entrance_fade',
1089 ),
1090 (
1091 ('chart', 'table', 'legend', 'timeline', 'track'),
1092 'entrance_wipe',
1093 ),
1094 (('card-', 'pillar-', 'item-', 'step-', 'stage-', 'tier-',
1095 'principle-', 'q-', 'schema-'), 'entrance_fly'),
1096 (('takeaway', 'callout', 'quote', 'source', 'conclusion', 'note',
1097 'try-at-home'), 'entrance_fade'),
1098 ]
1099
1100
1101 def _semantic_effect(group_id: str | None, idx: int = 0, offset: int = 0) -> str | None:
1102 """Return the effect mapped from a group id, or None if no pattern matches.
1103
1104 Image-like ids cycle through ``_IMAGE_POOL`` using ``idx + offset`` so the
1105 same deck shows different effects across multiple images. Other semantic
1106 matches return a single stable effect because information-dense elements
1107 benefit from consistency, not variation.
1108 """
1109 if not group_id:
1110 return None
1111 lower = group_id.lower()
1112 if any(k in lower for k in _IMAGE_KEYWORDS):
1113 return _IMAGE_POOL[(idx + offset) % len(_IMAGE_POOL)]
1114 for substrings, effect in _SEMANTIC_PATTERNS:
1115 if any(s in lower for s in substrings):
1116 return effect
1117 return None
1118
1119
1120 def create_timing_xml(
1121 animation: str = 'entrance_fade',
1122 duration: float = 1.0,
1123 delay: float = 0,
1124 shape_id: int = 2
1125 ) -> str:
1126 """
1127 Generate an object-animation timing XML fragment
1128
1129 Args:
1130 animation: Canonical PowerPoint effect name
1131 duration: Animation duration (seconds)
1132 delay: Animation delay (seconds)
1133 shape_id: Target shape ID (SVG image is typically 2)
1134
1135 Returns:
1136 A <p:timing> element string insertable into slide XML
1137 """
1138 animation = normalize_animation_effect(
1139 animation,
1140 allow_none=False,
1141 allow_modes=False,
1142 )
1143 shape_id = _positive_shape_id(shape_id)
1144 delay_ms = _seconds_to_ms(
1145 delay,
1146 'animation delay',
1147 allow_zero=True,
1148 )
1149 return create_sequence_timing_xml(
1150 [(shape_id, delay_ms, animation, duration)],
1151 duration=duration,
1152 trigger='after-previous',
1153 )
1154
1155
1156 def _ctn_numeric_delay(ctn: ET.Element) -> int:
1157 """Return the largest direct numeric start delay on one time node."""
1158 conditions = ctn.find(_qn(PML_NS, 'stCondLst'))
1159 if conditions is None:
1160 return 0
1161 values = [
1162 int(condition.get('delay', '0'))
1163 for condition in conditions.findall(_qn(PML_NS, 'cond'))
1164 if condition.get('delay', '').isdigit()
1165 ]
1166 return max(values, default=0)
1167
1168
1169 def _row_effective_duration_ms(row: ET.Element) -> int | None:
1170 """Return the finite end time PowerPoint exposes for one preset row."""
1171 ends: list[int] = []
1172 for ctn in row.iter(_qn(PML_NS, 'cTn')):
1173 if ctn is row:
1174 continue
1175 raw_duration = ctn.get('dur')
1176 if raw_duration is None or not raw_duration.isdigit():
1177 continue
1178 ends.append(_ctn_numeric_delay(ctn) + int(raw_duration))
1179 return max(ends) if ends else None
1180
1181
1182 def _scale_animation_row_duration(
1183 row: ET.Element,
1184 *,
1185 base_duration_ms: int,
1186 requested_duration_ms: int,
1187 ) -> None:
1188 """Scale all finite behavior durations/delays as one PowerPoint row."""
1189 ratio = requested_duration_ms / base_duration_ms
1190 for ctn in row.iter(_qn(PML_NS, 'cTn')):
1191 if ctn is row:
1192 continue
1193 raw_duration = ctn.get('dur')
1194 if raw_duration is not None and raw_duration.isdigit():
1195 numeric_duration = int(raw_duration)
1196 ctn.set(
1197 'dur',
1198 (
1199 '1'
1200 if numeric_duration == 1
1201 else str(max(1, round(numeric_duration * ratio)))
1202 ),
1203 )
1204 conditions = ctn.find(_qn(PML_NS, 'stCondLst'))
1205 if conditions is None:
1206 continue
1207 for condition in conditions.findall(_qn(PML_NS, 'cond')):
1208 raw_delay = condition.get('delay')
1209 if raw_delay is not None and raw_delay.isdigit():
1210 condition.set('delay', str(round(int(raw_delay) * ratio)))
1211
1212 actual_duration = _row_effective_duration_ms(row)
1213 if actual_duration is None or actual_duration == requested_duration_ms:
1214 return
1215 end_nodes = [
1216 ctn
1217 for ctn in row.iter(_qn(PML_NS, 'cTn'))
1218 if ctn is not row
1219 and (ctn.get('dur') or '').isdigit()
1220 and _ctn_numeric_delay(ctn) + int(ctn.get('dur', '0')) == actual_duration
1221 ]
1222 for ctn in end_nodes:
1223 delay = _ctn_numeric_delay(ctn)
1224 if delay >= requested_duration_ms:
1225 conditions = ctn.find(_qn(PML_NS, 'stCondLst'))
1226 numeric = (
1227 [
1228 condition
1229 for condition in conditions.findall(_qn(PML_NS, 'cond'))
1230 if condition.get('delay', '').isdigit()
1231 ]
1232 if conditions is not None
1233 else []
1234 )
1235 if numeric:
1236 numeric[-1].set('delay', str(max(0, requested_duration_ms - 1)))
1237 delay = _ctn_numeric_delay(ctn)
1238 ctn.set('dur', str(max(1, requested_duration_ms - delay)))
1239
1240
1241 def _row_semantic_signature(row: ET.Element) -> tuple[object, ...]:
1242 """Return an id/target/duration-independent preset behavior signature."""
1243 def canonical(element: ET.Element) -> tuple[object, ...]:
1244 attributes: list[tuple[str, str]] = []
1245 local_name = _local_name(element.tag)
1246 for name, value in sorted(element.attrib.items()):
1247 if name in {'id', 'nodeType', 'grpId'}:
1248 continue
1249 if local_name == 'spTgt' and name == 'spid':
1250 value = '#shape'
1251 elif local_name == 'cTn' and name == 'dur' and value.isdigit():
1252 value = '#duration'
1253 elif local_name == 'cond' and name == 'delay' and value.isdigit():
1254 value = '#delay'
1255 attributes.append((name, value))
1256 return (
1257 element.tag,
1258 tuple(attributes),
1259 (element.text or '').strip(),
1260 tuple(canonical(child) for child in list(element)),
1261 )
1262
1263 return canonical(row)
1264
1265
1266 def _row_timing_profile(
1267 row: ET.Element,
1268 ) -> tuple[tuple[str, float, float], ...]:
1269 """Return behavior timing ratios, excluding 1ms visibility bookkeeping."""
1270 total = _row_effective_duration_ms(row)
1271 if total is None or total <= 1:
1272 return ()
1273 parent_map = {
1274 child: parent
1275 for parent in row.iter()
1276 for child in list(parent)
1277 }
1278 behavior_names = {
1279 'anim',
1280 'animClr',
1281 'animEffect',
1282 'animMotion',
1283 'animRot',
1284 'animScale',
1285 'set',
1286 }
1287 profile: list[tuple[str, float, float]] = []
1288 for ctn in row.iter(_qn(PML_NS, 'cTn')):
1289 if ctn is row:
1290 continue
1291 raw_duration = ctn.get('dur')
1292 if raw_duration is None or not raw_duration.isdigit():
1293 continue
1294 duration = int(raw_duration)
1295 owner = parent_map.get(ctn)
1296 while owner is not None and _local_name(owner.tag) not in behavior_names:
1297 owner = parent_map.get(owner)
1298 behavior = _local_name(owner.tag) if owner is not None else 'unknown'
1299 if (
1300 duration == 1
1301 and behavior == 'set'
1302 and owner is not None
1303 and any(
1304 (attribute.text or '') == 'style.visibility'
1305 for attribute in owner.iter(_qn(PML_NS, 'attrName'))
1306 )
1307 ):
1308 continue
1309 profile.append(
1310 (
1311 behavior,
1312 _ctn_numeric_delay(ctn) / total,
1313 duration / total,
1314 )
1315 )
1316 return tuple(profile)
1317
1318
1319 def _animation_spec_matches_row(row: ET.Element, spec: Mapping[str, Any]) -> bool:
1320 """Require the PowerPoint-authored structure and internal timing ratios."""
1321 template = ET.fromstring(spec['rowXml'])
1322 if _row_semantic_signature(row) != _row_semantic_signature(template):
1323 return False
1324 actual_duration = _row_effective_duration_ms(row)
1325 if actual_duration is not None and actual_duration < 100:
1326 # Millisecond quantization necessarily collapses multi-step ratios at
1327 # sub-100ms speeds; structure and total duration remain authoritative.
1328 return True
1329 actual_profile = _row_timing_profile(row)
1330 template_profile = _row_timing_profile(template)
1331 if len(actual_profile) != len(template_profile):
1332 return False
1333 for actual, expected in zip(actual_profile, template_profile):
1334 if actual[0] != expected[0]:
1335 return False
1336 if abs(actual[1] - expected[1]) > 0.01:
1337 return False
1338 if abs(actual[2] - expected[2]) > 0.01:
1339 return False
1340 return True
1341
1342
1343 def _format_decimal(value: float) -> str:
1344 """Format one finite decimal without exponent notation or negative zero."""
1345 rendered = f'{value:.9f}'.rstrip('0').rstrip('.')
1346 return '0' if rendered in {'', '-0'} else rendered
1347
1348
1349 def _color_element(value: str) -> ET.Element:
1350 if value.startswith('#'):
1351 return ET.Element(_qn(_DML_NS, 'srgbClr'), {'val': value[1:]})
1352 return ET.Element(_qn(_DML_NS, 'schemeClr'), {'val': value.split(':', 1)[1]})
1353
1354
1355 def _replace_animation_colors(row: ET.Element, value: str) -> None:
1356 parent_map = {
1357 child: parent
1358 for parent in row.iter()
1359 for child in list(parent)
1360 }
1361 color_tags = {
1362 _qn(_DML_NS, 'srgbClr'),
1363 _qn(_DML_NS, 'schemeClr'),
1364 _qn(_DML_NS, 'hslClr'),
1365 _qn(_DML_NS, 'scrgbClr'),
1366 _qn(_DML_NS, 'sysClr'),
1367 _qn(_DML_NS, 'prstClr'),
1368 }
1369 colors = [element for element in row.iter() if element.tag in color_tags]
1370 if not colors:
1371 raise RuntimeError('color-capable animation preset lost its color node')
1372 for color in colors:
1373 parent = parent_map[color]
1374 index = list(parent).index(color)
1375 parent.remove(color)
1376 parent.insert(index, _color_element(value))
1377
1378
1379 def _set_effect_option_value(
1380 row: ET.Element,
1381 animation: str,
1382 name: str,
1383 value: object,
1384 ) -> None:
1385 if name == 'amount' and animation == 'emphasis_spin':
1386 rotations = list(row.iter(_qn(PML_NS, 'animRot')))
1387 if len(rotations) != 1:
1388 raise RuntimeError('emphasis_spin preset lost its p:animRot node')
1389 rotations[0].set('by', str(round(float(value) * 60000)))
1390 return
1391 if name == 'amount' and animation == 'emphasis_transparency':
1392 matched = False
1393 for node in row.iter(_qn(PML_NS, 'set')):
1394 attributes = {
1395 (attribute.text or '').strip()
1396 for attribute in node.iter(_qn(PML_NS, 'attrName'))
1397 }
1398 if 'style.opacity' not in attributes:
1399 continue
1400 values = list(node.iter(_qn(PML_NS, 'strVal')))
1401 if len(values) != 1:
1402 raise RuntimeError(
1403 'emphasis_transparency preset lost its opacity value'
1404 )
1405 values[0].set('val', _format_decimal(1 - float(value)))
1406 matched = True
1407 if not matched:
1408 raise RuntimeError(
1409 'emphasis_transparency preset lost its opacity behavior'
1410 )
1411 return
1412 if name == 'color':
1413 _replace_animation_colors(row, str(value))
1414 return
1415 if name == 'font_name':
1416 matched = False
1417 for node in row.iter(_qn(PML_NS, 'set')):
1418 attributes = {
1419 (attribute.text or '').strip()
1420 for attribute in node.iter(_qn(PML_NS, 'attrName'))
1421 }
1422 if 'style.fontFamily' not in attributes:
1423 continue
1424 values = list(node.iter(_qn(PML_NS, 'strVal')))
1425 if len(values) != 1:
1426 raise RuntimeError(
1427 'emphasis_change_font preset lost its font value'
1428 )
1429 values[0].set('val', str(value))
1430 matched = True
1431 if not matched:
1432 raise RuntimeError(
1433 'emphasis_change_font preset lost its font behavior'
1434 )
1435 return
1436 if name == 'relative':
1437 motions = list(row.iter(_qn(PML_NS, 'animMotion')))
1438 if len(motions) != 1:
1439 raise RuntimeError('motion-path preset lost its p:animMotion node')
1440 motions[0].set('pathEditMode', 'relative' if value else 'fixed')
1441 return
1442 if name == 'size':
1443 scales = list(row.iter(_qn(PML_NS, 'animScale')))
1444 if len(scales) != 1:
1445 raise RuntimeError(
1446 'emphasis_grow_shrink preset lost its p:animScale node'
1447 )
1448 amount = str(round(float(value) * 1000))
1449 targets = scales[0].findall(_qn(PML_NS, 'to'))
1450 if len(targets) > 1:
1451 raise RuntimeError(
1452 'emphasis_grow_shrink preset lost its scale value'
1453 )
1454 target = (
1455 targets[0]
1456 if targets
1457 else ET.SubElement(scales[0], _qn(PML_NS, 'to'))
1458 )
1459 target.set('x', amount)
1460 target.set('y', amount)
1461 return
1462 raise AssertionError(f'unhandled continuous animation option: {name}')
1463
1464
1465 def _animation_row_for_options(
1466 animation: str,
1467 effect_options: Mapping[str, object],
1468 ) -> ET.Element:
1469 """Return the authored variant row with continuous options applied."""
1470 spec = NATIVE_ANIMATIONS[animation]
1471 option_specs = spec['effectOptions']
1472 row_xml = spec['rowXml']
1473 for name, value in effect_options.items():
1474 option_spec = option_specs[name]
1475 if option_spec['type'] != 'enum':
1476 continue
1477 key = str(value)
1478 row_xml = option_spec['values'][key]
1479 row = ET.fromstring(row_xml)
1480 for name, value in effect_options.items():
1481 if option_specs[name]['type'] == 'enum':
1482 continue
1483 _set_effect_option_value(row, animation, name, value)
1484 return row
1485
1486
1487 def _interpolated_behavior_nodes(row: ET.Element) -> tuple[ET.Element, ...]:
1488 """Return behavior nodes that can carry PowerPoint bounce metadata."""
1489 return tuple(
1490 node
1491 for node in row.iter()
1492 if _local_name(node.tag) in _INTERPOLATED_BEHAVIOR_TAGS
1493 )
1494
1495
1496 def animation_effect_supports_bounce_end(
1497 effect: object,
1498 effect_options: object = None,
1499 ) -> bool:
1500 """Return whether one concrete effect has an interpolated behavior."""
1501 animation, options = normalize_animation_effect_request(
1502 effect,
1503 effect_options,
1504 allow_none=False,
1505 allow_modes=False,
1506 )
1507 if animation is None:
1508 raise AssertionError('concrete animation normalization returned none')
1509 row = _animation_row_for_options(animation, options)
1510 return bool(_interpolated_behavior_nodes(row))
1511
1512
1513 def _apply_timing_options(row: ET.Element, target: AnimationTarget) -> None:
1514 if target.repeat_count is not None:
1515 row.set('repeatCount', str(round(target.repeat_count * 1000)))
1516 row.attrib.pop('repeatDur', None)
1517 if target.repeat_duration_ms is not None:
1518 row.set('repeatDur', str(target.repeat_duration_ms))
1519 row.attrib.pop('repeatCount', None)
1520 if target.auto_reverse is not None:
1521 if target.auto_reverse:
1522 row.set('autoRev', '1')
1523 else:
1524 row.attrib.pop('autoRev', None)
1525 if target.rewind is not None:
1526 row.set('fill', 'remove' if target.rewind else 'hold')
1527 if target.accelerate is not None:
1528 if target.accelerate:
1529 row.set('accel', str(round(target.accelerate * 100000)))
1530 else:
1531 row.attrib.pop('accel', None)
1532 if target.decelerate is not None:
1533 if target.decelerate:
1534 row.set('decel', str(round(target.decelerate * 100000)))
1535 else:
1536 row.attrib.pop('decel', None)
1537 if target.bounce_end is not None:
1538 bounce_nodes = _interpolated_behavior_nodes(row)
1539 if target.bounce_end and not animation_effect_supports_bounce_end(
1540 target.effect,
1541 target.effect_options,
1542 ):
1543 raise ValueError(
1544 f'animation effect {target.effect!r} has no behavior that '
1545 'supports bounce_end'
1546 )
1547 if target.bounce_end:
1548 row.set(
1549 _qn(_P14_NS, 'presetBounceEnd'),
1550 str(round(target.bounce_end * 100000)),
1551 )
1552 else:
1553 row.attrib.pop(_qn(_P14_NS, 'presetBounceEnd'), None)
1554 for node in bounce_nodes:
1555 if target.bounce_end:
1556 node.set(
1557 _qn(_P14_NS, 'bounceEnd'),
1558 str(round(target.bounce_end * 100000)),
1559 )
1560 else:
1561 node.attrib.pop(_qn(_P14_NS, 'bounceEnd'), None)
1562 if target.restart is not None:
1563 row.set(
1564 'restart',
1565 {
1566 'always': 'always',
1567 'when-not-active': 'whenNotActive',
1568 'never': 'never',
1569 }[target.restart],
1570 )
1571
1572
1573 def _append_after_effect(
1574 row: ET.Element,
1575 target: AnimationTarget,
1576 row_id: int,
1577 ) -> None:
1578 if target.after_effect == 'none':
1579 return
1580 sub_timing = row.find(_qn(PML_NS, 'subTnLst'))
1581 if sub_timing is None:
1582 sub_timing = ET.SubElement(row, _qn(PML_NS, 'subTnLst'))
1583 if target.after_effect == 'dim':
1584 animation = ET.SubElement(
1585 sub_timing,
1586 _qn(PML_NS, 'animClr'),
1587 {'clrSpc': 'rgb', 'dir': 'cw'},
1588 )
1589 behavior = ET.SubElement(
1590 animation,
1591 _qn(PML_NS, 'cBhvr'),
1592 {'override': 'childStyle'},
1593 )
1594 ET.SubElement(
1595 behavior,
1596 _qn(PML_NS, 'cTn'),
1597 {
1598 'dur': '1',
1599 'fill': 'hold',
1600 'display': '0',
1601 'masterRel': 'nextClick',
1602 'afterEffect': '1',
1603 },
1604 )
1605 target_element = ET.SubElement(behavior, _qn(PML_NS, 'tgtEl'))
1606 ET.SubElement(
1607 target_element,
1608 _qn(PML_NS, 'spTgt'),
1609 {'spid': str(target.shape_id)},
1610 )
1611 names = ET.SubElement(behavior, _qn(PML_NS, 'attrNameLst'))
1612 ET.SubElement(names, _qn(PML_NS, 'attrName')).text = 'ppt_c'
1613 destination = ET.SubElement(animation, _qn(PML_NS, 'to'))
1614 destination.append(_color_element(str(target.after_effect_color)))
1615 return
1616
1617 setting = ET.SubElement(sub_timing, _qn(PML_NS, 'set'))
1618 behavior = ET.SubElement(
1619 setting,
1620 _qn(PML_NS, 'cBhvr'),
1621 {'override': 'childStyle'},
1622 )
1623 ctn_attributes = {
1624 'dur': '1',
1625 'fill': 'hold',
1626 'display': '0',
1627 'masterRel': (
1628 'sameClick'
1629 if target.after_effect == 'hide'
1630 else 'nextClick'
1631 ),
1632 'afterEffect': '1',
1633 }
1634 ctn = ET.SubElement(behavior, _qn(PML_NS, 'cTn'), ctn_attributes)
1635 if target.after_effect == 'hide':
1636 conditions = ET.SubElement(ctn, _qn(PML_NS, 'stCondLst'))
1637 condition = ET.SubElement(
1638 conditions,
1639 _qn(PML_NS, 'cond'),
1640 {'evt': 'end', 'delay': '0'},
1641 )
1642 ET.SubElement(condition, _qn(PML_NS, 'tn'), {'val': str(row_id)})
1643 target_element = ET.SubElement(behavior, _qn(PML_NS, 'tgtEl'))
1644 ET.SubElement(
1645 target_element,
1646 _qn(PML_NS, 'spTgt'),
1647 {'spid': str(target.shape_id)},
1648 )
1649 names = ET.SubElement(behavior, _qn(PML_NS, 'attrNameLst'))
1650 ET.SubElement(names, _qn(PML_NS, 'attrName')).text = 'style.visibility'
1651 destination = ET.SubElement(setting, _qn(PML_NS, 'to'))
1652 ET.SubElement(destination, _qn(PML_NS, 'strVal'), {'val': 'hidden'})
1653
1654
1655 def _append_animation_sound(row: ET.Element, target: AnimationTarget) -> None:
1656 if target.sound_relationship_id is None:
1657 return
1658 sub_timing = row.find(_qn(PML_NS, 'subTnLst'))
1659 if sub_timing is None:
1660 sub_timing = ET.SubElement(row, _qn(PML_NS, 'subTnLst'))
1661 audio = ET.SubElement(sub_timing, _qn(PML_NS, 'audio'))
1662 media = ET.SubElement(audio, _qn(PML_NS, 'cMediaNode'))
1663 ctn = ET.SubElement(
1664 media,
1665 _qn(PML_NS, 'cTn'),
1666 {'display': '0', 'masterRel': 'sameClick'},
1667 )
1668 conditions = ET.SubElement(ctn, _qn(PML_NS, 'stCondLst'))
1669 condition = ET.SubElement(
1670 conditions,
1671 _qn(PML_NS, 'cond'),
1672 {'evt': 'begin', 'delay': '0'},
1673 )
1674 ET.SubElement(condition, _qn(PML_NS, 'tn'), {'val': str(row.get('id'))})
1675 end_conditions = ET.SubElement(ctn, _qn(PML_NS, 'endCondLst'))
1676 end_condition = ET.SubElement(
1677 end_conditions,
1678 _qn(PML_NS, 'cond'),
1679 {'evt': 'onStopAudio', 'delay': '0'},
1680 )
1681 target_element = ET.SubElement(end_condition, _qn(PML_NS, 'tgtEl'))
1682 ET.SubElement(target_element, _qn(PML_NS, 'sldTgt'))
1683 target_element = ET.SubElement(media, _qn(PML_NS, 'tgtEl'))
1684 ET.SubElement(
1685 target_element,
1686 _qn(PML_NS, 'sndTgt'),
1687 {
1688 _qn(_REL_NS, 'embed'): str(target.sound_relationship_id),
1689 'name': str(target.sound_name),
1690 },
1691 )
1692
1693
1694 def _instantiate_animation_row(
1695 target: AnimationTarget,
1696 node_type: str,
1697 row_id: int,
1698 first_behavior_id: int,
1699 ) -> tuple[str, int]:
1700 """Instantiate one PowerPoint-authored preset row for a generated shape."""
1701 animation = target.effect
1702 shape_id = target.shape_id
1703 duration_ms = target.duration_ms
1704 spec = NATIVE_ANIMATIONS[animation]
1705 row = _animation_row_for_options(animation, target.effect_options)
1706 row.set('id', str(row_id))
1707 row.set('nodeType', node_type)
1708 conditions = row.find(_qn(PML_NS, 'stCondLst'))
1709 if conditions is None:
1710 raise RuntimeError(f'animation preset {animation!r} lost p:stCondLst')
1711 direct_conditions = conditions.findall(_qn(PML_NS, 'cond'))
1712 if len(direct_conditions) != 1:
1713 raise RuntimeError(
1714 f'animation preset {animation!r} must have one start condition'
1715 )
1716 direct_conditions[0].attrib.clear()
1717 direct_conditions[0].set(
1718 'delay',
1719 str(target.delay_ms),
1720 )
1721
1722 if spec['durationScalable']:
1723 base_duration_ms = int(spec['defaultDurationMs'])
1724 _scale_animation_row_duration(
1725 row,
1726 base_duration_ms=base_duration_ms,
1727 requested_duration_ms=duration_ms,
1728 )
1729
1730 _apply_timing_options(row, target)
1731 _append_after_effect(row, target, row_id)
1732 _append_animation_sound(row, target)
1733
1734 next_id = first_behavior_id
1735 for ctn in row.iter(_qn(PML_NS, 'cTn')):
1736 if ctn is row:
1737 continue
1738 ctn.set('id', str(next_id))
1739 next_id += 1
1740 for target in row.iter(_qn(PML_NS, 'spTgt')):
1741 target.set('spid', str(shape_id))
1742 return ET.tostring(row, encoding='unicode'), next_id
1743
1744
1745 def _build_animation_row_xml(
1746 target: AnimationTarget,
1747 trigger: str,
1748 row_id: int,
1749 first_behavior_id: int,
1750 ) -> tuple[str, int]:
1751 """Build one canonical PowerPoint-authored animation-pane row."""
1752 node_type = _TRIGGER_NODE_TYPES[trigger]
1753 return _instantiate_animation_row(
1754 target,
1755 node_type,
1756 row_id,
1757 first_behavior_id,
1758 )
1759
1760
1761 def _main_target_offsets(targets: Sequence[AnimationTarget]) -> list[int]:
1762 """Return each regular row's start offset within its click group."""
1763 offsets: list[int] = []
1764 previous_start_ms = 0
1765 previous_duration_ms = 0
1766 has_previous = False
1767 for target in targets:
1768 if target.trigger == 'on-click':
1769 start_ms = target.delay_ms
1770 elif target.trigger == 'with-previous':
1771 start_ms = (
1772 previous_start_ms if has_previous else 0
1773 ) + target.delay_ms
1774 else:
1775 start_ms = (
1776 previous_start_ms + previous_duration_ms
1777 if has_previous
1778 else 0
1779 ) + target.delay_ms
1780 if start_ms > MAX_OOXML_MILLISECONDS:
1781 raise ValueError(
1782 'animation sequence offset exceeds the OOXML millisecond '
1783 f'limit at target {len(offsets) + 1}: {start_ms}'
1784 )
1785 offsets.append(start_ms)
1786 previous_start_ms = start_ms
1787 previous_duration_ms = target.playback_duration_ms
1788 has_previous = True
1789 return offsets
1790
1791
1792 def _build_mixed_main_steps(
1793 targets: Sequence[AnimationTarget],
1794 next_id: int,
1795 ) -> tuple[str, int]:
1796 """Build one mainSeq containing mixed per-row PowerPoint Start modes."""
1797 offsets = _main_target_offsets(targets)
1798 groups: list[list[tuple[AnimationTarget, int]]] = []
1799 for target, offset_ms in zip(targets, offsets):
1800 if not groups or target.trigger == 'on-click':
1801 groups.append([])
1802 groups[-1].append((target, offset_ms))
1803
1804 rendered_groups: list[str] = []
1805 for group in groups:
1806 group_id = next_id
1807 next_id += 1
1808 first_target = group[0][0]
1809 if first_target.trigger == 'on-click':
1810 group_conditions = '<p:cond delay="indefinite"/>'
1811 else:
1812 group_conditions = (
1813 '<p:cond delay="indefinite"/>'
1814 '<p:cond evt="onBegin" delay="0"><p:tn val="2"/></p:cond>'
1815 )
1816 rendered_rows: list[str] = []
1817 for target, offset_ms in group:
1818 wrapper_id = next_id
1819 row_id = next_id + 1
1820 row_xml, next_id = _build_animation_row_xml(
1821 target,
1822 target.trigger,
1823 row_id,
1824 next_id + 2,
1825 )
1826 wrapper_offset_ms = offset_ms - target.delay_ms
1827 rendered_rows.append(f'''<p:par>
1828 <p:cTn id="{wrapper_id}" fill="hold">
1829 <p:stCondLst><p:cond delay="{wrapper_offset_ms}"/></p:stCondLst>
1830 <p:childTnLst><p:par>{row_xml}</p:par></p:childTnLst>
1831 </p:cTn>
1832 </p:par>''')
1833 rows_xml = '\n '.join(rendered_rows)
1834 rendered_groups.append(f'''<p:par>
1835 <p:cTn id="{group_id}" fill="hold">
1836 <p:stCondLst>{group_conditions}</p:stCondLst>
1837 <p:childTnLst>
1838 {rows_xml}
1839 </p:childTnLst>
1840 </p:cTn>
1841 </p:par>''')
1842 return '\n '.join(rendered_groups), next_id
1843
1844
1845 def create_sequence_timing_xml(
1846 targets: list,
1847 duration: float = 0.3,
1848 trigger: str = 'after-previous',
1849 ) -> str:
1850 """Generate a multi-target object-animation sequence.
1851
1852 Args:
1853 targets: list of (shape_id, delay_ms, animation_name) or
1854 (shape_id, delay_ms, animation_name, duration_seconds) tuples, in
1855 the order they should play. ``delay_ms`` is the gap before
1856 this element starts relative to its Start mode. Mapping targets
1857 may set an independent ``trigger``; otherwise they inherit the
1858 function-level ``trigger``. A target with ``trigger_shape_id``
1859 must use ``on-click`` and runs in an interactive sequence.
1860 duration: per-element animation duration in seconds. Instantaneous
1861 native presets retain their PowerPoint-authored duration.
1862 trigger: PowerPoint-standard Start mode for each element.
1863 ``'after-previous'`` — first element fires on slide entry,
1864 rest chain after the previous one with ``delay_ms`` spacing
1865 (default).
1866 ``'on-click'`` — one presenter click per element.
1867 ``'with-previous'`` — all elements start together on slide
1868 entry.
1869
1870 Returns:
1871 A ``<p:timing>`` element string. Returns an empty string when
1872 ``targets`` is empty.
1873 """
1874 trigger = normalize_animation_trigger(trigger)
1875 default_dur_ms = _seconds_to_ms(
1876 duration,
1877 'animation duration',
1878 allow_zero=False,
1879 )
1880 if targets is None or isinstance(targets, (str, bytes)):
1881 raise ValueError('animation targets must be a sequence of target tuples')
1882 if not targets:
1883 return ''
1884 normalized_targets = [
1885 _normalize_target(target, default_dur_ms, trigger)
1886 for target in targets
1887 ]
1888 next_id = 3
1889 main_targets = [
1890 target
1891 for target in normalized_targets
1892 if target.trigger_shape_id is None
1893 ]
1894 interactive_targets = [
1895 target
1896 for target in normalized_targets
1897 if target.trigger_shape_id is not None
1898 ]
1899
1900 main_triggers = {target.trigger for target in main_targets}
1901 main_trigger = (
1902 next(iter(main_triggers))
1903 if len(main_triggers) == 1
1904 else None
1905 )
1906 needs_per_target_layout = (
1907 main_trigger is None
1908 or (
1909 main_trigger == 'with-previous'
1910 and any(target.delay_ms for target in main_targets)
1911 )
1912 )
1913
1914 if needs_per_target_layout and main_targets:
1915 all_steps, next_id = _build_mixed_main_steps(main_targets, next_id)
1916 elif main_trigger == 'on-click':
1917 # Each element is an independent click-driven par directly under
1918 # mainSeq. Three-level nesting per element: outer cTn holds for
1919 # the click via delay="indefinite", innermost cTn owns the
1920 # clickEffect + animation children. Each click advances the seq.
1921 steps = []
1922 for target in main_targets:
1923 wrapper_id = next_id
1924 inner_id = next_id + 1
1925 leaf_id = next_id + 2
1926 row_xml, next_id = _build_animation_row_xml(
1927 target,
1928 main_trigger,
1929 leaf_id,
1930 next_id + 3,
1931 )
1932 steps.append(f'''<p:par>
1933 <p:cTn id="{wrapper_id}" fill="hold">
1934 <p:stCondLst><p:cond delay="indefinite"/></p:stCondLst>
1935 <p:childTnLst>
1936 <p:par>
1937 <p:cTn id="{inner_id}" fill="hold">
1938 <p:stCondLst><p:cond delay="0"/></p:stCondLst>
1939 <p:childTnLst>
1940 <p:par>
1941 {row_xml}
1942 </p:par>
1943 </p:childTnLst>
1944 </p:cTn>
1945 </p:par>
1946 </p:childTnLst>
1947 </p:cTn>
1948 </p:par>''')
1949 all_steps = '\n '.join(steps)
1950 else:
1951 # with-previous / after-previous: wrap the entire cascade in ONE
1952 # par so the sequence has a real trigger anchor under mainSeq.
1953 #
1954 # Native PowerPoint after-previous export uses two timing layers:
1955 # each row owns its TriggerDelayTime, while its wrapper owns the
1956 # previous row's absolute end. Their sum is the absolute start offset.
1957 outer_id = next_id
1958 next_id += 1
1959 inner_steps = []
1960 with_wrapper_id = None
1961 if main_trigger == 'with-previous':
1962 with_wrapper_id = next_id
1963 next_id += 1
1964 elapsed_ms = 0
1965 for target_index, target in enumerate(main_targets, 1):
1966 if main_trigger == 'with-previous':
1967 leaf_id = next_id
1968 row_xml, next_id = _build_animation_row_xml(
1969 target,
1970 main_trigger,
1971 leaf_id,
1972 next_id + 1,
1973 )
1974 inner_steps.append(f'''<p:par>
1975 {row_xml}
1976 </p:par>''')
1977 else:
1978 if elapsed_ms > MAX_OOXML_MILLISECONDS:
1979 raise ValueError(
1980 'animation sequence offset exceeds the OOXML '
1981 f'millisecond limit at target {target_index}: {elapsed_ms}'
1982 )
1983 wrapper_id = next_id
1984 leaf_id = next_id + 1
1985 row_xml, next_id = _build_animation_row_xml(
1986 target,
1987 main_trigger,
1988 leaf_id,
1989 next_id + 2,
1990 )
1991 inner_steps.append(f'''<p:par>
1992 <p:cTn id="{wrapper_id}" fill="hold">
1993 <p:stCondLst><p:cond delay="{elapsed_ms}"/></p:stCondLst>
1994 <p:childTnLst>
1995 <p:par>
1996 {row_xml}
1997 </p:par>
1998 </p:childTnLst>
1999 </p:cTn>
2000 </p:par>''')
2001 elapsed_ms += target.delay_ms + target.playback_duration_ms
2002
2003 inner_xml = '\n '.join(inner_steps)
2004 if main_trigger == 'with-previous':
2005 # Match PowerPoint's native "Start: With Previous" export:
2006 # one delay=0 wrapper begins on slide entry, and all withEffect
2007 # rows live under that wrapper so they truly start in parallel.
2008 inner_xml = f'''<p:par>
2009 <p:cTn id="{with_wrapper_id}" fill="hold">
2010 <p:stCondLst><p:cond delay="0"/></p:stCondLst>
2011 <p:childTnLst>
2012 {inner_xml}
2013 </p:childTnLst>
2014 </p:cTn>
2015 </p:par>'''
2016 if main_trigger in ('with-previous', 'after-previous'):
2017 # Match PowerPoint's native slide-entry export: the wrapper waits
2018 # for mainSeq to begin, then child nodes resolve their Start modes.
2019 outer_start_conditions = (
2020 '<p:cond delay="indefinite"/>'
2021 '<p:cond evt="onBegin" delay="0"><p:tn val="2"/></p:cond>'
2022 )
2023 else:
2024 outer_start_conditions = '<p:cond delay="0"/>'
2025 all_steps = f'''<p:par>
2026 <p:cTn id="{outer_id}" fill="hold">
2027 <p:stCondLst>{outer_start_conditions}</p:stCondLst>
2028 <p:childTnLst>
2029 {inner_xml}
2030 </p:childTnLst>
2031 </p:cTn>
2032 </p:par>'''
2033
2034 if main_targets:
2035 main_sequence_xml = f'''<p:seq concurrent="1" nextAc="seek">
2036 <p:cTn id="2" dur="indefinite" nodeType="mainSeq">
2037 <p:childTnLst>
2038 {all_steps}
2039 </p:childTnLst>
2040 </p:cTn>
2041 <p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst>
2042 <p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst>
2043 </p:seq>'''
2044 else:
2045 main_sequence_xml = ''
2046 next_id = 2
2047
2048 interactive_sequences: list[str] = []
2049 for target in interactive_targets:
2050 sequence_id = next_id
2051 wrapper_id = next_id + 1
2052 inner_id = next_id + 2
2053 row_id = next_id + 3
2054 row_xml, next_id = _build_animation_row_xml(
2055 target,
2056 'on-click',
2057 row_id,
2058 next_id + 4,
2059 )
2060 trigger_shape_id = target.trigger_shape_id
2061 interactive_sequences.append(f'''<p:seq concurrent="1" nextAc="seek">
2062 <p:cTn id="{sequence_id}" restart="whenNotActive" fill="hold" evtFilter="cancelBubble" nodeType="interactiveSeq">
2063 <p:stCondLst><p:cond evt="onClick" delay="0"><p:tgtEl><p:spTgt spid="{trigger_shape_id}"/></p:tgtEl></p:cond></p:stCondLst>
2064 <p:endSync evt="end" delay="0"><p:rtn val="all"/></p:endSync>
2065 <p:childTnLst>
2066 <p:par>
2067 <p:cTn id="{wrapper_id}" fill="hold">
2068 <p:stCondLst><p:cond delay="0"/></p:stCondLst>
2069 <p:childTnLst>
2070 <p:par>
2071 <p:cTn id="{inner_id}" fill="hold">
2072 <p:stCondLst><p:cond delay="0"/></p:stCondLst>
2073 <p:childTnLst><p:par>{row_xml}</p:par></p:childTnLst>
2074 </p:cTn>
2075 </p:par>
2076 </p:childTnLst>
2077 </p:cTn>
2078 </p:par>
2079 </p:childTnLst>
2080 </p:cTn>
2081 <p:nextCondLst><p:cond evt="onClick" delay="0"><p:tgtEl><p:spTgt spid="{trigger_shape_id}"/></p:tgtEl></p:cond></p:nextCondLst>
2082 </p:seq>''')
2083 sequence_xml = '\n '.join(
2084 [
2085 value
2086 for value in (
2087 main_sequence_xml,
2088 *interactive_sequences,
2089 )
2090 if value
2091 ]
2092 )
2093
2094 timing_xml = f''' <p:timing>
2095 <p:tnLst>
2096 <p:par>
2097 <p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot">
2098 <p:childTnLst>
2099 {sequence_xml}
2100 </p:childTnLst>
2101 </p:cTn>
2102 </p:par>
2103 </p:tnLst>
2104 </p:timing>'''
2105 if not any(target.bounce_end for target in normalized_targets):
2106 return timing_xml
2107
2108 fallback_xml = re.sub(
2109 r'\s+p14:(?:presetBounceEnd|bounceEnd)="\d+"',
2110 '',
2111 timing_xml,
2112 )
2113 fallback_xml = fallback_xml.replace(
2114 f' xmlns:p14="{_P14_NS}"',
2115 '',
2116 )
2117 return f''' <mc:AlternateContent xmlns:mc="{_MC_NS}">
2118 <mc:Choice xmlns:p14="{_P14_NS}" Requires="p14">
2119 {timing_xml}
2120 </mc:Choice>
2121 <mc:Fallback>
2122 {fallback_xml}
2123 </mc:Fallback>
2124 </mc:AlternateContent>'''
2125
2126
2127 def pick_animation_effect(
2128 mode: str,
2129 idx: int,
2130 offset: int = 0,
2131 group_id: str | None = None,
2132 *,
2133 rng: random.Random | None = None,
2134 ) -> str:
2135 """Resolve a per-element effect name from a mode string.
2136
2137 - A specific animation name returns itself (no variation).
2138 - 'auto': map ``group_id`` to an effect. Image-like ids
2139 (hero / figure- / image / img- / kpi) cycle through ``_IMAGE_POOL``
2140 (``entrance_zoom`` / ``entrance_dissolve`` / ``entrance_circle`` /
2141 ``entrance_box`` / ``entrance_diamond`` / ``entrance_wheel``) by
2142 ``idx + offset``
2143 so multiple images vary across the deck. Other semantic matches in
2144 ``_SEMANTIC_PATTERNS`` return a single stable effect
2145 (chart→``entrance_wipe``, card-/step-/pillar-→``entrance_fly``,
2146 title/takeaway→``entrance_fade``). When the id matches no pattern, cycle
2147 through ``_AUTO_POOL``.
2148 - 'mixed' (compatible mode name): first element fixed to
2149 ``entrance_fade``; the rest cycle through ``_MIXED_POOL`` plus
2150 ``offset`` so titles stay calm while content varies across slides.
2151 - 'random': uniform seeded choice from the same canonical preset pool.
2152 Unknown modes fail explicitly; no effect is silently substituted.
2153 """
2154 mode = normalize_animation_effect(
2155 mode,
2156 allow_none=False,
2157 allow_modes=True,
2158 )
2159 if isinstance(idx, bool) or not isinstance(idx, int) or idx < 0:
2160 raise ValueError(f'animation index must be a non-negative integer: {idx!r}')
2161 if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
2162 raise ValueError(
2163 f'animation offset must be a non-negative integer: {offset!r}'
2164 )
2165 if mode in NATIVE_ANIMATIONS:
2166 return mode
2167 if mode == 'auto':
2168 semantic = _semantic_effect(group_id, idx, offset)
2169 if semantic is not None:
2170 return semantic
2171 return _AUTO_POOL[(idx + offset) % len(_AUTO_POOL)]
2172 if mode == 'mixed':
2173 if idx == 0:
2174 return 'entrance_fade'
2175 return _MIXED_POOL[(idx - 1 + offset) % len(_MIXED_POOL)]
2176 if mode == 'random':
2177 chooser = rng if rng is not None else random
2178 return chooser.choice(_MIXED_POOL)
2179 raise AssertionError(f'unhandled animation mode: {mode}')
2180
2181
2182 def _int_attribute(
2183 element: ET.Element,
2184 name: str,
2185 label: str,
2186 errors: list[str],
2187 *,
2188 minimum: int = 0,
2189 maximum: int | None = None,
2190 ) -> int | None:
2191 value = element.get(name)
2192 if value is None or not re.fullmatch(r'\d+', value):
2193 errors.append(f'{label} must be an integer; found {value!r}')
2194 return None
2195 number = int(value)
2196 if number < minimum:
2197 errors.append(f'{label} must be at least {minimum}; found {number}')
2198 return None
2199 if maximum is not None and number > maximum:
2200 errors.append(f'{label} must be at most {maximum}; found {number}')
2201 return None
2202 return number
2203
2204
2205 def _direct_conditions(ctn: ET.Element) -> list[ET.Element]:
2206 condition_list = ctn.find(_qn(PML_NS, 'stCondLst'))
2207 if condition_list is None:
2208 return []
2209 return [
2210 child for child in list(condition_list)
2211 if child.tag == _qn(PML_NS, 'cond')
2212 ]
2213
2214
2215 def _shape_index(
2216 slide_root: ET.Element,
2217 ) -> tuple[dict[int, tuple[str, bool]], list[str]]:
2218 parent_map = {
2219 child: parent
2220 for parent in slide_root.iter()
2221 for child in list(parent)
2222 }
2223 index: dict[int, tuple[str, bool]] = {}
2224 errors: list[str] = []
2225 shape_tags = {'sp', 'grpSp', 'pic', 'graphicFrame', 'cxnSp', 'contentPart'}
2226 for non_visual in slide_root.iter(_qn(PML_NS, 'cNvPr')):
2227 shape_id = _int_attribute(
2228 non_visual,
2229 'id',
2230 'p:cNvPr@id',
2231 errors,
2232 minimum=1,
2233 maximum=MAX_OOXML_UNSIGNED_INT,
2234 )
2235 if shape_id is None:
2236 continue
2237 owner = parent_map.get(non_visual)
2238 while owner is not None and _local_name(owner.tag) not in shape_tags:
2239 owner = parent_map.get(owner)
2240 kind = _local_name(owner.tag) if owner is not None else 'unknown'
2241 has_text = bool(
2242 owner is not None
2243 and kind == 'sp'
2244 and any(
2245 _local_name(element.tag) == 't' and (element.text or '').strip()
2246 for element in owner.iter()
2247 )
2248 )
2249 if shape_id in index:
2250 errors.append(f'duplicate p:cNvPr@id {shape_id}')
2251 else:
2252 index[shape_id] = (kind, has_text)
2253 return index, errors
2254
2255
2256 def _row_shape_id(row: ET.Element, errors: list[str]) -> int | None:
2257 shape_ids: list[int] = []
2258 for target in row.iter(_qn(PML_NS, 'spTgt')):
2259 value = _int_attribute(
2260 target,
2261 'spid',
2262 'animation p:spTgt@spid',
2263 errors,
2264 minimum=1,
2265 maximum=MAX_OOXML_UNSIGNED_INT,
2266 )
2267 if value is not None:
2268 shape_ids.append(value)
2269 unique = sorted(set(shape_ids))
2270 if len(unique) != 1:
2271 errors.append(
2272 'one object-animation row must resolve to exactly one shape id; '
2273 f'found {unique or "none"}'
2274 )
2275 return None
2276 return unique[0]
2277
2278
2279 def _row_filter(
2280 row: ET.Element,
2281 preset_class: str,
2282 errors: list[str],
2283 ) -> str | None:
2284 effects = list(row.iter(_qn(PML_NS, 'animEffect')))
2285 if len(effects) > 1:
2286 errors.append(
2287 f'object-animation row contains {len(effects)} p:animEffect nodes'
2288 )
2289 if not effects:
2290 return None
2291 effect = effects[0]
2292 expected_transition = {'entr': 'in', 'exit': 'out'}.get(preset_class)
2293 if expected_transition is not None and effect.get('transition') != expected_transition:
2294 errors.append(
2295 f'{preset_class} p:animEffect must set '
2296 f'transition="{expected_transition}"'
2297 )
2298 return effect.get('filter')
2299
2300
2301 def _resolve_row_effect(
2302 row: ET.Element,
2303 filter_name: str | None,
2304 errors: list[str],
2305 ) -> tuple[tuple[str, ...], str | None, int | None, int | None]:
2306 preset_class = row.get('presetClass')
2307 if preset_class not in set(_PRESET_CLASS_BY_CATEGORY.values()):
2308 errors.append(
2309 f'unsupported p:cTn@presetClass {preset_class!r}; expected '
2310 + ', '.join(sorted(set(_PRESET_CLASS_BY_CATEGORY.values())))
2311 )
2312 return (), preset_class, None, None
2313 preset_id = _int_attribute(
2314 row,
2315 'presetID',
2316 'object-animation p:cTn@presetID',
2317 errors,
2318 maximum=MAX_OOXML_UNSIGNED_INT,
2319 )
2320 preset_subtype = _int_attribute(
2321 row,
2322 'presetSubtype',
2323 'object-animation p:cTn@presetSubtype',
2324 errors,
2325 maximum=MAX_OOXML_UNSIGNED_INT,
2326 )
2327 if preset_id is None or preset_subtype is None:
2328 return (), preset_class, preset_id, preset_subtype
2329 matches = [
2330 key
2331 for key, info in NATIVE_ANIMATIONS.items()
2332 if info['presetClass'] == preset_class
2333 if int(info['presetID']) == preset_id
2334 ]
2335 return tuple(matches), preset_class, preset_id, preset_subtype
2336
2337
2338 def _behavior_duration_ms(
2339 row: ET.Element,
2340 errors: list[str],
2341 ) -> int | None:
2342 duration = _row_effective_duration_ms(row)
2343 if duration is not None and duration > MAX_OOXML_MILLISECONDS:
2344 errors.append(
2345 'object-animation behavior duration exceeds the OOXML '
2346 f'millisecond limit: {duration}'
2347 )
2348 return duration
2349
2350
2351 def _read_animation_color(
2352 row: ET.Element,
2353 errors: list[str],
2354 label: str,
2355 ) -> str | None:
2356 colors = [
2357 element
2358 for element in row.iter()
2359 if element.tag in {
2360 _qn(_DML_NS, 'srgbClr'),
2361 _qn(_DML_NS, 'schemeClr'),
2362 }
2363 ]
2364 if not colors:
2365 errors.append(f'{label} is missing its color value')
2366 return None
2367 rendered = []
2368 for color in colors:
2369 if color.tag == _qn(_DML_NS, 'srgbClr'):
2370 value = color.get('val')
2371 rendered.append(f'#{value.upper()}' if value else '')
2372 else:
2373 value = color.get('val')
2374 rendered.append(f'theme:{value}' if value else '')
2375 unique = tuple(dict.fromkeys(value for value in rendered if value))
2376 if len(unique) != 1:
2377 errors.append(f'{label} contains inconsistent color values: {unique!r}')
2378 return None
2379 return unique[0] if unique else None
2380
2381
2382 def _read_effect_options(
2383 row: ET.Element,
2384 effect: str | None,
2385 filter_name: str | None,
2386 errors: list[str],
2387 ) -> dict[str, object]:
2388 if effect is None:
2389 return {}
2390 option_specs = NATIVE_ANIMATIONS[effect]['effectOptions']
2391 values: dict[str, object] = {}
2392 for name, spec in option_specs.items():
2393 option_type = spec['type']
2394 if option_type == 'enum':
2395 matches = []
2396 for value, variant_xml in spec['values'].items():
2397 variant = ET.fromstring(variant_xml)
2398 variant_filter = _row_filter(variant, variant.get('presetClass', ''), [])
2399 if variant.get('presetSubtype') != row.get('presetSubtype'):
2400 continue
2401 if variant_filter != filter_name:
2402 continue
2403 matches.append(value)
2404 if len(matches) != 1:
2405 errors.append(
2406 f'animation effect {effect!r} option {name!r} could not be '
2407 f'read from presetSubtype={row.get("presetSubtype")!r}, '
2408 f'filter={filter_name!r}'
2409 )
2410 continue
2411 value: object = matches[0]
2412 if name == 'amount' and re.fullmatch(r'\d+', str(value)):
2413 value = int(str(value))
2414 values[name] = value
2415 elif name == 'amount' and effect == 'emphasis_spin':
2416 rotations = list(row.iter(_qn(PML_NS, 'animRot')))
2417 raw = rotations[0].get('by') if len(rotations) == 1 else None
2418 if raw is None or not re.fullmatch(r'-?\d+', raw):
2419 errors.append('emphasis_spin row has an invalid rotation amount')
2420 else:
2421 values[name] = int(raw) / 60000
2422 elif name == 'amount' and effect == 'emphasis_transparency':
2423 opacity_values = []
2424 for node in row.iter(_qn(PML_NS, 'set')):
2425 attributes = {
2426 (attribute.text or '').strip()
2427 for attribute in node.iter(_qn(PML_NS, 'attrName'))
2428 }
2429 if 'style.opacity' in attributes:
2430 opacity_values.extend(
2431 value.get('val')
2432 for value in node.iter(_qn(PML_NS, 'strVal'))
2433 )
2434 if len(opacity_values) != 1:
2435 errors.append('emphasis_transparency row has an invalid opacity')
2436 else:
2437 try:
2438 values[name] = 1 - float(str(opacity_values[0]))
2439 except ValueError:
2440 errors.append(
2441 'emphasis_transparency row has a non-numeric opacity'
2442 )
2443 elif name == 'color':
2444 color = _read_animation_color(
2445 row,
2446 errors,
2447 f'animation effect {effect!r} color option',
2448 )
2449 if color is not None:
2450 values[name] = color
2451 elif name == 'font_name':
2452 fonts = []
2453 for node in row.iter(_qn(PML_NS, 'set')):
2454 attributes = {
2455 (attribute.text or '').strip()
2456 for attribute in node.iter(_qn(PML_NS, 'attrName'))
2457 }
2458 if 'style.fontFamily' in attributes:
2459 fonts.extend(
2460 value.get('val')
2461 for value in node.iter(_qn(PML_NS, 'strVal'))
2462 )
2463 if len(fonts) != 1:
2464 errors.append(
2465 'emphasis_change_font row must contain one font name'
2466 )
2467 continue
2468 try:
2469 values[name] = _normalize_powerpoint_font_name(
2470 fonts[0],
2471 'emphasis_change_font row font name',
2472 )
2473 except ValueError as exc:
2474 errors.append(str(exc))
2475 elif name == 'relative':
2476 motions = list(row.iter(_qn(PML_NS, 'animMotion')))
2477 if len(motions) != 1:
2478 errors.append(f'motion-path effect {effect!r} has no single path')
2479 else:
2480 values[name] = motions[0].get('pathEditMode') != 'fixed'
2481 elif name == 'size':
2482 scales = list(row.iter(_qn(PML_NS, 'animScale')))
2483 targets = (
2484 scales[0].findall(_qn(PML_NS, 'to'))
2485 if len(scales) == 1
2486 else []
2487 )
2488 raw = (
2489 targets[0].get('x')
2490 if len(targets) == 1
2491 else '100000' if not targets and len(scales) == 1 else None
2492 )
2493 if raw is None or not re.fullmatch(r'\d+', raw):
2494 errors.append('emphasis_grow_shrink row has an invalid size')
2495 else:
2496 values[name] = int(raw) / 1000
2497 else:
2498 errors.append(
2499 f'animation effect {effect!r} option {name!r} has no reader'
2500 )
2501 return values
2502
2503
2504 def _timing_summary(
2505 row: ET.Element,
2506 duration_ms: int | None,
2507 errors: list[str],
2508 ) -> tuple[
2509 float | None,
2510 int | None,
2511 bool,
2512 bool,
2513 float,
2514 float,
2515 float,
2516 str,
2517 int | None,
2518 ]:
2519 raw_repeat_count = row.get('repeatCount')
2520 repeat_count = None
2521 if raw_repeat_count is not None:
2522 if not re.fullmatch(r'\d+', raw_repeat_count):
2523 errors.append(
2524 f'object-animation repeatCount must be numeric; found '
2525 f'{raw_repeat_count!r}'
2526 )
2527 else:
2528 repeat_count = int(raw_repeat_count) / 1000
2529 raw_repeat_duration = row.get('repeatDur')
2530 repeat_duration_ms = None
2531 if raw_repeat_duration is not None:
2532 if not re.fullmatch(r'\d+', raw_repeat_duration):
2533 errors.append(
2534 f'object-animation repeatDur must be numeric; found '
2535 f'{raw_repeat_duration!r}'
2536 )
2537 else:
2538 repeat_duration_ms = int(raw_repeat_duration)
2539 if repeat_count is not None and repeat_duration_ms is not None:
2540 errors.append('object-animation row sets both repeatCount and repeatDur')
2541
2542 def ratio(attribute: str) -> float:
2543 raw = row.get(attribute)
2544 if raw is None:
2545 return 0.0
2546 if not re.fullmatch(r'\d+', raw):
2547 errors.append(
2548 f'object-animation {attribute} must be numeric; found {raw!r}'
2549 )
2550 return 0.0
2551 number = int(raw)
2552 if number > 100000:
2553 errors.append(
2554 f'object-animation {attribute} exceeds 100000; found {number}'
2555 )
2556 return number / 100000
2557
2558 accelerate = ratio('accel')
2559 decelerate = ratio('decel')
2560 if accelerate + decelerate > 1:
2561 errors.append('object-animation accel + decel exceeds 100000')
2562 raw_bounce_values = {
2563 node.get(_qn(_P14_NS, 'bounceEnd'))
2564 for node in row.iter()
2565 if node.get(_qn(_P14_NS, 'bounceEnd')) is not None
2566 }
2567 preset_bounce = row.get(_qn(_P14_NS, 'presetBounceEnd'))
2568 if preset_bounce is not None:
2569 raw_bounce_values.add(preset_bounce)
2570 bounce_end = 0.0
2571 if len(raw_bounce_values) > 1:
2572 errors.append(
2573 'object-animation behaviors disagree on p14:bounceEnd'
2574 )
2575 elif raw_bounce_values:
2576 raw_bounce = next(iter(raw_bounce_values))
2577 if raw_bounce is None or not re.fullmatch(r'\d+', raw_bounce):
2578 errors.append(
2579 f'object-animation p14:bounceEnd must be numeric; '
2580 f'found {raw_bounce!r}'
2581 )
2582 else:
2583 bounce_value = int(raw_bounce)
2584 if bounce_value > 100000:
2585 errors.append(
2586 'object-animation p14:bounceEnd exceeds 100000; '
2587 f'found {bounce_value}'
2588 )
2589 bounce_end = bounce_value / 100000
2590 auto_reverse = row.get('autoRev') == '1'
2591 rewind = row.get('fill') == 'remove'
2592 restart = {
2593 None: 'never',
2594 'always': 'always',
2595 'whenNotActive': 'when-not-active',
2596 'never': 'never',
2597 }.get(row.get('restart'))
2598 if restart is None:
2599 errors.append(
2600 f'object-animation restart has unknown value: {row.get("restart")!r}'
2601 )
2602 restart = 'never'
2603
2604 playback_duration_ms = None
2605 if duration_ms is not None:
2606 one_play = duration_ms * (2 if auto_reverse else 1)
2607 if repeat_duration_ms is not None:
2608 playback_duration_ms = repeat_duration_ms
2609 elif repeat_count is not None:
2610 playback_duration_ms = max(1, round(one_play * repeat_count))
2611 else:
2612 playback_duration_ms = one_play
2613 return (
2614 repeat_count,
2615 repeat_duration_ms,
2616 auto_reverse,
2617 rewind,
2618 accelerate,
2619 decelerate,
2620 bounce_end,
2621 restart,
2622 playback_duration_ms,
2623 )
2624
2625
2626 def _after_effect_summary(
2627 row: ET.Element,
2628 errors: list[str],
2629 ) -> tuple[str, str | None]:
2630 sub_timing = row.find(_qn(PML_NS, 'subTnLst'))
2631 if sub_timing is None:
2632 return 'none', None
2633 after_nodes = [
2634 node
2635 for node in list(sub_timing)
2636 if any(
2637 ctn.get('afterEffect') == '1'
2638 for ctn in node.iter(_qn(PML_NS, 'cTn'))
2639 )
2640 ]
2641 if not after_nodes:
2642 return 'none', None
2643 if len(after_nodes) != 1:
2644 errors.append(
2645 f'object-animation row contains {len(after_nodes)} after effects'
2646 )
2647 return 'none', None
2648 node = after_nodes[0]
2649 if node.tag == _qn(PML_NS, 'animClr'):
2650 return (
2651 'dim',
2652 _read_animation_color(node, errors, 'animation dim after effect'),
2653 )
2654 if node.tag == _qn(PML_NS, 'set'):
2655 ctns = list(node.iter(_qn(PML_NS, 'cTn')))
2656 master_relation = ctns[0].get('masterRel') if ctns else None
2657 if master_relation == 'sameClick':
2658 return 'hide', None
2659 if master_relation == 'nextClick':
2660 return 'hide-on-next-click', None
2661 errors.append('object-animation row contains an unknown after effect')
2662 return 'none', None
2663
2664
2665 def _sound_summary(
2666 row: ET.Element,
2667 errors: list[str],
2668 ) -> tuple[str | None, str | None]:
2669 sounds = list(row.iter(_qn(PML_NS, 'sndTgt')))
2670 if not sounds:
2671 return None, None
2672 if len(sounds) != 1:
2673 errors.append(f'object-animation row contains {len(sounds)} sounds')
2674 return None, None
2675 relationship_id = sounds[0].get(_qn(_REL_NS, 'embed'))
2676 name = sounds[0].get('name')
2677 if relationship_id is None or name is None:
2678 errors.append('object-animation sound is missing relationship id or name')
2679 return relationship_id, name
2680
2681
2682 def _row_trigger_shape_id(
2683 row: ET.Element,
2684 parent_map: Mapping[ET.Element, ET.Element],
2685 errors: list[str],
2686 ) -> int | None:
2687 current = parent_map.get(row)
2688 while current is not None:
2689 if (
2690 current.tag == _qn(PML_NS, 'cTn')
2691 and current.get('nodeType') == 'interactiveSeq'
2692 ):
2693 shape_targets = [
2694 target
2695 for condition in _direct_conditions(current)
2696 if condition.get('evt') == 'onClick'
2697 for target in condition.iter(_qn(PML_NS, 'spTgt'))
2698 ]
2699 if len(shape_targets) != 1:
2700 errors.append(
2701 'interactive animation sequence must have one trigger shape'
2702 )
2703 return None
2704 return _int_attribute(
2705 shape_targets[0],
2706 'spid',
2707 'interactive animation trigger shape id',
2708 errors,
2709 minimum=1,
2710 maximum=MAX_OOXML_UNSIGNED_INT,
2711 )
2712 current = parent_map.get(current)
2713 return None
2714
2715
2716 def _row_offset_ms(
2717 row: ET.Element,
2718 trigger: str,
2719 trigger_shape_id: int | None,
2720 parent_map: Mapping[ET.Element, ET.Element],
2721 errors: list[str],
2722 ) -> int:
2723 leaf_conditions = _direct_conditions(row)
2724 leaf_delay = None
2725 if (
2726 len(leaf_conditions) == 1
2727 and re.fullmatch(r'\d+', leaf_conditions[0].get('delay') or '')
2728 ):
2729 leaf_delay = int(leaf_conditions[0].get('delay', '0'))
2730 else:
2731 errors.append(
2732 'object-animation row must have one numeric leaf start condition'
2733 )
2734 current = parent_map.get(row)
2735 saw_indefinite = False
2736 numeric_offset: int | None = None
2737 while current is not None:
2738 if current.tag == _qn(PML_NS, 'cTn'):
2739 conditions = _direct_conditions(current)
2740 if any(condition.get('delay') == 'indefinite' for condition in conditions):
2741 saw_indefinite = True
2742 if trigger in {'with-previous', 'after-previous'}:
2743 numeric = [
2744 condition.get('delay')
2745 for condition in conditions
2746 if condition.get('evt') is None
2747 and re.fullmatch(r'\d+', condition.get('delay') or '')
2748 ]
2749 if numeric and numeric_offset is None:
2750 numeric_offset = int(numeric[0])
2751 if numeric_offset > MAX_OOXML_MILLISECONDS:
2752 errors.append(
2753 'animation row offset exceeds the OOXML '
2754 f'millisecond limit: {numeric_offset}'
2755 )
2756 current = parent_map.get(current)
2757
2758 if (
2759 trigger == 'on-click'
2760 and trigger_shape_id is None
2761 and not saw_indefinite
2762 ):
2763 errors.append(
2764 'on-click object-animation row is missing an indefinite click wrapper'
2765 )
2766 if trigger in {'with-previous', 'after-previous'} and not saw_indefinite:
2767 errors.append(f'{trigger} sequence is missing its sequence anchor')
2768 if trigger in {'with-previous', 'after-previous'} and numeric_offset is None:
2769 errors.append(
2770 f'{trigger} object-animation row is missing its numeric offset wrapper'
2771 )
2772 if trigger in {'with-previous', 'after-previous'}:
2773 absolute_offset = (numeric_offset or 0) + (leaf_delay or 0)
2774 if absolute_offset > MAX_OOXML_MILLISECONDS:
2775 errors.append(
2776 'animation row absolute offset exceeds the OOXML '
2777 f'millisecond limit: {absolute_offset}'
2778 )
2779 return absolute_offset
2780 if trigger_shape_id is not None:
2781 return leaf_delay or 0
2782 return leaf_delay or 0
2783
2784
2785 def _row_matches_powerpoint_behavior(
2786 row: ET.Element,
2787 *,
2788 shape_id: int,
2789 effect: str,
2790 effect_options: Mapping[str, object],
2791 trigger: str,
2792 duration_ms: int,
2793 repeat_count: float | None,
2794 repeat_duration_ms: int | None,
2795 auto_reverse: bool,
2796 rewind: bool,
2797 accelerate: float,
2798 decelerate: float,
2799 bounce_end: float,
2800 restart: str,
2801 after_effect: str,
2802 after_effect_color: str | None,
2803 sound_relationship_id: str | None,
2804 sound_name: str | None,
2805 ) -> bool:
2806 """Match one read-back row to its reconstructed native behavior tree."""
2807 spec = NATIVE_ANIMATIONS[effect]
2808 target = AnimationTarget(
2809 shape_id=shape_id,
2810 delay_ms=0,
2811 effect=effect,
2812 duration_ms=duration_ms,
2813 effect_options=effect_options,
2814 trigger=trigger,
2815 repeat_count=repeat_count,
2816 repeat_duration_ms=repeat_duration_ms,
2817 auto_reverse=True if auto_reverse else None,
2818 rewind=True if rewind else None,
2819 accelerate=accelerate or None,
2820 decelerate=decelerate or None,
2821 bounce_end=bounce_end or None,
2822 restart=restart if row.get('restart') is not None else None,
2823 after_effect=after_effect,
2824 after_effect_color=after_effect_color,
2825 sound_relationship_id=sound_relationship_id,
2826 sound_name=sound_name,
2827 )
2828 option_candidates = [dict(effect_options)]
2829 option_candidates.extend(
2830 {
2831 name: value
2832 for name, value in effect_options.items()
2833 if name != omitted
2834 }
2835 for omitted in effect_options
2836 )
2837 option_candidates.append({})
2838 seen: set[tuple[tuple[str, object], ...]] = set()
2839 for candidate in option_candidates:
2840 candidate_key = tuple(sorted(candidate.items()))
2841 if candidate_key in seen:
2842 continue
2843 seen.add(candidate_key)
2844 expected = _animation_row_for_options(effect, candidate)
2845 if spec['durationScalable']:
2846 _scale_animation_row_duration(
2847 expected,
2848 base_duration_ms=int(spec['defaultDurationMs']),
2849 requested_duration_ms=duration_ms,
2850 )
2851 _apply_timing_options(expected, target)
2852 row_id = row.get('id')
2853 expected.set('id', row_id if row_id and row_id.isdigit() else '1')
2854 _append_after_effect(
2855 expected,
2856 target,
2857 int(expected.get('id', '1')),
2858 )
2859 _append_animation_sound(expected, target)
2860 if _animation_spec_matches_row(
2861 row,
2862 {'rowXml': ET.tostring(expected, encoding='unicode')},
2863 ):
2864 return True
2865 return False
2866
2867
2868 def _animation_rows(
2869 slide_root: ET.Element,
2870 errors: list[str],
2871 *,
2872 require_behavior_signatures: bool = False,
2873 ) -> list[AnimationRowSummary]:
2874 parent_map = {
2875 child: parent
2876 for parent in slide_root.iter()
2877 for child in list(parent)
2878 }
2879 rows: list[AnimationRowSummary] = []
2880 for row in slide_root.iter(_qn(PML_NS, 'cTn')):
2881 preset_class = row.get('presetClass')
2882 if preset_class not in set(_PRESET_CLASS_BY_CATEGORY.values()):
2883 continue
2884 node_type = row.get('nodeType')
2885 trigger = _NODE_TYPE_TRIGGERS.get(node_type or '')
2886 if trigger is None:
2887 errors.append(
2888 f'unsupported object-animation nodeType {node_type!r}; expected '
2889 f'{", ".join(_NODE_TYPE_TRIGGERS)}'
2890 )
2891 continue
2892 shape_id = _row_shape_id(row, errors)
2893 filter_name = _row_filter(row, preset_class, errors)
2894 supported_effects, resolved_class, preset_id, preset_subtype = (
2895 _resolve_row_effect(
2896 row,
2897 filter_name,
2898 errors,
2899 )
2900 )
2901 duration_ms = _behavior_duration_ms(row, errors)
2902 trigger_shape_id = _row_trigger_shape_id(row, parent_map, errors)
2903 offset_ms = _row_offset_ms(
2904 row,
2905 trigger,
2906 trigger_shape_id,
2907 parent_map,
2908 errors,
2909 )
2910 resolved_effect = supported_effects[0] if supported_effects else None
2911 effect_options = _read_effect_options(
2912 row,
2913 resolved_effect,
2914 filter_name,
2915 errors,
2916 )
2917 (
2918 repeat_count,
2919 repeat_duration_ms,
2920 auto_reverse,
2921 rewind,
2922 accelerate,
2923 decelerate,
2924 bounce_end,
2925 restart,
2926 playback_duration_ms,
2927 ) = _timing_summary(row, duration_ms, errors)
2928 after_effect, after_effect_color = _after_effect_summary(row, errors)
2929 sound_relationship_id, sound_name = _sound_summary(row, errors)
2930 if (
2931 shape_id is None
2932 or resolved_class is None
2933 or preset_id is None
2934 or preset_subtype is None
2935 ):
2936 continue
2937 if (
2938 require_behavior_signatures
2939 and resolved_effect is not None
2940 and duration_ms is not None
2941 and not _row_matches_powerpoint_behavior(
2942 row,
2943 shape_id=shape_id,
2944 effect=resolved_effect,
2945 effect_options=effect_options,
2946 trigger=trigger,
2947 duration_ms=duration_ms,
2948 repeat_count=repeat_count,
2949 repeat_duration_ms=repeat_duration_ms,
2950 auto_reverse=auto_reverse,
2951 rewind=rewind,
2952 accelerate=accelerate,
2953 decelerate=decelerate,
2954 bounce_end=bounce_end,
2955 restart=restart,
2956 after_effect=after_effect,
2957 after_effect_color=after_effect_color,
2958 sound_relationship_id=sound_relationship_id,
2959 sound_name=sound_name,
2960 )
2961 ):
2962 errors.append(
2963 'object-animation PowerPoint-authored behavior tree changed '
2964 f'for shape {shape_id}'
2965 )
2966 rows.append(
2967 AnimationRowSummary(
2968 shape_id=shape_id,
2969 effect=resolved_effect,
2970 supported_effects=supported_effects,
2971 preset_class=resolved_class,
2972 trigger=trigger,
2973 duration_ms=duration_ms,
2974 offset_ms=offset_ms,
2975 preset_id=preset_id,
2976 preset_subtype=preset_subtype,
2977 filter_name=filter_name,
2978 effect_options=effect_options,
2979 trigger_shape_id=trigger_shape_id,
2980 repeat_count=repeat_count,
2981 repeat_duration_ms=repeat_duration_ms,
2982 auto_reverse=auto_reverse,
2983 rewind=rewind,
2984 accelerate=accelerate,
2985 decelerate=decelerate,
2986 bounce_end=bounce_end,
2987 restart=restart,
2988 after_effect=after_effect,
2989 after_effect_color=after_effect_color,
2990 sound_relationship_id=sound_relationship_id,
2991 sound_name=sound_name,
2992 playback_duration_ms=playback_duration_ms,
2993 )
2994 )
2995 return rows
2996
2997
2998 def _select_supported_timing_branch(slide_root: ET.Element) -> ET.Element:
2999 """Project p14 AlternateContent timing onto one effective slide tree."""
3000 projected = copy.deepcopy(slide_root)
3001 alternate_tag = _qn(_MC_NS, 'AlternateContent')
3002 choice_tag = _qn(_MC_NS, 'Choice')
3003 fallback_tag = _qn(_MC_NS, 'Fallback')
3004 for index, child in list(enumerate(list(projected))):
3005 if child.tag != alternate_tag:
3006 continue
3007 branches = [
3008 branch
3009 for branch in list(child)
3010 if branch.tag in {choice_tag, fallback_tag}
3011 ]
3012 selected_timing = None
3013 for branch in branches:
3014 if (
3015 branch.tag == choice_tag
3016 and 'p14' in (branch.get('Requires') or '').split()
3017 ):
3018 selected_timing = branch.find(_qn(PML_NS, 'timing'))
3019 if selected_timing is not None:
3020 break
3021 if selected_timing is None:
3022 for branch in branches:
3023 selected_timing = branch.find(_qn(PML_NS, 'timing'))
3024 if selected_timing is not None:
3025 break
3026 if selected_timing is None:
3027 continue
3028 projected.remove(child)
3029 projected.insert(index, copy.deepcopy(selected_timing))
3030 return projected
3031
3032
3033 def validate_slide_animation_structure(
3034 slide_root: ET.Element,
3035 *,
3036 require_supported_effects: bool = False,
3037 ) -> list[str]:
3038 """Return root timing, target, and generated-object structure errors."""
3039 errors: list[str] = []
3040 if slide_root.tag != _qn(PML_NS, 'sld'):
3041 return ['animation validation requires a PresentationML p:sld root']
3042 slide_root = _select_supported_timing_branch(slide_root)
3043
3044 direct_timings = [
3045 child for child in list(slide_root)
3046 if child.tag == _qn(PML_NS, 'timing')
3047 ]
3048 all_timings = list(slide_root.iter(_qn(PML_NS, 'timing')))
3049 nested_count = len(all_timings) - len(direct_timings)
3050 if nested_count:
3051 errors.append(
3052 f'slide contains {nested_count} nested p:timing element(s); '
3053 'timing must be a direct child of p:sld'
3054 )
3055 if len(direct_timings) > 1:
3056 errors.append(
3057 f'slide has {len(direct_timings)} root p:timing elements; expected at most 1'
3058 )
3059 if not direct_timings:
3060 return errors
3061
3062 timing = direct_timings[0]
3063 root_children = list(slide_root)
3064 timing_index = root_children.index(timing)
3065 for required_before in ('cSld', 'clrMapOvr', 'transition'):
3066 sibling = next(
3067 (
3068 child for child in root_children
3069 if child.tag == _qn(PML_NS, required_before)
3070 ),
3071 None,
3072 )
3073 if sibling is not None and root_children.index(sibling) > timing_index:
3074 errors.append(f'p:{required_before} must precede p:timing')
3075 extension_list = next(
3076 (
3077 child for child in root_children
3078 if child.tag == _qn(PML_NS, 'extLst')
3079 ),
3080 None,
3081 )
3082 if extension_list is not None and root_children.index(extension_list) < timing_index:
3083 errors.append('root p:extLst must follow p:timing')
3084
3085 timing_children = list(timing)
3086 timing_name_order = [_local_name(child.tag) for child in timing_children]
3087 if 'bldLst' in timing_name_order and 'tnLst' in timing_name_order:
3088 if timing_name_order.index('bldLst') < timing_name_order.index('tnLst'):
3089 errors.append('p:tnLst must precede p:bldLst')
3090
3091 parent_map = {
3092 child: parent
3093 for parent in timing.iter()
3094 for child in list(parent)
3095 }
3096 ctn_ids: list[int] = []
3097 for ctn in timing.iter(_qn(PML_NS, 'cTn')):
3098 if ctn.get('id') is None:
3099 ancestor = parent_map.get(ctn)
3100 while ancestor is not None and ancestor is not timing:
3101 if ancestor.tag == _qn(PML_NS, 'subTnLst'):
3102 break
3103 ancestor = parent_map.get(ancestor)
3104 if (
3105 ancestor is not None
3106 and ancestor.tag == _qn(PML_NS, 'subTnLst')
3107 ):
3108 continue
3109 value = _int_attribute(
3110 ctn,
3111 'id',
3112 'p:cTn@id',
3113 errors,
3114 maximum=MAX_OOXML_UNSIGNED_INT,
3115 )
3116 if value is not None:
3117 ctn_ids.append(value)
3118 duplicates = sorted(
3119 value for value in set(ctn_ids) if ctn_ids.count(value) > 1
3120 )
3121 if duplicates:
3122 errors.append(
3123 'duplicate p:cTn@id values: ' + ', '.join(map(str, duplicates))
3124 )
3125
3126 roots = [
3127 node for node in timing.iter(_qn(PML_NS, 'cTn'))
3128 if node.get('nodeType') == 'tmRoot'
3129 ]
3130 if len(roots) != 1:
3131 errors.append(
3132 f'p:timing must contain exactly one tmRoot time node; found {len(roots)}'
3133 )
3134
3135 shape_index, shape_errors = _shape_index(slide_root)
3136 errors.extend(shape_errors)
3137 for target in timing.iter(_qn(PML_NS, 'spTgt')):
3138 shape_id = _int_attribute(
3139 target,
3140 'spid',
3141 'p:spTgt@spid',
3142 errors,
3143 minimum=1,
3144 maximum=MAX_OOXML_UNSIGNED_INT,
3145 )
3146 if shape_id is not None and shape_id not in shape_index:
3147 errors.append(f'p:spTgt references missing shape id {shape_id}')
3148
3149 build_keys: list[tuple[int, int]] = []
3150 for build in timing.iter(_qn(PML_NS, 'bldP')):
3151 shape_id = _int_attribute(
3152 build,
3153 'spid',
3154 'p:bldP@spid',
3155 errors,
3156 minimum=1,
3157 maximum=MAX_OOXML_UNSIGNED_INT,
3158 )
3159 group_id = _int_attribute(
3160 build,
3161 'grpId',
3162 'p:bldP@grpId',
3163 errors,
3164 maximum=MAX_OOXML_UNSIGNED_INT,
3165 )
3166 if shape_id is None or group_id is None:
3167 continue
3168 build_keys.append((shape_id, group_id))
3169 kind, has_text = shape_index.get(shape_id, ('missing', False))
3170 if kind == 'missing':
3171 errors.append(f'p:bldP references missing shape id {shape_id}')
3172 elif require_supported_effects and (kind != 'sp' or not has_text):
3173 errors.append(
3174 f'p:bldP shape id {shape_id} must reference a text-bearing p:sp; '
3175 f'found {kind}'
3176 )
3177 if len(build_keys) != len(set(build_keys)):
3178 errors.append('p:bldP (spid, grpId) pairs must be unique')
3179
3180 animation_nodes = [
3181 node for node in timing.iter(_qn(PML_NS, 'cTn'))
3182 if node.get('presetClass') in set(_PRESET_CLASS_BY_CATEGORY.values())
3183 ]
3184 if require_supported_effects:
3185 rows = _animation_rows(
3186 slide_root,
3187 errors,
3188 require_behavior_signatures=True,
3189 )
3190 if not rows and animation_nodes:
3191 errors.append('generated object-animation rows could not be read back')
3192 else:
3193 rows = []
3194 if rows:
3195 regular_rows = [
3196 row for row in rows if row.trigger_shape_id is None
3197 ]
3198 interactive_rows = [
3199 row for row in rows if row.trigger_shape_id is not None
3200 ]
3201 main_sequences = [
3202 node for node in timing.iter(_qn(PML_NS, 'cTn'))
3203 if node.get('nodeType') == 'mainSeq'
3204 ]
3205 expected_main_sequences = 1 if regular_rows else 0
3206 if len(main_sequences) != expected_main_sequences:
3207 errors.append(
3208 'generated object-animation rows require '
3209 f'{expected_main_sequences} mainSeq time node(s); found '
3210 f'{len(main_sequences)}'
3211 )
3212 interactive_sequences = [
3213 node for node in timing.iter(_qn(PML_NS, 'cTn'))
3214 if node.get('nodeType') == 'interactiveSeq'
3215 ]
3216 if len(interactive_sequences) != len(interactive_rows):
3217 errors.append(
3218 'each generated trigger-shape animation must have one '
3219 'interactiveSeq time node'
3220 )
3221 for row in rows:
3222 if (
3223 row.trigger_shape_id is not None
3224 and row.trigger != 'on-click'
3225 ):
3226 errors.append(
3227 'trigger-shape animation must use the on-click Start mode '
3228 f'for shape {row.shape_id}'
3229 )
3230 if row.trigger_shape_id == row.shape_id:
3231 errors.append(
3232 'animation target and trigger shape must differ for shape '
3233 f'{row.shape_id}'
3234 )
3235 if not row.supported_effects:
3236 errors.append(
3237 'unsupported object-animation effect tuple for shape '
3238 f'{row.shape_id}: presetClass={row.preset_class}, '
3239 f'presetID={row.preset_id}, '
3240 f'presetSubtype={row.preset_subtype}, '
3241 f'filter={row.filter_name!r}'
3242 )
3243 return errors
3244
3245
3246 def read_slide_animation_sequence(
3247 slide_xml: str | bytes,
3248 *,
3249 require_supported_effects: bool = False,
3250 ) -> AnimationSequenceSummary:
3251 """Read and validate the logical object-animation sequence from slide XML."""
3252 data = slide_xml.encode('utf-8') if isinstance(slide_xml, str) else slide_xml
3253 try:
3254 root = ET.fromstring(data)
3255 except ET.ParseError as exc:
3256 raise ValueError(f'invalid slide XML: {exc}') from exc
3257 root = _select_supported_timing_branch(root)
3258 errors = validate_slide_animation_structure(
3259 root,
3260 require_supported_effects=require_supported_effects,
3261 )
3262 row_errors: list[str] = []
3263 rows = _animation_rows(
3264 root,
3265 row_errors,
3266 require_behavior_signatures=require_supported_effects,
3267 )
3268 for error in row_errors:
3269 if error not in errors:
3270 errors.append(error)
3271 if errors:
3272 raise ValueError('; '.join(errors))
3273 direct_timings = [
3274 child for child in list(root)
3275 if child.tag == _qn(PML_NS, 'timing')
3276 ]
3277 audio_targets: list[int] = []
3278 for audio in root.iter(_qn(PML_NS, 'audio')):
3279 for target in audio.iter(_qn(PML_NS, 'spTgt')):
3280 value = target.get('spid')
3281 if value and value.isdigit():
3282 audio_targets.append(int(value))
3283 regular_triggers = {
3284 row.trigger for row in rows if row.trigger_shape_id is None
3285 }
3286 trigger = (
3287 next(iter(regular_triggers))
3288 if len(regular_triggers) == 1
3289 else ('on-click' if rows and not regular_triggers else None)
3290 )
3291 return AnimationSequenceSummary(
3292 timing_count=len(direct_timings),
3293 trigger=trigger,
3294 rows=tuple(rows),
3295 audio_target_ids=tuple(audio_targets),
3296 )
3297
3298
3299 def validate_generated_animation_xml(
3300 slide_xml: str | bytes,
3301 targets: Sequence[Sequence[object] | Mapping[str, object]],
3302 *,
3303 duration: float = 0.3,
3304 trigger: str = 'after-previous',
3305 ) -> AnimationSequenceSummary:
3306 """Read back one generated sequence and require exact requested semantics."""
3307 trigger = normalize_animation_trigger(trigger)
3308 default_duration_ms = _seconds_to_ms(
3309 duration,
3310 'animation duration',
3311 allow_zero=False,
3312 )
3313 normalized_expected = tuple(
3314 _normalize_target(target, default_duration_ms, trigger)
3315 for target in targets
3316 )
3317 # PowerPoint stores ordinary rows in mainSeq and shape-triggered rows in
3318 # separate interactiveSeq containers. Their relative cross-sequence order
3319 # has no playback meaning, so read-back follows the native container order.
3320 expected = tuple(
3321 target
3322 for target in normalized_expected
3323 if target.trigger_shape_id is None
3324 ) + tuple(
3325 target
3326 for target in normalized_expected
3327 if target.trigger_shape_id is not None
3328 )
3329 summary = read_slide_animation_sequence(
3330 slide_xml,
3331 require_supported_effects=True,
3332 )
3333 data = slide_xml.encode('utf-8') if isinstance(slide_xml, str) else slide_xml
3334 actual_root = _select_supported_timing_branch(ET.fromstring(data))
3335 actual_row_elements = [
3336 row
3337 for row in actual_root.iter(_qn(PML_NS, 'cTn'))
3338 if row.get('presetClass') in set(_PRESET_CLASS_BY_CATEGORY.values())
3339 ]
3340 errors: list[str] = []
3341 if len(summary.rows) != len(expected):
3342 errors.append(
3343 f'animation read-back row count is {len(summary.rows)}; '
3344 f'expected {len(expected)}'
3345 )
3346 expected_main_targets = tuple(
3347 target for target in expected if target.trigger_shape_id is None
3348 )
3349 expected_main_triggers = {
3350 target.trigger for target in expected_main_targets
3351 }
3352 expected_sequence_trigger = (
3353 (
3354 next(iter(expected_main_triggers))
3355 if len(expected_main_triggers) == 1
3356 else None
3357 )
3358 if expected_main_targets
3359 else ('on-click' if expected else None)
3360 )
3361 if expected and summary.trigger != expected_sequence_trigger:
3362 errors.append(
3363 f'animation read-back trigger is {summary.trigger!r}; '
3364 f'expected {expected_sequence_trigger!r}'
3365 )
3366
3367 try:
3368 main_offsets = iter(_main_target_offsets(expected_main_targets))
3369 except ValueError as exc:
3370 errors.append(str(exc))
3371 main_offsets = iter(())
3372 expected_offsets = [
3373 (
3374 target.delay_ms
3375 if target.trigger_shape_id is not None
3376 else next(main_offsets, 0)
3377 )
3378 for target in expected
3379 ]
3380
3381 for index, (actual, target) in enumerate(zip(summary.rows, expected), 1):
3382 spec = NATIVE_ANIMATIONS[target.effect]
3383 expected_row = _animation_row_for_options(
3384 target.effect,
3385 target.effect_options,
3386 )
3387 if spec['durationScalable']:
3388 _scale_animation_row_duration(
3389 expected_row,
3390 base_duration_ms=int(spec['defaultDurationMs']),
3391 requested_duration_ms=target.duration_ms,
3392 )
3393 _apply_timing_options(expected_row, target)
3394 option_errors: list[str] = []
3395 expected_filter = _row_filter(
3396 expected_row,
3397 str(spec['presetClass']),
3398 option_errors,
3399 )
3400 expected_options = _read_effect_options(
3401 expected_row,
3402 target.effect,
3403 expected_filter,
3404 option_errors,
3405 )
3406 (
3407 expected_repeat_count,
3408 expected_repeat_duration_ms,
3409 expected_auto_reverse,
3410 expected_rewind,
3411 expected_accelerate,
3412 expected_decelerate,
3413 expected_bounce_end,
3414 expected_restart,
3415 expected_playback_duration_ms,
3416 ) = _timing_summary(
3417 expected_row,
3418 (
3419 target.duration_ms
3420 if spec['durationScalable']
3421 else spec['defaultDurationMs']
3422 ),
3423 option_errors,
3424 )
3425 if option_errors:
3426 errors.append(
3427 f'animation row {index} expected-option model failed: '
3428 + '; '.join(option_errors)
3429 )
3430 if index <= len(actual_row_elements):
3431 actual_row_element = actual_row_elements[index - 1]
3432 expected_behavior_row = copy.deepcopy(expected_row)
3433 actual_row_id = actual_row_element.get('id')
3434 expected_behavior_row.set(
3435 'id',
3436 actual_row_id
3437 if actual_row_id and actual_row_id.isdigit()
3438 else '1',
3439 )
3440 _append_after_effect(
3441 expected_behavior_row,
3442 target,
3443 int(expected_behavior_row.get('id', '1')),
3444 )
3445 _append_animation_sound(expected_behavior_row, target)
3446 behavior_spec = {
3447 'rowXml': ET.tostring(
3448 expected_behavior_row,
3449 encoding='unicode',
3450 )
3451 }
3452 if not _animation_spec_matches_row(
3453 actual_row_element,
3454 behavior_spec,
3455 ):
3456 errors.append(
3457 f'animation row {index} PowerPoint-authored behavior '
3458 'tree changed'
3459 )
3460 if actual.shape_id != target.shape_id:
3461 errors.append(
3462 f'animation row {index} targets shape {actual.shape_id}; '
3463 f'expected {target.shape_id}'
3464 )
3465 expected_row_trigger = (
3466 target.trigger
3467 )
3468 if actual.trigger != expected_row_trigger:
3469 errors.append(
3470 f'animation row {index} trigger is {actual.trigger!r}; '
3471 f'expected {expected_row_trigger!r}'
3472 )
3473 if actual.trigger_shape_id != target.trigger_shape_id:
3474 errors.append(
3475 f'animation row {index} trigger shape is '
3476 f'{actual.trigger_shape_id!r}; expected '
3477 f'{target.trigger_shape_id!r}'
3478 )
3479 if target.effect not in actual.supported_effects:
3480 errors.append(
3481 f'animation row {index} resolved effects '
3482 f'{actual.supported_effects!r}; expected {target.effect!r}'
3483 )
3484 if actual.preset_class != spec['presetClass']:
3485 errors.append(f'animation row {index} presetClass changed')
3486 if actual.preset_id != int(spec['presetID']):
3487 errors.append(f'animation row {index} presetID changed')
3488 if actual.preset_subtype != int(expected_row.get('presetSubtype', '-1')):
3489 errors.append(f'animation row {index} presetSubtype changed')
3490 if actual.filter_name != expected_filter:
3491 errors.append(f'animation row {index} filter changed')
3492 if dict(actual.effect_options) != expected_options:
3493 errors.append(
3494 f'animation row {index} effect_options are '
3495 f'{dict(actual.effect_options)!r}; expected {expected_options!r}'
3496 )
3497 expected_duration = (
3498 target.duration_ms
3499 if spec['durationScalable']
3500 else spec['defaultDurationMs']
3501 )
3502 if actual.duration_ms != expected_duration:
3503 errors.append(
3504 f'animation row {index} duration is {actual.duration_ms}ms; '
3505 f'expected {expected_duration}ms'
3506 )
3507 if actual.offset_ms != expected_offsets[index - 1]:
3508 errors.append(
3509 f'animation row {index} offset is {actual.offset_ms}ms; '
3510 f'expected {expected_offsets[index - 1]}ms'
3511 )
3512 timing_pairs = (
3513 ('repeat_count', actual.repeat_count, expected_repeat_count),
3514 (
3515 'repeat_duration_ms',
3516 actual.repeat_duration_ms,
3517 expected_repeat_duration_ms,
3518 ),
3519 ('auto_reverse', actual.auto_reverse, expected_auto_reverse),
3520 ('rewind', actual.rewind, expected_rewind),
3521 ('accelerate', actual.accelerate, expected_accelerate),
3522 ('decelerate', actual.decelerate, expected_decelerate),
3523 ('bounce_end', actual.bounce_end, expected_bounce_end),
3524 ('restart', actual.restart, expected_restart),
3525 (
3526 'playback_duration_ms',
3527 actual.playback_duration_ms,
3528 expected_playback_duration_ms,
3529 ),
3530 )
3531 for field, actual_value, expected_value in timing_pairs:
3532 if actual_value != expected_value:
3533 errors.append(
3534 f'animation row {index} {field} is {actual_value!r}; '
3535 f'expected {expected_value!r}'
3536 )
3537 if actual.after_effect != target.after_effect:
3538 errors.append(
3539 f'animation row {index} after_effect is '
3540 f'{actual.after_effect!r}; expected {target.after_effect!r}'
3541 )
3542 if actual.after_effect_color != target.after_effect_color:
3543 errors.append(
3544 f'animation row {index} after_effect_color is '
3545 f'{actual.after_effect_color!r}; '
3546 f'expected {target.after_effect_color!r}'
3547 )
3548 if actual.sound_relationship_id != target.sound_relationship_id:
3549 errors.append(
3550 f'animation row {index} sound relationship is '
3551 f'{actual.sound_relationship_id!r}; '
3552 f'expected {target.sound_relationship_id!r}'
3553 )
3554 if actual.sound_name != target.sound_name:
3555 errors.append(
3556 f'animation row {index} sound name is '
3557 f'{actual.sound_name!r}; expected {target.sound_name!r}'
3558 )
3559 if errors:
3560 raise ValueError('; '.join(errors))
3561 resolved_rows = tuple(
3562 replace(actual, effect=target.effect)
3563 for actual, target in zip(summary.rows, expected)
3564 )
3565 return replace(summary, rows=resolved_rows)
3566
3567
3568 def validate_pptx_animation_package(
3569 pptx_path: str | Path,
3570 *,
3571 require_supported_effects: bool = False,
3572 ) -> None:
3573 """Validate timing placement and shape references for every slide part."""
3574 path = Path(pptx_path)
3575 errors: list[str] = []
3576 try:
3577 with zipfile.ZipFile(path) as package:
3578 names = sorted(
3579 name
3580 for name in package.namelist()
3581 if re.fullmatch(r'ppt/slides/slide\d+\.xml', name)
3582 )
3583 for name in names:
3584 slide_data = package.read(name)
3585 try:
3586 root = ET.fromstring(slide_data)
3587 except ET.ParseError as exc:
3588 errors.append(f'{name}: invalid XML: {exc}')
3589 continue
3590 for error in validate_slide_animation_structure(
3591 root,
3592 require_supported_effects=require_supported_effects,
3593 ):
3594 errors.append(f'{name}: {error}')
3595 try:
3596 summary = read_slide_animation_sequence(slide_data)
3597 except ValueError:
3598 continue
3599 sound_ids = {
3600 row.sound_relationship_id
3601 for row in summary.rows
3602 if row.sound_relationship_id is not None
3603 }
3604 if not sound_ids:
3605 continue
3606 slide_leaf = posixpath.basename(name)
3607 rels_name = (
3608 f'ppt/slides/_rels/{slide_leaf}.rels'
3609 )
3610 if rels_name not in package.namelist():
3611 errors.append(
3612 f'{name}: animation sound relationships are missing'
3613 )
3614 continue
3615 try:
3616 relationships = ET.fromstring(package.read(rels_name))
3617 except ET.ParseError as exc:
3618 errors.append(f'{rels_name}: invalid XML: {exc}')
3619 continue
3620 by_id = {
3621 rel.get('Id'): rel
3622 for rel in relationships.findall(
3623 _qn(_PACKAGE_REL_NS, 'Relationship')
3624 )
3625 }
3626 for relationship_id in sorted(sound_ids):
3627 relationship = by_id.get(relationship_id)
3628 if relationship is None:
3629 errors.append(
3630 f'{name}: animation sound relationship '
3631 f'{relationship_id} is missing'
3632 )
3633 continue
3634 if relationship.get('Type') != _AUDIO_REL_TYPE:
3635 errors.append(
3636 f'{name}: animation sound relationship '
3637 f'{relationship_id} is not an audio relationship'
3638 )
3639 continue
3640 target = relationship.get('Target')
3641 if not target:
3642 errors.append(
3643 f'{name}: animation sound relationship '
3644 f'{relationship_id} has no target'
3645 )
3646 continue
3647 target_part = posixpath.normpath(
3648 posixpath.join(posixpath.dirname(name), target)
3649 )
3650 if target_part not in package.namelist():
3651 errors.append(
3652 f'{name}: animation sound target is missing: '
3653 f'{target_part}'
3654 )
3655 except (OSError, zipfile.BadZipFile) as exc:
3656 raise ValueError(f'unable to read PPTX package {path}: {exc}') from exc
3657 if errors:
3658 raise ValueError('; '.join(errors))
3659
3660
3661 def object_animation_fingerprint(slide_xml: str | bytes) -> str | None:
3662 """Return a prefix/whitespace-independent fingerprint of object animation.
3663
3664 Narration audio is intentionally excluded. Direct-PPTX routes use this
3665 fingerprint before and after their allowed edits to prove that they did not
3666 take ownership of or rewrite existing object animations.
3667 """
3668 data = slide_xml.encode('utf-8') if isinstance(slide_xml, str) else slide_xml
3669 try:
3670 root = ET.fromstring(data)
3671 except ET.ParseError as exc:
3672 raise ValueError(f'invalid slide XML: {exc}') from exc
3673 root = _select_supported_timing_branch(root)
3674 timings = [
3675 child for child in list(root)
3676 if child.tag == _qn(PML_NS, 'timing')
3677 ]
3678 if len(timings) > 1:
3679 raise ValueError(
3680 f'slide has {len(timings)} root p:timing elements; expected at most 1'
3681 )
3682 if not timings:
3683 return None
3684 timing = timings[0]
3685 behavior_tags = {
3686 _qn(PML_NS, name)
3687 for name in (
3688 'anim',
3689 'animClr',
3690 'animEffect',
3691 'animMotion',
3692 'animRot',
3693 'animScale',
3694 'cmd',
3695 'set',
3696 )
3697 }
3698 has_object_animation = any(
3699 element.tag in behavior_tags
3700 or (
3701 element.tag == _qn(PML_NS, 'cTn')
3702 and element.get('presetClass') is not None
3703 )
3704 for element in timing.iter()
3705 )
3706 if not has_object_animation:
3707 return None
3708
3709 def without_audio(element: ET.Element) -> tuple[object, ...] | None:
3710 if element.tag == _qn(PML_NS, 'audio'):
3711 return None
3712 children = tuple(
3713 value
3714 for child in list(element)
3715 if (value := without_audio(child)) is not None
3716 )
3717 return (
3718 element.tag,
3719 tuple(sorted(element.attrib.items())),
3720 (element.text or '').strip(),
3721 children,
3722 )
3723
3724 canonical = without_audio(timing)
3725 return hashlib.sha256(repr(canonical).encode('utf-8')).hexdigest()
3726
3727
3728 def entrance_animation_fingerprint(slide_xml: str | bytes) -> str | None:
3729 """Compatibility alias for :func:`object_animation_fingerprint`."""
3730 return object_animation_fingerprint(slide_xml)
3731
3732
3733 def get_available_transitions() -> list:
3734 """Get native transition keys followed by compatibility inputs."""
3735 return [*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS]
3736
3737
3738 def get_available_animations() -> list:
3739 """Get canonical object-animation keys followed by compatibility inputs."""
3740 return list(ANIMATIONS.keys())
3741
3742
3743 def get_transition_help() -> str:
3744 """Get categorized native transitions plus legacy compatibility inputs."""
3745 lines = ["Available transition effects:"]
3746 for category in TRANSITION_CATEGORIES:
3747 lines.append(f" PowerPoint-native {category} effects:")
3748 for key in NATIVE_TRANSITION_KEYS:
3749 info = NATIVE_TRANSITIONS[key]
3750 if info["category"] == category:
3751 lines.append(f" {key}: {info['name']}")
3752 lines.append(" Legacy compatibility inputs (never selected for new output):")
3753 for key in LEGACY_TRANSITION_KEYS:
3754 canonical = TRANSITION_ALIASES[key]
3755 implied = TRANSITION_ALIAS_OPTIONS.get(key)
3756 option_suffix = f", implies {implied}" if implied else ""
3757 lines.append(
3758 f" {key}: compatibility alias for {canonical} "
3759 f"({NATIVE_TRANSITIONS[canonical]['name']}{option_suffix})"
3760 )
3761 return '\n'.join(lines)
3762
3763
3764 def get_animation_help() -> str:
3765 """Get categorized help text for every object-animation effect."""
3766 lines = ['Available object animations:']
3767 for category in ANIMATION_CATEGORIES:
3768 lines.append(f' PowerPoint-native {category} effects:')
3769 for key in NATIVE_ANIMATION_KEYS:
3770 info = NATIVE_ANIMATIONS[key]
3771 if info['category'] == category:
3772 lines.append(f" {key}: {info['name']}")
3773 lines.append(' Legacy compatibility inputs (never selected for new output):')
3774 for key in LEGACY_ANIMATION_KEYS:
3775 canonical = ANIMATION_ALIASES[key]
3776 implied = ANIMATION_ALIAS_OPTIONS.get(key)
3777 option_suffix = (
3778 f', implies {implied}'
3779 if implied
3780 else ''
3781 )
3782 lines.append(
3783 f" {key}: compatibility alias for {canonical} "
3784 f"({NATIVE_ANIMATIONS[canonical]['name']}{option_suffix})"
3785 )
3786 return '\n'.join(lines)
3787
3788
3789 def describe_animation_effect(effect: object) -> dict[str, Any]:
3790 """Return the author-facing option contract for one animation effect."""
3791 canonical = normalize_animation_effect(
3792 effect,
3793 allow_none=False,
3794 allow_modes=False,
3795 )
3796 assert canonical is not None
3797 implied_options = (
3798 dict(ANIMATION_ALIAS_OPTIONS.get(effect, {}))
3799 if isinstance(effect, str)
3800 else {}
3801 )
3802 option_contract: dict[str, Any] = {}
3803 for name, raw_spec in NATIVE_ANIMATIONS[canonical]['effectOptions'].items():
3804 spec = {
3805 key: value
3806 for key, value in raw_spec.items()
3807 if key != 'values'
3808 }
3809 if raw_spec.get('type') == 'enum':
3810 spec['values'] = list(raw_spec['values'])
3811 option_contract[name] = spec
3812 return {
3813 'input': effect,
3814 'effect': canonical,
3815 'compatibility_alias': (
3816 effect if isinstance(effect, str) and effect in ANIMATION_ALIASES else None
3817 ),
3818 'implied_effect_options': implied_options,
3819 'effect_options': option_contract,
3820 'timing': {
3821 'duration': (
3822 'positive seconds; legacy group effect or effects[] row'
3823 ),
3824 'delay': (
3825 'non-negative seconds; legacy group effect or effects[] row'
3826 ),
3827 'stagger': 'non-negative seconds; animation scope only',
3828 'trigger': list(ANIMATION_TRIGGERS),
3829 'trigger_scope': (
3830 'animation default for a legacy group effect; each effects[] '
3831 'row may override it'
3832 ),
3833 'trigger_shape': (
3834 'other top-level SVG group id; legacy group effect or '
3835 'effects[] row; maps to PowerPoint "On Click of" and requires '
3836 'trigger on-click'
3837 ),
3838 'repeat_count': 'positive number; mutually exclusive with repeat_duration',
3839 'repeat_duration': 'positive seconds; mutually exclusive with repeat_count',
3840 'auto_reverse': 'boolean',
3841 'rewind': 'boolean',
3842 'accelerate': 'number from 0 to 1',
3843 'decelerate': 'number from 0 to 1',
3844 'bounce_end': (
3845 'number from 0 to 1; requires an interpolated behavior and '
3846 'is mutually exclusive with decelerate'
3847 ),
3848 'restart': list(ANIMATION_RESTARTS),
3849 },
3850 'after_effect': list(ANIMATION_AFTER_EFFECTS),
3851 'sound': 'project-relative or absolute .m4a, .mp3, or .wav path',
3852 'derived_not_configured': {
3853 'speed': 'derived from duration',
3854 'smooth_start': 'derived from accelerate',
3855 'smooth_end': 'derived from decelerate',
3856 },
3857 }
3858
3859
3860 def main() -> None:
3861 """Run the CLI entry point."""
3862 parser = argparse.ArgumentParser(
3863 description=__doc__,
3864 formatter_class=argparse.RawDescriptionHelpFormatter,
3865 )
3866 parser.add_argument(
3867 "--demo",
3868 action="store_true",
3869 help="print sample XML for a fade transition and entrance_fade animation",
3870 )
3871 parser.add_argument(
3872 '--list',
3873 action='store_true',
3874 help='list available transitions and object animations',
3875 )
3876 parser.add_argument(
3877 '--describe',
3878 metavar='EFFECT',
3879 help='print the complete parameter contract for one object animation',
3880 )
3881 parser.add_argument(
3882 '--describe-transition',
3883 metavar='EFFECT',
3884 help='print the PowerPoint Effect Options for one page transition',
3885 )
3886 args = parser.parse_args()
3887
3888 if args.describe:
3889 try:
3890 print(
3891 json.dumps(
3892 describe_animation_effect(args.describe),
3893 ensure_ascii=False,
3894 indent=2,
3895 )
3896 )
3897 except ValueError as exc:
3898 parser.error(str(exc))
3899 return
3900
3901 if args.describe_transition:
3902 try:
3903 print(
3904 json.dumps(
3905 describe_transition_effect(args.describe_transition),
3906 ensure_ascii=False,
3907 indent=2,
3908 )
3909 )
3910 except ValueError as exc:
3911 parser.error(str(exc))
3912 return
3913
3914 if args.list:
3915 print(get_transition_help())
3916 print()
3917 print(get_animation_help())
3918 return
3919
3920 if args.demo:
3921 print("=== Transition Effect XML Example (fade, 500ms) ===")
3922 print(create_transition_xml('fade', 0.5))
3923 print()
3924 print("=== Entrance Animation XML Example (entrance_fade) ===")
3925 print(create_timing_xml('entrance_fade', 1.0))
3926 return
3927
3928 parser.print_help()
3929
3930
3931 if __name__ == '__main__':
3932 main()
3933
3933 lines PYTHON