1360 lines
44 KiB
Python
1360 lines
44 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
PPT Master - PPTX Transition Core
|
|
|
|
Provide one strict transition registry plus 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
|
|
from pathlib import Path
|
|
from typing import Any, 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"
|
|
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"
|
|
|
|
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"
|
|
)
|
|
|
|
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
|
|
|
|
|
|
TRANSITIONS: dict[str, dict[str, Any]] = {
|
|
"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"},
|
|
},
|
|
"strips": {
|
|
"name": "Strips",
|
|
"element": "strips",
|
|
"attrs": {"dir": "rd"},
|
|
},
|
|
"cover": {
|
|
"name": "Cover",
|
|
"element": "cover",
|
|
"attrs": {"dir": "r"},
|
|
},
|
|
"random": {
|
|
"name": "Random",
|
|
"element": "random",
|
|
"attrs": {},
|
|
},
|
|
}
|
|
|
|
|
|
for _prefix, _uri in (("p", PML_NS), ("p14", P14_NS), ("mc", MC_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
|
|
|
|
|
|
@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
|
|
|
|
|
|
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 a known transition effect without silently changing it."""
|
|
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 not in TRANSITIONS:
|
|
valid = ", ".join(sorted(TRANSITIONS))
|
|
raise ValueError(
|
|
f"unknown transition effect {effect!r}; valid effects: {valid}, none"
|
|
)
|
|
return effect
|
|
|
|
|
|
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_xml(effect: str) -> tuple[str, str]:
|
|
info = TRANSITIONS[effect]
|
|
attrs = " ".join(
|
|
f'{key}="{value}"'
|
|
for key, value in info.get("attrs", {}).items()
|
|
)
|
|
suffix = f" {attrs}" if attrs else ""
|
|
return str(info["element"]), suffix
|
|
|
|
|
|
def create_transition_xml(
|
|
effect: str | None = DEFAULT_TRANSITION,
|
|
duration: float = 0.5,
|
|
advance_after: float | None = None,
|
|
advance_on_click: bool | None = None,
|
|
) -> str:
|
|
"""Build a legacy-compatible direct p:transition XML fragment."""
|
|
normalized_effect = normalize_transition_effect(effect)
|
|
attrs: list[str] = []
|
|
if normalized_effect is not None:
|
|
duration_ms = _seconds_to_ms(
|
|
duration,
|
|
"transition duration",
|
|
allow_zero=False,
|
|
)
|
|
attrs.extend((
|
|
f'p14:dur="{duration_ms}"',
|
|
f'xmlns:p14="{P14_NS}"',
|
|
))
|
|
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"
|
|
)
|
|
attrs.append(f'advClick="{1 if advance_on_click else 0}"')
|
|
if advance_after is not None:
|
|
advance_ms = _seconds_to_ms(
|
|
advance_after,
|
|
"transition advance_after",
|
|
allow_zero=True,
|
|
)
|
|
attrs.append(f'advTm="{advance_ms}"')
|
|
|
|
if normalized_effect is None and not attrs:
|
|
return ""
|
|
|
|
attr_text = " " + " ".join(attrs) if attrs else ""
|
|
if normalized_effect is None:
|
|
return f" <p:transition{attr_text}/>"
|
|
|
|
element_name, effect_attrs = _effect_xml(normalized_effect)
|
|
return (
|
|
f" <p:transition{attr_text}>\n"
|
|
f" <p:{element_name}{effect_attrs}/>\n"
|
|
" </p:transition>"
|
|
)
|
|
|
|
|
|
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,
|
|
) -> Any | None:
|
|
normalized_effect = normalize_transition_effect(effect)
|
|
if (
|
|
normalized_effect is None
|
|
and advance_after is None
|
|
and advance_on_click is not False
|
|
):
|
|
return None
|
|
|
|
nsmap = {"p": PML_NS}
|
|
if normalized_effect is not None:
|
|
nsmap["p14"] = P14_NS
|
|
transition = _new_element(
|
|
context,
|
|
_qn(PML_NS, "transition"),
|
|
nsmap=nsmap,
|
|
)
|
|
|
|
if normalized_effect is not None:
|
|
duration_ms = _seconds_to_ms(
|
|
duration,
|
|
"transition duration",
|
|
allow_zero=False,
|
|
)
|
|
transition.set(_qn(P14_NS, "dur"), str(duration_ms))
|
|
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"
|
|
)
|
|
transition.set("advClick", "1" if advance_on_click else "0")
|
|
if advance_after is not None:
|
|
transition.set(
|
|
"advTm",
|
|
str(
|
|
_seconds_to_ms(
|
|
advance_after,
|
|
"transition advance_after",
|
|
allow_zero=True,
|
|
)
|
|
),
|
|
)
|
|
|
|
if normalized_effect is not None:
|
|
info = TRANSITIONS[normalized_effect]
|
|
child = _new_element(context, _qn(PML_NS, str(info["element"])))
|
|
for key, value in info.get("attrs", {}).items():
|
|
child.set(key, str(value))
|
|
transition.append(child)
|
|
return transition
|
|
|
|
|
|
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]:
|
|
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)
|
|
return None, 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_identity(primary)
|
|
fallback_effect, fallback_namespace = _effect_identity(fallback)
|
|
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,
|
|
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,
|
|
)
|
|
|
|
|
|
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))}"
|
|
)
|
|
|
|
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,
|
|
)
|
|
if transition is not None:
|
|
_insert_transition_carrier(slide_root, transition)
|
|
return advance_after is not None
|
|
|
|
effect = (
|
|
normalize_transition_effect(enter.effect, 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,
|
|
)
|
|
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,
|
|
summary.fallback_effect,
|
|
summary.fallback_effect_namespace,
|
|
summary.duration_ms,
|
|
summary.speed,
|
|
)
|
|
|
|
|
|
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 = normalize_transition_effect(enter.effect, allow_none=False)
|
|
expected_duration = _seconds_to_ms(
|
|
enter.duration,
|
|
"transition duration",
|
|
allow_zero=False,
|
|
)
|
|
if (
|
|
after.carrier != "direct"
|
|
or after.logical_count != 1
|
|
or after.effect != TRANSITIONS[effect]["element"]
|
|
or after.effect_namespace != PML_NS
|
|
or after.fallback_effect is not None
|
|
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 TRANSITIONS[effect].get("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")
|
|
|
|
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)
|
|
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,
|
|
) -> TransitionSummary:
|
|
"""Validate a generated direct 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 = normalize_transition_effect(effect)
|
|
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
|
|
)
|
|
|
|
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_effect = (
|
|
str(TRANSITIONS[normalized_effect]["element"])
|
|
if normalized_effect is not None
|
|
else None
|
|
)
|
|
if (
|
|
summary.carrier != "direct"
|
|
or summary.logical_count != 1
|
|
or summary.effect != expected_effect
|
|
or summary.fallback_effect is not None
|
|
or summary.duration_ms != expected_duration_ms
|
|
or summary.advance_on_click != expected_click
|
|
or summary.advance_after_ms != expected_after_ms
|
|
):
|
|
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")
|
|
]
|
|
expected_attrs = TRANSITIONS[normalized_effect].get("attrs", {})
|
|
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 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)
|
|
)
|
|
|
|
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}")
|
|
|
|
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 _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:
|
|
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'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
f'<p:presentationPr xmlns:p="{PML_NS}"/>'
|
|
).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)
|