Files
wehub-resource-sync 8ef9db2513
Deploy to GitHub Pages / deploy (push) Has been cancelled
Deploy to GitHub Pages / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:56:49 +08:00

1498 lines
54 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - PPTX Animation Module
Provides one strict entrance-animation registry plus OOXML read/write helpers.
Supported transition effects:
- fade: Fade in/out
- push: Push
- wipe: Wipe
- split: Split
- strips: Strips (diagonal wipe)
- cover: Cover
- random: Random
Supported entrance animations (per-element):
appear, fade, fly, cut, zoom, wipe, split, blinds, checkerboard,
dissolve, random_bars, peek, wheel, box, circle, diamond, plus,
strips, wedge, stretch, expand, swivel
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 (zoom / dissolve / circle / box / diamond /
wheel) so multiple images vary across the deck. Other
semantic matches map to a single stable effect
(chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade).
Unmatched ids cycle through a small modern pool
(fade / wipe / fly / zoom).
- 'mixed' — legacy mode: first element fades, the rest cycle through a
larger curated visible pool (kept for backward compatibility).
- 'random' — pick a random effect from the legacy pool 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
"""
import argparse
import hashlib
import math
import random
import re
import zipfile
from dataclasses import dataclass
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 (
MAX_OOXML_MILLISECONDS,
MAX_OOXML_UNSIGNED_INT,
PML_NS,
TRANSITIONS,
create_transition_xml,
validate_seconds,
)
configure_utf8_stdio()
# ============================================================================
# Entrance animation definitions
# ============================================================================
#
# 'filter' values must be valid PowerPoint <p:animEffect filter=".."/> strings
# (see ECMA-376 §19.5.10 ST_TLAnimateEffectTransition / filter dictionary).
# Effects with filter=None render as plain "Appear" (visibility flip only).
#
ANIMATIONS: dict[str, dict[str, Any]] = {
'appear': {'name': 'Appear', 'filter': None, 'presetID': 1, 'presetSubtype': 0},
'fade': {'name': 'Fade', 'filter': 'fade', 'presetID': 10, 'presetSubtype': 0},
'fly': {'name': 'Fly In', 'filter': 'slide(fromBottom)', 'presetID': 2, 'presetSubtype': 4},
'cut': {'name': 'Cut In', 'filter': 'slide(fromLeft)', 'presetID': 42, 'presetSubtype': 8},
'zoom': {'name': 'Zoom', 'filter': 'image', 'presetID': 23, 'presetSubtype': 0},
'wipe': {'name': 'Wipe', 'filter': 'wipe(left)', 'presetID': 22, 'presetSubtype': 1},
'split': {'name': 'Split', 'filter': 'barn(inVertical)', 'presetID': 16, 'presetSubtype': 21},
'blinds': {'name': 'Blinds', 'filter': 'blinds(horizontal)', 'presetID': 3, 'presetSubtype': 10},
'checkerboard': {'name': 'Checkerboard', 'filter': 'checkerboard(across)', 'presetID': 5, 'presetSubtype': 6},
'dissolve': {'name': 'Dissolve', 'filter': 'dissolve', 'presetID': 9, 'presetSubtype': 0},
'random_bars': {'name': 'Random Bars', 'filter': 'randombar(horizontal)', 'presetID': 14, 'presetSubtype': 10},
'peek': {'name': 'Peek', 'filter': 'wipe(down)', 'presetID': 12, 'presetSubtype': 4},
'wheel': {'name': 'Wheel', 'filter': 'wheel(4)', 'presetID': 21, 'presetSubtype': 0},
'box': {'name': 'Box', 'filter': 'box(in)', 'presetID': 4, 'presetSubtype': 0},
'circle': {'name': 'Circle', 'filter': 'circle(in)', 'presetID': 6, 'presetSubtype': 0},
'diamond': {'name': 'Diamond', 'filter': 'diamond(in)', 'presetID': 8, 'presetSubtype': 0},
'plus': {'name': 'Plus', 'filter': 'plus(in)', 'presetID': 13, 'presetSubtype': 0},
'strips': {'name': 'Strips', 'filter': 'strips(downRight)', 'presetID': 18, 'presetSubtype': 12},
'wedge': {'name': 'Wedge', 'filter': 'wedge', 'presetID': 20, 'presetSubtype': 0},
'stretch': {'name': 'Stretch', 'filter': 'stretch(across)', 'presetID': 17, 'presetSubtype': 0},
'expand': {'name': 'Expand', 'filter': 'stretch(across)', 'presetID': 50, 'presetSubtype': 0},
'swivel': {'name': 'Swivel', 'filter': 'wheel(1)', 'presetID': 19, 'presetSubtype': 0},
}
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 entrance-animation request for one PowerPoint shape."""
shape_id: int
delay_ms: int
effect: str
duration_ms: int
@dataclass(frozen=True)
class AnimationRowSummary:
"""Read-back summary for one entrance row in the animation pane."""
shape_id: int
effect: str | None
trigger: str
duration_ms: int
offset_ms: int
preset_id: int
preset_subtype: int
filter_name: str | None
@dataclass(frozen=True)
class AnimationSequenceSummary:
"""Read-back summary for the logical entrance 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 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 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 _normalize_target(target: Sequence[object], default_duration_ms: int) -> AnimationTarget:
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 = normalize_animation_effect(
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,
)
# Pool used by 'mixed' / 'random' modes. Excludes 'appear' because it has no
# visible motion; mixed handles the first title-like element as fade separately.
_MIXED_POOL = [
'blinds', 'checkerboard', 'dissolve', 'fly', 'cut',
'random_bars', 'box', 'split', 'strips', 'wedge', 'wheel',
'wipe', 'expand', 'fade', 'swivel', '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 = ['fade', 'wipe', 'fly', '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 (blinds / checkerboard /
# random_bars / wedge) that would dominate raster content.
_IMAGE_POOL = ['zoom', 'dissolve', 'circle', 'box', 'diamond', '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'), 'fade'),
(('chart', 'table', 'legend', 'timeline', 'track'), 'wipe'),
(('card-', 'pillar-', 'item-', 'step-', 'stage-', 'tier-',
'principle-', 'q-', 'schema-'), 'fly'),
(('takeaway', 'callout', 'quote', 'source', 'conclusion', 'note',
'try-at-home'), '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 = 'fade',
duration: float = 1.0,
delay: float = 0,
shape_id: int = 2
) -> str:
"""
Generate an entrance animation timing XML fragment
Args:
animation: Animation effect name (fade/fly/zoom/appear)
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)
anim_info = ANIMATIONS[animation]
dur_ms = _seconds_to_ms(
duration,
'animation duration',
allow_zero=False,
)
delay_ms = _seconds_to_ms(
delay,
'animation delay',
allow_zero=True,
)
# Generate different effect XML depending on animation type
if anim_info['filter'] is None:
# appear animation: only sets visibility
effect_xml = f''' <p:set>
<p:cBhvr>
<p:cTn id="5" dur="1" fill="hold">
<p:stCondLst><p:cond delay="{delay_ms}"/></p:stCondLst>
</p:cTn>
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst>
</p:cBhvr>
<p:to><p:strVal val="visible"/></p:to>
</p:set>'''
else:
# Other animations: set visibility + animation effect
filter_name = anim_info['filter']
pr_attr = ''
if 'prLst' in anim_info:
pr_attr = f' prLst="{anim_info["prLst"]}"'
effect_xml = f''' <p:set>
<p:cBhvr>
<p:cTn id="5" dur="1" fill="hold">
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
</p:cTn>
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst>
</p:cBhvr>
<p:to><p:strVal val="visible"/></p:to>
</p:set>
<p:animEffect transition="in" filter="{filter_name}"{pr_attr}>
<p:cBhvr>
<p:cTn id="6" dur="{dur_ms}"/>
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
</p:cBhvr>
</p:animEffect>'''
return f''' <p:timing>
<p:tnLst>
<p:par>
<p:cTn id="1" dur="indefinite" nodeType="tmRoot">
<p:childTnLst>
<p:seq concurrent="1" nextAc="none">
<p:cTn id="2" dur="indefinite" nodeType="mainSeq">
<p:childTnLst>
<p:par>
<p:cTn id="3" fill="hold">
<p:stCondLst>
<p:cond delay="{delay_ms}"/>
</p:stCondLst>
<p:childTnLst>
<p:par>
<p:cTn id="4" fill="hold">
<p:childTnLst>
{effect_xml}
</p:childTnLst>
</p:cTn>
</p:par>
</p:childTnLst>
</p:cTn>
</p:par>
</p:childTnLst>
</p:cTn>
</p:seq>
</p:childTnLst>
</p:cTn>
</p:par>
</p:tnLst>
</p:timing>'''
def _build_effect_xml(
animation: str,
shape_id: int,
duration_ms: int,
set_id: int,
eff_id: int,
) -> str:
"""Inner effect block for one target.
Entrance effects are emitted as one animation pane row per target. Plain
Appear uses a visibility set; motion/filter effects use animEffect directly
to avoid duplicate rows for the same shape in PowerPoint.
"""
animation = normalize_animation_effect(
animation,
allow_none=False,
allow_modes=False,
)
shape_id = _positive_shape_id(shape_id)
duration_ms = _non_negative_milliseconds(
duration_ms,
'animation duration_ms',
)
if duration_ms == 0:
raise ValueError('animation duration_ms must be greater than zero')
anim_info = ANIMATIONS[animation]
set_block = f'''<p:set>
<p:cBhvr>
<p:cTn id="{set_id}" dur="1" fill="hold">
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
</p:cTn>
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst>
</p:cBhvr>
<p:to><p:strVal val="visible"/></p:to>
</p:set>'''
if anim_info['filter'] is None:
return set_block
return set_block + f'''
<p:animEffect transition="in" filter="{anim_info["filter"]}">
<p:cBhvr>
<p:cTn id="{eff_id}" dur="{duration_ms}"/>
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
</p:cBhvr>
</p:animEffect>'''
def create_sequence_timing_xml(
targets: list,
duration: float = 0.3,
trigger: str = 'after-previous',
) -> str:
"""Generate a multi-target entrance 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, measured from when the previous element
triggers (only used in ``after-previous`` mode; ignored in
the other two).
duration: per-element entrance duration in seconds.
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)
for target in targets
]
shape_ids = [target.shape_id for target in normalized_targets]
if len(shape_ids) != len(set(shape_ids)):
raise ValueError('animation targets must not contain duplicate shape ids')
next_id = 3
if 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 normalized_targets:
shape_id = target.shape_id
animation = target.effect
item_dur_ms = target.duration_ms
anim_info = ANIMATIONS[animation]
preset_id = anim_info.get('presetID', 1)
preset_subtype = anim_info.get('presetSubtype', 0)
wrapper_id = next_id
inner_id = next_id + 1
leaf_id = next_id + 2
set_id = next_id + 3
eff_id = next_id + 4
next_id += 5
effect_xml = _build_effect_xml(animation, shape_id, item_dur_ms, set_id, eff_id)
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>
<p:cTn id="{leaf_id}" presetID="{preset_id}" presetClass="entr"
presetSubtype="{preset_subtype}" fill="hold" nodeType="clickEffect">
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
<p:childTnLst>
{effect_xml}
</p:childTnLst>
</p:cTn>
</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 for
# each animation row: an outer wrapper owns the timeline offset, while
# the inner effect cTn stays nodeType="afterEffect" with delay="0".
# This keeps the animation pane editable as standard "After Previous"
# rows instead of exposing synthetic per-effect cumulative delays.
outer_id = next_id
next_id += 1
inner_steps = []
with_wrapper_id = None
if trigger == 'with-previous':
with_wrapper_id = next_id
next_id += 1
elapsed_ms = 0
prev_duration_ms = 0
for i, target in enumerate(normalized_targets):
shape_id = target.shape_id
delay_ms = target.delay_ms
animation = target.effect
item_dur_ms = target.duration_ms
anim_info = ANIMATIONS[animation]
preset_id = anim_info.get('presetID', 1)
preset_subtype = anim_info.get('presetSubtype', 0)
if trigger == 'with-previous':
leaf_id = next_id
set_id = next_id + 1
eff_id = next_id + 2
next_id += 3
effect_xml = _build_effect_xml(animation, shape_id, item_dur_ms, set_id, eff_id)
inner_steps.append(f'''<p:par>
<p:cTn id="{leaf_id}" presetID="{preset_id}" presetClass="entr"
presetSubtype="{preset_subtype}" fill="hold" nodeType="withEffect">
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
<p:childTnLst>
{effect_xml}
</p:childTnLst>
</p:cTn>
</p:par>''')
else:
if i == 0:
elapsed_ms = int(delay_ms)
else:
elapsed_ms += prev_duration_ms + int(delay_ms)
if elapsed_ms > MAX_OOXML_MILLISECONDS:
raise ValueError(
'animation sequence offset exceeds the OOXML '
f'millisecond limit at target {i + 1}: {elapsed_ms}'
)
wrapper_id = next_id
leaf_id = next_id + 1
set_id = next_id + 2
eff_id = next_id + 3
next_id += 4
effect_xml = _build_effect_xml(animation, shape_id, item_dur_ms, set_id, eff_id)
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>
<p:cTn id="{leaf_id}" presetID="{preset_id}" presetClass="entr"
presetSubtype="{preset_subtype}" fill="hold" nodeType="afterEffect">
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
<p:childTnLst>
{effect_xml}
</p:childTnLst>
</p:cTn>
</p:par>
</p:childTnLst>
</p:cTn>
</p:par>''')
prev_duration_ms = item_dur_ms
inner_xml = '\n '.join(inner_steps)
if 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 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>'''
return f''' <p:timing>
<p:tnLst>
<p:par>
<p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot">
<p:childTnLst>
<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>
</p:childTnLst>
</p:cTn>
</p:par>
</p:tnLst>
</p:timing>'''
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``
(zoom / dissolve / circle / box / diamond / wheel) by ``idx + offset``
so multiple images vary across the deck. Other semantic matches in
``_SEMANTIC_PATTERNS`` return a single stable effect (chart→wipe,
card-/step-/pillar-→fly, title/takeaway→fade). When the id matches no
pattern, cycle through ``_AUTO_POOL`` (fade / wipe / fly / zoom).
- 'mixed' (legacy): first element fixed to 'fade', rest cycle through
``_MIXED_POOL`` plus ``offset`` (so titles stay calm while content varies
across slides). Kept for backward compatibility with existing CLI flags
and animations.json sidecars.
- 'random' (legacy): uniform random choice from ``_MIXED_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 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 '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 entrance row must resolve to exactly one shape id; '
f'found {unique or "none"}'
)
return None
return unique[0]
def _row_filter(row: ET.Element, errors: list[str]) -> str | None:
effects = list(row.iter(_qn(PML_NS, 'animEffect')))
if len(effects) > 1:
errors.append(f'entrance row contains {len(effects)} p:animEffect nodes')
if not effects:
return None
effect = effects[0]
if effect.get('transition') != 'in':
errors.append('entrance p:animEffect must set transition="in"')
return effect.get('filter')
def _resolve_row_effect(
row: ET.Element,
filter_name: str | None,
errors: list[str],
) -> tuple[str | None, int | None, int | None]:
preset_id = _int_attribute(
row,
'presetID',
'entrance p:cTn@presetID',
errors,
maximum=MAX_OOXML_UNSIGNED_INT,
)
preset_subtype = _int_attribute(
row,
'presetSubtype',
'entrance p:cTn@presetSubtype',
errors,
maximum=MAX_OOXML_UNSIGNED_INT,
)
if preset_id is None or preset_subtype is None:
return None, preset_id, preset_subtype
matches = [
key
for key, info in ANIMATIONS.items()
if int(info['presetID']) == preset_id
and int(info['presetSubtype']) == preset_subtype
and info['filter'] == filter_name
]
if len(matches) == 1:
return matches[0], preset_id, preset_subtype
return None, preset_id, preset_subtype
def _behavior_duration_ms(
row: ET.Element,
filter_name: str | None,
errors: list[str],
) -> int:
if filter_name is None:
behaviors = list(row.iter(_qn(PML_NS, 'set')))
else:
behaviors = list(row.iter(_qn(PML_NS, 'animEffect')))
if len(behaviors) != 1:
errors.append(
'entrance row must contain exactly one primary behavior; '
f'found {len(behaviors)}'
)
return 0
common_behavior = behaviors[0].find(_qn(PML_NS, 'cBhvr'))
duration_node = (
common_behavior.find(_qn(PML_NS, 'cTn'))
if common_behavior is not None
else None
)
if duration_node is None:
errors.append('entrance behavior is missing p:cBhvr/p:cTn')
return 0
duration = _int_attribute(
duration_node,
'dur',
'entrance behavior duration',
errors,
minimum=1,
maximum=MAX_OOXML_MILLISECONDS,
)
return duration or 0
def _row_offset_ms(
row: ET.Element,
trigger: str,
parent_map: Mapping[ET.Element, ET.Element],
errors: list[str],
) -> int:
leaf_conditions = _direct_conditions(row)
if len(leaf_conditions) != 1 or leaf_conditions[0].get('delay') != '0':
errors.append('entrance row must have one leaf start condition with delay="0"')
current = parent_map.get(row)
saw_indefinite = False
saw_main_begin = 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 any(
condition.get('evt') == 'onBegin'
and condition.get('delay') == '0'
and any(
target.get('val') == '2'
for target in condition.iter(_qn(PML_NS, 'tn'))
)
for condition in conditions
):
saw_main_begin = 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 not saw_indefinite:
errors.append('on-click entrance row is missing an indefinite click wrapper')
if trigger in {'with-previous', 'after-previous'} and not (
saw_indefinite and saw_main_begin
):
errors.append(f'{trigger} sequence is missing the slide-entry onBegin anchor')
if trigger == 'after-previous' and numeric_offset is None:
errors.append('after-previous entrance row is missing its numeric offset wrapper')
if trigger == 'after-previous':
return numeric_offset or 0
return 0
def _animation_rows(
slide_root: ET.Element,
errors: list[str],
) -> 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')):
if row.get('presetClass') != 'entr':
continue
node_type = row.get('nodeType')
trigger = _NODE_TYPE_TRIGGERS.get(node_type or '')
if trigger is None:
errors.append(
f'unsupported entrance nodeType {node_type!r}; expected '
f'{", ".join(_NODE_TYPE_TRIGGERS)}'
)
continue
shape_id = _row_shape_id(row, errors)
filter_name = _row_filter(row, errors)
effect, preset_id, preset_subtype = _resolve_row_effect(
row,
filter_name,
errors,
)
duration_ms = _behavior_duration_ms(row, filter_name, errors)
offset_ms = _row_offset_ms(row, trigger, parent_map, errors)
if shape_id is None or preset_id is None or preset_subtype is None:
continue
rows.append(
AnimationRowSummary(
shape_id=shape_id,
effect=effect,
trigger=trigger,
duration_ms=duration_ms,
offset_ms=offset_ms,
preset_id=preset_id,
preset_subtype=preset_subtype,
filter_name=filter_name,
)
)
return rows
def validate_slide_animation_structure(
slide_root: ET.Element,
*,
require_supported_effects: bool = False,
) -> list[str]:
"""Return root timing, target, and generated-entrance structure errors."""
errors: list[str] = []
if slide_root.tag != _qn(PML_NS, 'sld'):
return ['animation validation requires a PresentationML p:sld 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')
ctn_ids: list[int] = []
for ctn in timing.iter(_qn(PML_NS, 'cTn')):
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')
entrance_nodes = [
node for node in timing.iter(_qn(PML_NS, 'cTn'))
if node.get('presetClass') == 'entr'
]
if entrance_nodes and require_supported_effects:
main_sequences = [
node for node in timing.iter(_qn(PML_NS, 'cTn'))
if node.get('nodeType') == 'mainSeq'
]
if len(main_sequences) != 1:
errors.append(
'slides with entrance rows must contain exactly one mainSeq time node'
)
if require_supported_effects:
rows = _animation_rows(slide_root, errors)
if not rows and entrance_nodes:
errors.append('generated entrance rows could not be read back')
else:
rows = []
if rows:
triggers = {row.trigger for row in rows}
if len(triggers) != 1:
errors.append(
'one generated entrance sequence must use one Start mode; found '
+ ', '.join(sorted(triggers))
)
row_shape_ids = [row.shape_id for row in rows]
if len(row_shape_ids) != len(set(row_shape_ids)):
errors.append('generated entrance sequence repeats a shape target')
for row in rows:
if row.effect is None:
errors.append(
'unsupported entrance effect tuple for shape '
f'{row.shape_id}: 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 entrance sequence from one 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
errors = validate_slide_animation_structure(
root,
require_supported_effects=require_supported_effects,
)
row_errors: list[str] = []
rows = _animation_rows(root, row_errors)
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))
trigger = rows[0].trigger if rows 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]],
*,
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,
)
expected = tuple(
_normalize_target(target, default_duration_ms)
for target in targets
)
summary = read_slide_animation_sequence(
slide_xml,
require_supported_effects=True,
)
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)}'
)
if expected and summary.trigger != trigger:
errors.append(
f'animation read-back trigger is {summary.trigger!r}; expected {trigger!r}'
)
expected_offsets: list[int] = []
elapsed_ms = 0
previous_duration_ms = 0
for index, target in enumerate(expected):
if trigger == 'after-previous':
if index == 0:
elapsed_ms = target.delay_ms
else:
elapsed_ms += previous_duration_ms + target.delay_ms
if elapsed_ms > MAX_OOXML_MILLISECONDS:
errors.append(
'requested animation sequence offset exceeds the OOXML '
f'millisecond limit at row {index + 1}: {elapsed_ms}'
)
expected_offsets.append(elapsed_ms)
previous_duration_ms = target.duration_ms
else:
expected_offsets.append(0)
for index, (actual, target) in enumerate(zip(summary.rows, expected), 1):
spec = ANIMATIONS[target.effect]
if actual.shape_id != target.shape_id:
errors.append(
f'animation row {index} targets shape {actual.shape_id}; '
f'expected {target.shape_id}'
)
if actual.effect != target.effect:
errors.append(
f'animation row {index} resolved effect {actual.effect!r}; '
f'expected {target.effect!r}'
)
if actual.preset_id != int(spec['presetID']):
errors.append(f'animation row {index} presetID changed')
if actual.preset_subtype != int(spec['presetSubtype']):
errors.append(f'animation row {index} presetSubtype changed')
if actual.filter_name != spec['filter']:
errors.append(f'animation row {index} filter changed')
expected_duration = 1 if target.effect == 'appear' else target.duration_ms
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'
)
if errors:
raise ValueError('; '.join(errors))
return summary
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:
try:
root = ET.fromstring(package.read(name))
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}')
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
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 a list of all available transition effects"""
return list(TRANSITIONS.keys())
def get_available_animations() -> list:
"""Get a list of all available entrance animations"""
return list(ANIMATIONS.keys())
def get_transition_help() -> str:
"""Get help text for transition effects"""
lines = ["Available transition effects:"]
for key, info in TRANSITIONS.items():
lines.append(f" {key}: {info['name']}")
return '\n'.join(lines)
def get_animation_help() -> str:
"""Get help text for entrance animations"""
lines = ["Available entrance animations:"]
for key, info in ANIMATIONS.items():
lines.append(f" {key}: {info['name']}")
return '\n'.join(lines)
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 animation")
parser.add_argument("--list", action="store_true", help="list available transitions and entrance animations")
args = parser.parse_args()
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 (fade) ===")
print(create_timing_xml('fade', 1.0))
return
parser.print_help()
if __name__ == '__main__':
main()