#!/usr/bin/env python3
"""
PPT Master - PPTX Transition Core
Provide one strict PowerPoint-native transition registry, a compatibility input
map, and shared OOXML read/write helpers for generated slides, template-filled
PPTX files, and native PPTX enhancement.
See references/animations.md for the public workflow and
scripts/docs/pptx-transitions.md for the OOXML contract.
Usage:
Import from PPT Master PPTX builders and direct-package workflows.
Examples:
from pptx_transitions import AdvanceUpdate, EnterUpdate, apply_slide_motion
Dependencies:
lxml (preferred for prefix-preserving source-PPTX mutation)
"""
from __future__ import annotations
import io
import math
import posixpath
import re
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Mapping, MutableMapping
from xml.etree import ElementTree as ET
from xml.sax.saxutils import quoteattr
try:
from lxml import etree as LET
except ImportError:
LET = None
PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main"
P15_NS = "http://schemas.microsoft.com/office/powerpoint/2012/main"
P159_NS = "http://schemas.microsoft.com/office/powerpoint/2015/09/main"
MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
RELATIONSHIPS_NS = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships"
)
PRESENTATION_PROPS_PART = "ppt/presProps.xml"
PRESENTATION_RELS_PART = "ppt/_rels/presentation.xml.rels"
CONTENT_TYPES_PART = "[Content_Types].xml"
PRESENTATION_PROPS_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps"
)
PRESENTATION_PROPS_CONTENT_TYPE = (
"application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"
)
AUDIO_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
)
WAV_CONTENT_TYPES = frozenset({"audio/wav", "audio/x-wav"})
DEFAULT_TRANSITION = "fade"
DEFAULT_TRANSITION_DURATION = 0.4
MAX_OOXML_MILLISECONDS = 4_294_967_295
# OOXML stores millisecond values and identifiers (shape ids, time-node ids,
# preset codes) as xsd:unsignedInt, so both ceilings share one value.
MAX_OOXML_UNSIGNED_INT = MAX_OOXML_MILLISECONDS
_TRANSITION_SPECS: dict[str, dict[str, Any]] = {
# PowerPoint-native public ordering is assembled by gallery category below.
"fade": {
"name": "Fade",
"element": "fade",
"attrs": {},
},
"push": {
"name": "Push",
"element": "push",
"attrs": {"dir": "r"},
},
"wipe": {
"name": "Wipe",
"element": "wipe",
"attrs": {"dir": "r"},
},
"split": {
"name": "Split",
"element": "split",
"attrs": {"orient": "horz", "dir": "out"},
},
"cover": {
"name": "Cover",
"element": "cover",
"attrs": {"dir": "r"},
},
"random": {
"name": "Random",
"element": "random",
"attrs": {},
},
"blinds": {
"name": "Blinds",
"element": "blinds",
"attrs": {"dir": "vert"},
},
"checkerboard": {
"name": "Checkerboard",
"element": "checker",
"attrs": {"dir": "horz"},
},
"comb": {
"name": "Comb",
"element": "comb",
"attrs": {"dir": "horz"},
},
"cut": {
"name": "Cut",
"element": "cut",
"attrs": {"thruBlk": "0"},
},
"dissolve": {
"name": "Dissolve",
"element": "dissolve",
"attrs": {},
},
"random_bars": {
"name": "Random Bars",
"element": "randomBar",
"attrs": {"dir": "vert"},
},
"zoom": {
"name": "Zoom",
"prefix": "p14",
"element": "warp",
"attrs": {"dir": "in"},
"fallback": "fade",
},
# Current PowerPoint transition gallery: Subtle.
"morph": {
"name": "Morph",
"prefix": "p159",
"element": "morph",
"attrs": {"option": "byObject"},
"fallback": "fade",
},
"reveal": {
"name": "Reveal",
"prefix": "p14",
"element": "reveal",
"attrs": {"dir": "r"},
"fallback": "fade",
},
"shape": {
"name": "Shape",
"element": "circle",
"attrs": {},
},
"uncover": {
"name": "Uncover",
"element": "pull",
"attrs": {"dir": "r"},
},
"flash": {
"name": "Flash",
"prefix": "p14",
"element": "flash",
"attrs": {},
"fallback": "fade",
},
# Current PowerPoint transition gallery: Exciting.
"fall_over": {
"name": "Fall Over",
"prefix": "p15",
"element": "prstTrans",
"attrs": {"prst": "fallOver", "invX": "1"},
"fallback": "fade",
},
"drape": {
"name": "Drape",
"prefix": "p15",
"element": "prstTrans",
"attrs": {"prst": "drape", "invX": "1"},
"fallback": "fade",
},
"curtains": {
"name": "Curtains",
"prefix": "p15",
"element": "prstTrans",
"attrs": {"prst": "curtains"},
"fallback": "fade",
},
"wind": {
"name": "Wind",
"prefix": "p15",
"element": "prstTrans",
"attrs": {"prst": "wind"},
"fallback": "fade",
},
"prestige": {
"name": "Prestige",
"prefix": "p15",
"element": "prstTrans",
"attrs": {"prst": "prestige"},
"fallback": "fade",
},
"fracture": {
"name": "Fracture",
"prefix": "p15",
"element": "prstTrans",
"attrs": {"prst": "fracture"},
"fallback": "fade",
},
"crush": {
"name": "Crush",
"prefix": "p15",
"element": "prstTrans",
"attrs": {"prst": "crush"},
"fallback": "fade",
},
"peel_off": {
"name": "Peel Off",
"prefix": "p15",
"element": "prstTrans",
"attrs": {"prst": "peelOff", "invX": "1"},
"fallback": "fade",
},
"page_curl": {
"name": "Page Curl",
"prefix": "p15",
"element": "prstTrans",
"attrs": {"prst": "pageCurlSingle", "invX": "1"},
"fallback": "fade",
},
"airplane": {
"name": "Airplane",
"prefix": "p15",
"element": "prstTrans",
"attrs": {"prst": "airplane"},
"fallback": "fade",
},
"origami": {
"name": "Origami",
"prefix": "p15",
"element": "prstTrans",
"attrs": {"prst": "origami"},
"fallback": "fade",
},
"clock": {
"name": "Clock",
"element": "wheel",
"attrs": {"spokes": "1"},
},
"ripple": {
"name": "Ripple",
"prefix": "p14",
"element": "ripple",
"attrs": {},
"fallback": "fade",
},
"honeycomb": {
"name": "Honeycomb",
"prefix": "p14",
"element": "honeycomb",
"attrs": {},
"fallback": "fade",
},
"glitter": {
"name": "Glitter",
"prefix": "p14",
"element": "glitter",
"attrs": {},
"fallback": "fade",
},
"vortex": {
"name": "Vortex",
"prefix": "p14",
"element": "vortex",
"attrs": {"dir": "r"},
"fallback": "fade",
},
"shred": {
"name": "Shred",
"prefix": "p14",
"element": "shred",
"attrs": {"dir": "out"},
"fallback": "fade",
},
"switch": {
"name": "Switch",
"prefix": "p14",
"element": "switch",
"attrs": {"dir": "r"},
"fallback": "fade",
},
"flip": {
"name": "Flip",
"prefix": "p14",
"element": "flip",
"attrs": {"dir": "r"},
"fallback": "fade",
},
"gallery": {
"name": "Gallery",
"prefix": "p14",
"element": "gallery",
"attrs": {"dir": "r"},
"fallback": "fade",
},
"cube": {
"name": "Cube",
"prefix": "p14",
"element": "prism",
"attrs": {"dir": "r"},
"fallback": "fade",
},
"doors": {
"name": "Doors",
"prefix": "p14",
"element": "doors",
"attrs": {"dir": "vert"},
"fallback": "fade",
},
"box": {
"name": "Box",
"element": "zoom",
"attrs": {},
},
# Current PowerPoint transition gallery: Dynamic Content.
"pan": {
"name": "Pan",
"prefix": "p14",
"element": "pan",
"attrs": {"dir": "r"},
"fallback": "fade",
},
"ferris_wheel": {
"name": "Ferris Wheel",
"prefix": "p14",
"element": "ferris",
"attrs": {"dir": "r"},
"fallback": "fade",
},
"conveyor": {
"name": "Conveyor",
"prefix": "p14",
"element": "conveyor",
"attrs": {"dir": "r"},
"fallback": "fade",
},
"rotate": {
"name": "Rotate",
"prefix": "p14",
"element": "prism",
"attrs": {"dir": "r", "isContent": "1"},
"fallback": "fade",
},
"window": {
"name": "Window",
"prefix": "p14",
"element": "window",
"attrs": {},
"fallback": "fade",
},
"orbit": {
"name": "Orbit",
"prefix": "p14",
"element": "prism",
"attrs": {"dir": "r", "isContent": "1", "isInverted": "1"},
"fallback": "fade",
},
"fly_through": {
"name": "Fly Through",
"prefix": "p14",
"element": "flythrough",
"attrs": {},
"fallback": "fade",
},
}
TRANSITION_CATEGORIES = ("subtle", "exciting", "dynamic_content")
_TRANSITION_KEYS_BY_CATEGORY = {
"subtle": (
"morph",
"fade",
"push",
"wipe",
"split",
"reveal",
"cut",
"random_bars",
"shape",
"uncover",
"cover",
"flash",
),
"exciting": (
"fall_over",
"drape",
"curtains",
"wind",
"prestige",
"fracture",
"crush",
"peel_off",
"page_curl",
"airplane",
"origami",
"dissolve",
"checkerboard",
"blinds",
"clock",
"ripple",
"honeycomb",
"glitter",
"vortex",
"shred",
"switch",
"flip",
"gallery",
"cube",
"doors",
"box",
"comb",
"zoom",
"random",
),
"dynamic_content": (
"pan",
"ferris_wheel",
"conveyor",
"rotate",
"window",
"orbit",
"fly_through",
),
}
TRANSITION_EFFECT_OPTION_FIELDS = (
"direction",
"orientation",
"style",
"shape",
"pattern",
"origin",
"pages",
"morph_by",
"through_black",
"bounce",
)
def _enum_option(
default: str,
values: Mapping[str, Mapping[str, Any]],
) -> dict[str, Any]:
return {
"type": "enum",
"default": default,
"values": dict(values),
}
def _attribute_enum(
default: str,
attribute: str,
values: Mapping[str, str | None],
) -> dict[str, Any]:
overrides: dict[str, dict[str, Any]] = {}
for name, value in values.items():
if value is None:
overrides[name] = {"remove_attrs": (attribute,)}
else:
overrides[name] = {"attrs": {attribute: value}}
return _enum_option(default, overrides)
def _boolean_option(
default: bool,
attribute: str,
) -> dict[str, Any]:
return {
"type": "boolean",
"default": default,
"values": {
False: {"remove_attrs": (attribute,)},
True: {"attrs": {attribute: "1"}},
},
}
_CARDINAL_DIRECTIONS = {
"left": None,
"right": "r",
"up": "u",
"down": "d",
}
_CORNER_DIRECTIONS = {
**_CARDINAL_DIRECTIONS,
"up_left": "lu",
"up_right": "ru",
"down_left": "ld",
"down_right": "rd",
}
_TRANSITION_EFFECT_OPTIONS: dict[str, dict[str, dict[str, Any]]] = {
"morph": {
"morph_by": _attribute_enum(
"object",
"option",
{
"object": "byObject",
"word": "byWord",
"character": "byChar",
},
),
},
"fade": {
"style": _attribute_enum(
"smoothly",
"thruBlk",
{"smoothly": None, "through_black": "1"},
),
},
"push": {
"direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
},
"wipe": {
"direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
},
"split": {
"orientation": _attribute_enum(
"horizontal",
"orient",
{"horizontal": None, "vertical": "vert"},
),
"direction": _attribute_enum(
"out",
"dir",
{"in": "in", "out": None},
),
},
"reveal": {
"direction": _attribute_enum(
"right",
"dir",
{"left": None, "right": "r"},
),
"through_black": _boolean_option(False, "thruBlk"),
},
"cut": {
"through_black": {
"type": "boolean",
"default": False,
"values": {
False: {"remove_attrs": ("thruBlk",)},
True: {"attrs": {"thruBlk": "1"}},
},
},
},
"random_bars": {
"orientation": _attribute_enum(
"vertical",
"dir",
{"horizontal": None, "vertical": "vert"},
),
},
"shape": {
"shape": _enum_option(
"circle",
{
"circle": {"element": "circle"},
"diamond": {"element": "diamond"},
"plus": {"element": "plus"},
},
),
},
"uncover": {
"direction": _attribute_enum("right", "dir", _CORNER_DIRECTIONS),
},
"cover": {
"direction": _attribute_enum("right", "dir", _CORNER_DIRECTIONS),
},
"fall_over": {
"direction": _attribute_enum(
"right",
"invX",
{"left": None, "right": "1"},
),
},
"drape": {
"direction": _attribute_enum(
"right",
"invX",
{"left": None, "right": "1"},
),
},
"wind": {
"direction": _attribute_enum(
"right",
"invX",
{"left": "1", "right": None},
),
},
"peel_off": {
"direction": _attribute_enum(
"right",
"invX",
{"left": None, "right": "1"},
),
},
"page_curl": {
"direction": _attribute_enum(
"right",
"invX",
{"left": None, "right": "1"},
),
"pages": _attribute_enum(
"single",
"prst",
{"single": "pageCurlSingle", "double": "pageCurlDouble"},
),
},
"airplane": {
"direction": _attribute_enum(
"right",
"invX",
{"left": "1", "right": None},
),
},
"origami": {
"direction": _attribute_enum(
"right",
"invX",
{"left": "1", "right": None},
),
},
"checkerboard": {
"direction": _attribute_enum(
"across",
"dir",
{"across": None, "down": "vert"},
),
},
"blinds": {
"orientation": _attribute_enum(
"vertical",
"dir",
{"horizontal": None, "vertical": "vert"},
),
},
"clock": {
"style": _enum_option(
"clockwise",
{
"clockwise": {
"element": "wheel",
"attrs": {"spokes": "1"},
},
"counterclockwise": {
"prefix": "p14",
"element": "wheelReverse",
"attrs": {"spokes": "1"},
"fallback": "fade",
},
"wedge": {
"element": "wedge",
"remove_attrs": ("spokes",),
},
},
),
},
"ripple": {
"origin": _attribute_enum(
"center",
"dir",
{
"center": None,
"up_left": "lu",
"up_right": "ru",
"down_left": "ld",
"down_right": "rd",
},
),
},
"glitter": {
"shape": _attribute_enum(
"diamond",
"pattern",
{"diamond": None, "hexagon": "hexagon"},
),
"direction": _attribute_enum(
"right",
"dir",
{
"left": "r",
"right": None,
"up": "d",
"down": "u",
},
),
},
"vortex": {
"direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
},
"shred": {
"pattern": _attribute_enum(
"strips",
"pattern",
{"strips": None, "rectangle": "rectangle"},
),
"direction": _attribute_enum(
"out",
"dir",
{"in": None, "out": "out"},
),
},
"switch": {
"direction": _attribute_enum(
"right",
"dir",
{"left": "l", "right": "r"},
),
},
"flip": {
"direction": _attribute_enum(
"right",
"dir",
{"left": "l", "right": "r"},
),
},
"gallery": {
"direction": _attribute_enum(
"right",
"dir",
{"left": "l", "right": "r"},
),
},
"cube": {
"direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
},
"doors": {
"orientation": _attribute_enum(
"vertical",
"dir",
{"horizontal": None, "vertical": "vert"},
),
},
"box": {
"direction": _attribute_enum(
"out",
"dir",
{"in": "in", "out": None},
),
},
"comb": {
"orientation": _attribute_enum(
"horizontal",
"dir",
{"horizontal": None, "vertical": "vert"},
),
},
"zoom": {
"direction": _attribute_enum(
"in",
"dir",
{"in": "in", "out": None},
),
},
"pan": {
"direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
},
"ferris_wheel": {
"direction": _attribute_enum(
"right",
"dir",
{"left": "l", "right": "r"},
),
},
"conveyor": {
"direction": _attribute_enum(
"right",
"dir",
{"left": "l", "right": "r"},
),
},
"rotate": {
"direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
},
"window": {
"orientation": _attribute_enum(
"horizontal",
"dir",
{"horizontal": None, "vertical": "vert"},
),
},
"orbit": {
"direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
},
"fly_through": {
"direction": _attribute_enum(
"in",
"dir",
{"in": None, "out": "out"},
),
"bounce": _boolean_option(False, "hasBounce"),
},
}
NATIVE_TRANSITIONS: dict[str, dict[str, Any]] = {}
for _category in TRANSITION_CATEGORIES:
for _key in _TRANSITION_KEYS_BY_CATEGORY[_category]:
if _key in NATIVE_TRANSITIONS:
raise RuntimeError(f"duplicate native transition key: {_key}")
_spec = dict(_TRANSITION_SPECS[_key])
_spec["category"] = _category
_spec["effectOptions"] = _TRANSITION_EFFECT_OPTIONS.get(_key, {})
NATIVE_TRANSITIONS[_key] = _spec
if set(NATIVE_TRANSITIONS) != set(_TRANSITION_SPECS):
raise RuntimeError("native transition gallery categories are incomplete")
if len(NATIVE_TRANSITIONS) != 48:
raise RuntimeError(
f"native transition gallery count changed: {len(NATIVE_TRANSITIONS)}"
)
for _key, _spec in NATIVE_TRANSITIONS.items():
_options = _spec["effectOptions"]
_unknown_options = set(_options) - set(TRANSITION_EFFECT_OPTION_FIELDS)
if _unknown_options:
raise RuntimeError(
f"native transition {_key!r} has unknown effect option(s): "
+ ", ".join(sorted(_unknown_options))
)
for _option_name, _option_spec in _options.items():
if _option_spec.get("type") not in {"enum", "boolean"}:
raise RuntimeError(
f"native transition {_key!r} option {_option_name!r} "
"must be enum or boolean"
)
_values = _option_spec.get("values")
if not isinstance(_values, dict) or not _values:
raise RuntimeError(
f"native transition {_key!r} option {_option_name!r} "
"must define values"
)
if _option_spec.get("default") not in _values:
raise RuntimeError(
f"native transition {_key!r} option {_option_name!r} "
"has an unknown default"
)
NATIVE_TRANSITION_KEYS = tuple(NATIVE_TRANSITIONS)
# Retained as a module-level compatibility name for existing imports. New code
# should use ``NATIVE_TRANSITIONS`` to distinguish the native registry from
# accepted legacy input names.
CANONICAL_TRANSITIONS = NATIVE_TRANSITIONS
TRANSITION_ALIASES: dict[str, str] = {
"strips": "wipe",
"circle": "shape",
"diamond": "shape",
"newsflash": "flash",
"plus": "shape",
"pull": "uncover",
"wedge": "clock",
"wheel": "clock",
}
LEGACY_TRANSITION_KEYS = tuple(TRANSITION_ALIASES)
TRANSITION_ALIAS_OPTIONS: dict[str, dict[str, object]] = {
"strips": {"direction": "right"},
"circle": {"shape": "circle"},
"diamond": {"shape": "diamond"},
"plus": {"shape": "plus"},
"wedge": {"style": "wedge"},
"wheel": {"style": "clockwise"},
}
TRANSITIONS: dict[str, dict[str, Any]] = dict(NATIVE_TRANSITIONS)
for _alias, _canonical_key in TRANSITION_ALIASES.items():
TRANSITIONS[_alias] = NATIVE_TRANSITIONS[_canonical_key]
TRANSITION_NAMESPACES = {
"p": PML_NS,
"p14": P14_NS,
"p15": P15_NS,
"p159": P159_NS,
}
for _prefix, _uri in (
*TRANSITION_NAMESPACES.items(),
("mc", MC_NS),
("r", RELATIONSHIPS_NS),
):
try:
ET.register_namespace(_prefix, _uri)
except (AttributeError, ValueError):
pass
@dataclass(frozen=True)
class EnterUpdate:
"""Describe how the current slide's visual transition enters."""
policy: str = "replace"
effect: str | None = DEFAULT_TRANSITION
duration: float = DEFAULT_TRANSITION_DURATION
effect_options: Mapping[str, object] | None = None
@dataclass(frozen=True)
class AdvanceUpdate:
"""Describe how the current slide advances to the next slide."""
mode: str = "preserve"
after: float | None = None
@dataclass(frozen=True)
class TransitionSummary:
"""Read-back summary of one slide's logical transition slot."""
carrier: str
logical_count: int
effect: str | None = None
effect_namespace: str | None = None
fallback_effect: str | None = None
fallback_effect_namespace: str | None = None
duration_ms: int | None = None
speed: str | None = None
advance_on_click: bool | None = None
advance_after_ms: int | None = None
effect_attributes: Mapping[str, str] = field(default_factory=dict)
canonical_effect: str | None = None
effect_options: Mapping[str, object] = field(default_factory=dict)
sound_relationship_id: str | None = None
sound_name: str | None = None
fallback_sound_relationship_id: str | None = None
fallback_sound_name: str | None = None
@dataclass(frozen=True)
class MorphPairExpectation:
"""One forced-Morph name expected on two adjacent generated slides."""
source_slide_number: int
destination_slide_number: int
key: str
@property
def shape_name(self) -> str:
return f"!!{self.key}"
def _qn(namespace: str, tag: str) -> str:
return f"{{{namespace}}}{tag}"
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
def _namespace_name(tag: str) -> str | None:
if tag.startswith("{") and "}" in tag:
return tag[1:].split("}", 1)[0]
return None
def _value_repr(value: object) -> str:
try:
return repr(value)
except Exception:
return f"<{type(value).__name__}>"
def validate_seconds(
value: object,
field: str,
*,
allow_zero: bool,
) -> float:
"""Return a finite seconds value or raise a field-specific error."""
display_value = _value_repr(value)
if isinstance(value, bool):
raise ValueError(f"{field} must be a finite number, not {display_value}")
try:
number = float(value)
except (TypeError, ValueError, OverflowError) as exc:
raise ValueError(
f"{field} must be a finite number: {display_value}"
) from exc
if not math.isfinite(number):
raise ValueError(f"{field} must be finite: {display_value}")
if allow_zero:
if number < 0:
raise ValueError(f"{field} must be non-negative: {display_value}")
elif number <= 0:
raise ValueError(f"{field} must be greater than zero: {display_value}")
return number
def normalize_transition_effect(effect: object, *, allow_none: bool = True) -> str | None:
"""Return one canonical PowerPoint transition or explicit no-effect."""
if effect is None or effect == "none":
if allow_none:
return None
raise ValueError("transition effect is required")
if not isinstance(effect, str):
raise ValueError(f"transition effect must be a string: {effect!r}")
if effect in TRANSITION_ALIASES:
return TRANSITION_ALIASES[effect]
if effect in NATIVE_TRANSITIONS:
return effect
valid = ", ".join((*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS))
raise ValueError(
f"unknown transition effect {effect!r}; valid effects: {valid}, none"
)
def normalize_transition_effect_options(
effect: str,
options: object = None,
) -> dict[str, object]:
"""Validate PowerPoint Effect Options for one native transition."""
if effect not in NATIVE_TRANSITIONS:
if options in (None, {}):
return {}
raise ValueError(
"transition effect_options require one explicit native effect; "
f"found {effect!r}"
)
if options is None:
options = {}
if not isinstance(options, Mapping):
raise ValueError(
f"transition effect_options must be an object: {options!r}"
)
option_specs = NATIVE_TRANSITIONS[effect]["effectOptions"]
unknown = set(options) - set(option_specs)
if unknown:
unsupported = ", ".join(sorted(unknown))
supported = ", ".join(option_specs) or "(none)"
raise ValueError(
f"transition effect {effect!r} does not support effect option(s): "
f"{unsupported}; supported options: {supported}"
)
normalized: dict[str, object] = {}
for name, value in options.items():
spec = option_specs[name]
field = f"transition effect_options.{name}"
if spec["type"] == "enum":
if not isinstance(value, str) or value not in spec["values"]:
valid = ", ".join(spec["values"])
raise ValueError(
f"{field} for {effect!r} must be one of {valid}: {value!r}"
)
normalized[name] = value
elif spec["type"] == "boolean":
if not isinstance(value, bool):
raise ValueError(f"{field} must be a boolean: {value!r}")
normalized[name] = value
else:
raise AssertionError(
f"unhandled transition option type: {spec['type']!r}"
)
return normalized
def normalize_transition_effect_request(
effect: object,
options: object = None,
*,
allow_none: bool = True,
) -> tuple[str | None, dict[str, object]]:
"""Normalize one native effect plus options and legacy semantic aliases."""
raw_effect = effect
canonical = normalize_transition_effect(effect, allow_none=allow_none)
alias_options = (
TRANSITION_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"transition 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 transition 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:
if merged:
raise ValueError(
"transition effect_options require one explicit native effect; "
"found 'none'"
)
return None, {}
return canonical, normalize_transition_effect_options(canonical, merged)
def describe_transition_effect(effect: object) -> dict[str, Any]:
"""Return the author-facing parameter contract for one transition."""
canonical, implied_options = normalize_transition_effect_request(
effect,
allow_none=False,
)
option_contract: dict[str, Any] = {}
for name, raw_spec in NATIVE_TRANSITIONS[canonical]["effectOptions"].items():
option_contract[name] = {
"type": raw_spec["type"],
"default": raw_spec["default"],
"values": list(raw_spec["values"]),
}
return {
"input": effect,
"effect": canonical,
"name": NATIVE_TRANSITIONS[canonical]["name"],
"category": NATIVE_TRANSITIONS[canonical]["category"],
"compatibility_alias": (
effect
if isinstance(effect, str) and effect in TRANSITION_ALIASES
else None
),
"implied_effect_options": implied_options,
"effect_options": option_contract,
"timing": {
"duration": "positive seconds",
"auto_advance": "non-negative seconds",
},
"sound": "project-relative .wav path or null in animations.json",
}
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 _effect_spec(
effect: str,
effect_options: object = None,
) -> tuple[str, str, str, dict[str, Any], str | None]:
info = NATIVE_TRANSITIONS[effect]
prefix = str(info.get("prefix", "p"))
namespace = TRANSITION_NAMESPACES[prefix]
element = str(info["element"])
attrs = dict(info.get("attrs", {}))
fallback = info.get("fallback")
options = {
name: option_spec["default"]
for name, option_spec in info["effectOptions"].items()
}
options.update(
normalize_transition_effect_options(effect, effect_options)
)
option_specs = info["effectOptions"]
for name, value in options.items():
override = option_specs[name]["values"][value]
if "prefix" in override:
prefix = str(override["prefix"])
namespace = TRANSITION_NAMESPACES[prefix]
if "element" in override:
element = str(override["element"])
for attribute in override.get("remove_attrs", ()):
attrs.pop(str(attribute), None)
attrs.update(
{
str(attribute): str(attribute_value)
for attribute, attribute_value in override.get("attrs", {}).items()
}
)
if "fallback" in override:
fallback = override["fallback"]
return prefix, namespace, element, attrs, str(fallback) if fallback else None
def _effect_xml(
effect: str,
effect_options: object = None,
) -> tuple[str, str, str]:
prefix, _namespace, element, effect_attrs, _fallback = _effect_spec(
effect,
effect_options,
)
attrs = " ".join(
f'{key}="{value}"'
for key, value in effect_attrs.items()
)
suffix = f" {attrs}" if attrs else ""
return prefix, element, suffix
def _transition_attributes(
*,
duration_ms: int | None,
advance_after_ms: int | None,
advance_on_click: bool | None,
declare_p14: bool,
) -> str:
attrs: list[str] = []
if duration_ms is not None:
attrs.append(f'p14:dur="{duration_ms}"')
if declare_p14:
attrs.append(f'xmlns:p14="{P14_NS}"')
if advance_on_click is not None:
attrs.append(f'advClick="{1 if advance_on_click else 0}"')
if advance_after_ms is not None:
attrs.append(f'advTm="{advance_after_ms}"')
return " " + " ".join(attrs) if attrs else ""
def _normalize_transition_sound(
sound: Mapping[str, object] | None,
) -> dict[str, str] | None:
"""Validate one packaged transition-sound relationship descriptor."""
if sound is None:
return None
if not isinstance(sound, Mapping):
raise ValueError("transition sound must be a relationship descriptor")
unknown = set(sound) - {"relationship_id", "name"}
if unknown:
raise ValueError(
"transition sound has unknown field(s): "
+ ", ".join(sorted(str(field) for field in unknown))
)
relationship_id = sound.get("relationship_id")
name = sound.get("name")
if not isinstance(relationship_id, str) or not relationship_id.strip():
raise ValueError(
"transition sound relationship_id must be a non-empty string"
)
if not isinstance(name, str) or not name.strip():
raise ValueError("transition sound name must be a non-empty string")
return {
"relationship_id": relationship_id,
"name": name,
}
def _transition_sound_xml(
sound: Mapping[str, str] | None,
*,
indent: str,
) -> str:
"""Return the optional p:sndAc payload at one indentation level."""
if sound is None:
return ""
relationship_id = quoteattr(sound["relationship_id"])
name = quoteattr(sound["name"])
return (
f"{indent}\n"
f"{indent} \n"
f"{indent} \n"
f"{indent} \n"
f"{indent}\n"
)
def create_transition_xml(
effect: str | None = DEFAULT_TRANSITION,
duration: float = 0.5,
advance_after: float | None = None,
advance_on_click: bool | None = None,
effect_options: Mapping[str, object] | None = None,
sound: Mapping[str, object] | None = None,
) -> str:
"""Build a direct or MCE-backed p:transition XML fragment."""
normalized_effect, normalized_options = normalize_transition_effect_request(
effect,
effect_options,
)
normalized_sound = _normalize_transition_sound(sound)
duration_ms = None
if normalized_effect is not None:
duration_ms = _seconds_to_ms(
duration,
"transition duration",
allow_zero=False,
)
if advance_on_click is not None:
if not isinstance(advance_on_click, bool):
raise ValueError(
"transition advance_on_click must be a boolean or None"
)
advance_ms = None
if advance_after is not None:
advance_ms = _seconds_to_ms(
advance_after,
"transition advance_after",
allow_zero=True,
)
if (
normalized_effect is None
and advance_ms is None
and advance_on_click is None
and normalized_sound is None
):
return ""
attr_text = _transition_attributes(
duration_ms=duration_ms,
advance_after_ms=advance_ms,
advance_on_click=advance_on_click,
declare_p14=normalized_effect is not None,
)
if normalized_effect is None:
if normalized_sound is None:
return f" "
return (
f" \n"
+ _transition_sound_xml(normalized_sound, indent=" ")
+ " "
)
prefix, element_name, effect_attrs = _effect_xml(
normalized_effect,
normalized_options,
)
if prefix != "p":
_effect_prefix, _namespace, _element, _attrs, fallback = _effect_spec(
normalized_effect,
normalized_options,
)
fallback_effect = fallback or "fade"
fallback_prefix, fallback_name, fallback_attrs = _effect_xml(
fallback_effect
)
fallback_attr_text = _transition_attributes(
duration_ms=None,
advance_after_ms=advance_ms,
advance_on_click=advance_on_click,
declare_p14=False,
)
choice_sound_xml = _transition_sound_xml(
normalized_sound,
indent=" ",
)
fallback_sound_xml = _transition_sound_xml(
normalized_sound,
indent=" ",
)
return (
f' \n'
f' \n'
f" \n"
f" <{prefix}:{element_name}{effect_attrs}/>\n"
f"{choice_sound_xml}"
" \n"
" \n"
" \n"
f" \n"
f" <{fallback_prefix}:{fallback_name}{fallback_attrs}/>\n"
f"{fallback_sound_xml}"
" \n"
" \n"
" "
)
sound_xml = _transition_sound_xml(normalized_sound, indent=" ")
return (
f" \n"
f" <{prefix}:{element_name}{effect_attrs}/>\n"
f"{sound_xml}"
" "
)
def _is_lxml_element(element: object) -> bool:
return LET is not None and isinstance(element, LET._Element)
def _new_element(
context: Any,
tag: str,
*,
nsmap: dict[str, str] | None = None,
) -> Any:
if _is_lxml_element(context):
return LET.Element(tag, nsmap=nsmap)
return ET.Element(tag)
def _build_transition_element(
context: Any,
*,
effect: str | None,
duration: float,
advance_after: float | None,
advance_on_click: bool | None,
effect_options: Mapping[str, object] | None = None,
) -> Any | None:
normalized_effect, normalized_options = normalize_transition_effect_request(
effect,
effect_options,
)
if (
normalized_effect is None
and advance_after is None
and advance_on_click is not False
):
return None
if advance_on_click is not None:
if not isinstance(advance_on_click, bool):
raise ValueError(
"transition advance_on_click must be a boolean or None"
)
duration_ms = (
_seconds_to_ms(
duration,
"transition duration",
allow_zero=False,
)
if normalized_effect is not None
else None
)
advance_ms = (
_seconds_to_ms(
advance_after,
"transition advance_after",
allow_zero=True,
)
if advance_after is not None
else None
)
def build_transition(
effect_name: str | None,
*,
include_duration: bool,
options: Mapping[str, object] | None = None,
) -> Any:
nsmap = {"p": PML_NS}
if include_duration:
nsmap["p14"] = P14_NS
transition = _new_element(
context,
_qn(PML_NS, "transition"),
nsmap=nsmap,
)
if include_duration and duration_ms is not None:
transition.set(_qn(P14_NS, "dur"), str(duration_ms))
if advance_on_click is not None:
transition.set("advClick", "1" if advance_on_click else "0")
if advance_ms is not None:
transition.set("advTm", str(advance_ms))
if effect_name is not None:
_prefix, namespace, element_name, effect_attrs, _fallback = (
_effect_spec(effect_name, options)
)
child = _new_element(context, _qn(namespace, element_name))
for key, value in effect_attrs.items():
child.set(key, str(value))
transition.append(child)
return transition
if normalized_effect is None:
return build_transition(None, include_duration=False)
prefix, _namespace, _element, _attrs, fallback = _effect_spec(
normalized_effect,
normalized_options,
)
if prefix == "p":
return build_transition(
normalized_effect,
include_duration=True,
options=normalized_options,
)
carrier = _new_element(
context,
_qn(MC_NS, "AlternateContent"),
nsmap={"mc": MC_NS},
)
choice = _new_element(
context,
_qn(MC_NS, "Choice"),
nsmap={prefix: TRANSITION_NAMESPACES[prefix]},
)
choice.set("Requires", prefix)
choice.append(
build_transition(
normalized_effect,
include_duration=True,
options=normalized_options,
)
)
fallback_node = _new_element(context, _qn(MC_NS, "Fallback"))
fallback_node.append(
build_transition(fallback or "fade", include_duration=False)
)
carrier.extend((choice, fallback_node))
return carrier
def _transition_elements(carrier: Any) -> list[Any]:
if carrier.tag == _qn(PML_NS, "transition"):
return [carrier]
return [
element
for element in carrier.iter()
if element.tag == _qn(PML_NS, "transition")
]
def transition_carriers(slide_root: Any) -> list[Any]:
"""Return root-level direct or AlternateContent transition carriers."""
carriers: list[Any] = []
for child in list(slide_root):
if child.tag == _qn(PML_NS, "transition"):
carriers.append(child)
continue
if child.tag != _qn(MC_NS, "AlternateContent"):
continue
if _transition_elements(child):
carriers.append(child)
return carriers
def _primary_and_fallback(carrier: Any) -> tuple[Any | None, Any | None]:
if carrier.tag == _qn(PML_NS, "transition"):
return carrier, None
primary = None
fallback = None
for child in list(carrier):
transitions = _transition_elements(child)
if not transitions:
continue
if child.tag == _qn(MC_NS, "Choice") and primary is None:
primary = transitions[0]
elif child.tag == _qn(MC_NS, "Fallback") and fallback is None:
fallback = transitions[0]
if primary is None:
transitions = _transition_elements(carrier)
primary = transitions[0] if transitions else None
return primary, fallback
def _effect_identity(
transition: Any | None,
) -> tuple[str | None, str | None, dict[str, str]]:
if transition is None:
return None, None, {}
for child in list(transition):
if child.tag == _qn(PML_NS, "sndAc"):
continue
return (
_local_name(child.tag),
_namespace_name(child.tag),
{str(name): str(value) for name, value in child.attrib.items()},
)
return None, None, {}
def _sound_identity(
transition: Any | None,
) -> tuple[str | None, str | None]:
"""Return one embedded transition sound relationship and display name."""
if transition is None:
return None, None
sound_action = next(
(
child
for child in list(transition)
if child.tag == _qn(PML_NS, "sndAc")
),
None,
)
if sound_action is None:
return None, None
start_sound = next(
(
child
for child in list(sound_action)
if child.tag == _qn(PML_NS, "stSnd")
),
None,
)
if start_sound is None:
return None, None
sound = next(
(
child
for child in list(start_sound)
if child.tag == _qn(PML_NS, "snd")
),
None,
)
if sound is None:
return None, None
return sound.get(_qn(RELATIONSHIPS_NS, "embed")), sound.get("name")
def _effective_transition_options(
effect: str,
options: Mapping[str, object] | None = None,
) -> dict[str, object]:
effective = {
name: spec["default"]
for name, spec in NATIVE_TRANSITIONS[effect]["effectOptions"].items()
}
effective.update(normalize_transition_effect_options(effect, options))
return effective
def _transition_option_combinations(effect: str) -> list[dict[str, object]]:
combinations: list[dict[str, object]] = [{}]
for name, spec in NATIVE_TRANSITIONS[effect]["effectOptions"].items():
combinations = [
{**combination, name: value}
for combination in combinations
for value in spec["values"]
]
return combinations
def _identify_native_transition(
element: str | None,
namespace: str | None,
attributes: Mapping[str, str],
) -> tuple[str | None, dict[str, object]]:
if element is None or namespace is None:
return None, {}
for effect in NATIVE_TRANSITION_KEYS:
for options in _transition_option_combinations(effect):
_prefix, expected_namespace, expected_element, expected_attrs, _fallback = (
_effect_spec(effect, options)
)
if (
namespace == expected_namespace
and element == expected_element
and dict(attributes) == {
str(name): str(value)
for name, value in expected_attrs.items()
}
):
return effect, options
return None, {}
def _int_attribute(element: Any | None, *names: str) -> int | None:
if element is None:
return None
for name in names:
raw = element.get(name)
if raw is None:
continue
try:
return int(raw)
except (TypeError, ValueError):
return None
return None
def _bool_attribute(element: Any, name: str, default: bool) -> bool:
raw = element.get(name)
if raw is None:
return default
return str(raw).strip().lower() not in {"0", "false", "off", "no"}
def read_slide_transition(slide_root: Any) -> TransitionSummary:
"""Read the primary transition without mistaking fallback for success."""
carriers = transition_carriers(slide_root)
if not carriers:
return TransitionSummary(carrier="none", logical_count=0)
carrier = carriers[0]
primary, fallback = _primary_and_fallback(carrier)
effect, effect_namespace, effect_attributes = _effect_identity(primary)
fallback_effect, fallback_namespace, _fallback_attributes = _effect_identity(
fallback
)
sound_relationship_id, sound_name = _sound_identity(primary)
fallback_sound_relationship_id, fallback_sound_name = _sound_identity(
fallback
)
canonical_effect, effect_options = _identify_native_transition(
effect,
effect_namespace,
effect_attributes,
)
carrier_name = (
"alternate-content"
if carrier.tag == _qn(MC_NS, "AlternateContent")
else "direct"
)
duration_ms = _int_attribute(
primary,
_qn(P14_NS, "dur"),
"dur",
)
advance_after_ms = _int_attribute(primary, "advTm")
return TransitionSummary(
carrier=carrier_name if len(carriers) == 1 else "multiple",
logical_count=len(carriers),
effect=effect,
effect_namespace=effect_namespace,
effect_attributes=effect_attributes,
canonical_effect=canonical_effect,
effect_options=effect_options,
fallback_effect=fallback_effect,
fallback_effect_namespace=fallback_namespace,
duration_ms=duration_ms,
speed=primary.get("spd") if primary is not None else None,
advance_on_click=(
_bool_attribute(primary, "advClick", True)
if primary is not None
else None
),
advance_after_ms=advance_after_ms,
sound_relationship_id=sound_relationship_id,
sound_name=sound_name,
fallback_sound_relationship_id=fallback_sound_relationship_id,
fallback_sound_name=fallback_sound_name,
)
def _captured_advance(carriers: list[Any]) -> tuple[bool | None, float | None]:
if not carriers:
return None, None
primary, _fallback = _primary_and_fallback(carriers[0])
if primary is None:
return None, None
click = (
_bool_attribute(primary, "advClick", True)
if primary.get("advClick") is not None
else None
)
advance_ms = _int_attribute(primary, "advTm")
after = advance_ms / 1000 if advance_ms is not None else None
return click, after
def _resolve_advance(
update: AdvanceUpdate,
*,
preserved_click: bool | None,
preserved_after: float | None,
) -> tuple[bool | None, float | None]:
valid_modes = {"preserve", "click", "after", "both", "narration"}
if update.mode not in valid_modes:
raise ValueError(
f"unknown slide advance mode {update.mode!r}; "
f"valid modes: {', '.join(sorted(valid_modes))}"
)
if update.mode == "preserve":
return preserved_click, preserved_after
if update.mode == "click":
return True, None
if update.after is None:
raise ValueError(f"slide advance mode {update.mode!r} requires 'after'")
after = validate_seconds(
update.after,
"slide advance after",
allow_zero=True,
)
if update.mode == "both":
return True, after
return False, after
def _set_advance_attributes(
transition: Any,
*,
advance_on_click: bool | None,
advance_after: float | None,
) -> None:
if advance_on_click is None:
transition.attrib.pop("advClick", None)
else:
transition.set("advClick", "1" if advance_on_click else "0")
if advance_after is None:
transition.attrib.pop("advTm", None)
else:
transition.set(
"advTm",
str(
_seconds_to_ms(
advance_after,
"slide advance after",
allow_zero=True,
)
),
)
def _insert_transition_carrier(slide_root: Any, carrier: Any) -> None:
children = list(slide_root)
for index, child in enumerate(children):
if child.tag in {
_qn(PML_NS, "timing"),
_qn(PML_NS, "extLst"),
}:
slide_root.insert(index, carrier)
return
clr_map = None
for child in children:
if child.tag == _qn(PML_NS, "clrMapOvr"):
clr_map = child
if clr_map is not None:
slide_root.insert(list(slide_root).index(clr_map) + 1, carrier)
return
slide_root.append(carrier)
def _apply_slide_motion_unchecked(
slide_root: Any,
*,
enter: EnterUpdate,
advance: AdvanceUpdate,
) -> bool:
"""Apply one resolved enter/advance update and return whether advTm remains."""
valid_policies = {"preserve", "replace", "none"}
if enter.policy not in valid_policies:
raise ValueError(
f"unknown transition enter policy {enter.policy!r}; "
f"valid policies: {', '.join(sorted(valid_policies))}"
)
if (
enter.policy != "replace"
and enter.effect_options not in (None, {})
):
raise ValueError(
"transition effect_options require enter policy 'replace'"
)
carriers = transition_carriers(slide_root)
if len(carriers) > 1:
raise ValueError(
"slide contains multiple logical transition carriers; "
"refusing to preserve or replace an ambiguous source"
)
preserved_click, preserved_after = _captured_advance(carriers)
advance_on_click, advance_after = _resolve_advance(
advance,
preserved_click=preserved_click,
preserved_after=preserved_after,
)
if enter.policy == "preserve":
if advance.mode == "preserve":
return any(
transition.get("advTm") is not None
for carrier in carriers
for transition in _transition_elements(carrier)
)
if carriers:
for transition in _transition_elements(carriers[0]):
_set_advance_attributes(
transition,
advance_on_click=advance_on_click,
advance_after=advance_after,
)
return advance_after is not None
transition = _build_transition_element(
slide_root,
effect=None,
duration=enter.duration,
advance_after=advance_after,
advance_on_click=advance_on_click,
effect_options=None,
)
if transition is not None:
_insert_transition_carrier(slide_root, transition)
return advance_after is not None
effect, effect_options = (
normalize_transition_effect_request(
enter.effect,
enter.effect_options,
allow_none=False,
)
if enter.policy == "replace"
else (None, {})
)
duration = validate_seconds(
enter.duration,
"transition duration",
allow_zero=False,
)
if carriers:
slide_root.remove(carriers[0])
transition = _build_transition_element(
slide_root,
effect=effect,
duration=duration,
advance_after=advance_after,
advance_on_click=advance_on_click,
effect_options=effect_options,
)
if transition is not None:
_insert_transition_carrier(slide_root, transition)
return advance_after is not None
def _visual_identity(summary: TransitionSummary) -> tuple[Any, ...]:
return (
summary.carrier,
summary.logical_count,
summary.effect,
summary.effect_namespace,
tuple(sorted(summary.effect_attributes.items())),
summary.canonical_effect,
tuple(sorted(summary.effect_options.items())),
summary.fallback_effect,
summary.fallback_effect_namespace,
summary.duration_ms,
summary.speed,
summary.sound_relationship_id,
summary.sound_name,
summary.fallback_sound_relationship_id,
summary.fallback_sound_name,
)
def _expected_visual_identity(
effect: str,
effect_options: Mapping[str, object] | None = None,
) -> tuple[str, str, str, str | None, str | None, dict[str, Any]]:
prefix, namespace, element, attrs, fallback = _effect_spec(
effect,
effect_options,
)
carrier = "direct" if prefix == "p" else "alternate-content"
fallback_element = None
fallback_namespace = None
if carrier == "alternate-content":
(
_fallback_prefix,
fallback_namespace,
fallback_element,
_fallback_attrs,
_nested_fallback,
) = _effect_spec(fallback or "fade")
return (
carrier,
element,
namespace,
fallback_element,
fallback_namespace,
attrs,
)
def _validate_applied_motion(
slide_root: Any,
*,
before: TransitionSummary,
enter: EnterUpdate,
advance: AdvanceUpdate,
) -> None:
errors = validate_slide_transition_structure(slide_root)
after = read_slide_transition(slide_root)
if enter.policy == "preserve":
if before.logical_count and _visual_identity(after) != _visual_identity(before):
errors.append("preserve policy changed the source visual transition")
elif not before.logical_count and after.effect is not None:
errors.append("preserve policy added a visual transition")
elif enter.policy == "replace":
effect, effect_options = normalize_transition_effect_request(
enter.effect,
enter.effect_options,
allow_none=False,
)
expected_effect_options = _effective_transition_options(
effect,
effect_options,
)
expected_duration = _seconds_to_ms(
enter.duration,
"transition duration",
allow_zero=False,
)
(
expected_carrier,
expected_effect,
expected_namespace,
expected_fallback,
expected_fallback_namespace,
expected_attrs,
) = _expected_visual_identity(effect, effect_options)
if (
after.carrier != expected_carrier
or after.logical_count != 1
or after.effect != expected_effect
or after.effect_namespace != expected_namespace
or after.canonical_effect != effect
or dict(after.effect_options) != expected_effect_options
or after.fallback_effect != expected_fallback
or after.fallback_effect_namespace != expected_fallback_namespace
or after.duration_ms != expected_duration
):
errors.append(
f"replace policy read-back does not match effect {effect!r}"
)
carriers = transition_carriers(slide_root)
if carriers:
primary, _fallback = _primary_and_fallback(carriers[0])
effect_children = [
child
for child in (list(primary) if primary is not None else [])
if child.tag != _qn(PML_NS, "sndAc")
]
if effect_children:
actual_attrs = effect_children[0].attrib
for name, value in expected_attrs.items():
if actual_attrs.get(name) != str(value):
errors.append(
f"replace policy wrote invalid {effect} {name} attribute"
)
elif enter.policy == "none":
if after.effect is not None or after.fallback_effect is not None:
errors.append("none policy retained a visual transition")
if (
after.sound_relationship_id is not None
or after.fallback_sound_relationship_id is not None
):
errors.append("none policy retained a transition sound")
preserved_click = (
before.advance_on_click
if before.advance_on_click is not None
else True
)
preserved_after = (
before.advance_after_ms / 1000
if before.advance_after_ms is not None
else None
)
expected_click, expected_after = _resolve_advance(
advance,
preserved_click=preserved_click,
preserved_after=preserved_after,
)
actual_click = (
after.advance_on_click
if after.advance_on_click is not None
else True
)
actual_after_ms = after.advance_after_ms
expected_after_ms = (
_seconds_to_ms(
expected_after,
"transition advance_after",
allow_zero=True,
)
if expected_after is not None
else None
)
if actual_click != expected_click or actual_after_ms != expected_after_ms:
errors.append("transition advance read-back does not match the requested mode")
if advance.mode != "preserve":
for carrier in transition_carriers(slide_root):
for transition in _transition_elements(carrier):
branch_click = _bool_attribute(transition, "advClick", True)
branch_after_ms = _int_attribute(transition, "advTm")
if (
branch_click != expected_click
or branch_after_ms != expected_after_ms
):
errors.append(
"transition fallback advance does not match the requested mode"
)
break
if errors:
raise ValueError("; ".join(errors))
def apply_slide_motion(
slide_root: Any,
*,
enter: EnterUpdate,
advance: AdvanceUpdate,
) -> bool:
"""Apply and immediately read back one resolved transition update."""
before = read_slide_transition(slide_root)
uses_timings = _apply_slide_motion_unchecked(
slide_root,
enter=enter,
advance=advance,
)
_validate_applied_motion(
slide_root,
before=before,
enter=enter,
advance=advance,
)
return uses_timings
def _xml_declaration(source: str) -> str:
match = re.match(r"\s*(<\?xml[^?]*\?>)", source)
return match.group(1) if match else ""
def _serialize_lxml_like(root: Any, source: str) -> str:
body = LET.tostring(root, encoding="unicode", pretty_print=False)
declaration = _xml_declaration(source)
return f"{declaration}\n{body}" if declaration else body
def namespace_bindings(xml_data: str | bytes) -> dict[str, str]:
"""Return source prefix bindings without changing the document."""
data = xml_data.encode("utf-8") if isinstance(xml_data, str) else xml_data
bindings: dict[str, str] = {}
for _event, (prefix, uri) in ET.iterparse(
io.BytesIO(data),
events=("start-ns",),
):
bindings[prefix or ""] = uri
return bindings
def register_source_namespaces(xml_data: str | bytes) -> dict[str, str]:
"""Register source prefixes before stdlib ElementTree re-serialization."""
bindings = namespace_bindings(xml_data)
for prefix, uri in bindings.items():
if prefix == "xml":
continue
try:
ET.register_namespace(prefix, uri)
except (AttributeError, ValueError):
continue
return bindings
def parse_source_xml(xml_data: str | bytes) -> ET.Element:
"""Parse XML after registering its original namespace prefixes."""
data = xml_data.encode("utf-8") if isinstance(xml_data, str) else xml_data
register_source_namespaces(data)
return ET.fromstring(data)
def _required_mce_prefixes(root: Any) -> set[str]:
prefixes = set(str(root.get(_qn(MC_NS, "Ignorable")) or "").split())
for element in root.iter():
if element.tag == _qn(MC_NS, "Choice"):
prefixes.update(str(element.get("Requires") or "").split())
return {prefix for prefix in prefixes if prefix}
def _inject_root_namespace_declarations(
xml_data: bytes,
*,
bindings: dict[str, str],
prefixes: set[str],
) -> bytes:
text = xml_data.decode("utf-8")
declaration_end = text.find("?>")
root_start = text.find("<", declaration_end + 2 if declaration_end >= 0 else 0)
root_end = text.find(">", root_start)
if root_start < 0 or root_end < 0:
raise ValueError("unable to locate serialized XML root element")
opening = text[root_start:root_end]
declarations: list[str] = []
for prefix in sorted(prefixes):
uri = bindings.get(prefix)
if not uri:
raise ValueError(
f"MCE prefix {prefix!r} has no namespace binding in source XML"
)
if re.search(rf"\bxmlns:{re.escape(prefix)}\s*=", opening):
continue
declarations.append(f" xmlns:{prefix}={quoteattr(uri)}")
if declarations:
text = text[:root_end] + "".join(declarations) + text[root_end:]
return text.encode("utf-8")
def validate_mce_prefixes(xml_data: str | bytes) -> list[str]:
"""Return unresolved MCE Requires/Ignorable prefix errors."""
if LET is None:
return []
data = xml_data.encode("utf-8") if isinstance(xml_data, str) else xml_data
try:
root = LET.fromstring(data)
except LET.XMLSyntaxError as exc:
return [f"invalid XML: {exc}"]
errors: list[str] = []
ignorable = str(root.get(_qn(MC_NS, "Ignorable")) or "").split()
for prefix in ignorable:
if prefix not in root.nsmap:
errors.append(f"mc:Ignorable prefix is not bound: {prefix}")
for choice in root.iter(_qn(MC_NS, "Choice")):
for prefix in str(choice.get("Requires") or "").split():
if prefix not in choice.nsmap:
errors.append(f"mc:Choice Requires prefix is not bound: {prefix}")
return errors
def serialize_source_xml(root: ET.Element, source_xml: str | bytes) -> bytes:
"""Serialize stdlib XML while retaining MCE prefix bindings."""
expected_transition = (
read_slide_transition(root)
if root.tag == _qn(PML_NS, "sld")
else None
)
source = (
source_xml.encode("utf-8")
if isinstance(source_xml, str)
else source_xml
)
bindings = register_source_namespaces(source)
prefixes = _required_mce_prefixes(root)
for prefix in prefixes:
namespace = TRANSITION_NAMESPACES.get(prefix)
if namespace is None:
continue
bindings[prefix] = namespace
ET.register_namespace(prefix, namespace)
serialized = ET.tostring(root, encoding="utf-8", xml_declaration=True)
serialized = _inject_root_namespace_declarations(
serialized,
bindings=bindings,
prefixes=prefixes,
)
errors = validate_mce_prefixes(serialized)
if expected_transition is not None:
errors.extend(validate_slide_transition_xml(serialized))
actual_transition = read_slide_transition_xml(serialized)
if actual_transition != expected_transition:
errors.append("slide transition changed during XML serialization")
if errors:
raise ValueError("; ".join(errors))
return serialized
def apply_slide_motion_xml(
slide_xml: str,
*,
enter: EnterUpdate,
advance: AdvanceUpdate,
) -> tuple[str, bool]:
"""Apply motion to source XML while preserving MCE namespace prefixes."""
source_bytes = slide_xml.encode("utf-8")
if LET is not None:
parser = LET.XMLParser(
remove_blank_text=False,
resolve_entities=False,
no_network=True,
)
root = LET.fromstring(source_bytes, parser)
uses_timings = apply_slide_motion(
root,
enter=enter,
advance=advance,
)
output = _serialize_lxml_like(root, slide_xml)
else:
root = parse_source_xml(source_bytes)
uses_timings = apply_slide_motion(
root,
enter=enter,
advance=advance,
)
output = serialize_source_xml(root, source_bytes).decode("utf-8")
expected_transition = read_slide_transition(root)
errors = validate_slide_transition_xml(output)
if read_slide_transition_xml(output) != expected_transition:
errors.append("slide transition changed during XML serialization")
if errors:
raise ValueError("; ".join(errors))
return output, uses_timings
def read_slide_transition_xml(slide_xml: str | bytes) -> TransitionSummary:
"""Read a transition summary from raw slide XML."""
data = (
slide_xml.encode("utf-8")
if isinstance(slide_xml, str)
else slide_xml
)
if LET is not None:
root = LET.fromstring(data)
else:
root = parse_source_xml(data)
return read_slide_transition(root)
def validate_generated_transition_xml(
slide_xml: str | bytes,
*,
effect: str | None,
duration: object,
advance_on_click: bool | None,
advance_after: object | None,
effect_options: Mapping[str, object] | None = None,
sound: Mapping[str, object] | None = None,
) -> TransitionSummary:
"""Validate a generated transition against its resolved settings."""
data = slide_xml.encode("utf-8") if isinstance(slide_xml, str) else slide_xml
root = LET.fromstring(data) if LET is not None else parse_source_xml(data)
errors = validate_slide_transition_structure(root) + validate_mce_prefixes(data)
summary = read_slide_transition(root)
normalized_effect, normalized_options = normalize_transition_effect_request(
effect,
effect_options,
)
normalized_sound = _normalize_transition_sound(sound)
expected_click = True if advance_on_click is None else advance_on_click
if not isinstance(expected_click, bool):
errors.append("transition advance_on_click must be a boolean or None")
expected_after_ms = (
_seconds_to_ms(
advance_after,
"transition advance_after",
allow_zero=True,
)
if advance_after is not None
else None
)
expects_carrier = (
normalized_effect is not None
or expected_after_ms is not None
or expected_click is False
or normalized_sound is not None
)
if not expects_carrier:
if summary.logical_count != 0:
errors.append("generated slide unexpectedly contains a transition carrier")
else:
expected_duration_ms = (
_seconds_to_ms(
duration,
"transition duration",
allow_zero=False,
)
if normalized_effect is not None
else None
)
expected_carrier = "direct"
expected_effect = None
expected_namespace = None
expected_fallback = None
expected_fallback_namespace = None
expected_attrs: dict[str, Any] = {}
expected_sound_relationship_id = (
normalized_sound["relationship_id"]
if normalized_sound is not None
else None
)
expected_sound_name = (
normalized_sound["name"]
if normalized_sound is not None
else None
)
expected_fallback_sound_relationship_id = None
expected_fallback_sound_name = None
if normalized_effect is not None:
(
expected_carrier,
expected_effect,
expected_namespace,
expected_fallback,
expected_fallback_namespace,
expected_attrs,
) = _expected_visual_identity(
normalized_effect,
normalized_options,
)
expected_effect_options = _effective_transition_options(
normalized_effect,
normalized_options,
)
if expected_carrier == "alternate-content":
expected_fallback_sound_relationship_id = (
expected_sound_relationship_id
)
expected_fallback_sound_name = expected_sound_name
else:
expected_effect_options = {}
if (
summary.carrier != expected_carrier
or summary.logical_count != 1
or summary.effect != expected_effect
or summary.effect_namespace != expected_namespace
or summary.canonical_effect != normalized_effect
or dict(summary.effect_options) != expected_effect_options
or summary.fallback_effect != expected_fallback
or summary.fallback_effect_namespace != expected_fallback_namespace
or summary.duration_ms != expected_duration_ms
or summary.advance_on_click != expected_click
or summary.advance_after_ms != expected_after_ms
or summary.sound_relationship_id != expected_sound_relationship_id
or summary.sound_name != expected_sound_name
or summary.fallback_sound_relationship_id
!= expected_fallback_sound_relationship_id
or summary.fallback_sound_name != expected_fallback_sound_name
):
errors.append("generated transition read-back does not match its settings")
if normalized_effect is not None:
carriers = transition_carriers(root)
if carriers:
primary, _fallback = _primary_and_fallback(carriers[0])
effect_children = [
child
for child in (list(primary) if primary is not None else [])
if child.tag != _qn(PML_NS, "sndAc")
]
if not effect_children:
errors.append("generated transition has no visual effect child")
else:
for name, value in expected_attrs.items():
if effect_children[0].get(name) != str(value):
errors.append(
f"generated {normalized_effect} transition has "
f"invalid {name} attribute"
)
if errors:
raise ValueError("; ".join(errors))
return summary
def _slide_relationships_part(slide_part: str) -> str:
directory = posixpath.dirname(slide_part)
filename = posixpath.basename(slide_part)
return posixpath.join(directory, "_rels", f"{filename}.rels")
def _resolve_slide_relationship_target(
slide_part: str,
target: str,
) -> str | None:
normalized_target = target.replace("\\", "/")
if not normalized_target:
return None
if normalized_target.startswith("/"):
resolved = posixpath.normpath(normalized_target.lstrip("/"))
else:
resolved = posixpath.normpath(
posixpath.join(posixpath.dirname(slide_part), normalized_target)
)
if resolved == ".." or resolved.startswith("../"):
return None
return resolved
def _transition_sound_elements(slide_root: Any) -> list[Any]:
sounds: list[Any] = []
for carrier in transition_carriers(slide_root):
for transition in _transition_elements(carrier):
for sound_action in list(transition):
if sound_action.tag != _qn(PML_NS, "sndAc"):
continue
for start_sound in list(sound_action):
if start_sound.tag != _qn(PML_NS, "stSnd"):
continue
sounds.extend(
child
for child in list(start_sound)
if child.tag == _qn(PML_NS, "snd")
)
return sounds
def _package_part_content_type(
package: zipfile.ZipFile,
target_part: str,
) -> str | None:
"""Resolve one package part's MIME type from defaults or overrides."""
content_types_root = ET.fromstring(package.read(CONTENT_TYPES_PART))
normalized_target = "/" + target_part.lstrip("/")
for entry in content_types_root:
if (
entry.tag == _qn(CONTENT_TYPES_NS, "Override")
and entry.get("PartName") == normalized_target
):
return entry.get("ContentType")
extension = Path(target_part).suffix.lstrip(".").lower()
for entry in content_types_root:
if (
entry.tag == _qn(CONTENT_TYPES_NS, "Default")
and str(entry.get("Extension") or "").lower() == extension
):
return entry.get("ContentType")
return None
def _validate_transition_sound_package_parts(
package: zipfile.ZipFile,
package_names: set[str],
slide_part: str,
slide_xml: bytes,
) -> list[str]:
"""Validate transition-sound relationships and embedded WAV targets."""
errors: list[str] = []
slide_root = (
LET.fromstring(slide_xml)
if LET is not None
else parse_source_xml(slide_xml)
)
sounds = _transition_sound_elements(slide_root)
if not sounds:
return errors
if CONTENT_TYPES_PART not in package_names:
return ["transition sound is missing [Content_Types].xml"]
relationships_part = _slide_relationships_part(slide_part)
if relationships_part not in package_names:
return [
f"transition sound is missing slide relationships: "
f"{relationships_part}"
]
try:
relationships_root = ET.fromstring(package.read(relationships_part))
except (KeyError, ET.ParseError) as exc:
return [f"unable to read {relationships_part}: {exc}"]
relationships: dict[str, Any] = {}
duplicate_ids: set[str] = set()
for relationship in relationships_root:
relationship_id = str(relationship.get("Id") or "")
if not relationship_id:
continue
if relationship_id in relationships:
duplicate_ids.add(relationship_id)
relationships[relationship_id] = relationship
if duplicate_ids:
errors.append(
f"{relationships_part} contains duplicate relationship id(s): "
+ ", ".join(sorted(duplicate_ids))
)
for sound in sounds:
relationship_id = sound.get(_qn(RELATIONSHIPS_NS, "embed"))
if not relationship_id:
errors.append("p:snd must declare a non-empty r:embed")
continue
relationship = relationships.get(relationship_id)
if relationship is None:
errors.append(
f"p:snd references missing slide relationship: "
f"{relationship_id}"
)
continue
if relationship.get("Type") != AUDIO_REL_TYPE:
errors.append(
f"transition sound relationship {relationship_id} must use "
"the OOXML audio relationship type"
)
if relationship.get("TargetMode") == "External":
errors.append(
f"transition sound relationship {relationship_id} must be internal"
)
continue
target = str(relationship.get("Target") or "")
target_part = _resolve_slide_relationship_target(slide_part, target)
if target_part is None:
errors.append(
f"transition sound relationship {relationship_id} has an "
f"invalid target: {target!r}"
)
continue
if Path(target_part).suffix.lower() != ".wav":
errors.append(
f"transition sound relationship {relationship_id} must target "
f"a .wav part: {target_part}"
)
if target_part not in package_names:
errors.append(
f"transition sound relationship {relationship_id} target is "
f"missing: {target_part}"
)
continue
content_type = _package_part_content_type(package, target_part)
if str(content_type or "").lower() not in WAV_CONTENT_TYPES:
errors.append(
f"transition sound target {target_part} must declare a WAV "
f"content type; found {content_type!r}"
)
payload = package.read(target_part)
if not (
len(payload) >= 12
and payload[:4] in {b"RIFF", b"RF64"}
and payload[8:12] == b"WAVE"
):
errors.append(
f"transition sound target {target_part} is not RIFF/RF64 WAVE"
)
return errors
def validate_pptx_transition_package(
pptx_path: Path,
*,
require_use_timings: bool = False,
) -> dict[str, TransitionSummary]:
"""Read back and validate every slide transition in a written PPTX.
Return summaries keyed by package part name. Raise ``ValueError`` when the
ZIP, slide transition structure, MCE bindings, or required presentation
timing metadata is invalid.
"""
errors: list[str] = []
summaries: dict[str, TransitionSummary] = {}
try:
with zipfile.ZipFile(pptx_path, "r") as package:
names = package.namelist()
part_counts: dict[str, int] = {}
for name in names:
part_counts[name] = part_counts.get(name, 0) + 1
duplicate_names = sorted(
name for name, count in part_counts.items() if count > 1
)
if duplicate_names:
errors.append(
"duplicate package parts: " + ", ".join(duplicate_names)
)
package_names = set(names)
slide_names = sorted(
name
for name in names
if name.startswith("ppt/slides/slide")
and name.endswith(".xml")
)
for slide_name in slide_names:
slide_xml = package.read(slide_name)
for problem in validate_slide_transition_xml(slide_xml):
errors.append(f"{slide_name}: {problem}")
try:
summaries[slide_name] = read_slide_transition_xml(slide_xml)
except Exception as exc:
errors.append(f"{slide_name}: transition read-back failed: {exc}")
try:
sound_errors = _validate_transition_sound_package_parts(
package,
package_names,
slide_name,
slide_xml,
)
except Exception as exc:
errors.append(
f"{slide_name}: transition sound read-back failed: {exc}"
)
else:
for problem in sound_errors:
errors.append(f"{slide_name}: {problem}")
if require_use_timings:
errors.extend(_validate_package_use_timings(package, names))
except (OSError, zipfile.BadZipFile, KeyError, ET.ParseError) as exc:
errors.append(f"unable to read PPTX transition package: {exc}")
if errors:
raise ValueError("; ".join(errors))
return summaries
def _top_level_shape_types_by_name(
slide_xml: bytes,
) -> dict[str, list[str]]:
"""Return top-level Selection Pane names and their OOXML container types."""
root = LET.fromstring(slide_xml) if LET is not None else parse_source_xml(slide_xml)
sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
if sp_tree is None:
raise ValueError("slide has no p:cSld/p:spTree")
shapes: dict[str, list[str]] = {}
for child in sp_tree:
c_nv_pr = next(child.iter(_qn(PML_NS, "cNvPr")), None)
name = c_nv_pr.get("name") if c_nv_pr is not None else None
if not name:
continue
shapes.setdefault(name, []).append(_local_name(child.tag))
return shapes
def validate_pptx_morph_pairs(
pptx_path: Path,
expectations: Iterable[MorphPairExpectation],
) -> None:
"""Prove that every requested forced-Morph pair survives final packaging."""
expected_pairs = tuple(expectations)
if not expected_pairs:
return
errors: list[str] = []
slide_shapes: dict[int, dict[str, list[str]]] = {}
slide_transitions: dict[int, TransitionSummary] = {}
involved_slides = {
slide_number
for pair in expected_pairs
for slide_number in (
pair.source_slide_number,
pair.destination_slide_number,
)
}
try:
with zipfile.ZipFile(pptx_path, "r") as package:
names = set(package.namelist())
for slide_number in sorted(involved_slides):
part = f"ppt/slides/slide{slide_number}.xml"
if part not in names:
errors.append(f"{part}: Morph slide part is missing")
continue
slide_xml = package.read(part)
try:
slide_shapes[slide_number] = _top_level_shape_types_by_name(
slide_xml
)
slide_transitions[slide_number] = read_slide_transition_xml(
slide_xml
)
except Exception as exc:
errors.append(f"{part}: Morph read-back failed: {exc}")
except (OSError, zipfile.BadZipFile, KeyError, ET.ParseError) as exc:
errors.append(f"unable to read PPTX Morph package: {exc}")
for slide_number, names_to_types in slide_shapes.items():
duplicate_names = sorted(
name
for name, types in names_to_types.items()
if name.startswith("!!") and len(types) != 1
)
if duplicate_names:
errors.append(
f"ppt/slides/slide{slide_number}.xml: duplicate forced-Morph "
f"name(s): {', '.join(duplicate_names)}"
)
for pair in expected_pairs:
if pair.destination_slide_number != pair.source_slide_number + 1:
errors.append(
f'Morph pair "{pair.key}" must connect adjacent generated slides'
)
continue
source_shapes = slide_shapes.get(pair.source_slide_number)
destination_shapes = slide_shapes.get(pair.destination_slide_number)
if source_shapes is None or destination_shapes is None:
continue
shape_name = pair.shape_name
source_types = source_shapes.get(shape_name, [])
destination_types = destination_shapes.get(shape_name, [])
if len(source_types) != 1:
errors.append(
f'Morph pair "{pair.key}" expected exactly one source object '
f'named "{shape_name}" on slide {pair.source_slide_number}'
)
if len(destination_types) != 1:
errors.append(
f'Morph pair "{pair.key}" expected exactly one destination '
f'object named "{shape_name}" on slide '
f'{pair.destination_slide_number}'
)
if (
len(source_types) == 1
and len(destination_types) == 1
and source_types[0] != destination_types[0]
):
errors.append(
f'Morph pair "{pair.key}" changes OOXML object type from '
f'{source_types[0]} to {destination_types[0]}'
)
transition = slide_transitions.get(pair.destination_slide_number)
if (
transition is not None
and (
transition.canonical_effect != "morph"
or transition.effect_options.get("morph_by") != "object"
)
):
errors.append(
f'Morph pair "{pair.key}" destination slide '
f'{pair.destination_slide_number} does not use Morph by object'
)
declared_names_by_edge: dict[tuple[int, int], set[str]] = {}
for pair in expected_pairs:
declared_names_by_edge.setdefault(
(
pair.source_slide_number,
pair.destination_slide_number,
),
set(),
).add(pair.shape_name)
for source_slide_number in sorted(slide_shapes):
destination_slide_number = source_slide_number + 1
if destination_slide_number not in slide_shapes:
continue
transition = slide_transitions.get(destination_slide_number)
if (
transition is None
or transition.canonical_effect != "morph"
):
continue
source_names = {
name
for name in slide_shapes[source_slide_number]
if name.startswith("!!")
}
destination_names = {
name
for name in slide_shapes[destination_slide_number]
if name.startswith("!!")
}
declared_names = declared_names_by_edge.get(
(source_slide_number, destination_slide_number),
set(),
)
unexpected_names = sorted(
(source_names & destination_names) - declared_names
)
if unexpected_names:
errors.append(
f"Morph edge {source_slide_number}->{destination_slide_number} "
"contains undeclared forced name(s): "
+ ", ".join(unexpected_names)
)
if errors:
raise ValueError("; ".join(dict.fromkeys(errors)))
def _validate_package_use_timings(
package: zipfile.ZipFile,
names: list[str],
) -> list[str]:
errors: list[str] = []
required_parts = {
PRESENTATION_RELS_PART,
CONTENT_TYPES_PART,
}
missing = sorted(required_parts - set(names))
if missing:
return ["timed advance is missing package parts: " + ", ".join(missing)]
rels_root = ET.fromstring(package.read(PRESENTATION_RELS_PART))
props_part = _presentation_props_part(rels_root)
if props_part is None:
return ["presentation relationships do not reference presentation properties"]
if props_part not in names:
return [f"presentation properties part is missing: {props_part}"]
props_root = ET.fromstring(package.read(props_part))
show_properties = props_root.find(_qn(PML_NS, "showPr"))
if (
show_properties is None
or show_properties.get("useTimings") not in {"1", "true", "on"}
):
errors.append(f"{props_part} must set p:showPr@useTimings=1")
content_root = ET.fromstring(package.read(CONTENT_TYPES_PART))
package_part_name = "/" + props_part.lstrip("/")
if not any(
override.get("PartName") == package_part_name
and override.get("ContentType") == PRESENTATION_PROPS_CONTENT_TYPE
for override in content_root
):
errors.append(f"[Content_Types].xml must declare {props_part}")
return errors
def validate_slide_transition_structure(slide_root: Any) -> list[str]:
"""Return logical-carrier and schema-order errors for one slide root."""
errors: list[str] = []
children = list(slide_root)
carriers = transition_carriers(slide_root)
if len(carriers) > 1:
errors.append(
f"slide has {len(carriers)} logical transition carriers; expected at most 1"
)
if carriers:
carrier_index = children.index(carriers[0])
common_slide = next(
(
child
for child in children
if child.tag == _qn(PML_NS, "cSld")
),
None,
)
if common_slide is None:
errors.append("slide with transition carrier must contain p:cSld")
elif carrier_index < children.index(common_slide):
errors.append("transition carrier must follow p:cSld")
color_map = next(
(
child
for child in children
if child.tag == _qn(PML_NS, "clrMapOvr")
),
None,
)
if color_map is not None and carrier_index < children.index(color_map):
errors.append("transition carrier must follow p:clrMapOvr")
for tag in ("timing", "extLst"):
element = next(
(
child
for child in children
if child.tag == _qn(PML_NS, tag)
),
None,
)
if element is not None and carrier_index > children.index(element):
errors.append(f"transition carrier must precede p:{tag}")
for carrier in carriers:
for transition in _transition_elements(carrier):
sound_actions = [
child
for child in list(transition)
if child.tag == _qn(PML_NS, "sndAc")
]
if len(sound_actions) > 1:
errors.append(
"p:transition must contain at most one p:sndAc; "
f"found {len(sound_actions)}"
)
if sound_actions:
start_sounds = [
child
for child in list(sound_actions[0])
if child.tag == _qn(PML_NS, "stSnd")
]
if len(start_sounds) > 1:
errors.append(
"p:sndAc must contain at most one p:stSnd; "
f"found {len(start_sounds)}"
)
if start_sounds:
sounds = [
child
for child in list(start_sounds[0])
if child.tag == _qn(PML_NS, "snd")
]
if len(sounds) != 1:
errors.append(
"p:stSnd must contain exactly one p:snd; "
f"found {len(sounds)}"
)
if carrier.tag != _qn(MC_NS, "AlternateContent"):
continue
choices = [
child
for child in list(carrier)
if child.tag == _qn(MC_NS, "Choice")
]
if not choices:
errors.append("mc:AlternateContent transition must contain mc:Choice")
for branch_name in ("Choice", "Fallback"):
branches = [
child
for child in list(carrier)
if child.tag == _qn(MC_NS, branch_name)
]
for branch in branches:
count = len(_transition_elements(branch))
if count != 1:
errors.append(
f"mc:{branch_name} must contain exactly one p:transition; "
f"found {count}"
)
return errors
def validate_slide_transition_xml(slide_xml: str | bytes) -> list[str]:
"""Validate raw slide transition structure and MCE prefix bindings."""
data = slide_xml.encode("utf-8") if isinstance(slide_xml, str) else slide_xml
try:
if LET is not None:
root = LET.fromstring(data)
else:
root = parse_source_xml(data)
except Exception as exc:
return [f"invalid slide XML: {exc}"]
return validate_slide_transition_structure(root) + validate_mce_prefixes(data)
def _insert_show_properties(root: Any, element: Any) -> None:
for index, child in enumerate(list(root)):
if child.tag in {
_qn(PML_NS, "clrMru"),
_qn(PML_NS, "extLst"),
}:
root.insert(index, element)
return
root.append(element)
def _next_relationship_id(rels_xml: str) -> str:
root = ET.fromstring(rels_xml)
numbers: list[int] = []
for relationship in root:
match = re.fullmatch(r"rId(\d+)", relationship.get("Id", ""))
if match is not None:
numbers.append(int(match.group(1)))
return f"rId{max(numbers, default=0) + 1}"
def _presentation_props_part(rels_root: Any) -> str | None:
for relationship in rels_root:
if relationship.get("Type") != PRESENTATION_PROPS_REL_TYPE:
continue
if relationship.get("TargetMode") == "External":
raise ValueError("presentation properties relationship must be internal")
target = str(relationship.get("Target") or "").replace("\\", "/")
if not target:
raise ValueError("presentation properties relationship has no target")
if target.startswith("/"):
part_name = target.lstrip("/")
else:
part_name = posixpath.normpath(posixpath.join("ppt", target))
if part_name == ".." or part_name.startswith("../"):
raise ValueError(
"presentation properties relationship escapes the PPTX package"
)
return part_name
return None
def _ensure_presentation_props_references(
parts: MutableMapping[str, bytes],
*,
props_part: str,
) -> None:
rels_source = parts[PRESENTATION_RELS_PART]
rels_root = parse_source_xml(rels_source)
if _presentation_props_part(rels_root) is None:
ET.SubElement(
rels_root,
_qn(PACKAGE_REL_NS, "Relationship"),
{
"Id": _next_relationship_id(rels_source.decode("utf-8")),
"Type": PRESENTATION_PROPS_REL_TYPE,
"Target": "presProps.xml",
},
)
parts[PRESENTATION_RELS_PART] = serialize_source_xml(
rels_root,
rels_source,
)
content_source = parts[CONTENT_TYPES_PART]
content_root = parse_source_xml(content_source)
part_name = "/" + props_part.lstrip("/")
if not any(
override.get("PartName") == part_name
and override.get("ContentType") == PRESENTATION_PROPS_CONTENT_TYPE
for override in content_root
):
ET.SubElement(
content_root,
_qn(CONTENT_TYPES_NS, "Override"),
{
"PartName": part_name,
"ContentType": PRESENTATION_PROPS_CONTENT_TYPE,
},
)
parts[CONTENT_TYPES_PART] = serialize_source_xml(
content_root,
content_source,
)
def set_package_use_timings(
parts: MutableMapping[str, bytes],
*,
enabled: bool = True,
) -> None:
"""Set presentation-wide timing playback in ppt/presProps.xml."""
rels_root = parse_source_xml(parts[PRESENTATION_RELS_PART])
existing_props_part = _presentation_props_part(rels_root)
props_part = existing_props_part or PRESENTATION_PROPS_PART
if props_part in parts:
source = parts[props_part]
root = parse_source_xml(source)
else:
if existing_props_part is not None:
raise ValueError(
f"presentation properties part is missing: {props_part}"
)
source = (
f''
f''
).encode("utf-8")
root = parse_source_xml(source)
show_properties = root.find(_qn(PML_NS, "showPr"))
if show_properties is None:
show_properties = ET.Element(_qn(PML_NS, "showPr"))
_insert_show_properties(root, show_properties)
show_properties.set("useTimings", "1" if enabled else "0")
parts[props_part] = serialize_source_xml(root, source)
_ensure_presentation_props_references(parts, props_part=props_part)
def set_directory_use_timings(
extract_dir: Path,
*,
enabled: bool = True,
) -> None:
"""Set presentation timing playback in an extracted PPTX directory."""
part_names = {
PRESENTATION_RELS_PART,
CONTENT_TYPES_PART,
}
rels_source = (extract_dir / PRESENTATION_RELS_PART).read_bytes()
rels_root = parse_source_xml(rels_source)
props_part = _presentation_props_part(rels_root) or PRESENTATION_PROPS_PART
if (extract_dir / props_part).is_file():
part_names.add(props_part)
parts = {
name: (extract_dir / name).read_bytes()
for name in part_names
}
set_package_use_timings(parts, enabled=enabled)
for name, payload in parts.items():
path = extract_dir / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(payload)