Attach a <p:timing> tree to a slide with a single animation effect bound to a shape. The manifest expands to one case per effect kind (fade-in, fade-out, fly-in-left, fly-in-right, zoom-in, zoom-out). python-pptx exposes no high-level animation API; the generator builds the timing tree directly via lxml. Each case encodes the effect as an <p:par>/<p:cTn>/.../<p:par> chain whose leaf carries a preset animation attribute combination (presetID, presetClass, presetSubtype). Authoring-side only: the corpus records what was written, not playback.
Library verdicts: python-pptx: — pptxjs: —
| Feature id | pptx/animation-effect--zoom-in |
|---|---|
| Format | pptx |
| Category | animation |
| Family | pptx/animation-effect |
| Axis values | effect=zoom-in |
| Spec | ecma-376-5-part-1 § 19.5.70 p:timing |
| ID / part | Predicate | XPath | python-pptx | pptxjs |
|---|---|---|---|---|
timing-present-zoom-inppt/slides/slide1.xml | existThe slide must carry a <p:timing> element holding the animation tree. | //p:timing | — | — |
preset-id-zoom-inppt/slides/slide1.xml | equal = 23The innermost animation <p:cTn> must carry presetID='23' identifying the effect kind (10=fade, 2=fly, 23=zoom). | //p:timing//p:cTn[@presetID and @presetClass]/@presetID | — | — |
preset-class-zoom-inppt/slides/slide1.xml | equal = entrThe innermost animation <p:cTn> must carry presetClass='entr' (entr for enter, exit for exit). | //p:timing//p:cTn[@presetID and @presetClass]/@presetClass | — | — |
preset-subtype-zoom-inppt/slides/slide1.xml | equal = 0The innermost animation <p:cTn> must carry presetSubtype='0' (direction/variant code; 0 for plain fade/zoom, 4=left, 8=right for fly-in). | //p:timing//p:cTn[@presetID and @presetClass]/@presetSubtype | — | — |
No render assertions declared.
scripts/gen_animation_effect.py
#!/usr/bin/env python3
"""Generate a single ``fixtures/pptx/animation-effect--<id>.pptx``.
Parameterised generator: the ``features/pptx/animation-effect.json``
manifest expands to one case per effect kind (fade-in, fade-out,
fly-in-left, fly-in-right, zoom-in, zoom-out), and the conformance
runner invokes this script once per case with ``--effect <id> --out
<path>``.
Each invocation builds a one-slide presentation with a single shape,
then appends a <p:timing> tree to the slide element carrying a single
animation effect keyed to the shape by its shape-id. python-pptx
exposes no high-level animation API, so the timing tree is written
directly via lxml. The emitted structure follows the PowerPoint-
authored minimal envelope:
<p:timing>
<p:tnLst>
<p:par>
<p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot">
<p:childTnLst>
<p:seq concurrent="1" nextAc="seek">
<p:cTn id="2" dur="indefinite" nodeType="mainSeq">
<p:childTnLst>
<p:par>
<p:cTn id="3" fill="hold">
<p:stCondLst>...</p:stCondLst>
<p:childTnLst>
<p:par>
<p:cTn id="4" fill="hold">
<p:stCondLst>...</p:stCondLst>
<p:childTnLst>
<p:par>
<p:cTn id="5" presetID="..."
presetClass="..."
presetSubtype="..."
fill="hold" nodeType="clickEffect">
<p:childTnLst>
<p:set>...</p:set>
</p:childTnLst>
</p:cTn>
</p:par>
</p:childTnLst>
</p:cTn>
</p:par>
</p:childTnLst>
</p:cTn>
</p:par>
</p:childTnLst>
</p:cTn>
</p:seq>
</p:childTnLst>
</p:cTn>
</p:par>
</p:tnLst>
</p:timing>
The assertions only look at the innermost cTn's presetID /
presetClass / presetSubtype attributes, so the exact envelope detail
is not load-bearing beyond "exists and parses".
"""
from __future__ import annotations
import argparse
import datetime as _dt
from pathlib import Path
from lxml import etree
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE
from pptx.oxml.ns import qn
from pptx.util import Inches
_REPO_ROOT = Path(__file__).resolve().parent.parent
_REPRODUCIBLE_DT = _dt.datetime(2000, 1, 1, 0, 0, 0)
# Effect-table: id -> (presetID, presetClass, presetSubtype).
# Matches the values enumerated in the manifest parameter list.
_EFFECTS: dict[str, tuple[str, str, str]] = {
"fade-in": ("10", "entr", "0"),
"fade-out": ("10", "exit", "0"),
"fly-in-left": ("2", "entr", "4"),
"fly-in-right": ("2", "entr", "8"),
"zoom-in": ("23", "entr", "0"),
"zoom-out": ("23", "exit", "0"),
}
def _build_timing(shape_id: int, preset_id: str, preset_class: str, preset_subtype: str) -> etree._Element:
"""Build the <p:timing> subtree encoding the requested effect.
The inner <p:cTn id='5'> element is the one carrying the three
preset-attribute triple asserted by the manifest.
"""
timing = etree.Element(qn("p:timing"))
tnLst = etree.SubElement(timing, qn("p:tnLst"))
par_root = etree.SubElement(tnLst, qn("p:par"))
cTn_root = etree.SubElement(par_root, qn("p:cTn"))
cTn_root.set("id", "1")
cTn_root.set("dur", "indefinite")
cTn_root.set("restart", "never")
cTn_root.set("nodeType", "tmRoot")
child_root = etree.SubElement(cTn_root, qn("p:childTnLst"))
seq = etree.SubElement(child_root, qn("p:seq"))
seq.set("concurrent", "1")
seq.set("nextAc", "seek")
cTn_seq = etree.SubElement(seq, qn("p:cTn"))
cTn_seq.set("id", "2")
cTn_seq.set("dur", "indefinite")
cTn_seq.set("nodeType", "mainSeq")
child_seq = etree.SubElement(cTn_seq, qn("p:childTnLst"))
par_click = etree.SubElement(child_seq, qn("p:par"))
cTn_click = etree.SubElement(par_click, qn("p:cTn"))
cTn_click.set("id", "3")
cTn_click.set("fill", "hold")
stcl_click = etree.SubElement(cTn_click, qn("p:stCondLst"))
cond_click = etree.SubElement(stcl_click, qn("p:cond"))
cond_click.set("delay", "indefinite")
child_click = etree.SubElement(cTn_click, qn("p:childTnLst"))
par_group = etree.SubElement(child_click, qn("p:par"))
cTn_group = etree.SubElement(par_group, qn("p:cTn"))
cTn_group.set("id", "4")
cTn_group.set("fill", "hold")
stcl_group = etree.SubElement(cTn_group, qn("p:stCondLst"))
cond_group = etree.SubElement(stcl_group, qn("p:cond"))
cond_group.set("delay", "0")
child_group = etree.SubElement(cTn_group, qn("p:childTnLst"))
par_effect = etree.SubElement(child_group, qn("p:par"))
cTn_effect = etree.SubElement(par_effect, qn("p:cTn"))
cTn_effect.set("id", "5")
cTn_effect.set("presetID", preset_id)
cTn_effect.set("presetClass", preset_class)
cTn_effect.set("presetSubtype", preset_subtype)
cTn_effect.set("fill", "hold")
cTn_effect.set("nodeType", "clickEffect")
child_effect = etree.SubElement(cTn_effect, qn("p:childTnLst"))
set_el = etree.SubElement(child_effect, qn("p:set"))
cBhvr = etree.SubElement(set_el, qn("p:cBhvr"))
cTn_set = etree.SubElement(cBhvr, qn("p:cTn"))
cTn_set.set("id", "6")
cTn_set.set("dur", "1")
cTn_set.set("fill", "hold")
tgtEl = etree.SubElement(cBhvr, qn("p:tgtEl"))
spTgt = etree.SubElement(tgtEl, qn("p:spTgt"))
spTgt.set("spid", str(shape_id))
return timing
def _write(effect_id: str, out: Path) -> None:
preset_id, preset_class, preset_subtype = _EFFECTS[effect_id]
prs = Presentation()
# "Title Only" layout; the shape we animate is added below.
slide = prs.slides.add_slide(prs.slide_layouts[5])
shape = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, Inches(2), Inches(2), Inches(3), Inches(2)
)
# shape.shape_id is the p:cNvPr/@id the timing tree references.
shape_id = shape.shape_id
timing = _build_timing(shape_id, preset_id, preset_class, preset_subtype)
# <p:timing> is a direct child of <p:sld>, positioned after
# <p:cSld>/<p:clrMapOvr> and before any <p:extLst>. Appending at
# the end is schema-valid when <p:extLst> is absent (the default).
slide._element.append(timing)
out.parent.mkdir(parents=True, exist_ok=True)
prs.save(out, zip_date_time=_REPRODUCIBLE_DT)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--effect",
required=True,
choices=sorted(_EFFECTS.keys()),
help="Animation effect id (fade-in, fade-out, fly-in-left, fly-in-right, zoom-in, zoom-out).",
)
parser.add_argument(
"--out",
required=True,
help="Output fixture path (absolute or repo-relative).",
)
args = parser.parse_args()
out_path = Path(args.out)
if not out_path.is_absolute():
out_path = _REPO_ROOT / out_path
_write(args.effect, out_path)
print(out_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
{
"$schema": "../manifest.schema.json",
"id": "pptx/animation-effect--zoom-in",
"kind": "literal",
"title": "Animation effect",
"format": "pptx",
"category": "animation",
"roles": [
"authoring"
],
"summary": "Attach a <p:timing> tree to a slide with a single animation effect bound to a shape. The manifest expands to one case per effect kind (fade-in, fade-out, fly-in-left, fly-in-right, zoom-in, zoom-out). python-pptx exposes no high-level animation API; the generator builds the timing tree directly via lxml. Each case encodes the effect as an <p:par>/<p:cTn>/.../<p:par> chain whose leaf carries a preset animation attribute combination (presetID, presetClass, presetSubtype). Authoring-side only: the corpus records what was written, not playback.",
"spec": {
"source": "ecma-376-5-part-1",
"clause": "19.5.70",
"element": "p:timing",
"rnc_reference": "spec/ecma-376-5/part-1/rnc/pml.rnc",
"xsd_reference": "spec/ecma-376-5/part-1/xsd/pml.xsd",
"notes": "<p:timing> carries the slide's animation tree: a hierarchy of <p:par> (parallel) and <p:seq> (sequence) time nodes, each with a <p:cTn> (common timing) descriptor. Effects are identified by presetID / presetClass / presetSubtype attributes on <p:cTn> of the innermost <p:par>. The six effects asserted here are the best-known PowerPoint defaults: fade (presetID=10, class=entr/exit), fly (presetID=2 with presetSubtype for direction), zoom (presetID=23, class=entr/exit). Since python-pptx has no high-level animation API, the XML is appended directly — the assertions check for the presence of <p:timing>//p:par with a <p:cTn> carrying the expected presetID + presetClass + presetSubtype attributes."
},
"fixtures": {
"machine": "pptx/animation-effect--zoom-in"
},
"generator": {
"python": "scripts/gen_animation_effect.py",
"arg_template": "--effect {effect.id} --out fixtures/pptx/animation-effect--{effect.id}.pptx"
},
"_expansion": {
"parent_id": "pptx/animation-effect",
"bindings": {
"effect": "zoom-in"
}
},
"assertions": [
{
"id": "timing-present-zoom-in",
"part": "ppt/slides/slide1.xml",
"namespaces": {
"p": "http://schemas.openxmlformats.org/presentationml/2006/main"
},
"xpath": "//p:timing",
"must": "exist",
"description": "The slide must carry a <p:timing> element holding the animation tree."
},
{
"id": "preset-id-zoom-in",
"part": "ppt/slides/slide1.xml",
"namespaces": {
"p": "http://schemas.openxmlformats.org/presentationml/2006/main"
},
"xpath": "//p:timing//p:cTn[@presetID and @presetClass]/@presetID",
"must": "equal",
"value": "23",
"description": "The innermost animation <p:cTn> must carry presetID='23' identifying the effect kind (10=fade, 2=fly, 23=zoom)."
},
{
"id": "preset-class-zoom-in",
"part": "ppt/slides/slide1.xml",
"namespaces": {
"p": "http://schemas.openxmlformats.org/presentationml/2006/main"
},
"xpath": "//p:timing//p:cTn[@presetID and @presetClass]/@presetClass",
"must": "equal",
"value": "entr",
"description": "The innermost animation <p:cTn> must carry presetClass='entr' (entr for enter, exit for exit)."
},
{
"id": "preset-subtype-zoom-in",
"part": "ppt/slides/slide1.xml",
"namespaces": {
"p": "http://schemas.openxmlformats.org/presentationml/2006/main"
},
"xpath": "//p:timing//p:cTn[@presetID and @presetClass]/@presetSubtype",
"must": "equal",
"value": "0",
"description": "The innermost animation <p:cTn> must carry presetSubtype='0' (direction/variant code; 0 for plain fade/zoom, 4=left, 8=right for fly-in)."
}
]
}
