3933 lines
138 KiB
Python
3933 lines
138 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
PPT Master - PPTX Animation Module
|
|
|
|
Provides one strict object-animation registry plus OOXML read/write helpers.
|
|
|
|
Supported transition effects:
|
|
- Complete current PowerPoint gallery: Subtle, Exciting, Dynamic Content
|
|
- Compatibility aliases: strips, circle, diamond, newsflash, plus, pull,
|
|
wedge, wheel
|
|
- none: no visual transition (handled by the shared transition core)
|
|
|
|
PowerPoint-native object animations:
|
|
- 53 entrance effects (``entrance_*``)
|
|
- 33 emphasis effects (``emphasis_*``)
|
|
- 64 motion paths (``path_*``)
|
|
- 53 exit effects (``exit_*``)
|
|
|
|
Legacy compatibility inputs (accepted but never selected for new output):
|
|
appear, fade, fly, fly_left, fly_right, fly_top, cut, zoom, wipe,
|
|
wipe_left, wipe_right, wipe_up, wipe_down, split, blinds, checkerboard,
|
|
dissolve, random_bars, peek, wheel, box, circle, diamond, plus, strips,
|
|
wedge, stretch, expand, swivel
|
|
|
|
The four media commands in MsoAnimEffect are intentionally excluded because
|
|
they require audio/video shapes or bookmarks rather than generated SVG groups.
|
|
|
|
Animation modes used by the builder:
|
|
- single effect name (one of the above) — apply to every element
|
|
- 'auto' — pick effect from the group's SVG id. Image-like ids
|
|
(hero / figure- / image / img- / kpi) cycle through a
|
|
visual pool (``entrance_zoom`` / ``entrance_dissolve`` /
|
|
``entrance_circle`` / ``entrance_box`` /
|
|
``entrance_diamond`` / ``entrance_wheel``) so multiple
|
|
images vary across the deck. Other
|
|
semantic matches map to a single stable effect
|
|
(chart→``entrance_wipe``,
|
|
card-/step-/pillar-→``entrance_fly``,
|
|
title/takeaway→``entrance_fade``).
|
|
Unmatched ids cycle through a small modern pool
|
|
(``entrance_fade`` / ``entrance_wipe`` /
|
|
``entrance_fly`` / ``entrance_zoom``).
|
|
- 'mixed' — compatible mode name: first element fades, the rest cycle
|
|
through a larger canonical PowerPoint entrance pool.
|
|
- 'random' — pick a seeded canonical PowerPoint entrance per element
|
|
|
|
Generated animation rows are validated against their requested effect, target,
|
|
duration, order, and Start mode before a PPTX is published. Package validation
|
|
also checks timing-tree placement, time-node identifiers, and shape references.
|
|
|
|
See references/animations.md for the public workflow contract.
|
|
|
|
Dependencies: None (standard-library XML generation and validation)
|
|
|
|
Usage:
|
|
python3 scripts/pptx_animations.py --demo
|
|
python3 scripts/pptx_animations.py --list
|
|
python3 scripts/pptx_animations.py --describe entrance_fly
|
|
"""
|
|
|
|
import argparse
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import posixpath
|
|
import random
|
|
import re
|
|
import zipfile
|
|
from dataclasses import dataclass, replace
|
|
from pathlib import Path
|
|
from typing import Any, Mapping, Sequence
|
|
from xml.etree import ElementTree as ET
|
|
|
|
from console_encoding import configure_utf8_stdio
|
|
from pptx_transitions import (
|
|
LEGACY_TRANSITION_KEYS,
|
|
MAX_OOXML_MILLISECONDS,
|
|
MAX_OOXML_UNSIGNED_INT,
|
|
NATIVE_TRANSITION_KEYS,
|
|
NATIVE_TRANSITIONS,
|
|
PML_NS,
|
|
TRANSITION_ALIAS_OPTIONS,
|
|
TRANSITION_ALIASES,
|
|
TRANSITION_CATEGORIES,
|
|
create_transition_xml,
|
|
describe_transition_effect,
|
|
validate_seconds,
|
|
)
|
|
|
|
configure_utf8_stdio()
|
|
|
|
|
|
# ============================================================================
|
|
# Object animation definitions
|
|
# ============================================================================
|
|
|
|
# Compatibility names normalize to canonical PowerPoint-authored presets.
|
|
# ``cut`` has no current object-animation preset, so the compatibility name
|
|
# resolves to the standard instantaneous entrance, ``entrance_appear``.
|
|
ANIMATION_ALIASES: dict[str, str] = {
|
|
'appear': 'entrance_appear',
|
|
'fade': 'entrance_fade',
|
|
'fly': 'entrance_fly',
|
|
'fly_left': 'entrance_fly',
|
|
'fly_right': 'entrance_fly',
|
|
'fly_top': 'entrance_fly',
|
|
'cut': 'entrance_appear',
|
|
'zoom': 'entrance_zoom',
|
|
'wipe': 'entrance_wipe',
|
|
'wipe_left': 'entrance_wipe',
|
|
'wipe_right': 'entrance_wipe',
|
|
'wipe_up': 'entrance_wipe',
|
|
'wipe_down': 'entrance_wipe',
|
|
'split': 'entrance_split',
|
|
'blinds': 'entrance_blinds',
|
|
'checkerboard': 'entrance_checkerboard',
|
|
'dissolve': 'entrance_dissolve',
|
|
'random_bars': 'entrance_random_bars',
|
|
'peek': 'entrance_peek',
|
|
'wheel': 'entrance_wheel',
|
|
'box': 'entrance_box',
|
|
'circle': 'entrance_circle',
|
|
'diamond': 'entrance_diamond',
|
|
'plus': 'entrance_plus',
|
|
'strips': 'entrance_strips',
|
|
'wedge': 'entrance_wedge',
|
|
'stretch': 'entrance_stretch',
|
|
'expand': 'entrance_expand',
|
|
'swivel': 'entrance_swivel',
|
|
}
|
|
|
|
LEGACY_ANIMATION_KEYS = tuple(ANIMATION_ALIASES)
|
|
ANIMATION_CATEGORIES = ('entrance', 'emphasis', 'path', 'exit')
|
|
_PRESET_CLASS_BY_CATEGORY = {
|
|
'entrance': 'entr',
|
|
'emphasis': 'emph',
|
|
'path': 'path',
|
|
'exit': 'exit',
|
|
}
|
|
_DML_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'
|
|
_REL_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
|
|
_PACKAGE_REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'
|
|
_AUDIO_REL_TYPE = (
|
|
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio'
|
|
)
|
|
_P14_NS = 'http://schemas.microsoft.com/office/powerpoint/2010/main'
|
|
_MC_NS = 'http://schemas.openxmlformats.org/markup-compatibility/2006'
|
|
ET.register_namespace('p', PML_NS)
|
|
ET.register_namespace('a', _DML_NS)
|
|
ET.register_namespace('r', _REL_NS)
|
|
ET.register_namespace('p14', _P14_NS)
|
|
|
|
ANIMATION_EFFECT_OPTION_FIELDS = (
|
|
'direction',
|
|
'amount',
|
|
'color',
|
|
'font_name',
|
|
'relative',
|
|
'size',
|
|
)
|
|
ANIMATION_TIMING_OPTION_FIELDS = (
|
|
'repeat_count',
|
|
'repeat_duration',
|
|
'auto_reverse',
|
|
'rewind',
|
|
'accelerate',
|
|
'decelerate',
|
|
'bounce_end',
|
|
'restart',
|
|
)
|
|
ANIMATION_RESTARTS = ('always', 'when-not-active', 'never')
|
|
ANIMATION_AFTER_EFFECTS = ('none', 'dim', 'hide', 'hide-on-next-click')
|
|
_INTERPOLATED_BEHAVIOR_TAGS = frozenset({
|
|
'anim',
|
|
'animClr',
|
|
'animEffect',
|
|
'animMotion',
|
|
'animRot',
|
|
'animScale',
|
|
})
|
|
_NON_CONCRETE_FONT_NAMES = frozenset({
|
|
'-apple-system',
|
|
'blinkmacsystemfont',
|
|
'cursive',
|
|
'emoji',
|
|
'fantasy',
|
|
'inherit',
|
|
'initial',
|
|
'math',
|
|
'monospace',
|
|
'revert',
|
|
'revert-layer',
|
|
'sans-serif',
|
|
'serif',
|
|
'system-ui',
|
|
'ui-monospace',
|
|
'ui-rounded',
|
|
'ui-sans-serif',
|
|
'ui-serif',
|
|
'unset',
|
|
})
|
|
|
|
# Legacy directional names retain their historical semantics by desugaring
|
|
# into one canonical effect plus the matching PowerPoint EffectParameters
|
|
# value. New plans never select these aliases.
|
|
ANIMATION_ALIAS_OPTIONS: dict[str, dict[str, object]] = {
|
|
'fly_left': {'direction': 'left'},
|
|
'fly_right': {'direction': 'right'},
|
|
'fly_top': {'direction': 'up'},
|
|
'wipe_left': {'direction': 'left'},
|
|
'wipe_right': {'direction': 'right'},
|
|
'wipe_up': {'direction': 'up'},
|
|
'wipe_down': {'direction': 'down'},
|
|
'wheel': {'amount': 4},
|
|
}
|
|
|
|
|
|
def _load_native_animations() -> dict[str, dict[str, Any]]:
|
|
"""Load the PowerPoint-authored preset rows shipped with this module."""
|
|
manifest_path = Path(__file__).with_name('pptx_animation_presets.json')
|
|
try:
|
|
manifest = json.loads(manifest_path.read_text(encoding='utf-8'))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise RuntimeError(
|
|
f'unable to load native animation presets from {manifest_path}: {exc}'
|
|
) from exc
|
|
if manifest.get('version') != 2:
|
|
raise RuntimeError(
|
|
f'unsupported native animation preset version: {manifest.get("version")!r}'
|
|
)
|
|
raw_effects = manifest.get('effects')
|
|
if not isinstance(raw_effects, list):
|
|
raise RuntimeError('native animation preset manifest field "effects" must be a list')
|
|
|
|
native: dict[str, dict[str, Any]] = {}
|
|
category_counts = {category: 0 for category in ANIMATION_CATEGORIES}
|
|
for raw in raw_effects:
|
|
if not isinstance(raw, dict):
|
|
raise RuntimeError('native animation preset entries must be objects')
|
|
key = raw.get('key')
|
|
category = raw.get('category')
|
|
if not isinstance(key, str) or not key:
|
|
raise RuntimeError(f'native animation preset has invalid key: {key!r}')
|
|
if category not in ANIMATION_CATEGORIES:
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} has invalid category: {category!r}'
|
|
)
|
|
if key in native or key in ANIMATION_ALIASES:
|
|
raise RuntimeError(f'duplicate animation preset key: {key}')
|
|
row_xml = raw.get('row_xml')
|
|
if not isinstance(row_xml, str):
|
|
raise RuntimeError(f'native animation preset {key!r} is missing row_xml')
|
|
try:
|
|
row = ET.fromstring(row_xml)
|
|
except ET.ParseError as exc:
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} contains invalid row_xml: {exc}'
|
|
) from exc
|
|
if row.tag != f'{{{PML_NS}}}cTn':
|
|
raise RuntimeError(f'native animation preset {key!r} is not a p:cTn row')
|
|
|
|
spec = {
|
|
'name': str(raw.get('name') or key),
|
|
'filter': raw.get('filter'),
|
|
'presetID': int(raw.get('preset_id')),
|
|
'presetSubtype': int(raw.get('preset_subtype')),
|
|
'presetClass': _PRESET_CLASS_BY_CATEGORY[category],
|
|
'category': category,
|
|
'msoEffectId': int(raw.get('mso_effect_id')),
|
|
'defaultDurationMs': raw.get('default_duration_ms'),
|
|
'durationScalable': bool(raw.get('duration_scalable')),
|
|
'rowXml': row_xml,
|
|
'effectOptions': raw.get('effect_options', {}),
|
|
}
|
|
if row.get('presetClass') != spec['presetClass']:
|
|
raise RuntimeError(f'native animation preset {key!r} changed presetClass')
|
|
if int(row.get('presetID', '-1')) != spec['presetID']:
|
|
raise RuntimeError(f'native animation preset {key!r} changed presetID')
|
|
if int(row.get('presetSubtype', '-1')) != spec['presetSubtype']:
|
|
raise RuntimeError(f'native animation preset {key!r} changed presetSubtype')
|
|
effect_options = spec['effectOptions']
|
|
if not isinstance(effect_options, dict):
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} effect_options must be an object'
|
|
)
|
|
unknown_options = set(effect_options) - set(ANIMATION_EFFECT_OPTION_FIELDS)
|
|
if unknown_options:
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} has unknown effect option(s): '
|
|
+ ', '.join(sorted(unknown_options))
|
|
)
|
|
for option_name, option_spec in effect_options.items():
|
|
if not isinstance(option_spec, dict):
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} option {option_name!r} '
|
|
'must be an object'
|
|
)
|
|
required = option_spec.get('required', False)
|
|
if not isinstance(required, bool):
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} option {option_name!r} '
|
|
'required must be a boolean'
|
|
)
|
|
if required and 'default' in option_spec:
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} option {option_name!r} '
|
|
'cannot define both required and default'
|
|
)
|
|
option_type = option_spec.get('type')
|
|
if option_type == 'enum':
|
|
values = option_spec.get('values')
|
|
if not isinstance(values, dict) or not values:
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} enum option '
|
|
f'{option_name!r} must define values'
|
|
)
|
|
default = str(option_spec.get('default'))
|
|
if default not in values:
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} enum option '
|
|
f'{option_name!r} has an unknown default'
|
|
)
|
|
for option_value, variant_xml in values.items():
|
|
if not isinstance(option_value, str) or not isinstance(
|
|
variant_xml,
|
|
str,
|
|
):
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} enum option '
|
|
f'{option_name!r} contains an invalid variant'
|
|
)
|
|
try:
|
|
variant = ET.fromstring(variant_xml)
|
|
except ET.ParseError as exc:
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} enum option '
|
|
f'{option_name!r}/{option_value!r} contains invalid XML: '
|
|
f'{exc}'
|
|
) from exc
|
|
if variant.tag != f'{{{PML_NS}}}cTn':
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} enum option '
|
|
f'{option_name!r}/{option_value!r} is not a p:cTn row'
|
|
)
|
|
if variant.get('presetClass') != spec['presetClass']:
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} enum option '
|
|
f'{option_name!r}/{option_value!r} changed presetClass'
|
|
)
|
|
if int(variant.get('presetID', '-1')) != spec['presetID']:
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} enum option '
|
|
f'{option_name!r}/{option_value!r} changed presetID'
|
|
)
|
|
elif option_type not in {'number', 'string', 'boolean', 'color'}:
|
|
raise RuntimeError(
|
|
f'native animation preset {key!r} option {option_name!r} '
|
|
f'has unknown type: {option_type!r}'
|
|
)
|
|
native[key] = spec
|
|
category_counts[category] += 1
|
|
|
|
expected_counts = {'entrance': 53, 'emphasis': 33, 'path': 64, 'exit': 53}
|
|
if category_counts != expected_counts:
|
|
raise RuntimeError(
|
|
'native animation preset category counts changed: '
|
|
f'{category_counts!r}; expected {expected_counts!r}'
|
|
)
|
|
return native
|
|
|
|
|
|
NATIVE_ANIMATIONS = _load_native_animations()
|
|
NATIVE_ANIMATION_KEYS = tuple(NATIVE_ANIMATIONS)
|
|
ANIMATIONS = {
|
|
**NATIVE_ANIMATIONS,
|
|
**{
|
|
alias: NATIVE_ANIMATIONS[canonical]
|
|
for alias, canonical in ANIMATION_ALIASES.items()
|
|
},
|
|
}
|
|
|
|
ANIMATION_MODES = ('auto', 'mixed', 'random')
|
|
ANIMATION_TRIGGERS = ('on-click', 'with-previous', 'after-previous')
|
|
|
|
_TRIGGER_NODE_TYPES = {
|
|
'on-click': 'clickEffect',
|
|
'with-previous': 'withEffect',
|
|
'after-previous': 'afterEffect',
|
|
}
|
|
_NODE_TYPE_TRIGGERS = {
|
|
value: key for key, value in _TRIGGER_NODE_TYPES.items()
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AnimationTarget:
|
|
"""Resolved object-animation request for one PowerPoint shape."""
|
|
|
|
shape_id: int
|
|
delay_ms: int
|
|
effect: str
|
|
duration_ms: int
|
|
effect_options: Mapping[str, object]
|
|
trigger: str = 'after-previous'
|
|
trigger_shape_id: int | None = None
|
|
repeat_count: float | None = None
|
|
repeat_duration_ms: int | None = None
|
|
auto_reverse: bool | None = None
|
|
rewind: bool | None = None
|
|
accelerate: float | None = None
|
|
decelerate: float | None = None
|
|
bounce_end: float | None = None
|
|
restart: str | None = None
|
|
after_effect: str = 'none'
|
|
after_effect_color: str | None = None
|
|
sound_relationship_id: str | None = None
|
|
sound_name: str | None = None
|
|
|
|
@property
|
|
def playback_duration_ms(self) -> int:
|
|
"""Return the wall-clock duration used by after-previous scheduling."""
|
|
one_play = self.duration_ms * (2 if self.auto_reverse else 1)
|
|
if self.repeat_duration_ms is not None:
|
|
return self.repeat_duration_ms
|
|
if self.repeat_count is not None:
|
|
return max(1, round(one_play * self.repeat_count))
|
|
return one_play
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AnimationRowSummary:
|
|
"""Read-back summary for one object-animation row in the animation pane."""
|
|
|
|
shape_id: int
|
|
effect: str | None
|
|
supported_effects: tuple[str, ...]
|
|
preset_class: str
|
|
trigger: str
|
|
duration_ms: int | None
|
|
offset_ms: int
|
|
preset_id: int
|
|
preset_subtype: int
|
|
filter_name: str | None
|
|
effect_options: Mapping[str, object]
|
|
trigger_shape_id: int | None
|
|
repeat_count: float | None
|
|
repeat_duration_ms: int | None
|
|
auto_reverse: bool
|
|
rewind: bool
|
|
accelerate: float
|
|
decelerate: float
|
|
bounce_end: float
|
|
restart: str
|
|
after_effect: str
|
|
after_effect_color: str | None
|
|
sound_relationship_id: str | None
|
|
sound_name: str | None
|
|
playback_duration_ms: int | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AnimationSequenceSummary:
|
|
"""Read-back summary for the logical object sequence on one slide."""
|
|
|
|
timing_count: int
|
|
trigger: str | None
|
|
rows: tuple[AnimationRowSummary, ...]
|
|
audio_target_ids: tuple[int, ...]
|
|
|
|
|
|
def _qn(namespace: str, tag: str) -> str:
|
|
return f'{{{namespace}}}{tag}'
|
|
|
|
|
|
def _local_name(tag: str) -> str:
|
|
return tag.rsplit('}', 1)[-1]
|
|
|
|
|
|
def normalize_animation_effect(
|
|
effect: object,
|
|
*,
|
|
allow_none: bool = True,
|
|
allow_modes: bool = True,
|
|
) -> str | None:
|
|
"""Return a supported effect/mode without silently substituting another."""
|
|
if effect is None or effect == 'none':
|
|
if allow_none:
|
|
return None
|
|
raise ValueError('animation effect is required')
|
|
if not isinstance(effect, str):
|
|
raise ValueError(f'animation effect must be a string: {effect!r}')
|
|
if effect in ANIMATION_ALIASES:
|
|
return ANIMATION_ALIASES[effect]
|
|
if effect in NATIVE_ANIMATIONS:
|
|
return effect
|
|
if allow_modes and effect in ANIMATION_MODES:
|
|
return effect
|
|
valid = list(ANIMATIONS)
|
|
if allow_modes:
|
|
valid.extend(ANIMATION_MODES)
|
|
if allow_none:
|
|
valid.append('none')
|
|
raise ValueError(
|
|
f'unknown animation effect {effect!r}; valid effects: {", ".join(valid)}'
|
|
)
|
|
|
|
|
|
def _finite_number(value: object, field: str) -> float:
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
raise ValueError(f'{field} must be a finite number: {value!r}')
|
|
number = float(value)
|
|
if not math.isfinite(number):
|
|
raise ValueError(f'{field} must be a finite number: {value!r}')
|
|
return number
|
|
|
|
|
|
def _normalize_animation_color(value: object, field: str) -> str:
|
|
if not isinstance(value, str):
|
|
raise ValueError(
|
|
f'{field} must be #RRGGBB or theme:<scheme-color>: {value!r}'
|
|
)
|
|
if re.fullmatch(r'#[0-9A-Fa-f]{6}', value):
|
|
return value.upper()
|
|
if re.fullmatch(
|
|
r'theme:(?:dk1|lt1|dk2|lt2|tx1|tx2|bg1|bg2|accent[1-6]|'
|
|
r'hlink|folHlink)',
|
|
value,
|
|
):
|
|
return value
|
|
raise ValueError(
|
|
f'{field} must be #RRGGBB or theme:<scheme-color>: {value!r}'
|
|
)
|
|
|
|
|
|
def _normalize_powerpoint_font_name(value: object, field: str) -> str:
|
|
"""Return one concrete PowerPoint font name without checking installation."""
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ValueError(
|
|
f'{field} must be one concrete PowerPoint font name: {value!r}'
|
|
)
|
|
normalized = value.strip()
|
|
if len(normalized) > 255:
|
|
raise ValueError(f'{field} exceeds 255 characters')
|
|
if ',' in normalized:
|
|
raise ValueError(
|
|
f'{field} must be one concrete PowerPoint font name, '
|
|
f'not a CSS font stack: {value!r}'
|
|
)
|
|
if normalized.casefold() in _NON_CONCRETE_FONT_NAMES:
|
|
raise ValueError(
|
|
f'{field} must be one concrete PowerPoint font name, '
|
|
f'not a generic family or CSS-wide keyword: {value!r}'
|
|
)
|
|
return normalized
|
|
|
|
|
|
def normalize_animation_effect_options(
|
|
effect: str,
|
|
options: object = None,
|
|
) -> dict[str, object]:
|
|
"""Validate effect-specific PowerPoint EffectParameters values."""
|
|
if effect not in NATIVE_ANIMATIONS:
|
|
if options in (None, {}):
|
|
return {}
|
|
raise ValueError(
|
|
'animation effect_options require one explicit canonical effect; '
|
|
f'found {effect!r}'
|
|
)
|
|
if options is None:
|
|
options = {}
|
|
if not isinstance(options, Mapping):
|
|
raise ValueError(f'animation effect_options must be an object: {options!r}')
|
|
|
|
option_specs = NATIVE_ANIMATIONS[effect]['effectOptions']
|
|
unknown = set(options) - set(option_specs)
|
|
if unknown:
|
|
unsupported = ', '.join(sorted(unknown))
|
|
supported = ', '.join(option_specs) or '(none)'
|
|
raise ValueError(
|
|
f'animation effect {effect!r} does not support effect option(s): '
|
|
f'{unsupported}; supported options: {supported}'
|
|
)
|
|
missing_required = sorted(
|
|
name
|
|
for name, spec in option_specs.items()
|
|
if spec.get('required') and name not in options
|
|
)
|
|
if missing_required:
|
|
required_fields = ', '.join(
|
|
f'effect_options.{name}' for name in missing_required
|
|
)
|
|
raise ValueError(
|
|
f'animation effect {effect!r} requires {required_fields}'
|
|
)
|
|
|
|
normalized: dict[str, object] = {}
|
|
for name, value in options.items():
|
|
spec = option_specs[name]
|
|
option_type = spec['type']
|
|
field = f'animation effect_options.{name}'
|
|
if option_type == 'enum':
|
|
key = str(value)
|
|
if isinstance(value, bool) or key not in spec['values']:
|
|
valid = ', '.join(spec['values'])
|
|
raise ValueError(
|
|
f'{field} for {effect!r} must be one of {valid}: {value!r}'
|
|
)
|
|
normalized[name] = (
|
|
int(key)
|
|
if name == 'amount' and re.fullmatch(r'\d+', key)
|
|
else key
|
|
)
|
|
elif option_type == 'number':
|
|
number = _finite_number(value, field)
|
|
minimum = spec.get('minimum')
|
|
maximum = spec.get('maximum')
|
|
if minimum is not None and number < float(minimum):
|
|
raise ValueError(
|
|
f'{field} for {effect!r} must be at least {minimum}: {value!r}'
|
|
)
|
|
if maximum is not None and number > float(maximum):
|
|
raise ValueError(
|
|
f'{field} for {effect!r} must be at most {maximum}: {value!r}'
|
|
)
|
|
normalized[name] = number
|
|
elif option_type == 'string':
|
|
if name == 'font_name':
|
|
normalized[name] = _normalize_powerpoint_font_name(value, field)
|
|
else:
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ValueError(
|
|
f'{field} must be a non-empty string: {value!r}'
|
|
)
|
|
normalized_value = value.strip()
|
|
if len(normalized_value) > 255:
|
|
raise ValueError(f'{field} exceeds 255 characters')
|
|
normalized[name] = normalized_value
|
|
elif option_type == 'boolean':
|
|
if not isinstance(value, bool):
|
|
raise ValueError(f'{field} must be a boolean: {value!r}')
|
|
normalized[name] = value
|
|
elif option_type == 'color':
|
|
normalized[name] = _normalize_animation_color(value, field)
|
|
else:
|
|
raise AssertionError(f'unhandled animation effect option type: {option_type}')
|
|
return normalized
|
|
|
|
|
|
def normalize_animation_effect_request(
|
|
effect: object,
|
|
options: object = None,
|
|
*,
|
|
allow_none: bool = True,
|
|
allow_modes: bool = True,
|
|
) -> tuple[str | None, dict[str, object]]:
|
|
"""Normalize one effect plus options, including legacy semantic aliases."""
|
|
raw_effect = effect
|
|
canonical = normalize_animation_effect(
|
|
effect,
|
|
allow_none=allow_none,
|
|
allow_modes=allow_modes,
|
|
)
|
|
alias_options = (
|
|
ANIMATION_ALIAS_OPTIONS.get(raw_effect, {})
|
|
if isinstance(raw_effect, str)
|
|
else {}
|
|
)
|
|
explicit_options: Mapping[str, object]
|
|
if options is None:
|
|
explicit_options = {}
|
|
elif isinstance(options, Mapping):
|
|
explicit_options = options
|
|
else:
|
|
raise ValueError(f'animation effect_options must be an object: {options!r}')
|
|
for name, alias_value in alias_options.items():
|
|
if name in explicit_options and explicit_options[name] != alias_value:
|
|
raise ValueError(
|
|
f'legacy animation effect {raw_effect!r} implies '
|
|
f'effect_options.{name}={alias_value!r}, which conflicts with '
|
|
f'{explicit_options[name]!r}'
|
|
)
|
|
merged = {**alias_options, **explicit_options}
|
|
if canonical is None or canonical in ANIMATION_MODES:
|
|
if merged:
|
|
raise ValueError(
|
|
'animation effect_options require one explicit canonical effect; '
|
|
f'found {canonical or "none"!r}'
|
|
)
|
|
return canonical, {}
|
|
return canonical, normalize_animation_effect_options(canonical, merged)
|
|
|
|
|
|
def normalize_animation_trigger(trigger: object) -> str:
|
|
"""Return a supported PowerPoint Start mode or raise a precise error."""
|
|
if not isinstance(trigger, str):
|
|
raise ValueError(f'animation trigger must be a string: {trigger!r}')
|
|
if trigger not in ANIMATION_TRIGGERS:
|
|
raise ValueError(
|
|
f'unknown animation trigger {trigger!r}; valid triggers: '
|
|
f'{", ".join(ANIMATION_TRIGGERS)}'
|
|
)
|
|
return trigger
|
|
|
|
|
|
def _seconds_to_ms(value: object, field: str, *, allow_zero: bool) -> int:
|
|
seconds = validate_seconds(value, field, allow_zero=allow_zero)
|
|
raw_milliseconds = seconds * 1000
|
|
if (
|
|
not math.isfinite(raw_milliseconds)
|
|
or raw_milliseconds > MAX_OOXML_MILLISECONDS
|
|
):
|
|
raise ValueError(f'{field} exceeds the OOXML millisecond limit: {value!r}')
|
|
milliseconds = int(raw_milliseconds)
|
|
return milliseconds if allow_zero else max(1, milliseconds)
|
|
|
|
|
|
def animation_seconds_to_milliseconds(
|
|
value: object,
|
|
field: str,
|
|
*,
|
|
allow_zero: bool,
|
|
) -> int:
|
|
"""Convert validated animation seconds to the OOXML millisecond range."""
|
|
return _seconds_to_ms(value, field, allow_zero=allow_zero)
|
|
|
|
|
|
def _positive_shape_id(value: object, field: str = 'animation shape_id') -> int:
|
|
if isinstance(value, bool):
|
|
raise ValueError(f'{field} must be a positive integer: {value!r}')
|
|
if isinstance(value, int):
|
|
shape_id = value
|
|
elif isinstance(value, str) and re.fullmatch(r'[1-9]\d*', value):
|
|
shape_id = int(value)
|
|
else:
|
|
raise ValueError(f'{field} must be a positive integer: {value!r}')
|
|
if shape_id <= 0 or shape_id > MAX_OOXML_UNSIGNED_INT:
|
|
raise ValueError(f'{field} must be a positive integer: {value!r}')
|
|
return shape_id
|
|
|
|
|
|
def _non_negative_milliseconds(value: object, field: str) -> int:
|
|
if isinstance(value, bool):
|
|
raise ValueError(f'{field} must be a non-negative integer: {value!r}')
|
|
if isinstance(value, int):
|
|
milliseconds = value
|
|
elif isinstance(value, str) and re.fullmatch(r'\d+', value):
|
|
milliseconds = int(value)
|
|
else:
|
|
raise ValueError(f'{field} must be a non-negative integer: {value!r}')
|
|
if milliseconds < 0 or milliseconds > MAX_OOXML_MILLISECONDS:
|
|
raise ValueError(
|
|
f'{field} must be between 0 and {MAX_OOXML_MILLISECONDS}: {value!r}'
|
|
)
|
|
return milliseconds
|
|
|
|
|
|
def _optional_bool(value: object, field: str) -> bool:
|
|
if not isinstance(value, bool):
|
|
raise ValueError(f'{field} must be a boolean: {value!r}')
|
|
return value
|
|
|
|
|
|
def _optional_ratio(value: object, field: str) -> float:
|
|
ratio = _finite_number(value, field)
|
|
if ratio < 0 or ratio > 1:
|
|
raise ValueError(f'{field} must be between 0 and 1: {value!r}')
|
|
return ratio
|
|
|
|
|
|
def _normalize_repeat_count(value: object) -> float:
|
|
count = _finite_number(value, 'animation repeat_count')
|
|
if count <= 0 or count * 1000 > MAX_OOXML_UNSIGNED_INT:
|
|
raise ValueError(
|
|
'animation repeat_count must be positive and fit the OOXML range: '
|
|
f'{value!r}'
|
|
)
|
|
return count
|
|
|
|
|
|
def _normalize_after_effect(value: object) -> tuple[str, str | None]:
|
|
if value is None:
|
|
return 'none', None
|
|
if isinstance(value, str):
|
|
effect_type = value
|
|
color = None
|
|
elif isinstance(value, Mapping):
|
|
unknown = set(value) - {'type', 'color'}
|
|
if unknown:
|
|
raise ValueError(
|
|
'animation after_effect has unknown field(s): '
|
|
+ ', '.join(sorted(unknown))
|
|
)
|
|
effect_type = value.get('type', 'none')
|
|
color = value.get('color')
|
|
else:
|
|
raise ValueError(
|
|
f'animation after_effect must be a string or object: {value!r}'
|
|
)
|
|
if effect_type not in ANIMATION_AFTER_EFFECTS:
|
|
raise ValueError(
|
|
f'animation after_effect.type must be one of '
|
|
f'{", ".join(ANIMATION_AFTER_EFFECTS)}: {effect_type!r}'
|
|
)
|
|
if effect_type == 'dim':
|
|
if color is None:
|
|
raise ValueError('animation dim after_effect requires color')
|
|
return effect_type, _normalize_animation_color(
|
|
color,
|
|
'animation after_effect.color',
|
|
)
|
|
if color is not None:
|
|
raise ValueError(
|
|
f'animation after_effect.color is valid only with type "dim": {color!r}'
|
|
)
|
|
return effect_type, None
|
|
|
|
|
|
def _normalize_sound(value: object) -> tuple[str | None, str | None]:
|
|
if value is None:
|
|
return None, None
|
|
if not isinstance(value, Mapping):
|
|
raise ValueError(
|
|
'low-level animation sound must be an object with '
|
|
'relationship_id and name'
|
|
)
|
|
unknown = set(value) - {'relationship_id', 'name'}
|
|
if unknown:
|
|
raise ValueError(
|
|
'low-level animation sound has unknown field(s): '
|
|
+ ', '.join(sorted(unknown))
|
|
)
|
|
relationship_id = value.get('relationship_id')
|
|
name = value.get('name')
|
|
if not isinstance(relationship_id, str) or not re.fullmatch(
|
|
r'rId[1-9]\d*',
|
|
relationship_id,
|
|
):
|
|
raise ValueError(
|
|
'low-level animation sound relationship_id must match rIdN: '
|
|
f'{relationship_id!r}'
|
|
)
|
|
if not isinstance(name, str) or not name.strip():
|
|
raise ValueError(
|
|
f'low-level animation sound name must be non-empty: {name!r}'
|
|
)
|
|
return relationship_id, name
|
|
|
|
|
|
def _normalize_target_mapping(
|
|
target: Mapping[str, object],
|
|
default_duration_ms: int,
|
|
default_trigger: str,
|
|
) -> AnimationTarget:
|
|
allowed = {
|
|
'shape_id',
|
|
'delay_ms',
|
|
'effect',
|
|
'duration',
|
|
'effect_options',
|
|
'trigger',
|
|
'trigger_shape_id',
|
|
*ANIMATION_TIMING_OPTION_FIELDS,
|
|
'after_effect',
|
|
'sound',
|
|
}
|
|
unknown = set(target) - allowed
|
|
if unknown:
|
|
raise ValueError(
|
|
'animation target has unknown field(s): ' + ', '.join(sorted(unknown))
|
|
)
|
|
shape_id = _positive_shape_id(target.get('shape_id'))
|
|
delay_ms = _non_negative_milliseconds(
|
|
target.get('delay_ms', 0),
|
|
'animation target delay_ms',
|
|
)
|
|
effect, effect_options = normalize_animation_effect_request(
|
|
target.get('effect'),
|
|
target.get('effect_options'),
|
|
allow_none=False,
|
|
allow_modes=False,
|
|
)
|
|
duration_ms = default_duration_ms
|
|
if target.get('duration') is not None:
|
|
duration_ms = _seconds_to_ms(
|
|
target.get('duration'),
|
|
'animation target duration',
|
|
allow_zero=False,
|
|
)
|
|
trigger_shape_id = (
|
|
_positive_shape_id(
|
|
target['trigger_shape_id'],
|
|
'animation target trigger_shape_id',
|
|
)
|
|
if 'trigger_shape_id' in target
|
|
else None
|
|
)
|
|
if trigger_shape_id == shape_id:
|
|
raise ValueError(
|
|
'animation trigger_shape_id must target a different shape'
|
|
)
|
|
target_trigger = (
|
|
normalize_animation_trigger(target['trigger'])
|
|
if 'trigger' in target
|
|
else default_trigger
|
|
)
|
|
if trigger_shape_id is not None:
|
|
if 'trigger' in target and target_trigger != 'on-click':
|
|
raise ValueError(
|
|
'animation target with trigger_shape_id must use '
|
|
'trigger "on-click"'
|
|
)
|
|
target_trigger = 'on-click'
|
|
repeat_count = (
|
|
_normalize_repeat_count(target['repeat_count'])
|
|
if 'repeat_count' in target
|
|
else None
|
|
)
|
|
repeat_duration_ms = (
|
|
_seconds_to_ms(
|
|
target['repeat_duration'],
|
|
'animation repeat_duration',
|
|
allow_zero=False,
|
|
)
|
|
if 'repeat_duration' in target
|
|
else None
|
|
)
|
|
if repeat_count is not None and repeat_duration_ms is not None:
|
|
raise ValueError(
|
|
'animation repeat_count and repeat_duration are mutually exclusive'
|
|
)
|
|
auto_reverse = (
|
|
_optional_bool(target['auto_reverse'], 'animation auto_reverse')
|
|
if 'auto_reverse' in target
|
|
else None
|
|
)
|
|
rewind = (
|
|
_optional_bool(target['rewind'], 'animation rewind')
|
|
if 'rewind' in target
|
|
else None
|
|
)
|
|
accelerate = (
|
|
_optional_ratio(target['accelerate'], 'animation accelerate')
|
|
if 'accelerate' in target
|
|
else None
|
|
)
|
|
decelerate = (
|
|
_optional_ratio(target['decelerate'], 'animation decelerate')
|
|
if 'decelerate' in target
|
|
else None
|
|
)
|
|
if (
|
|
accelerate is not None
|
|
and decelerate is not None
|
|
and accelerate + decelerate > 1
|
|
):
|
|
raise ValueError(
|
|
'animation accelerate + decelerate must not exceed 1'
|
|
)
|
|
bounce_end = (
|
|
_optional_ratio(target['bounce_end'], 'animation bounce_end')
|
|
if 'bounce_end' in target
|
|
else None
|
|
)
|
|
if bounce_end and decelerate:
|
|
raise ValueError(
|
|
'animation bounce_end and decelerate are mutually exclusive '
|
|
'in PowerPoint'
|
|
)
|
|
restart = target.get('restart')
|
|
if restart is not None and restart not in ANIMATION_RESTARTS:
|
|
raise ValueError(
|
|
f'animation restart must be one of '
|
|
f'{", ".join(ANIMATION_RESTARTS)}: {restart!r}'
|
|
)
|
|
after_effect, after_effect_color = _normalize_after_effect(
|
|
target.get('after_effect')
|
|
)
|
|
sound_relationship_id, sound_name = _normalize_sound(target.get('sound'))
|
|
return AnimationTarget(
|
|
shape_id=shape_id,
|
|
delay_ms=delay_ms,
|
|
effect=effect,
|
|
duration_ms=duration_ms,
|
|
effect_options=effect_options,
|
|
trigger=target_trigger,
|
|
trigger_shape_id=trigger_shape_id,
|
|
repeat_count=repeat_count,
|
|
repeat_duration_ms=repeat_duration_ms,
|
|
auto_reverse=auto_reverse,
|
|
rewind=rewind,
|
|
accelerate=accelerate,
|
|
decelerate=decelerate,
|
|
bounce_end=bounce_end,
|
|
restart=restart,
|
|
after_effect=after_effect,
|
|
after_effect_color=after_effect_color,
|
|
sound_relationship_id=sound_relationship_id,
|
|
sound_name=sound_name,
|
|
)
|
|
|
|
|
|
def _normalize_target(
|
|
target: Sequence[object] | Mapping[str, object],
|
|
default_duration_ms: int,
|
|
default_trigger: str = 'after-previous',
|
|
) -> AnimationTarget:
|
|
if isinstance(target, Mapping):
|
|
return _normalize_target_mapping(
|
|
target,
|
|
default_duration_ms,
|
|
default_trigger,
|
|
)
|
|
if isinstance(target, (str, bytes)) or not isinstance(target, Sequence):
|
|
raise ValueError(f'animation target must be a 3- or 4-item sequence: {target!r}')
|
|
if len(target) not in (3, 4):
|
|
raise ValueError(f'animation target must contain 3 or 4 items: {target!r}')
|
|
shape_id = _positive_shape_id(target[0])
|
|
delay_ms = _non_negative_milliseconds(target[1], 'animation target delay_ms')
|
|
effect, effect_options = normalize_animation_effect_request(
|
|
target[2],
|
|
allow_none=False,
|
|
allow_modes=False,
|
|
)
|
|
duration_ms = default_duration_ms
|
|
if len(target) == 4 and target[3] is not None:
|
|
duration_ms = _seconds_to_ms(
|
|
target[3],
|
|
'animation target duration',
|
|
allow_zero=False,
|
|
)
|
|
return AnimationTarget(
|
|
shape_id=shape_id,
|
|
delay_ms=delay_ms,
|
|
effect=effect,
|
|
duration_ms=duration_ms,
|
|
effect_options=effect_options,
|
|
trigger=default_trigger,
|
|
)
|
|
|
|
# Pool used by 'mixed' / 'random' modes. Every entry is a canonical
|
|
# PowerPoint-authored preset; compatibility aliases never enter selection.
|
|
_MIXED_POOL = [
|
|
'entrance_blinds', 'entrance_checkerboard', 'entrance_dissolve',
|
|
'entrance_fly', 'entrance_ascend', 'entrance_random_bars',
|
|
'entrance_box', 'entrance_split', 'entrance_strips', 'entrance_wedge',
|
|
'entrance_wheel', 'entrance_wipe', 'entrance_expand', 'entrance_fade',
|
|
'entrance_swivel', 'entrance_zoom',
|
|
]
|
|
|
|
# Small modern pool used by 'auto' mode when the group id matches no semantic
|
|
# pattern. Restricted to four widely supported, restrained effects so the
|
|
# fallback cycle never produces PowerPoint-era visuals.
|
|
_AUTO_POOL = [
|
|
'entrance_fade',
|
|
'entrance_wipe',
|
|
'entrance_fly',
|
|
'entrance_zoom',
|
|
]
|
|
|
|
# Image-only diversity pool. Image-like groups (`hero`, `figure-`, `image`,
|
|
# `img-`, `kpi`) deliberately cycle through a richer set of visual effects
|
|
# rather than mapping to a single effect: images are visual focal points, so
|
|
# variation is desirable on them even when surrounding information-dense
|
|
# elements (titles, charts, lists) stay reserved. Pool members are chosen for
|
|
# image-friendly motion — no PowerPoint-era patterns (``entrance_blinds`` /
|
|
# ``entrance_checkerboard`` / ``entrance_random_bars`` / ``entrance_wedge``)
|
|
# that would dominate raster content.
|
|
_IMAGE_POOL = [
|
|
'entrance_zoom',
|
|
'entrance_dissolve',
|
|
'entrance_circle',
|
|
'entrance_box',
|
|
'entrance_diamond',
|
|
'entrance_wheel',
|
|
]
|
|
_IMAGE_KEYWORDS: tuple[str, ...] = ('hero', 'figure-', 'image', 'img-', 'kpi')
|
|
|
|
# Ordered (substring, effect) patterns consumed by 'auto' mode for non-image
|
|
# groups. The first matching substring in the lowercased group id wins;
|
|
# ordering matters where substrings could overlap (e.g. 'title' before 'item'
|
|
# prevents 'item-title' from being misread as a list item). All substrings are
|
|
# lowercase. Image-like ids are handled separately via ``_IMAGE_POOL`` because
|
|
# they cycle rather than map to a single effect.
|
|
_SEMANTIC_PATTERNS: list[tuple[tuple[str, ...], str]] = [
|
|
(
|
|
('title', 'chapter-', 'section-', 'cover-', 'tagline', 'subtitle'),
|
|
'entrance_fade',
|
|
),
|
|
(
|
|
('chart', 'table', 'legend', 'timeline', 'track'),
|
|
'entrance_wipe',
|
|
),
|
|
(('card-', 'pillar-', 'item-', 'step-', 'stage-', 'tier-',
|
|
'principle-', 'q-', 'schema-'), 'entrance_fly'),
|
|
(('takeaway', 'callout', 'quote', 'source', 'conclusion', 'note',
|
|
'try-at-home'), 'entrance_fade'),
|
|
]
|
|
|
|
|
|
def _semantic_effect(group_id: str | None, idx: int = 0, offset: int = 0) -> str | None:
|
|
"""Return the effect mapped from a group id, or None if no pattern matches.
|
|
|
|
Image-like ids cycle through ``_IMAGE_POOL`` using ``idx + offset`` so the
|
|
same deck shows different effects across multiple images. Other semantic
|
|
matches return a single stable effect because information-dense elements
|
|
benefit from consistency, not variation.
|
|
"""
|
|
if not group_id:
|
|
return None
|
|
lower = group_id.lower()
|
|
if any(k in lower for k in _IMAGE_KEYWORDS):
|
|
return _IMAGE_POOL[(idx + offset) % len(_IMAGE_POOL)]
|
|
for substrings, effect in _SEMANTIC_PATTERNS:
|
|
if any(s in lower for s in substrings):
|
|
return effect
|
|
return None
|
|
|
|
|
|
def create_timing_xml(
|
|
animation: str = 'entrance_fade',
|
|
duration: float = 1.0,
|
|
delay: float = 0,
|
|
shape_id: int = 2
|
|
) -> str:
|
|
"""
|
|
Generate an object-animation timing XML fragment
|
|
|
|
Args:
|
|
animation: Canonical PowerPoint effect name
|
|
duration: Animation duration (seconds)
|
|
delay: Animation delay (seconds)
|
|
shape_id: Target shape ID (SVG image is typically 2)
|
|
|
|
Returns:
|
|
A <p:timing> element string insertable into slide XML
|
|
"""
|
|
animation = normalize_animation_effect(
|
|
animation,
|
|
allow_none=False,
|
|
allow_modes=False,
|
|
)
|
|
shape_id = _positive_shape_id(shape_id)
|
|
delay_ms = _seconds_to_ms(
|
|
delay,
|
|
'animation delay',
|
|
allow_zero=True,
|
|
)
|
|
return create_sequence_timing_xml(
|
|
[(shape_id, delay_ms, animation, duration)],
|
|
duration=duration,
|
|
trigger='after-previous',
|
|
)
|
|
|
|
|
|
def _ctn_numeric_delay(ctn: ET.Element) -> int:
|
|
"""Return the largest direct numeric start delay on one time node."""
|
|
conditions = ctn.find(_qn(PML_NS, 'stCondLst'))
|
|
if conditions is None:
|
|
return 0
|
|
values = [
|
|
int(condition.get('delay', '0'))
|
|
for condition in conditions.findall(_qn(PML_NS, 'cond'))
|
|
if condition.get('delay', '').isdigit()
|
|
]
|
|
return max(values, default=0)
|
|
|
|
|
|
def _row_effective_duration_ms(row: ET.Element) -> int | None:
|
|
"""Return the finite end time PowerPoint exposes for one preset row."""
|
|
ends: list[int] = []
|
|
for ctn in row.iter(_qn(PML_NS, 'cTn')):
|
|
if ctn is row:
|
|
continue
|
|
raw_duration = ctn.get('dur')
|
|
if raw_duration is None or not raw_duration.isdigit():
|
|
continue
|
|
ends.append(_ctn_numeric_delay(ctn) + int(raw_duration))
|
|
return max(ends) if ends else None
|
|
|
|
|
|
def _scale_animation_row_duration(
|
|
row: ET.Element,
|
|
*,
|
|
base_duration_ms: int,
|
|
requested_duration_ms: int,
|
|
) -> None:
|
|
"""Scale all finite behavior durations/delays as one PowerPoint row."""
|
|
ratio = requested_duration_ms / base_duration_ms
|
|
for ctn in row.iter(_qn(PML_NS, 'cTn')):
|
|
if ctn is row:
|
|
continue
|
|
raw_duration = ctn.get('dur')
|
|
if raw_duration is not None and raw_duration.isdigit():
|
|
numeric_duration = int(raw_duration)
|
|
ctn.set(
|
|
'dur',
|
|
(
|
|
'1'
|
|
if numeric_duration == 1
|
|
else str(max(1, round(numeric_duration * ratio)))
|
|
),
|
|
)
|
|
conditions = ctn.find(_qn(PML_NS, 'stCondLst'))
|
|
if conditions is None:
|
|
continue
|
|
for condition in conditions.findall(_qn(PML_NS, 'cond')):
|
|
raw_delay = condition.get('delay')
|
|
if raw_delay is not None and raw_delay.isdigit():
|
|
condition.set('delay', str(round(int(raw_delay) * ratio)))
|
|
|
|
actual_duration = _row_effective_duration_ms(row)
|
|
if actual_duration is None or actual_duration == requested_duration_ms:
|
|
return
|
|
end_nodes = [
|
|
ctn
|
|
for ctn in row.iter(_qn(PML_NS, 'cTn'))
|
|
if ctn is not row
|
|
and (ctn.get('dur') or '').isdigit()
|
|
and _ctn_numeric_delay(ctn) + int(ctn.get('dur', '0')) == actual_duration
|
|
]
|
|
for ctn in end_nodes:
|
|
delay = _ctn_numeric_delay(ctn)
|
|
if delay >= requested_duration_ms:
|
|
conditions = ctn.find(_qn(PML_NS, 'stCondLst'))
|
|
numeric = (
|
|
[
|
|
condition
|
|
for condition in conditions.findall(_qn(PML_NS, 'cond'))
|
|
if condition.get('delay', '').isdigit()
|
|
]
|
|
if conditions is not None
|
|
else []
|
|
)
|
|
if numeric:
|
|
numeric[-1].set('delay', str(max(0, requested_duration_ms - 1)))
|
|
delay = _ctn_numeric_delay(ctn)
|
|
ctn.set('dur', str(max(1, requested_duration_ms - delay)))
|
|
|
|
|
|
def _row_semantic_signature(row: ET.Element) -> tuple[object, ...]:
|
|
"""Return an id/target/duration-independent preset behavior signature."""
|
|
def canonical(element: ET.Element) -> tuple[object, ...]:
|
|
attributes: list[tuple[str, str]] = []
|
|
local_name = _local_name(element.tag)
|
|
for name, value in sorted(element.attrib.items()):
|
|
if name in {'id', 'nodeType', 'grpId'}:
|
|
continue
|
|
if local_name == 'spTgt' and name == 'spid':
|
|
value = '#shape'
|
|
elif local_name == 'cTn' and name == 'dur' and value.isdigit():
|
|
value = '#duration'
|
|
elif local_name == 'cond' and name == 'delay' and value.isdigit():
|
|
value = '#delay'
|
|
attributes.append((name, value))
|
|
return (
|
|
element.tag,
|
|
tuple(attributes),
|
|
(element.text or '').strip(),
|
|
tuple(canonical(child) for child in list(element)),
|
|
)
|
|
|
|
return canonical(row)
|
|
|
|
|
|
def _row_timing_profile(
|
|
row: ET.Element,
|
|
) -> tuple[tuple[str, float, float], ...]:
|
|
"""Return behavior timing ratios, excluding 1ms visibility bookkeeping."""
|
|
total = _row_effective_duration_ms(row)
|
|
if total is None or total <= 1:
|
|
return ()
|
|
parent_map = {
|
|
child: parent
|
|
for parent in row.iter()
|
|
for child in list(parent)
|
|
}
|
|
behavior_names = {
|
|
'anim',
|
|
'animClr',
|
|
'animEffect',
|
|
'animMotion',
|
|
'animRot',
|
|
'animScale',
|
|
'set',
|
|
}
|
|
profile: list[tuple[str, float, float]] = []
|
|
for ctn in row.iter(_qn(PML_NS, 'cTn')):
|
|
if ctn is row:
|
|
continue
|
|
raw_duration = ctn.get('dur')
|
|
if raw_duration is None or not raw_duration.isdigit():
|
|
continue
|
|
duration = int(raw_duration)
|
|
owner = parent_map.get(ctn)
|
|
while owner is not None and _local_name(owner.tag) not in behavior_names:
|
|
owner = parent_map.get(owner)
|
|
behavior = _local_name(owner.tag) if owner is not None else 'unknown'
|
|
if (
|
|
duration == 1
|
|
and behavior == 'set'
|
|
and owner is not None
|
|
and any(
|
|
(attribute.text or '') == 'style.visibility'
|
|
for attribute in owner.iter(_qn(PML_NS, 'attrName'))
|
|
)
|
|
):
|
|
continue
|
|
profile.append(
|
|
(
|
|
behavior,
|
|
_ctn_numeric_delay(ctn) / total,
|
|
duration / total,
|
|
)
|
|
)
|
|
return tuple(profile)
|
|
|
|
|
|
def _animation_spec_matches_row(row: ET.Element, spec: Mapping[str, Any]) -> bool:
|
|
"""Require the PowerPoint-authored structure and internal timing ratios."""
|
|
template = ET.fromstring(spec['rowXml'])
|
|
if _row_semantic_signature(row) != _row_semantic_signature(template):
|
|
return False
|
|
actual_duration = _row_effective_duration_ms(row)
|
|
if actual_duration is not None and actual_duration < 100:
|
|
# Millisecond quantization necessarily collapses multi-step ratios at
|
|
# sub-100ms speeds; structure and total duration remain authoritative.
|
|
return True
|
|
actual_profile = _row_timing_profile(row)
|
|
template_profile = _row_timing_profile(template)
|
|
if len(actual_profile) != len(template_profile):
|
|
return False
|
|
for actual, expected in zip(actual_profile, template_profile):
|
|
if actual[0] != expected[0]:
|
|
return False
|
|
if abs(actual[1] - expected[1]) > 0.01:
|
|
return False
|
|
if abs(actual[2] - expected[2]) > 0.01:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _format_decimal(value: float) -> str:
|
|
"""Format one finite decimal without exponent notation or negative zero."""
|
|
rendered = f'{value:.9f}'.rstrip('0').rstrip('.')
|
|
return '0' if rendered in {'', '-0'} else rendered
|
|
|
|
|
|
def _color_element(value: str) -> ET.Element:
|
|
if value.startswith('#'):
|
|
return ET.Element(_qn(_DML_NS, 'srgbClr'), {'val': value[1:]})
|
|
return ET.Element(_qn(_DML_NS, 'schemeClr'), {'val': value.split(':', 1)[1]})
|
|
|
|
|
|
def _replace_animation_colors(row: ET.Element, value: str) -> None:
|
|
parent_map = {
|
|
child: parent
|
|
for parent in row.iter()
|
|
for child in list(parent)
|
|
}
|
|
color_tags = {
|
|
_qn(_DML_NS, 'srgbClr'),
|
|
_qn(_DML_NS, 'schemeClr'),
|
|
_qn(_DML_NS, 'hslClr'),
|
|
_qn(_DML_NS, 'scrgbClr'),
|
|
_qn(_DML_NS, 'sysClr'),
|
|
_qn(_DML_NS, 'prstClr'),
|
|
}
|
|
colors = [element for element in row.iter() if element.tag in color_tags]
|
|
if not colors:
|
|
raise RuntimeError('color-capable animation preset lost its color node')
|
|
for color in colors:
|
|
parent = parent_map[color]
|
|
index = list(parent).index(color)
|
|
parent.remove(color)
|
|
parent.insert(index, _color_element(value))
|
|
|
|
|
|
def _set_effect_option_value(
|
|
row: ET.Element,
|
|
animation: str,
|
|
name: str,
|
|
value: object,
|
|
) -> None:
|
|
if name == 'amount' and animation == 'emphasis_spin':
|
|
rotations = list(row.iter(_qn(PML_NS, 'animRot')))
|
|
if len(rotations) != 1:
|
|
raise RuntimeError('emphasis_spin preset lost its p:animRot node')
|
|
rotations[0].set('by', str(round(float(value) * 60000)))
|
|
return
|
|
if name == 'amount' and animation == 'emphasis_transparency':
|
|
matched = False
|
|
for node in row.iter(_qn(PML_NS, 'set')):
|
|
attributes = {
|
|
(attribute.text or '').strip()
|
|
for attribute in node.iter(_qn(PML_NS, 'attrName'))
|
|
}
|
|
if 'style.opacity' not in attributes:
|
|
continue
|
|
values = list(node.iter(_qn(PML_NS, 'strVal')))
|
|
if len(values) != 1:
|
|
raise RuntimeError(
|
|
'emphasis_transparency preset lost its opacity value'
|
|
)
|
|
values[0].set('val', _format_decimal(1 - float(value)))
|
|
matched = True
|
|
if not matched:
|
|
raise RuntimeError(
|
|
'emphasis_transparency preset lost its opacity behavior'
|
|
)
|
|
return
|
|
if name == 'color':
|
|
_replace_animation_colors(row, str(value))
|
|
return
|
|
if name == 'font_name':
|
|
matched = False
|
|
for node in row.iter(_qn(PML_NS, 'set')):
|
|
attributes = {
|
|
(attribute.text or '').strip()
|
|
for attribute in node.iter(_qn(PML_NS, 'attrName'))
|
|
}
|
|
if 'style.fontFamily' not in attributes:
|
|
continue
|
|
values = list(node.iter(_qn(PML_NS, 'strVal')))
|
|
if len(values) != 1:
|
|
raise RuntimeError(
|
|
'emphasis_change_font preset lost its font value'
|
|
)
|
|
values[0].set('val', str(value))
|
|
matched = True
|
|
if not matched:
|
|
raise RuntimeError(
|
|
'emphasis_change_font preset lost its font behavior'
|
|
)
|
|
return
|
|
if name == 'relative':
|
|
motions = list(row.iter(_qn(PML_NS, 'animMotion')))
|
|
if len(motions) != 1:
|
|
raise RuntimeError('motion-path preset lost its p:animMotion node')
|
|
motions[0].set('pathEditMode', 'relative' if value else 'fixed')
|
|
return
|
|
if name == 'size':
|
|
scales = list(row.iter(_qn(PML_NS, 'animScale')))
|
|
if len(scales) != 1:
|
|
raise RuntimeError(
|
|
'emphasis_grow_shrink preset lost its p:animScale node'
|
|
)
|
|
amount = str(round(float(value) * 1000))
|
|
targets = scales[0].findall(_qn(PML_NS, 'to'))
|
|
if len(targets) > 1:
|
|
raise RuntimeError(
|
|
'emphasis_grow_shrink preset lost its scale value'
|
|
)
|
|
target = (
|
|
targets[0]
|
|
if targets
|
|
else ET.SubElement(scales[0], _qn(PML_NS, 'to'))
|
|
)
|
|
target.set('x', amount)
|
|
target.set('y', amount)
|
|
return
|
|
raise AssertionError(f'unhandled continuous animation option: {name}')
|
|
|
|
|
|
def _animation_row_for_options(
|
|
animation: str,
|
|
effect_options: Mapping[str, object],
|
|
) -> ET.Element:
|
|
"""Return the authored variant row with continuous options applied."""
|
|
spec = NATIVE_ANIMATIONS[animation]
|
|
option_specs = spec['effectOptions']
|
|
row_xml = spec['rowXml']
|
|
for name, value in effect_options.items():
|
|
option_spec = option_specs[name]
|
|
if option_spec['type'] != 'enum':
|
|
continue
|
|
key = str(value)
|
|
row_xml = option_spec['values'][key]
|
|
row = ET.fromstring(row_xml)
|
|
for name, value in effect_options.items():
|
|
if option_specs[name]['type'] == 'enum':
|
|
continue
|
|
_set_effect_option_value(row, animation, name, value)
|
|
return row
|
|
|
|
|
|
def _interpolated_behavior_nodes(row: ET.Element) -> tuple[ET.Element, ...]:
|
|
"""Return behavior nodes that can carry PowerPoint bounce metadata."""
|
|
return tuple(
|
|
node
|
|
for node in row.iter()
|
|
if _local_name(node.tag) in _INTERPOLATED_BEHAVIOR_TAGS
|
|
)
|
|
|
|
|
|
def animation_effect_supports_bounce_end(
|
|
effect: object,
|
|
effect_options: object = None,
|
|
) -> bool:
|
|
"""Return whether one concrete effect has an interpolated behavior."""
|
|
animation, options = normalize_animation_effect_request(
|
|
effect,
|
|
effect_options,
|
|
allow_none=False,
|
|
allow_modes=False,
|
|
)
|
|
if animation is None:
|
|
raise AssertionError('concrete animation normalization returned none')
|
|
row = _animation_row_for_options(animation, options)
|
|
return bool(_interpolated_behavior_nodes(row))
|
|
|
|
|
|
def _apply_timing_options(row: ET.Element, target: AnimationTarget) -> None:
|
|
if target.repeat_count is not None:
|
|
row.set('repeatCount', str(round(target.repeat_count * 1000)))
|
|
row.attrib.pop('repeatDur', None)
|
|
if target.repeat_duration_ms is not None:
|
|
row.set('repeatDur', str(target.repeat_duration_ms))
|
|
row.attrib.pop('repeatCount', None)
|
|
if target.auto_reverse is not None:
|
|
if target.auto_reverse:
|
|
row.set('autoRev', '1')
|
|
else:
|
|
row.attrib.pop('autoRev', None)
|
|
if target.rewind is not None:
|
|
row.set('fill', 'remove' if target.rewind else 'hold')
|
|
if target.accelerate is not None:
|
|
if target.accelerate:
|
|
row.set('accel', str(round(target.accelerate * 100000)))
|
|
else:
|
|
row.attrib.pop('accel', None)
|
|
if target.decelerate is not None:
|
|
if target.decelerate:
|
|
row.set('decel', str(round(target.decelerate * 100000)))
|
|
else:
|
|
row.attrib.pop('decel', None)
|
|
if target.bounce_end is not None:
|
|
bounce_nodes = _interpolated_behavior_nodes(row)
|
|
if target.bounce_end and not animation_effect_supports_bounce_end(
|
|
target.effect,
|
|
target.effect_options,
|
|
):
|
|
raise ValueError(
|
|
f'animation effect {target.effect!r} has no behavior that '
|
|
'supports bounce_end'
|
|
)
|
|
if target.bounce_end:
|
|
row.set(
|
|
_qn(_P14_NS, 'presetBounceEnd'),
|
|
str(round(target.bounce_end * 100000)),
|
|
)
|
|
else:
|
|
row.attrib.pop(_qn(_P14_NS, 'presetBounceEnd'), None)
|
|
for node in bounce_nodes:
|
|
if target.bounce_end:
|
|
node.set(
|
|
_qn(_P14_NS, 'bounceEnd'),
|
|
str(round(target.bounce_end * 100000)),
|
|
)
|
|
else:
|
|
node.attrib.pop(_qn(_P14_NS, 'bounceEnd'), None)
|
|
if target.restart is not None:
|
|
row.set(
|
|
'restart',
|
|
{
|
|
'always': 'always',
|
|
'when-not-active': 'whenNotActive',
|
|
'never': 'never',
|
|
}[target.restart],
|
|
)
|
|
|
|
|
|
def _append_after_effect(
|
|
row: ET.Element,
|
|
target: AnimationTarget,
|
|
row_id: int,
|
|
) -> None:
|
|
if target.after_effect == 'none':
|
|
return
|
|
sub_timing = row.find(_qn(PML_NS, 'subTnLst'))
|
|
if sub_timing is None:
|
|
sub_timing = ET.SubElement(row, _qn(PML_NS, 'subTnLst'))
|
|
if target.after_effect == 'dim':
|
|
animation = ET.SubElement(
|
|
sub_timing,
|
|
_qn(PML_NS, 'animClr'),
|
|
{'clrSpc': 'rgb', 'dir': 'cw'},
|
|
)
|
|
behavior = ET.SubElement(
|
|
animation,
|
|
_qn(PML_NS, 'cBhvr'),
|
|
{'override': 'childStyle'},
|
|
)
|
|
ET.SubElement(
|
|
behavior,
|
|
_qn(PML_NS, 'cTn'),
|
|
{
|
|
'dur': '1',
|
|
'fill': 'hold',
|
|
'display': '0',
|
|
'masterRel': 'nextClick',
|
|
'afterEffect': '1',
|
|
},
|
|
)
|
|
target_element = ET.SubElement(behavior, _qn(PML_NS, 'tgtEl'))
|
|
ET.SubElement(
|
|
target_element,
|
|
_qn(PML_NS, 'spTgt'),
|
|
{'spid': str(target.shape_id)},
|
|
)
|
|
names = ET.SubElement(behavior, _qn(PML_NS, 'attrNameLst'))
|
|
ET.SubElement(names, _qn(PML_NS, 'attrName')).text = 'ppt_c'
|
|
destination = ET.SubElement(animation, _qn(PML_NS, 'to'))
|
|
destination.append(_color_element(str(target.after_effect_color)))
|
|
return
|
|
|
|
setting = ET.SubElement(sub_timing, _qn(PML_NS, 'set'))
|
|
behavior = ET.SubElement(
|
|
setting,
|
|
_qn(PML_NS, 'cBhvr'),
|
|
{'override': 'childStyle'},
|
|
)
|
|
ctn_attributes = {
|
|
'dur': '1',
|
|
'fill': 'hold',
|
|
'display': '0',
|
|
'masterRel': (
|
|
'sameClick'
|
|
if target.after_effect == 'hide'
|
|
else 'nextClick'
|
|
),
|
|
'afterEffect': '1',
|
|
}
|
|
ctn = ET.SubElement(behavior, _qn(PML_NS, 'cTn'), ctn_attributes)
|
|
if target.after_effect == 'hide':
|
|
conditions = ET.SubElement(ctn, _qn(PML_NS, 'stCondLst'))
|
|
condition = ET.SubElement(
|
|
conditions,
|
|
_qn(PML_NS, 'cond'),
|
|
{'evt': 'end', 'delay': '0'},
|
|
)
|
|
ET.SubElement(condition, _qn(PML_NS, 'tn'), {'val': str(row_id)})
|
|
target_element = ET.SubElement(behavior, _qn(PML_NS, 'tgtEl'))
|
|
ET.SubElement(
|
|
target_element,
|
|
_qn(PML_NS, 'spTgt'),
|
|
{'spid': str(target.shape_id)},
|
|
)
|
|
names = ET.SubElement(behavior, _qn(PML_NS, 'attrNameLst'))
|
|
ET.SubElement(names, _qn(PML_NS, 'attrName')).text = 'style.visibility'
|
|
destination = ET.SubElement(setting, _qn(PML_NS, 'to'))
|
|
ET.SubElement(destination, _qn(PML_NS, 'strVal'), {'val': 'hidden'})
|
|
|
|
|
|
def _append_animation_sound(row: ET.Element, target: AnimationTarget) -> None:
|
|
if target.sound_relationship_id is None:
|
|
return
|
|
sub_timing = row.find(_qn(PML_NS, 'subTnLst'))
|
|
if sub_timing is None:
|
|
sub_timing = ET.SubElement(row, _qn(PML_NS, 'subTnLst'))
|
|
audio = ET.SubElement(sub_timing, _qn(PML_NS, 'audio'))
|
|
media = ET.SubElement(audio, _qn(PML_NS, 'cMediaNode'))
|
|
ctn = ET.SubElement(
|
|
media,
|
|
_qn(PML_NS, 'cTn'),
|
|
{'display': '0', 'masterRel': 'sameClick'},
|
|
)
|
|
conditions = ET.SubElement(ctn, _qn(PML_NS, 'stCondLst'))
|
|
condition = ET.SubElement(
|
|
conditions,
|
|
_qn(PML_NS, 'cond'),
|
|
{'evt': 'begin', 'delay': '0'},
|
|
)
|
|
ET.SubElement(condition, _qn(PML_NS, 'tn'), {'val': str(row.get('id'))})
|
|
end_conditions = ET.SubElement(ctn, _qn(PML_NS, 'endCondLst'))
|
|
end_condition = ET.SubElement(
|
|
end_conditions,
|
|
_qn(PML_NS, 'cond'),
|
|
{'evt': 'onStopAudio', 'delay': '0'},
|
|
)
|
|
target_element = ET.SubElement(end_condition, _qn(PML_NS, 'tgtEl'))
|
|
ET.SubElement(target_element, _qn(PML_NS, 'sldTgt'))
|
|
target_element = ET.SubElement(media, _qn(PML_NS, 'tgtEl'))
|
|
ET.SubElement(
|
|
target_element,
|
|
_qn(PML_NS, 'sndTgt'),
|
|
{
|
|
_qn(_REL_NS, 'embed'): str(target.sound_relationship_id),
|
|
'name': str(target.sound_name),
|
|
},
|
|
)
|
|
|
|
|
|
def _instantiate_animation_row(
|
|
target: AnimationTarget,
|
|
node_type: str,
|
|
row_id: int,
|
|
first_behavior_id: int,
|
|
) -> tuple[str, int]:
|
|
"""Instantiate one PowerPoint-authored preset row for a generated shape."""
|
|
animation = target.effect
|
|
shape_id = target.shape_id
|
|
duration_ms = target.duration_ms
|
|
spec = NATIVE_ANIMATIONS[animation]
|
|
row = _animation_row_for_options(animation, target.effect_options)
|
|
row.set('id', str(row_id))
|
|
row.set('nodeType', node_type)
|
|
conditions = row.find(_qn(PML_NS, 'stCondLst'))
|
|
if conditions is None:
|
|
raise RuntimeError(f'animation preset {animation!r} lost p:stCondLst')
|
|
direct_conditions = conditions.findall(_qn(PML_NS, 'cond'))
|
|
if len(direct_conditions) != 1:
|
|
raise RuntimeError(
|
|
f'animation preset {animation!r} must have one start condition'
|
|
)
|
|
direct_conditions[0].attrib.clear()
|
|
direct_conditions[0].set(
|
|
'delay',
|
|
str(target.delay_ms),
|
|
)
|
|
|
|
if spec['durationScalable']:
|
|
base_duration_ms = int(spec['defaultDurationMs'])
|
|
_scale_animation_row_duration(
|
|
row,
|
|
base_duration_ms=base_duration_ms,
|
|
requested_duration_ms=duration_ms,
|
|
)
|
|
|
|
_apply_timing_options(row, target)
|
|
_append_after_effect(row, target, row_id)
|
|
_append_animation_sound(row, target)
|
|
|
|
next_id = first_behavior_id
|
|
for ctn in row.iter(_qn(PML_NS, 'cTn')):
|
|
if ctn is row:
|
|
continue
|
|
ctn.set('id', str(next_id))
|
|
next_id += 1
|
|
for target in row.iter(_qn(PML_NS, 'spTgt')):
|
|
target.set('spid', str(shape_id))
|
|
return ET.tostring(row, encoding='unicode'), next_id
|
|
|
|
|
|
def _build_animation_row_xml(
|
|
target: AnimationTarget,
|
|
trigger: str,
|
|
row_id: int,
|
|
first_behavior_id: int,
|
|
) -> tuple[str, int]:
|
|
"""Build one canonical PowerPoint-authored animation-pane row."""
|
|
node_type = _TRIGGER_NODE_TYPES[trigger]
|
|
return _instantiate_animation_row(
|
|
target,
|
|
node_type,
|
|
row_id,
|
|
first_behavior_id,
|
|
)
|
|
|
|
|
|
def _main_target_offsets(targets: Sequence[AnimationTarget]) -> list[int]:
|
|
"""Return each regular row's start offset within its click group."""
|
|
offsets: list[int] = []
|
|
previous_start_ms = 0
|
|
previous_duration_ms = 0
|
|
has_previous = False
|
|
for target in targets:
|
|
if target.trigger == 'on-click':
|
|
start_ms = target.delay_ms
|
|
elif target.trigger == 'with-previous':
|
|
start_ms = (
|
|
previous_start_ms if has_previous else 0
|
|
) + target.delay_ms
|
|
else:
|
|
start_ms = (
|
|
previous_start_ms + previous_duration_ms
|
|
if has_previous
|
|
else 0
|
|
) + target.delay_ms
|
|
if start_ms > MAX_OOXML_MILLISECONDS:
|
|
raise ValueError(
|
|
'animation sequence offset exceeds the OOXML millisecond '
|
|
f'limit at target {len(offsets) + 1}: {start_ms}'
|
|
)
|
|
offsets.append(start_ms)
|
|
previous_start_ms = start_ms
|
|
previous_duration_ms = target.playback_duration_ms
|
|
has_previous = True
|
|
return offsets
|
|
|
|
|
|
def _build_mixed_main_steps(
|
|
targets: Sequence[AnimationTarget],
|
|
next_id: int,
|
|
) -> tuple[str, int]:
|
|
"""Build one mainSeq containing mixed per-row PowerPoint Start modes."""
|
|
offsets = _main_target_offsets(targets)
|
|
groups: list[list[tuple[AnimationTarget, int]]] = []
|
|
for target, offset_ms in zip(targets, offsets):
|
|
if not groups or target.trigger == 'on-click':
|
|
groups.append([])
|
|
groups[-1].append((target, offset_ms))
|
|
|
|
rendered_groups: list[str] = []
|
|
for group in groups:
|
|
group_id = next_id
|
|
next_id += 1
|
|
first_target = group[0][0]
|
|
if first_target.trigger == 'on-click':
|
|
group_conditions = '<p:cond delay="indefinite"/>'
|
|
else:
|
|
group_conditions = (
|
|
'<p:cond delay="indefinite"/>'
|
|
'<p:cond evt="onBegin" delay="0"><p:tn val="2"/></p:cond>'
|
|
)
|
|
rendered_rows: list[str] = []
|
|
for target, offset_ms in group:
|
|
wrapper_id = next_id
|
|
row_id = next_id + 1
|
|
row_xml, next_id = _build_animation_row_xml(
|
|
target,
|
|
target.trigger,
|
|
row_id,
|
|
next_id + 2,
|
|
)
|
|
wrapper_offset_ms = offset_ms - target.delay_ms
|
|
rendered_rows.append(f'''<p:par>
|
|
<p:cTn id="{wrapper_id}" fill="hold">
|
|
<p:stCondLst><p:cond delay="{wrapper_offset_ms}"/></p:stCondLst>
|
|
<p:childTnLst><p:par>{row_xml}</p:par></p:childTnLst>
|
|
</p:cTn>
|
|
</p:par>''')
|
|
rows_xml = '\n '.join(rendered_rows)
|
|
rendered_groups.append(f'''<p:par>
|
|
<p:cTn id="{group_id}" fill="hold">
|
|
<p:stCondLst>{group_conditions}</p:stCondLst>
|
|
<p:childTnLst>
|
|
{rows_xml}
|
|
</p:childTnLst>
|
|
</p:cTn>
|
|
</p:par>''')
|
|
return '\n '.join(rendered_groups), next_id
|
|
|
|
|
|
def create_sequence_timing_xml(
|
|
targets: list,
|
|
duration: float = 0.3,
|
|
trigger: str = 'after-previous',
|
|
) -> str:
|
|
"""Generate a multi-target object-animation sequence.
|
|
|
|
Args:
|
|
targets: list of (shape_id, delay_ms, animation_name) or
|
|
(shape_id, delay_ms, animation_name, duration_seconds) tuples, in
|
|
the order they should play. ``delay_ms`` is the gap before
|
|
this element starts relative to its Start mode. Mapping targets
|
|
may set an independent ``trigger``; otherwise they inherit the
|
|
function-level ``trigger``. A target with ``trigger_shape_id``
|
|
must use ``on-click`` and runs in an interactive sequence.
|
|
duration: per-element animation duration in seconds. Instantaneous
|
|
native presets retain their PowerPoint-authored duration.
|
|
trigger: PowerPoint-standard Start mode for each element.
|
|
``'after-previous'`` — first element fires on slide entry,
|
|
rest chain after the previous one with ``delay_ms`` spacing
|
|
(default).
|
|
``'on-click'`` — one presenter click per element.
|
|
``'with-previous'`` — all elements start together on slide
|
|
entry.
|
|
|
|
Returns:
|
|
A ``<p:timing>`` element string. Returns an empty string when
|
|
``targets`` is empty.
|
|
"""
|
|
trigger = normalize_animation_trigger(trigger)
|
|
default_dur_ms = _seconds_to_ms(
|
|
duration,
|
|
'animation duration',
|
|
allow_zero=False,
|
|
)
|
|
if targets is None or isinstance(targets, (str, bytes)):
|
|
raise ValueError('animation targets must be a sequence of target tuples')
|
|
if not targets:
|
|
return ''
|
|
normalized_targets = [
|
|
_normalize_target(target, default_dur_ms, trigger)
|
|
for target in targets
|
|
]
|
|
next_id = 3
|
|
main_targets = [
|
|
target
|
|
for target in normalized_targets
|
|
if target.trigger_shape_id is None
|
|
]
|
|
interactive_targets = [
|
|
target
|
|
for target in normalized_targets
|
|
if target.trigger_shape_id is not None
|
|
]
|
|
|
|
main_triggers = {target.trigger for target in main_targets}
|
|
main_trigger = (
|
|
next(iter(main_triggers))
|
|
if len(main_triggers) == 1
|
|
else None
|
|
)
|
|
needs_per_target_layout = (
|
|
main_trigger is None
|
|
or (
|
|
main_trigger == 'with-previous'
|
|
and any(target.delay_ms for target in main_targets)
|
|
)
|
|
)
|
|
|
|
if needs_per_target_layout and main_targets:
|
|
all_steps, next_id = _build_mixed_main_steps(main_targets, next_id)
|
|
elif main_trigger == 'on-click':
|
|
# Each element is an independent click-driven par directly under
|
|
# mainSeq. Three-level nesting per element: outer cTn holds for
|
|
# the click via delay="indefinite", innermost cTn owns the
|
|
# clickEffect + animation children. Each click advances the seq.
|
|
steps = []
|
|
for target in main_targets:
|
|
wrapper_id = next_id
|
|
inner_id = next_id + 1
|
|
leaf_id = next_id + 2
|
|
row_xml, next_id = _build_animation_row_xml(
|
|
target,
|
|
main_trigger,
|
|
leaf_id,
|
|
next_id + 3,
|
|
)
|
|
steps.append(f'''<p:par>
|
|
<p:cTn id="{wrapper_id}" fill="hold">
|
|
<p:stCondLst><p:cond delay="indefinite"/></p:stCondLst>
|
|
<p:childTnLst>
|
|
<p:par>
|
|
<p:cTn id="{inner_id}" fill="hold">
|
|
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
|
|
<p:childTnLst>
|
|
<p:par>
|
|
{row_xml}
|
|
</p:par>
|
|
</p:childTnLst>
|
|
</p:cTn>
|
|
</p:par>
|
|
</p:childTnLst>
|
|
</p:cTn>
|
|
</p:par>''')
|
|
all_steps = '\n '.join(steps)
|
|
else:
|
|
# with-previous / after-previous: wrap the entire cascade in ONE
|
|
# par so the sequence has a real trigger anchor under mainSeq.
|
|
#
|
|
# Native PowerPoint after-previous export uses two timing layers:
|
|
# each row owns its TriggerDelayTime, while its wrapper owns the
|
|
# previous row's absolute end. Their sum is the absolute start offset.
|
|
outer_id = next_id
|
|
next_id += 1
|
|
inner_steps = []
|
|
with_wrapper_id = None
|
|
if main_trigger == 'with-previous':
|
|
with_wrapper_id = next_id
|
|
next_id += 1
|
|
elapsed_ms = 0
|
|
for target_index, target in enumerate(main_targets, 1):
|
|
if main_trigger == 'with-previous':
|
|
leaf_id = next_id
|
|
row_xml, next_id = _build_animation_row_xml(
|
|
target,
|
|
main_trigger,
|
|
leaf_id,
|
|
next_id + 1,
|
|
)
|
|
inner_steps.append(f'''<p:par>
|
|
{row_xml}
|
|
</p:par>''')
|
|
else:
|
|
if elapsed_ms > MAX_OOXML_MILLISECONDS:
|
|
raise ValueError(
|
|
'animation sequence offset exceeds the OOXML '
|
|
f'millisecond limit at target {target_index}: {elapsed_ms}'
|
|
)
|
|
wrapper_id = next_id
|
|
leaf_id = next_id + 1
|
|
row_xml, next_id = _build_animation_row_xml(
|
|
target,
|
|
main_trigger,
|
|
leaf_id,
|
|
next_id + 2,
|
|
)
|
|
inner_steps.append(f'''<p:par>
|
|
<p:cTn id="{wrapper_id}" fill="hold">
|
|
<p:stCondLst><p:cond delay="{elapsed_ms}"/></p:stCondLst>
|
|
<p:childTnLst>
|
|
<p:par>
|
|
{row_xml}
|
|
</p:par>
|
|
</p:childTnLst>
|
|
</p:cTn>
|
|
</p:par>''')
|
|
elapsed_ms += target.delay_ms + target.playback_duration_ms
|
|
|
|
inner_xml = '\n '.join(inner_steps)
|
|
if main_trigger == 'with-previous':
|
|
# Match PowerPoint's native "Start: With Previous" export:
|
|
# one delay=0 wrapper begins on slide entry, and all withEffect
|
|
# rows live under that wrapper so they truly start in parallel.
|
|
inner_xml = f'''<p:par>
|
|
<p:cTn id="{with_wrapper_id}" fill="hold">
|
|
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
|
|
<p:childTnLst>
|
|
{inner_xml}
|
|
</p:childTnLst>
|
|
</p:cTn>
|
|
</p:par>'''
|
|
if main_trigger in ('with-previous', 'after-previous'):
|
|
# Match PowerPoint's native slide-entry export: the wrapper waits
|
|
# for mainSeq to begin, then child nodes resolve their Start modes.
|
|
outer_start_conditions = (
|
|
'<p:cond delay="indefinite"/>'
|
|
'<p:cond evt="onBegin" delay="0"><p:tn val="2"/></p:cond>'
|
|
)
|
|
else:
|
|
outer_start_conditions = '<p:cond delay="0"/>'
|
|
all_steps = f'''<p:par>
|
|
<p:cTn id="{outer_id}" fill="hold">
|
|
<p:stCondLst>{outer_start_conditions}</p:stCondLst>
|
|
<p:childTnLst>
|
|
{inner_xml}
|
|
</p:childTnLst>
|
|
</p:cTn>
|
|
</p:par>'''
|
|
|
|
if main_targets:
|
|
main_sequence_xml = f'''<p:seq concurrent="1" nextAc="seek">
|
|
<p:cTn id="2" dur="indefinite" nodeType="mainSeq">
|
|
<p:childTnLst>
|
|
{all_steps}
|
|
</p:childTnLst>
|
|
</p:cTn>
|
|
<p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst>
|
|
<p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst>
|
|
</p:seq>'''
|
|
else:
|
|
main_sequence_xml = ''
|
|
next_id = 2
|
|
|
|
interactive_sequences: list[str] = []
|
|
for target in interactive_targets:
|
|
sequence_id = next_id
|
|
wrapper_id = next_id + 1
|
|
inner_id = next_id + 2
|
|
row_id = next_id + 3
|
|
row_xml, next_id = _build_animation_row_xml(
|
|
target,
|
|
'on-click',
|
|
row_id,
|
|
next_id + 4,
|
|
)
|
|
trigger_shape_id = target.trigger_shape_id
|
|
interactive_sequences.append(f'''<p:seq concurrent="1" nextAc="seek">
|
|
<p:cTn id="{sequence_id}" restart="whenNotActive" fill="hold" evtFilter="cancelBubble" nodeType="interactiveSeq">
|
|
<p:stCondLst><p:cond evt="onClick" delay="0"><p:tgtEl><p:spTgt spid="{trigger_shape_id}"/></p:tgtEl></p:cond></p:stCondLst>
|
|
<p:endSync evt="end" delay="0"><p:rtn val="all"/></p:endSync>
|
|
<p:childTnLst>
|
|
<p:par>
|
|
<p:cTn id="{wrapper_id}" fill="hold">
|
|
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
|
|
<p:childTnLst>
|
|
<p:par>
|
|
<p:cTn id="{inner_id}" fill="hold">
|
|
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
|
|
<p:childTnLst><p:par>{row_xml}</p:par></p:childTnLst>
|
|
</p:cTn>
|
|
</p:par>
|
|
</p:childTnLst>
|
|
</p:cTn>
|
|
</p:par>
|
|
</p:childTnLst>
|
|
</p:cTn>
|
|
<p:nextCondLst><p:cond evt="onClick" delay="0"><p:tgtEl><p:spTgt spid="{trigger_shape_id}"/></p:tgtEl></p:cond></p:nextCondLst>
|
|
</p:seq>''')
|
|
sequence_xml = '\n '.join(
|
|
[
|
|
value
|
|
for value in (
|
|
main_sequence_xml,
|
|
*interactive_sequences,
|
|
)
|
|
if value
|
|
]
|
|
)
|
|
|
|
timing_xml = f''' <p:timing>
|
|
<p:tnLst>
|
|
<p:par>
|
|
<p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot">
|
|
<p:childTnLst>
|
|
{sequence_xml}
|
|
</p:childTnLst>
|
|
</p:cTn>
|
|
</p:par>
|
|
</p:tnLst>
|
|
</p:timing>'''
|
|
if not any(target.bounce_end for target in normalized_targets):
|
|
return timing_xml
|
|
|
|
fallback_xml = re.sub(
|
|
r'\s+p14:(?:presetBounceEnd|bounceEnd)="\d+"',
|
|
'',
|
|
timing_xml,
|
|
)
|
|
fallback_xml = fallback_xml.replace(
|
|
f' xmlns:p14="{_P14_NS}"',
|
|
'',
|
|
)
|
|
return f''' <mc:AlternateContent xmlns:mc="{_MC_NS}">
|
|
<mc:Choice xmlns:p14="{_P14_NS}" Requires="p14">
|
|
{timing_xml}
|
|
</mc:Choice>
|
|
<mc:Fallback>
|
|
{fallback_xml}
|
|
</mc:Fallback>
|
|
</mc:AlternateContent>'''
|
|
|
|
|
|
def pick_animation_effect(
|
|
mode: str,
|
|
idx: int,
|
|
offset: int = 0,
|
|
group_id: str | None = None,
|
|
*,
|
|
rng: random.Random | None = None,
|
|
) -> str:
|
|
"""Resolve a per-element effect name from a mode string.
|
|
|
|
- A specific animation name returns itself (no variation).
|
|
- 'auto': map ``group_id`` to an effect. Image-like ids
|
|
(hero / figure- / image / img- / kpi) cycle through ``_IMAGE_POOL``
|
|
(``entrance_zoom`` / ``entrance_dissolve`` / ``entrance_circle`` /
|
|
``entrance_box`` / ``entrance_diamond`` / ``entrance_wheel``) by
|
|
``idx + offset``
|
|
so multiple images vary across the deck. Other semantic matches in
|
|
``_SEMANTIC_PATTERNS`` return a single stable effect
|
|
(chart→``entrance_wipe``, card-/step-/pillar-→``entrance_fly``,
|
|
title/takeaway→``entrance_fade``). When the id matches no pattern, cycle
|
|
through ``_AUTO_POOL``.
|
|
- 'mixed' (compatible mode name): first element fixed to
|
|
``entrance_fade``; the rest cycle through ``_MIXED_POOL`` plus
|
|
``offset`` so titles stay calm while content varies across slides.
|
|
- 'random': uniform seeded choice from the same canonical preset pool.
|
|
Unknown modes fail explicitly; no effect is silently substituted.
|
|
"""
|
|
mode = normalize_animation_effect(
|
|
mode,
|
|
allow_none=False,
|
|
allow_modes=True,
|
|
)
|
|
if isinstance(idx, bool) or not isinstance(idx, int) or idx < 0:
|
|
raise ValueError(f'animation index must be a non-negative integer: {idx!r}')
|
|
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
|
raise ValueError(
|
|
f'animation offset must be a non-negative integer: {offset!r}'
|
|
)
|
|
if mode in NATIVE_ANIMATIONS:
|
|
return mode
|
|
if mode == 'auto':
|
|
semantic = _semantic_effect(group_id, idx, offset)
|
|
if semantic is not None:
|
|
return semantic
|
|
return _AUTO_POOL[(idx + offset) % len(_AUTO_POOL)]
|
|
if mode == 'mixed':
|
|
if idx == 0:
|
|
return 'entrance_fade'
|
|
return _MIXED_POOL[(idx - 1 + offset) % len(_MIXED_POOL)]
|
|
if mode == 'random':
|
|
chooser = rng if rng is not None else random
|
|
return chooser.choice(_MIXED_POOL)
|
|
raise AssertionError(f'unhandled animation mode: {mode}')
|
|
|
|
|
|
def _int_attribute(
|
|
element: ET.Element,
|
|
name: str,
|
|
label: str,
|
|
errors: list[str],
|
|
*,
|
|
minimum: int = 0,
|
|
maximum: int | None = None,
|
|
) -> int | None:
|
|
value = element.get(name)
|
|
if value is None or not re.fullmatch(r'\d+', value):
|
|
errors.append(f'{label} must be an integer; found {value!r}')
|
|
return None
|
|
number = int(value)
|
|
if number < minimum:
|
|
errors.append(f'{label} must be at least {minimum}; found {number}')
|
|
return None
|
|
if maximum is not None and number > maximum:
|
|
errors.append(f'{label} must be at most {maximum}; found {number}')
|
|
return None
|
|
return number
|
|
|
|
|
|
def _direct_conditions(ctn: ET.Element) -> list[ET.Element]:
|
|
condition_list = ctn.find(_qn(PML_NS, 'stCondLst'))
|
|
if condition_list is None:
|
|
return []
|
|
return [
|
|
child for child in list(condition_list)
|
|
if child.tag == _qn(PML_NS, 'cond')
|
|
]
|
|
|
|
|
|
def _shape_index(
|
|
slide_root: ET.Element,
|
|
) -> tuple[dict[int, tuple[str, bool]], list[str]]:
|
|
parent_map = {
|
|
child: parent
|
|
for parent in slide_root.iter()
|
|
for child in list(parent)
|
|
}
|
|
index: dict[int, tuple[str, bool]] = {}
|
|
errors: list[str] = []
|
|
shape_tags = {'sp', 'grpSp', 'pic', 'graphicFrame', 'cxnSp', 'contentPart'}
|
|
for non_visual in slide_root.iter(_qn(PML_NS, 'cNvPr')):
|
|
shape_id = _int_attribute(
|
|
non_visual,
|
|
'id',
|
|
'p:cNvPr@id',
|
|
errors,
|
|
minimum=1,
|
|
maximum=MAX_OOXML_UNSIGNED_INT,
|
|
)
|
|
if shape_id is None:
|
|
continue
|
|
owner = parent_map.get(non_visual)
|
|
while owner is not None and _local_name(owner.tag) not in shape_tags:
|
|
owner = parent_map.get(owner)
|
|
kind = _local_name(owner.tag) if owner is not None else 'unknown'
|
|
has_text = bool(
|
|
owner is not None
|
|
and kind == 'sp'
|
|
and any(
|
|
_local_name(element.tag) == 't' and (element.text or '').strip()
|
|
for element in owner.iter()
|
|
)
|
|
)
|
|
if shape_id in index:
|
|
errors.append(f'duplicate p:cNvPr@id {shape_id}')
|
|
else:
|
|
index[shape_id] = (kind, has_text)
|
|
return index, errors
|
|
|
|
|
|
def _row_shape_id(row: ET.Element, errors: list[str]) -> int | None:
|
|
shape_ids: list[int] = []
|
|
for target in row.iter(_qn(PML_NS, 'spTgt')):
|
|
value = _int_attribute(
|
|
target,
|
|
'spid',
|
|
'animation p:spTgt@spid',
|
|
errors,
|
|
minimum=1,
|
|
maximum=MAX_OOXML_UNSIGNED_INT,
|
|
)
|
|
if value is not None:
|
|
shape_ids.append(value)
|
|
unique = sorted(set(shape_ids))
|
|
if len(unique) != 1:
|
|
errors.append(
|
|
'one object-animation row must resolve to exactly one shape id; '
|
|
f'found {unique or "none"}'
|
|
)
|
|
return None
|
|
return unique[0]
|
|
|
|
|
|
def _row_filter(
|
|
row: ET.Element,
|
|
preset_class: str,
|
|
errors: list[str],
|
|
) -> str | None:
|
|
effects = list(row.iter(_qn(PML_NS, 'animEffect')))
|
|
if len(effects) > 1:
|
|
errors.append(
|
|
f'object-animation row contains {len(effects)} p:animEffect nodes'
|
|
)
|
|
if not effects:
|
|
return None
|
|
effect = effects[0]
|
|
expected_transition = {'entr': 'in', 'exit': 'out'}.get(preset_class)
|
|
if expected_transition is not None and effect.get('transition') != expected_transition:
|
|
errors.append(
|
|
f'{preset_class} p:animEffect must set '
|
|
f'transition="{expected_transition}"'
|
|
)
|
|
return effect.get('filter')
|
|
|
|
|
|
def _resolve_row_effect(
|
|
row: ET.Element,
|
|
filter_name: str | None,
|
|
errors: list[str],
|
|
) -> tuple[tuple[str, ...], str | None, int | None, int | None]:
|
|
preset_class = row.get('presetClass')
|
|
if preset_class not in set(_PRESET_CLASS_BY_CATEGORY.values()):
|
|
errors.append(
|
|
f'unsupported p:cTn@presetClass {preset_class!r}; expected '
|
|
+ ', '.join(sorted(set(_PRESET_CLASS_BY_CATEGORY.values())))
|
|
)
|
|
return (), preset_class, None, None
|
|
preset_id = _int_attribute(
|
|
row,
|
|
'presetID',
|
|
'object-animation p:cTn@presetID',
|
|
errors,
|
|
maximum=MAX_OOXML_UNSIGNED_INT,
|
|
)
|
|
preset_subtype = _int_attribute(
|
|
row,
|
|
'presetSubtype',
|
|
'object-animation p:cTn@presetSubtype',
|
|
errors,
|
|
maximum=MAX_OOXML_UNSIGNED_INT,
|
|
)
|
|
if preset_id is None or preset_subtype is None:
|
|
return (), preset_class, preset_id, preset_subtype
|
|
matches = [
|
|
key
|
|
for key, info in NATIVE_ANIMATIONS.items()
|
|
if info['presetClass'] == preset_class
|
|
if int(info['presetID']) == preset_id
|
|
]
|
|
return tuple(matches), preset_class, preset_id, preset_subtype
|
|
|
|
|
|
def _behavior_duration_ms(
|
|
row: ET.Element,
|
|
errors: list[str],
|
|
) -> int | None:
|
|
duration = _row_effective_duration_ms(row)
|
|
if duration is not None and duration > MAX_OOXML_MILLISECONDS:
|
|
errors.append(
|
|
'object-animation behavior duration exceeds the OOXML '
|
|
f'millisecond limit: {duration}'
|
|
)
|
|
return duration
|
|
|
|
|
|
def _read_animation_color(
|
|
row: ET.Element,
|
|
errors: list[str],
|
|
label: str,
|
|
) -> str | None:
|
|
colors = [
|
|
element
|
|
for element in row.iter()
|
|
if element.tag in {
|
|
_qn(_DML_NS, 'srgbClr'),
|
|
_qn(_DML_NS, 'schemeClr'),
|
|
}
|
|
]
|
|
if not colors:
|
|
errors.append(f'{label} is missing its color value')
|
|
return None
|
|
rendered = []
|
|
for color in colors:
|
|
if color.tag == _qn(_DML_NS, 'srgbClr'):
|
|
value = color.get('val')
|
|
rendered.append(f'#{value.upper()}' if value else '')
|
|
else:
|
|
value = color.get('val')
|
|
rendered.append(f'theme:{value}' if value else '')
|
|
unique = tuple(dict.fromkeys(value for value in rendered if value))
|
|
if len(unique) != 1:
|
|
errors.append(f'{label} contains inconsistent color values: {unique!r}')
|
|
return None
|
|
return unique[0] if unique else None
|
|
|
|
|
|
def _read_effect_options(
|
|
row: ET.Element,
|
|
effect: str | None,
|
|
filter_name: str | None,
|
|
errors: list[str],
|
|
) -> dict[str, object]:
|
|
if effect is None:
|
|
return {}
|
|
option_specs = NATIVE_ANIMATIONS[effect]['effectOptions']
|
|
values: dict[str, object] = {}
|
|
for name, spec in option_specs.items():
|
|
option_type = spec['type']
|
|
if option_type == 'enum':
|
|
matches = []
|
|
for value, variant_xml in spec['values'].items():
|
|
variant = ET.fromstring(variant_xml)
|
|
variant_filter = _row_filter(variant, variant.get('presetClass', ''), [])
|
|
if variant.get('presetSubtype') != row.get('presetSubtype'):
|
|
continue
|
|
if variant_filter != filter_name:
|
|
continue
|
|
matches.append(value)
|
|
if len(matches) != 1:
|
|
errors.append(
|
|
f'animation effect {effect!r} option {name!r} could not be '
|
|
f'read from presetSubtype={row.get("presetSubtype")!r}, '
|
|
f'filter={filter_name!r}'
|
|
)
|
|
continue
|
|
value: object = matches[0]
|
|
if name == 'amount' and re.fullmatch(r'\d+', str(value)):
|
|
value = int(str(value))
|
|
values[name] = value
|
|
elif name == 'amount' and effect == 'emphasis_spin':
|
|
rotations = list(row.iter(_qn(PML_NS, 'animRot')))
|
|
raw = rotations[0].get('by') if len(rotations) == 1 else None
|
|
if raw is None or not re.fullmatch(r'-?\d+', raw):
|
|
errors.append('emphasis_spin row has an invalid rotation amount')
|
|
else:
|
|
values[name] = int(raw) / 60000
|
|
elif name == 'amount' and effect == 'emphasis_transparency':
|
|
opacity_values = []
|
|
for node in row.iter(_qn(PML_NS, 'set')):
|
|
attributes = {
|
|
(attribute.text or '').strip()
|
|
for attribute in node.iter(_qn(PML_NS, 'attrName'))
|
|
}
|
|
if 'style.opacity' in attributes:
|
|
opacity_values.extend(
|
|
value.get('val')
|
|
for value in node.iter(_qn(PML_NS, 'strVal'))
|
|
)
|
|
if len(opacity_values) != 1:
|
|
errors.append('emphasis_transparency row has an invalid opacity')
|
|
else:
|
|
try:
|
|
values[name] = 1 - float(str(opacity_values[0]))
|
|
except ValueError:
|
|
errors.append(
|
|
'emphasis_transparency row has a non-numeric opacity'
|
|
)
|
|
elif name == 'color':
|
|
color = _read_animation_color(
|
|
row,
|
|
errors,
|
|
f'animation effect {effect!r} color option',
|
|
)
|
|
if color is not None:
|
|
values[name] = color
|
|
elif name == 'font_name':
|
|
fonts = []
|
|
for node in row.iter(_qn(PML_NS, 'set')):
|
|
attributes = {
|
|
(attribute.text or '').strip()
|
|
for attribute in node.iter(_qn(PML_NS, 'attrName'))
|
|
}
|
|
if 'style.fontFamily' in attributes:
|
|
fonts.extend(
|
|
value.get('val')
|
|
for value in node.iter(_qn(PML_NS, 'strVal'))
|
|
)
|
|
if len(fonts) != 1:
|
|
errors.append(
|
|
'emphasis_change_font row must contain one font name'
|
|
)
|
|
continue
|
|
try:
|
|
values[name] = _normalize_powerpoint_font_name(
|
|
fonts[0],
|
|
'emphasis_change_font row font name',
|
|
)
|
|
except ValueError as exc:
|
|
errors.append(str(exc))
|
|
elif name == 'relative':
|
|
motions = list(row.iter(_qn(PML_NS, 'animMotion')))
|
|
if len(motions) != 1:
|
|
errors.append(f'motion-path effect {effect!r} has no single path')
|
|
else:
|
|
values[name] = motions[0].get('pathEditMode') != 'fixed'
|
|
elif name == 'size':
|
|
scales = list(row.iter(_qn(PML_NS, 'animScale')))
|
|
targets = (
|
|
scales[0].findall(_qn(PML_NS, 'to'))
|
|
if len(scales) == 1
|
|
else []
|
|
)
|
|
raw = (
|
|
targets[0].get('x')
|
|
if len(targets) == 1
|
|
else '100000' if not targets and len(scales) == 1 else None
|
|
)
|
|
if raw is None or not re.fullmatch(r'\d+', raw):
|
|
errors.append('emphasis_grow_shrink row has an invalid size')
|
|
else:
|
|
values[name] = int(raw) / 1000
|
|
else:
|
|
errors.append(
|
|
f'animation effect {effect!r} option {name!r} has no reader'
|
|
)
|
|
return values
|
|
|
|
|
|
def _timing_summary(
|
|
row: ET.Element,
|
|
duration_ms: int | None,
|
|
errors: list[str],
|
|
) -> tuple[
|
|
float | None,
|
|
int | None,
|
|
bool,
|
|
bool,
|
|
float,
|
|
float,
|
|
float,
|
|
str,
|
|
int | None,
|
|
]:
|
|
raw_repeat_count = row.get('repeatCount')
|
|
repeat_count = None
|
|
if raw_repeat_count is not None:
|
|
if not re.fullmatch(r'\d+', raw_repeat_count):
|
|
errors.append(
|
|
f'object-animation repeatCount must be numeric; found '
|
|
f'{raw_repeat_count!r}'
|
|
)
|
|
else:
|
|
repeat_count = int(raw_repeat_count) / 1000
|
|
raw_repeat_duration = row.get('repeatDur')
|
|
repeat_duration_ms = None
|
|
if raw_repeat_duration is not None:
|
|
if not re.fullmatch(r'\d+', raw_repeat_duration):
|
|
errors.append(
|
|
f'object-animation repeatDur must be numeric; found '
|
|
f'{raw_repeat_duration!r}'
|
|
)
|
|
else:
|
|
repeat_duration_ms = int(raw_repeat_duration)
|
|
if repeat_count is not None and repeat_duration_ms is not None:
|
|
errors.append('object-animation row sets both repeatCount and repeatDur')
|
|
|
|
def ratio(attribute: str) -> float:
|
|
raw = row.get(attribute)
|
|
if raw is None:
|
|
return 0.0
|
|
if not re.fullmatch(r'\d+', raw):
|
|
errors.append(
|
|
f'object-animation {attribute} must be numeric; found {raw!r}'
|
|
)
|
|
return 0.0
|
|
number = int(raw)
|
|
if number > 100000:
|
|
errors.append(
|
|
f'object-animation {attribute} exceeds 100000; found {number}'
|
|
)
|
|
return number / 100000
|
|
|
|
accelerate = ratio('accel')
|
|
decelerate = ratio('decel')
|
|
if accelerate + decelerate > 1:
|
|
errors.append('object-animation accel + decel exceeds 100000')
|
|
raw_bounce_values = {
|
|
node.get(_qn(_P14_NS, 'bounceEnd'))
|
|
for node in row.iter()
|
|
if node.get(_qn(_P14_NS, 'bounceEnd')) is not None
|
|
}
|
|
preset_bounce = row.get(_qn(_P14_NS, 'presetBounceEnd'))
|
|
if preset_bounce is not None:
|
|
raw_bounce_values.add(preset_bounce)
|
|
bounce_end = 0.0
|
|
if len(raw_bounce_values) > 1:
|
|
errors.append(
|
|
'object-animation behaviors disagree on p14:bounceEnd'
|
|
)
|
|
elif raw_bounce_values:
|
|
raw_bounce = next(iter(raw_bounce_values))
|
|
if raw_bounce is None or not re.fullmatch(r'\d+', raw_bounce):
|
|
errors.append(
|
|
f'object-animation p14:bounceEnd must be numeric; '
|
|
f'found {raw_bounce!r}'
|
|
)
|
|
else:
|
|
bounce_value = int(raw_bounce)
|
|
if bounce_value > 100000:
|
|
errors.append(
|
|
'object-animation p14:bounceEnd exceeds 100000; '
|
|
f'found {bounce_value}'
|
|
)
|
|
bounce_end = bounce_value / 100000
|
|
auto_reverse = row.get('autoRev') == '1'
|
|
rewind = row.get('fill') == 'remove'
|
|
restart = {
|
|
None: 'never',
|
|
'always': 'always',
|
|
'whenNotActive': 'when-not-active',
|
|
'never': 'never',
|
|
}.get(row.get('restart'))
|
|
if restart is None:
|
|
errors.append(
|
|
f'object-animation restart has unknown value: {row.get("restart")!r}'
|
|
)
|
|
restart = 'never'
|
|
|
|
playback_duration_ms = None
|
|
if duration_ms is not None:
|
|
one_play = duration_ms * (2 if auto_reverse else 1)
|
|
if repeat_duration_ms is not None:
|
|
playback_duration_ms = repeat_duration_ms
|
|
elif repeat_count is not None:
|
|
playback_duration_ms = max(1, round(one_play * repeat_count))
|
|
else:
|
|
playback_duration_ms = one_play
|
|
return (
|
|
repeat_count,
|
|
repeat_duration_ms,
|
|
auto_reverse,
|
|
rewind,
|
|
accelerate,
|
|
decelerate,
|
|
bounce_end,
|
|
restart,
|
|
playback_duration_ms,
|
|
)
|
|
|
|
|
|
def _after_effect_summary(
|
|
row: ET.Element,
|
|
errors: list[str],
|
|
) -> tuple[str, str | None]:
|
|
sub_timing = row.find(_qn(PML_NS, 'subTnLst'))
|
|
if sub_timing is None:
|
|
return 'none', None
|
|
after_nodes = [
|
|
node
|
|
for node in list(sub_timing)
|
|
if any(
|
|
ctn.get('afterEffect') == '1'
|
|
for ctn in node.iter(_qn(PML_NS, 'cTn'))
|
|
)
|
|
]
|
|
if not after_nodes:
|
|
return 'none', None
|
|
if len(after_nodes) != 1:
|
|
errors.append(
|
|
f'object-animation row contains {len(after_nodes)} after effects'
|
|
)
|
|
return 'none', None
|
|
node = after_nodes[0]
|
|
if node.tag == _qn(PML_NS, 'animClr'):
|
|
return (
|
|
'dim',
|
|
_read_animation_color(node, errors, 'animation dim after effect'),
|
|
)
|
|
if node.tag == _qn(PML_NS, 'set'):
|
|
ctns = list(node.iter(_qn(PML_NS, 'cTn')))
|
|
master_relation = ctns[0].get('masterRel') if ctns else None
|
|
if master_relation == 'sameClick':
|
|
return 'hide', None
|
|
if master_relation == 'nextClick':
|
|
return 'hide-on-next-click', None
|
|
errors.append('object-animation row contains an unknown after effect')
|
|
return 'none', None
|
|
|
|
|
|
def _sound_summary(
|
|
row: ET.Element,
|
|
errors: list[str],
|
|
) -> tuple[str | None, str | None]:
|
|
sounds = list(row.iter(_qn(PML_NS, 'sndTgt')))
|
|
if not sounds:
|
|
return None, None
|
|
if len(sounds) != 1:
|
|
errors.append(f'object-animation row contains {len(sounds)} sounds')
|
|
return None, None
|
|
relationship_id = sounds[0].get(_qn(_REL_NS, 'embed'))
|
|
name = sounds[0].get('name')
|
|
if relationship_id is None or name is None:
|
|
errors.append('object-animation sound is missing relationship id or name')
|
|
return relationship_id, name
|
|
|
|
|
|
def _row_trigger_shape_id(
|
|
row: ET.Element,
|
|
parent_map: Mapping[ET.Element, ET.Element],
|
|
errors: list[str],
|
|
) -> int | None:
|
|
current = parent_map.get(row)
|
|
while current is not None:
|
|
if (
|
|
current.tag == _qn(PML_NS, 'cTn')
|
|
and current.get('nodeType') == 'interactiveSeq'
|
|
):
|
|
shape_targets = [
|
|
target
|
|
for condition in _direct_conditions(current)
|
|
if condition.get('evt') == 'onClick'
|
|
for target in condition.iter(_qn(PML_NS, 'spTgt'))
|
|
]
|
|
if len(shape_targets) != 1:
|
|
errors.append(
|
|
'interactive animation sequence must have one trigger shape'
|
|
)
|
|
return None
|
|
return _int_attribute(
|
|
shape_targets[0],
|
|
'spid',
|
|
'interactive animation trigger shape id',
|
|
errors,
|
|
minimum=1,
|
|
maximum=MAX_OOXML_UNSIGNED_INT,
|
|
)
|
|
current = parent_map.get(current)
|
|
return None
|
|
|
|
|
|
def _row_offset_ms(
|
|
row: ET.Element,
|
|
trigger: str,
|
|
trigger_shape_id: int | None,
|
|
parent_map: Mapping[ET.Element, ET.Element],
|
|
errors: list[str],
|
|
) -> int:
|
|
leaf_conditions = _direct_conditions(row)
|
|
leaf_delay = None
|
|
if (
|
|
len(leaf_conditions) == 1
|
|
and re.fullmatch(r'\d+', leaf_conditions[0].get('delay') or '')
|
|
):
|
|
leaf_delay = int(leaf_conditions[0].get('delay', '0'))
|
|
else:
|
|
errors.append(
|
|
'object-animation row must have one numeric leaf start condition'
|
|
)
|
|
current = parent_map.get(row)
|
|
saw_indefinite = False
|
|
numeric_offset: int | None = None
|
|
while current is not None:
|
|
if current.tag == _qn(PML_NS, 'cTn'):
|
|
conditions = _direct_conditions(current)
|
|
if any(condition.get('delay') == 'indefinite' for condition in conditions):
|
|
saw_indefinite = True
|
|
if trigger in {'with-previous', 'after-previous'}:
|
|
numeric = [
|
|
condition.get('delay')
|
|
for condition in conditions
|
|
if condition.get('evt') is None
|
|
and re.fullmatch(r'\d+', condition.get('delay') or '')
|
|
]
|
|
if numeric and numeric_offset is None:
|
|
numeric_offset = int(numeric[0])
|
|
if numeric_offset > MAX_OOXML_MILLISECONDS:
|
|
errors.append(
|
|
'animation row offset exceeds the OOXML '
|
|
f'millisecond limit: {numeric_offset}'
|
|
)
|
|
current = parent_map.get(current)
|
|
|
|
if (
|
|
trigger == 'on-click'
|
|
and trigger_shape_id is None
|
|
and not saw_indefinite
|
|
):
|
|
errors.append(
|
|
'on-click object-animation row is missing an indefinite click wrapper'
|
|
)
|
|
if trigger in {'with-previous', 'after-previous'} and not saw_indefinite:
|
|
errors.append(f'{trigger} sequence is missing its sequence anchor')
|
|
if trigger in {'with-previous', 'after-previous'} and numeric_offset is None:
|
|
errors.append(
|
|
f'{trigger} object-animation row is missing its numeric offset wrapper'
|
|
)
|
|
if trigger in {'with-previous', 'after-previous'}:
|
|
absolute_offset = (numeric_offset or 0) + (leaf_delay or 0)
|
|
if absolute_offset > MAX_OOXML_MILLISECONDS:
|
|
errors.append(
|
|
'animation row absolute offset exceeds the OOXML '
|
|
f'millisecond limit: {absolute_offset}'
|
|
)
|
|
return absolute_offset
|
|
if trigger_shape_id is not None:
|
|
return leaf_delay or 0
|
|
return leaf_delay or 0
|
|
|
|
|
|
def _row_matches_powerpoint_behavior(
|
|
row: ET.Element,
|
|
*,
|
|
shape_id: int,
|
|
effect: str,
|
|
effect_options: Mapping[str, object],
|
|
trigger: str,
|
|
duration_ms: int,
|
|
repeat_count: float | None,
|
|
repeat_duration_ms: int | None,
|
|
auto_reverse: bool,
|
|
rewind: bool,
|
|
accelerate: float,
|
|
decelerate: float,
|
|
bounce_end: float,
|
|
restart: str,
|
|
after_effect: str,
|
|
after_effect_color: str | None,
|
|
sound_relationship_id: str | None,
|
|
sound_name: str | None,
|
|
) -> bool:
|
|
"""Match one read-back row to its reconstructed native behavior tree."""
|
|
spec = NATIVE_ANIMATIONS[effect]
|
|
target = AnimationTarget(
|
|
shape_id=shape_id,
|
|
delay_ms=0,
|
|
effect=effect,
|
|
duration_ms=duration_ms,
|
|
effect_options=effect_options,
|
|
trigger=trigger,
|
|
repeat_count=repeat_count,
|
|
repeat_duration_ms=repeat_duration_ms,
|
|
auto_reverse=True if auto_reverse else None,
|
|
rewind=True if rewind else None,
|
|
accelerate=accelerate or None,
|
|
decelerate=decelerate or None,
|
|
bounce_end=bounce_end or None,
|
|
restart=restart if row.get('restart') is not None else None,
|
|
after_effect=after_effect,
|
|
after_effect_color=after_effect_color,
|
|
sound_relationship_id=sound_relationship_id,
|
|
sound_name=sound_name,
|
|
)
|
|
option_candidates = [dict(effect_options)]
|
|
option_candidates.extend(
|
|
{
|
|
name: value
|
|
for name, value in effect_options.items()
|
|
if name != omitted
|
|
}
|
|
for omitted in effect_options
|
|
)
|
|
option_candidates.append({})
|
|
seen: set[tuple[tuple[str, object], ...]] = set()
|
|
for candidate in option_candidates:
|
|
candidate_key = tuple(sorted(candidate.items()))
|
|
if candidate_key in seen:
|
|
continue
|
|
seen.add(candidate_key)
|
|
expected = _animation_row_for_options(effect, candidate)
|
|
if spec['durationScalable']:
|
|
_scale_animation_row_duration(
|
|
expected,
|
|
base_duration_ms=int(spec['defaultDurationMs']),
|
|
requested_duration_ms=duration_ms,
|
|
)
|
|
_apply_timing_options(expected, target)
|
|
row_id = row.get('id')
|
|
expected.set('id', row_id if row_id and row_id.isdigit() else '1')
|
|
_append_after_effect(
|
|
expected,
|
|
target,
|
|
int(expected.get('id', '1')),
|
|
)
|
|
_append_animation_sound(expected, target)
|
|
if _animation_spec_matches_row(
|
|
row,
|
|
{'rowXml': ET.tostring(expected, encoding='unicode')},
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _animation_rows(
|
|
slide_root: ET.Element,
|
|
errors: list[str],
|
|
*,
|
|
require_behavior_signatures: bool = False,
|
|
) -> list[AnimationRowSummary]:
|
|
parent_map = {
|
|
child: parent
|
|
for parent in slide_root.iter()
|
|
for child in list(parent)
|
|
}
|
|
rows: list[AnimationRowSummary] = []
|
|
for row in slide_root.iter(_qn(PML_NS, 'cTn')):
|
|
preset_class = row.get('presetClass')
|
|
if preset_class not in set(_PRESET_CLASS_BY_CATEGORY.values()):
|
|
continue
|
|
node_type = row.get('nodeType')
|
|
trigger = _NODE_TYPE_TRIGGERS.get(node_type or '')
|
|
if trigger is None:
|
|
errors.append(
|
|
f'unsupported object-animation nodeType {node_type!r}; expected '
|
|
f'{", ".join(_NODE_TYPE_TRIGGERS)}'
|
|
)
|
|
continue
|
|
shape_id = _row_shape_id(row, errors)
|
|
filter_name = _row_filter(row, preset_class, errors)
|
|
supported_effects, resolved_class, preset_id, preset_subtype = (
|
|
_resolve_row_effect(
|
|
row,
|
|
filter_name,
|
|
errors,
|
|
)
|
|
)
|
|
duration_ms = _behavior_duration_ms(row, errors)
|
|
trigger_shape_id = _row_trigger_shape_id(row, parent_map, errors)
|
|
offset_ms = _row_offset_ms(
|
|
row,
|
|
trigger,
|
|
trigger_shape_id,
|
|
parent_map,
|
|
errors,
|
|
)
|
|
resolved_effect = supported_effects[0] if supported_effects else None
|
|
effect_options = _read_effect_options(
|
|
row,
|
|
resolved_effect,
|
|
filter_name,
|
|
errors,
|
|
)
|
|
(
|
|
repeat_count,
|
|
repeat_duration_ms,
|
|
auto_reverse,
|
|
rewind,
|
|
accelerate,
|
|
decelerate,
|
|
bounce_end,
|
|
restart,
|
|
playback_duration_ms,
|
|
) = _timing_summary(row, duration_ms, errors)
|
|
after_effect, after_effect_color = _after_effect_summary(row, errors)
|
|
sound_relationship_id, sound_name = _sound_summary(row, errors)
|
|
if (
|
|
shape_id is None
|
|
or resolved_class is None
|
|
or preset_id is None
|
|
or preset_subtype is None
|
|
):
|
|
continue
|
|
if (
|
|
require_behavior_signatures
|
|
and resolved_effect is not None
|
|
and duration_ms is not None
|
|
and not _row_matches_powerpoint_behavior(
|
|
row,
|
|
shape_id=shape_id,
|
|
effect=resolved_effect,
|
|
effect_options=effect_options,
|
|
trigger=trigger,
|
|
duration_ms=duration_ms,
|
|
repeat_count=repeat_count,
|
|
repeat_duration_ms=repeat_duration_ms,
|
|
auto_reverse=auto_reverse,
|
|
rewind=rewind,
|
|
accelerate=accelerate,
|
|
decelerate=decelerate,
|
|
bounce_end=bounce_end,
|
|
restart=restart,
|
|
after_effect=after_effect,
|
|
after_effect_color=after_effect_color,
|
|
sound_relationship_id=sound_relationship_id,
|
|
sound_name=sound_name,
|
|
)
|
|
):
|
|
errors.append(
|
|
'object-animation PowerPoint-authored behavior tree changed '
|
|
f'for shape {shape_id}'
|
|
)
|
|
rows.append(
|
|
AnimationRowSummary(
|
|
shape_id=shape_id,
|
|
effect=resolved_effect,
|
|
supported_effects=supported_effects,
|
|
preset_class=resolved_class,
|
|
trigger=trigger,
|
|
duration_ms=duration_ms,
|
|
offset_ms=offset_ms,
|
|
preset_id=preset_id,
|
|
preset_subtype=preset_subtype,
|
|
filter_name=filter_name,
|
|
effect_options=effect_options,
|
|
trigger_shape_id=trigger_shape_id,
|
|
repeat_count=repeat_count,
|
|
repeat_duration_ms=repeat_duration_ms,
|
|
auto_reverse=auto_reverse,
|
|
rewind=rewind,
|
|
accelerate=accelerate,
|
|
decelerate=decelerate,
|
|
bounce_end=bounce_end,
|
|
restart=restart,
|
|
after_effect=after_effect,
|
|
after_effect_color=after_effect_color,
|
|
sound_relationship_id=sound_relationship_id,
|
|
sound_name=sound_name,
|
|
playback_duration_ms=playback_duration_ms,
|
|
)
|
|
)
|
|
return rows
|
|
|
|
|
|
def _select_supported_timing_branch(slide_root: ET.Element) -> ET.Element:
|
|
"""Project p14 AlternateContent timing onto one effective slide tree."""
|
|
projected = copy.deepcopy(slide_root)
|
|
alternate_tag = _qn(_MC_NS, 'AlternateContent')
|
|
choice_tag = _qn(_MC_NS, 'Choice')
|
|
fallback_tag = _qn(_MC_NS, 'Fallback')
|
|
for index, child in list(enumerate(list(projected))):
|
|
if child.tag != alternate_tag:
|
|
continue
|
|
branches = [
|
|
branch
|
|
for branch in list(child)
|
|
if branch.tag in {choice_tag, fallback_tag}
|
|
]
|
|
selected_timing = None
|
|
for branch in branches:
|
|
if (
|
|
branch.tag == choice_tag
|
|
and 'p14' in (branch.get('Requires') or '').split()
|
|
):
|
|
selected_timing = branch.find(_qn(PML_NS, 'timing'))
|
|
if selected_timing is not None:
|
|
break
|
|
if selected_timing is None:
|
|
for branch in branches:
|
|
selected_timing = branch.find(_qn(PML_NS, 'timing'))
|
|
if selected_timing is not None:
|
|
break
|
|
if selected_timing is None:
|
|
continue
|
|
projected.remove(child)
|
|
projected.insert(index, copy.deepcopy(selected_timing))
|
|
return projected
|
|
|
|
|
|
def validate_slide_animation_structure(
|
|
slide_root: ET.Element,
|
|
*,
|
|
require_supported_effects: bool = False,
|
|
) -> list[str]:
|
|
"""Return root timing, target, and generated-object structure errors."""
|
|
errors: list[str] = []
|
|
if slide_root.tag != _qn(PML_NS, 'sld'):
|
|
return ['animation validation requires a PresentationML p:sld root']
|
|
slide_root = _select_supported_timing_branch(slide_root)
|
|
|
|
direct_timings = [
|
|
child for child in list(slide_root)
|
|
if child.tag == _qn(PML_NS, 'timing')
|
|
]
|
|
all_timings = list(slide_root.iter(_qn(PML_NS, 'timing')))
|
|
nested_count = len(all_timings) - len(direct_timings)
|
|
if nested_count:
|
|
errors.append(
|
|
f'slide contains {nested_count} nested p:timing element(s); '
|
|
'timing must be a direct child of p:sld'
|
|
)
|
|
if len(direct_timings) > 1:
|
|
errors.append(
|
|
f'slide has {len(direct_timings)} root p:timing elements; expected at most 1'
|
|
)
|
|
if not direct_timings:
|
|
return errors
|
|
|
|
timing = direct_timings[0]
|
|
root_children = list(slide_root)
|
|
timing_index = root_children.index(timing)
|
|
for required_before in ('cSld', 'clrMapOvr', 'transition'):
|
|
sibling = next(
|
|
(
|
|
child for child in root_children
|
|
if child.tag == _qn(PML_NS, required_before)
|
|
),
|
|
None,
|
|
)
|
|
if sibling is not None and root_children.index(sibling) > timing_index:
|
|
errors.append(f'p:{required_before} must precede p:timing')
|
|
extension_list = next(
|
|
(
|
|
child for child in root_children
|
|
if child.tag == _qn(PML_NS, 'extLst')
|
|
),
|
|
None,
|
|
)
|
|
if extension_list is not None and root_children.index(extension_list) < timing_index:
|
|
errors.append('root p:extLst must follow p:timing')
|
|
|
|
timing_children = list(timing)
|
|
timing_name_order = [_local_name(child.tag) for child in timing_children]
|
|
if 'bldLst' in timing_name_order and 'tnLst' in timing_name_order:
|
|
if timing_name_order.index('bldLst') < timing_name_order.index('tnLst'):
|
|
errors.append('p:tnLst must precede p:bldLst')
|
|
|
|
parent_map = {
|
|
child: parent
|
|
for parent in timing.iter()
|
|
for child in list(parent)
|
|
}
|
|
ctn_ids: list[int] = []
|
|
for ctn in timing.iter(_qn(PML_NS, 'cTn')):
|
|
if ctn.get('id') is None:
|
|
ancestor = parent_map.get(ctn)
|
|
while ancestor is not None and ancestor is not timing:
|
|
if ancestor.tag == _qn(PML_NS, 'subTnLst'):
|
|
break
|
|
ancestor = parent_map.get(ancestor)
|
|
if (
|
|
ancestor is not None
|
|
and ancestor.tag == _qn(PML_NS, 'subTnLst')
|
|
):
|
|
continue
|
|
value = _int_attribute(
|
|
ctn,
|
|
'id',
|
|
'p:cTn@id',
|
|
errors,
|
|
maximum=MAX_OOXML_UNSIGNED_INT,
|
|
)
|
|
if value is not None:
|
|
ctn_ids.append(value)
|
|
duplicates = sorted(
|
|
value for value in set(ctn_ids) if ctn_ids.count(value) > 1
|
|
)
|
|
if duplicates:
|
|
errors.append(
|
|
'duplicate p:cTn@id values: ' + ', '.join(map(str, duplicates))
|
|
)
|
|
|
|
roots = [
|
|
node for node in timing.iter(_qn(PML_NS, 'cTn'))
|
|
if node.get('nodeType') == 'tmRoot'
|
|
]
|
|
if len(roots) != 1:
|
|
errors.append(
|
|
f'p:timing must contain exactly one tmRoot time node; found {len(roots)}'
|
|
)
|
|
|
|
shape_index, shape_errors = _shape_index(slide_root)
|
|
errors.extend(shape_errors)
|
|
for target in timing.iter(_qn(PML_NS, 'spTgt')):
|
|
shape_id = _int_attribute(
|
|
target,
|
|
'spid',
|
|
'p:spTgt@spid',
|
|
errors,
|
|
minimum=1,
|
|
maximum=MAX_OOXML_UNSIGNED_INT,
|
|
)
|
|
if shape_id is not None and shape_id not in shape_index:
|
|
errors.append(f'p:spTgt references missing shape id {shape_id}')
|
|
|
|
build_keys: list[tuple[int, int]] = []
|
|
for build in timing.iter(_qn(PML_NS, 'bldP')):
|
|
shape_id = _int_attribute(
|
|
build,
|
|
'spid',
|
|
'p:bldP@spid',
|
|
errors,
|
|
minimum=1,
|
|
maximum=MAX_OOXML_UNSIGNED_INT,
|
|
)
|
|
group_id = _int_attribute(
|
|
build,
|
|
'grpId',
|
|
'p:bldP@grpId',
|
|
errors,
|
|
maximum=MAX_OOXML_UNSIGNED_INT,
|
|
)
|
|
if shape_id is None or group_id is None:
|
|
continue
|
|
build_keys.append((shape_id, group_id))
|
|
kind, has_text = shape_index.get(shape_id, ('missing', False))
|
|
if kind == 'missing':
|
|
errors.append(f'p:bldP references missing shape id {shape_id}')
|
|
elif require_supported_effects and (kind != 'sp' or not has_text):
|
|
errors.append(
|
|
f'p:bldP shape id {shape_id} must reference a text-bearing p:sp; '
|
|
f'found {kind}'
|
|
)
|
|
if len(build_keys) != len(set(build_keys)):
|
|
errors.append('p:bldP (spid, grpId) pairs must be unique')
|
|
|
|
animation_nodes = [
|
|
node for node in timing.iter(_qn(PML_NS, 'cTn'))
|
|
if node.get('presetClass') in set(_PRESET_CLASS_BY_CATEGORY.values())
|
|
]
|
|
if require_supported_effects:
|
|
rows = _animation_rows(
|
|
slide_root,
|
|
errors,
|
|
require_behavior_signatures=True,
|
|
)
|
|
if not rows and animation_nodes:
|
|
errors.append('generated object-animation rows could not be read back')
|
|
else:
|
|
rows = []
|
|
if rows:
|
|
regular_rows = [
|
|
row for row in rows if row.trigger_shape_id is None
|
|
]
|
|
interactive_rows = [
|
|
row for row in rows if row.trigger_shape_id is not None
|
|
]
|
|
main_sequences = [
|
|
node for node in timing.iter(_qn(PML_NS, 'cTn'))
|
|
if node.get('nodeType') == 'mainSeq'
|
|
]
|
|
expected_main_sequences = 1 if regular_rows else 0
|
|
if len(main_sequences) != expected_main_sequences:
|
|
errors.append(
|
|
'generated object-animation rows require '
|
|
f'{expected_main_sequences} mainSeq time node(s); found '
|
|
f'{len(main_sequences)}'
|
|
)
|
|
interactive_sequences = [
|
|
node for node in timing.iter(_qn(PML_NS, 'cTn'))
|
|
if node.get('nodeType') == 'interactiveSeq'
|
|
]
|
|
if len(interactive_sequences) != len(interactive_rows):
|
|
errors.append(
|
|
'each generated trigger-shape animation must have one '
|
|
'interactiveSeq time node'
|
|
)
|
|
for row in rows:
|
|
if (
|
|
row.trigger_shape_id is not None
|
|
and row.trigger != 'on-click'
|
|
):
|
|
errors.append(
|
|
'trigger-shape animation must use the on-click Start mode '
|
|
f'for shape {row.shape_id}'
|
|
)
|
|
if row.trigger_shape_id == row.shape_id:
|
|
errors.append(
|
|
'animation target and trigger shape must differ for shape '
|
|
f'{row.shape_id}'
|
|
)
|
|
if not row.supported_effects:
|
|
errors.append(
|
|
'unsupported object-animation effect tuple for shape '
|
|
f'{row.shape_id}: presetClass={row.preset_class}, '
|
|
f'presetID={row.preset_id}, '
|
|
f'presetSubtype={row.preset_subtype}, '
|
|
f'filter={row.filter_name!r}'
|
|
)
|
|
return errors
|
|
|
|
|
|
def read_slide_animation_sequence(
|
|
slide_xml: str | bytes,
|
|
*,
|
|
require_supported_effects: bool = False,
|
|
) -> AnimationSequenceSummary:
|
|
"""Read and validate the logical object-animation sequence from slide XML."""
|
|
data = slide_xml.encode('utf-8') if isinstance(slide_xml, str) else slide_xml
|
|
try:
|
|
root = ET.fromstring(data)
|
|
except ET.ParseError as exc:
|
|
raise ValueError(f'invalid slide XML: {exc}') from exc
|
|
root = _select_supported_timing_branch(root)
|
|
errors = validate_slide_animation_structure(
|
|
root,
|
|
require_supported_effects=require_supported_effects,
|
|
)
|
|
row_errors: list[str] = []
|
|
rows = _animation_rows(
|
|
root,
|
|
row_errors,
|
|
require_behavior_signatures=require_supported_effects,
|
|
)
|
|
for error in row_errors:
|
|
if error not in errors:
|
|
errors.append(error)
|
|
if errors:
|
|
raise ValueError('; '.join(errors))
|
|
direct_timings = [
|
|
child for child in list(root)
|
|
if child.tag == _qn(PML_NS, 'timing')
|
|
]
|
|
audio_targets: list[int] = []
|
|
for audio in root.iter(_qn(PML_NS, 'audio')):
|
|
for target in audio.iter(_qn(PML_NS, 'spTgt')):
|
|
value = target.get('spid')
|
|
if value and value.isdigit():
|
|
audio_targets.append(int(value))
|
|
regular_triggers = {
|
|
row.trigger for row in rows if row.trigger_shape_id is None
|
|
}
|
|
trigger = (
|
|
next(iter(regular_triggers))
|
|
if len(regular_triggers) == 1
|
|
else ('on-click' if rows and not regular_triggers else None)
|
|
)
|
|
return AnimationSequenceSummary(
|
|
timing_count=len(direct_timings),
|
|
trigger=trigger,
|
|
rows=tuple(rows),
|
|
audio_target_ids=tuple(audio_targets),
|
|
)
|
|
|
|
|
|
def validate_generated_animation_xml(
|
|
slide_xml: str | bytes,
|
|
targets: Sequence[Sequence[object] | Mapping[str, object]],
|
|
*,
|
|
duration: float = 0.3,
|
|
trigger: str = 'after-previous',
|
|
) -> AnimationSequenceSummary:
|
|
"""Read back one generated sequence and require exact requested semantics."""
|
|
trigger = normalize_animation_trigger(trigger)
|
|
default_duration_ms = _seconds_to_ms(
|
|
duration,
|
|
'animation duration',
|
|
allow_zero=False,
|
|
)
|
|
normalized_expected = tuple(
|
|
_normalize_target(target, default_duration_ms, trigger)
|
|
for target in targets
|
|
)
|
|
# PowerPoint stores ordinary rows in mainSeq and shape-triggered rows in
|
|
# separate interactiveSeq containers. Their relative cross-sequence order
|
|
# has no playback meaning, so read-back follows the native container order.
|
|
expected = tuple(
|
|
target
|
|
for target in normalized_expected
|
|
if target.trigger_shape_id is None
|
|
) + tuple(
|
|
target
|
|
for target in normalized_expected
|
|
if target.trigger_shape_id is not None
|
|
)
|
|
summary = read_slide_animation_sequence(
|
|
slide_xml,
|
|
require_supported_effects=True,
|
|
)
|
|
data = slide_xml.encode('utf-8') if isinstance(slide_xml, str) else slide_xml
|
|
actual_root = _select_supported_timing_branch(ET.fromstring(data))
|
|
actual_row_elements = [
|
|
row
|
|
for row in actual_root.iter(_qn(PML_NS, 'cTn'))
|
|
if row.get('presetClass') in set(_PRESET_CLASS_BY_CATEGORY.values())
|
|
]
|
|
errors: list[str] = []
|
|
if len(summary.rows) != len(expected):
|
|
errors.append(
|
|
f'animation read-back row count is {len(summary.rows)}; '
|
|
f'expected {len(expected)}'
|
|
)
|
|
expected_main_targets = tuple(
|
|
target for target in expected if target.trigger_shape_id is None
|
|
)
|
|
expected_main_triggers = {
|
|
target.trigger for target in expected_main_targets
|
|
}
|
|
expected_sequence_trigger = (
|
|
(
|
|
next(iter(expected_main_triggers))
|
|
if len(expected_main_triggers) == 1
|
|
else None
|
|
)
|
|
if expected_main_targets
|
|
else ('on-click' if expected else None)
|
|
)
|
|
if expected and summary.trigger != expected_sequence_trigger:
|
|
errors.append(
|
|
f'animation read-back trigger is {summary.trigger!r}; '
|
|
f'expected {expected_sequence_trigger!r}'
|
|
)
|
|
|
|
try:
|
|
main_offsets = iter(_main_target_offsets(expected_main_targets))
|
|
except ValueError as exc:
|
|
errors.append(str(exc))
|
|
main_offsets = iter(())
|
|
expected_offsets = [
|
|
(
|
|
target.delay_ms
|
|
if target.trigger_shape_id is not None
|
|
else next(main_offsets, 0)
|
|
)
|
|
for target in expected
|
|
]
|
|
|
|
for index, (actual, target) in enumerate(zip(summary.rows, expected), 1):
|
|
spec = NATIVE_ANIMATIONS[target.effect]
|
|
expected_row = _animation_row_for_options(
|
|
target.effect,
|
|
target.effect_options,
|
|
)
|
|
if spec['durationScalable']:
|
|
_scale_animation_row_duration(
|
|
expected_row,
|
|
base_duration_ms=int(spec['defaultDurationMs']),
|
|
requested_duration_ms=target.duration_ms,
|
|
)
|
|
_apply_timing_options(expected_row, target)
|
|
option_errors: list[str] = []
|
|
expected_filter = _row_filter(
|
|
expected_row,
|
|
str(spec['presetClass']),
|
|
option_errors,
|
|
)
|
|
expected_options = _read_effect_options(
|
|
expected_row,
|
|
target.effect,
|
|
expected_filter,
|
|
option_errors,
|
|
)
|
|
(
|
|
expected_repeat_count,
|
|
expected_repeat_duration_ms,
|
|
expected_auto_reverse,
|
|
expected_rewind,
|
|
expected_accelerate,
|
|
expected_decelerate,
|
|
expected_bounce_end,
|
|
expected_restart,
|
|
expected_playback_duration_ms,
|
|
) = _timing_summary(
|
|
expected_row,
|
|
(
|
|
target.duration_ms
|
|
if spec['durationScalable']
|
|
else spec['defaultDurationMs']
|
|
),
|
|
option_errors,
|
|
)
|
|
if option_errors:
|
|
errors.append(
|
|
f'animation row {index} expected-option model failed: '
|
|
+ '; '.join(option_errors)
|
|
)
|
|
if index <= len(actual_row_elements):
|
|
actual_row_element = actual_row_elements[index - 1]
|
|
expected_behavior_row = copy.deepcopy(expected_row)
|
|
actual_row_id = actual_row_element.get('id')
|
|
expected_behavior_row.set(
|
|
'id',
|
|
actual_row_id
|
|
if actual_row_id and actual_row_id.isdigit()
|
|
else '1',
|
|
)
|
|
_append_after_effect(
|
|
expected_behavior_row,
|
|
target,
|
|
int(expected_behavior_row.get('id', '1')),
|
|
)
|
|
_append_animation_sound(expected_behavior_row, target)
|
|
behavior_spec = {
|
|
'rowXml': ET.tostring(
|
|
expected_behavior_row,
|
|
encoding='unicode',
|
|
)
|
|
}
|
|
if not _animation_spec_matches_row(
|
|
actual_row_element,
|
|
behavior_spec,
|
|
):
|
|
errors.append(
|
|
f'animation row {index} PowerPoint-authored behavior '
|
|
'tree changed'
|
|
)
|
|
if actual.shape_id != target.shape_id:
|
|
errors.append(
|
|
f'animation row {index} targets shape {actual.shape_id}; '
|
|
f'expected {target.shape_id}'
|
|
)
|
|
expected_row_trigger = (
|
|
target.trigger
|
|
)
|
|
if actual.trigger != expected_row_trigger:
|
|
errors.append(
|
|
f'animation row {index} trigger is {actual.trigger!r}; '
|
|
f'expected {expected_row_trigger!r}'
|
|
)
|
|
if actual.trigger_shape_id != target.trigger_shape_id:
|
|
errors.append(
|
|
f'animation row {index} trigger shape is '
|
|
f'{actual.trigger_shape_id!r}; expected '
|
|
f'{target.trigger_shape_id!r}'
|
|
)
|
|
if target.effect not in actual.supported_effects:
|
|
errors.append(
|
|
f'animation row {index} resolved effects '
|
|
f'{actual.supported_effects!r}; expected {target.effect!r}'
|
|
)
|
|
if actual.preset_class != spec['presetClass']:
|
|
errors.append(f'animation row {index} presetClass changed')
|
|
if actual.preset_id != int(spec['presetID']):
|
|
errors.append(f'animation row {index} presetID changed')
|
|
if actual.preset_subtype != int(expected_row.get('presetSubtype', '-1')):
|
|
errors.append(f'animation row {index} presetSubtype changed')
|
|
if actual.filter_name != expected_filter:
|
|
errors.append(f'animation row {index} filter changed')
|
|
if dict(actual.effect_options) != expected_options:
|
|
errors.append(
|
|
f'animation row {index} effect_options are '
|
|
f'{dict(actual.effect_options)!r}; expected {expected_options!r}'
|
|
)
|
|
expected_duration = (
|
|
target.duration_ms
|
|
if spec['durationScalable']
|
|
else spec['defaultDurationMs']
|
|
)
|
|
if actual.duration_ms != expected_duration:
|
|
errors.append(
|
|
f'animation row {index} duration is {actual.duration_ms}ms; '
|
|
f'expected {expected_duration}ms'
|
|
)
|
|
if actual.offset_ms != expected_offsets[index - 1]:
|
|
errors.append(
|
|
f'animation row {index} offset is {actual.offset_ms}ms; '
|
|
f'expected {expected_offsets[index - 1]}ms'
|
|
)
|
|
timing_pairs = (
|
|
('repeat_count', actual.repeat_count, expected_repeat_count),
|
|
(
|
|
'repeat_duration_ms',
|
|
actual.repeat_duration_ms,
|
|
expected_repeat_duration_ms,
|
|
),
|
|
('auto_reverse', actual.auto_reverse, expected_auto_reverse),
|
|
('rewind', actual.rewind, expected_rewind),
|
|
('accelerate', actual.accelerate, expected_accelerate),
|
|
('decelerate', actual.decelerate, expected_decelerate),
|
|
('bounce_end', actual.bounce_end, expected_bounce_end),
|
|
('restart', actual.restart, expected_restart),
|
|
(
|
|
'playback_duration_ms',
|
|
actual.playback_duration_ms,
|
|
expected_playback_duration_ms,
|
|
),
|
|
)
|
|
for field, actual_value, expected_value in timing_pairs:
|
|
if actual_value != expected_value:
|
|
errors.append(
|
|
f'animation row {index} {field} is {actual_value!r}; '
|
|
f'expected {expected_value!r}'
|
|
)
|
|
if actual.after_effect != target.after_effect:
|
|
errors.append(
|
|
f'animation row {index} after_effect is '
|
|
f'{actual.after_effect!r}; expected {target.after_effect!r}'
|
|
)
|
|
if actual.after_effect_color != target.after_effect_color:
|
|
errors.append(
|
|
f'animation row {index} after_effect_color is '
|
|
f'{actual.after_effect_color!r}; '
|
|
f'expected {target.after_effect_color!r}'
|
|
)
|
|
if actual.sound_relationship_id != target.sound_relationship_id:
|
|
errors.append(
|
|
f'animation row {index} sound relationship is '
|
|
f'{actual.sound_relationship_id!r}; '
|
|
f'expected {target.sound_relationship_id!r}'
|
|
)
|
|
if actual.sound_name != target.sound_name:
|
|
errors.append(
|
|
f'animation row {index} sound name is '
|
|
f'{actual.sound_name!r}; expected {target.sound_name!r}'
|
|
)
|
|
if errors:
|
|
raise ValueError('; '.join(errors))
|
|
resolved_rows = tuple(
|
|
replace(actual, effect=target.effect)
|
|
for actual, target in zip(summary.rows, expected)
|
|
)
|
|
return replace(summary, rows=resolved_rows)
|
|
|
|
|
|
def validate_pptx_animation_package(
|
|
pptx_path: str | Path,
|
|
*,
|
|
require_supported_effects: bool = False,
|
|
) -> None:
|
|
"""Validate timing placement and shape references for every slide part."""
|
|
path = Path(pptx_path)
|
|
errors: list[str] = []
|
|
try:
|
|
with zipfile.ZipFile(path) as package:
|
|
names = sorted(
|
|
name
|
|
for name in package.namelist()
|
|
if re.fullmatch(r'ppt/slides/slide\d+\.xml', name)
|
|
)
|
|
for name in names:
|
|
slide_data = package.read(name)
|
|
try:
|
|
root = ET.fromstring(slide_data)
|
|
except ET.ParseError as exc:
|
|
errors.append(f'{name}: invalid XML: {exc}')
|
|
continue
|
|
for error in validate_slide_animation_structure(
|
|
root,
|
|
require_supported_effects=require_supported_effects,
|
|
):
|
|
errors.append(f'{name}: {error}')
|
|
try:
|
|
summary = read_slide_animation_sequence(slide_data)
|
|
except ValueError:
|
|
continue
|
|
sound_ids = {
|
|
row.sound_relationship_id
|
|
for row in summary.rows
|
|
if row.sound_relationship_id is not None
|
|
}
|
|
if not sound_ids:
|
|
continue
|
|
slide_leaf = posixpath.basename(name)
|
|
rels_name = (
|
|
f'ppt/slides/_rels/{slide_leaf}.rels'
|
|
)
|
|
if rels_name not in package.namelist():
|
|
errors.append(
|
|
f'{name}: animation sound relationships are missing'
|
|
)
|
|
continue
|
|
try:
|
|
relationships = ET.fromstring(package.read(rels_name))
|
|
except ET.ParseError as exc:
|
|
errors.append(f'{rels_name}: invalid XML: {exc}')
|
|
continue
|
|
by_id = {
|
|
rel.get('Id'): rel
|
|
for rel in relationships.findall(
|
|
_qn(_PACKAGE_REL_NS, 'Relationship')
|
|
)
|
|
}
|
|
for relationship_id in sorted(sound_ids):
|
|
relationship = by_id.get(relationship_id)
|
|
if relationship is None:
|
|
errors.append(
|
|
f'{name}: animation sound relationship '
|
|
f'{relationship_id} is missing'
|
|
)
|
|
continue
|
|
if relationship.get('Type') != _AUDIO_REL_TYPE:
|
|
errors.append(
|
|
f'{name}: animation sound relationship '
|
|
f'{relationship_id} is not an audio relationship'
|
|
)
|
|
continue
|
|
target = relationship.get('Target')
|
|
if not target:
|
|
errors.append(
|
|
f'{name}: animation sound relationship '
|
|
f'{relationship_id} has no target'
|
|
)
|
|
continue
|
|
target_part = posixpath.normpath(
|
|
posixpath.join(posixpath.dirname(name), target)
|
|
)
|
|
if target_part not in package.namelist():
|
|
errors.append(
|
|
f'{name}: animation sound target is missing: '
|
|
f'{target_part}'
|
|
)
|
|
except (OSError, zipfile.BadZipFile) as exc:
|
|
raise ValueError(f'unable to read PPTX package {path}: {exc}') from exc
|
|
if errors:
|
|
raise ValueError('; '.join(errors))
|
|
|
|
|
|
def object_animation_fingerprint(slide_xml: str | bytes) -> str | None:
|
|
"""Return a prefix/whitespace-independent fingerprint of object animation.
|
|
|
|
Narration audio is intentionally excluded. Direct-PPTX routes use this
|
|
fingerprint before and after their allowed edits to prove that they did not
|
|
take ownership of or rewrite existing object animations.
|
|
"""
|
|
data = slide_xml.encode('utf-8') if isinstance(slide_xml, str) else slide_xml
|
|
try:
|
|
root = ET.fromstring(data)
|
|
except ET.ParseError as exc:
|
|
raise ValueError(f'invalid slide XML: {exc}') from exc
|
|
root = _select_supported_timing_branch(root)
|
|
timings = [
|
|
child for child in list(root)
|
|
if child.tag == _qn(PML_NS, 'timing')
|
|
]
|
|
if len(timings) > 1:
|
|
raise ValueError(
|
|
f'slide has {len(timings)} root p:timing elements; expected at most 1'
|
|
)
|
|
if not timings:
|
|
return None
|
|
timing = timings[0]
|
|
behavior_tags = {
|
|
_qn(PML_NS, name)
|
|
for name in (
|
|
'anim',
|
|
'animClr',
|
|
'animEffect',
|
|
'animMotion',
|
|
'animRot',
|
|
'animScale',
|
|
'cmd',
|
|
'set',
|
|
)
|
|
}
|
|
has_object_animation = any(
|
|
element.tag in behavior_tags
|
|
or (
|
|
element.tag == _qn(PML_NS, 'cTn')
|
|
and element.get('presetClass') is not None
|
|
)
|
|
for element in timing.iter()
|
|
)
|
|
if not has_object_animation:
|
|
return None
|
|
|
|
def without_audio(element: ET.Element) -> tuple[object, ...] | None:
|
|
if element.tag == _qn(PML_NS, 'audio'):
|
|
return None
|
|
children = tuple(
|
|
value
|
|
for child in list(element)
|
|
if (value := without_audio(child)) is not None
|
|
)
|
|
return (
|
|
element.tag,
|
|
tuple(sorted(element.attrib.items())),
|
|
(element.text or '').strip(),
|
|
children,
|
|
)
|
|
|
|
canonical = without_audio(timing)
|
|
return hashlib.sha256(repr(canonical).encode('utf-8')).hexdigest()
|
|
|
|
|
|
def entrance_animation_fingerprint(slide_xml: str | bytes) -> str | None:
|
|
"""Compatibility alias for :func:`object_animation_fingerprint`."""
|
|
return object_animation_fingerprint(slide_xml)
|
|
|
|
|
|
def get_available_transitions() -> list:
|
|
"""Get native transition keys followed by compatibility inputs."""
|
|
return [*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS]
|
|
|
|
|
|
def get_available_animations() -> list:
|
|
"""Get canonical object-animation keys followed by compatibility inputs."""
|
|
return list(ANIMATIONS.keys())
|
|
|
|
|
|
def get_transition_help() -> str:
|
|
"""Get categorized native transitions plus legacy compatibility inputs."""
|
|
lines = ["Available transition effects:"]
|
|
for category in TRANSITION_CATEGORIES:
|
|
lines.append(f" PowerPoint-native {category} effects:")
|
|
for key in NATIVE_TRANSITION_KEYS:
|
|
info = NATIVE_TRANSITIONS[key]
|
|
if info["category"] == category:
|
|
lines.append(f" {key}: {info['name']}")
|
|
lines.append(" Legacy compatibility inputs (never selected for new output):")
|
|
for key in LEGACY_TRANSITION_KEYS:
|
|
canonical = TRANSITION_ALIASES[key]
|
|
implied = TRANSITION_ALIAS_OPTIONS.get(key)
|
|
option_suffix = f", implies {implied}" if implied else ""
|
|
lines.append(
|
|
f" {key}: compatibility alias for {canonical} "
|
|
f"({NATIVE_TRANSITIONS[canonical]['name']}{option_suffix})"
|
|
)
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def get_animation_help() -> str:
|
|
"""Get categorized help text for every object-animation effect."""
|
|
lines = ['Available object animations:']
|
|
for category in ANIMATION_CATEGORIES:
|
|
lines.append(f' PowerPoint-native {category} effects:')
|
|
for key in NATIVE_ANIMATION_KEYS:
|
|
info = NATIVE_ANIMATIONS[key]
|
|
if info['category'] == category:
|
|
lines.append(f" {key}: {info['name']}")
|
|
lines.append(' Legacy compatibility inputs (never selected for new output):')
|
|
for key in LEGACY_ANIMATION_KEYS:
|
|
canonical = ANIMATION_ALIASES[key]
|
|
implied = ANIMATION_ALIAS_OPTIONS.get(key)
|
|
option_suffix = (
|
|
f', implies {implied}'
|
|
if implied
|
|
else ''
|
|
)
|
|
lines.append(
|
|
f" {key}: compatibility alias for {canonical} "
|
|
f"({NATIVE_ANIMATIONS[canonical]['name']}{option_suffix})"
|
|
)
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def describe_animation_effect(effect: object) -> dict[str, Any]:
|
|
"""Return the author-facing option contract for one animation effect."""
|
|
canonical = normalize_animation_effect(
|
|
effect,
|
|
allow_none=False,
|
|
allow_modes=False,
|
|
)
|
|
assert canonical is not None
|
|
implied_options = (
|
|
dict(ANIMATION_ALIAS_OPTIONS.get(effect, {}))
|
|
if isinstance(effect, str)
|
|
else {}
|
|
)
|
|
option_contract: dict[str, Any] = {}
|
|
for name, raw_spec in NATIVE_ANIMATIONS[canonical]['effectOptions'].items():
|
|
spec = {
|
|
key: value
|
|
for key, value in raw_spec.items()
|
|
if key != 'values'
|
|
}
|
|
if raw_spec.get('type') == 'enum':
|
|
spec['values'] = list(raw_spec['values'])
|
|
option_contract[name] = spec
|
|
return {
|
|
'input': effect,
|
|
'effect': canonical,
|
|
'compatibility_alias': (
|
|
effect if isinstance(effect, str) and effect in ANIMATION_ALIASES else None
|
|
),
|
|
'implied_effect_options': implied_options,
|
|
'effect_options': option_contract,
|
|
'timing': {
|
|
'duration': (
|
|
'positive seconds; legacy group effect or effects[] row'
|
|
),
|
|
'delay': (
|
|
'non-negative seconds; legacy group effect or effects[] row'
|
|
),
|
|
'stagger': 'non-negative seconds; animation scope only',
|
|
'trigger': list(ANIMATION_TRIGGERS),
|
|
'trigger_scope': (
|
|
'animation default for a legacy group effect; each effects[] '
|
|
'row may override it'
|
|
),
|
|
'trigger_shape': (
|
|
'other top-level SVG group id; legacy group effect or '
|
|
'effects[] row; maps to PowerPoint "On Click of" and requires '
|
|
'trigger on-click'
|
|
),
|
|
'repeat_count': 'positive number; mutually exclusive with repeat_duration',
|
|
'repeat_duration': 'positive seconds; mutually exclusive with repeat_count',
|
|
'auto_reverse': 'boolean',
|
|
'rewind': 'boolean',
|
|
'accelerate': 'number from 0 to 1',
|
|
'decelerate': 'number from 0 to 1',
|
|
'bounce_end': (
|
|
'number from 0 to 1; requires an interpolated behavior and '
|
|
'is mutually exclusive with decelerate'
|
|
),
|
|
'restart': list(ANIMATION_RESTARTS),
|
|
},
|
|
'after_effect': list(ANIMATION_AFTER_EFFECTS),
|
|
'sound': 'project-relative or absolute .m4a, .mp3, or .wav path',
|
|
'derived_not_configured': {
|
|
'speed': 'derived from duration',
|
|
'smooth_start': 'derived from accelerate',
|
|
'smooth_end': 'derived from decelerate',
|
|
},
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
"""Run the CLI entry point."""
|
|
parser = argparse.ArgumentParser(
|
|
description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"--demo",
|
|
action="store_true",
|
|
help="print sample XML for a fade transition and entrance_fade animation",
|
|
)
|
|
parser.add_argument(
|
|
'--list',
|
|
action='store_true',
|
|
help='list available transitions and object animations',
|
|
)
|
|
parser.add_argument(
|
|
'--describe',
|
|
metavar='EFFECT',
|
|
help='print the complete parameter contract for one object animation',
|
|
)
|
|
parser.add_argument(
|
|
'--describe-transition',
|
|
metavar='EFFECT',
|
|
help='print the PowerPoint Effect Options for one page transition',
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.describe:
|
|
try:
|
|
print(
|
|
json.dumps(
|
|
describe_animation_effect(args.describe),
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
)
|
|
except ValueError as exc:
|
|
parser.error(str(exc))
|
|
return
|
|
|
|
if args.describe_transition:
|
|
try:
|
|
print(
|
|
json.dumps(
|
|
describe_transition_effect(args.describe_transition),
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
)
|
|
except ValueError as exc:
|
|
parser.error(str(exc))
|
|
return
|
|
|
|
if args.list:
|
|
print(get_transition_help())
|
|
print()
|
|
print(get_animation_help())
|
|
return
|
|
|
|
if args.demo:
|
|
print("=== Transition Effect XML Example (fade, 500ms) ===")
|
|
print(create_transition_xml('fade', 0.5))
|
|
print()
|
|
print("=== Entrance Animation XML Example (entrance_fade) ===")
|
|
print(create_timing_xml('entrance_fade', 1.0))
|
|
return
|
|
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|