python-pptx2 2.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pptx2/__init__.py +152 -0
- pptx2/_color.py +75 -0
- pptx2/_slide_importer.py +597 -0
- pptx2/_svg.py +155 -0
- pptx2/_template_applier.py +292 -0
- pptx2/_textstyle.py +187 -0
- pptx2/accessibility.py +365 -0
- pptx2/action.py +270 -0
- pptx2/animation.py +2237 -0
- pptx2/api.py +49 -0
- pptx2/audit.py +258 -0
- pptx2/chart/__init__.py +0 -0
- pptx2/chart/analytics.py +381 -0
- pptx2/chart/axis.py +543 -0
- pptx2/chart/category.py +200 -0
- pptx2/chart/chart.py +670 -0
- pptx2/chart/data.py +864 -0
- pptx2/chart/datalabel.py +406 -0
- pptx2/chart/legend.py +86 -0
- pptx2/chart/marker.py +70 -0
- pptx2/chart/palettes.py +129 -0
- pptx2/chart/plot.py +462 -0
- pptx2/chart/point.py +101 -0
- pptx2/chart/quick_layouts.py +325 -0
- pptx2/chart/series.py +334 -0
- pptx2/chart/xlsx.py +272 -0
- pptx2/chart/xmlwriter.py +1845 -0
- pptx2/compose/__init__.py +28 -0
- pptx2/compose/from_spec.py +1094 -0
- pptx2/design/__init__.py +8 -0
- pptx2/design/components.py +607 -0
- pptx2/design/figures.py +389 -0
- pptx2/design/layout.py +370 -0
- pptx2/design/recipes.py +1967 -0
- pptx2/design/style.py +209 -0
- pptx2/design/tokens.py +915 -0
- pptx2/diagrams.py +754 -0
- pptx2/dml/__init__.py +0 -0
- pptx2/dml/chtfmt.py +40 -0
- pptx2/dml/color.py +496 -0
- pptx2/dml/effect.py +909 -0
- pptx2/dml/fill.py +691 -0
- pptx2/dml/line.py +287 -0
- pptx2/dml/picture.py +212 -0
- pptx2/dml/three_d.py +381 -0
- pptx2/enum/__init__.py +0 -0
- pptx2/enum/action.py +71 -0
- pptx2/enum/animation.py +31 -0
- pptx2/enum/base.py +218 -0
- pptx2/enum/chart.py +574 -0
- pptx2/enum/dml.py +740 -0
- pptx2/enum/lang.py +685 -0
- pptx2/enum/presentation.py +133 -0
- pptx2/enum/shapes.py +1029 -0
- pptx2/enum/text.py +230 -0
- pptx2/exc.py +42 -0
- pptx2/formats.py +139 -0
- pptx2/geometry.py +420 -0
- pptx2/inherit.py +109 -0
- pptx2/lint.py +2256 -0
- pptx2/math.py +177 -0
- pptx2/media.py +197 -0
- pptx2/opc/__init__.py +0 -0
- pptx2/opc/constants.py +332 -0
- pptx2/opc/oxml.py +188 -0
- pptx2/opc/package.py +762 -0
- pptx2/opc/packuri.py +109 -0
- pptx2/opc/serialized.py +296 -0
- pptx2/opc/shared.py +20 -0
- pptx2/opc/spec.py +45 -0
- pptx2/oxml/__init__.py +555 -0
- pptx2/oxml/action.py +53 -0
- pptx2/oxml/chart/__init__.py +0 -0
- pptx2/oxml/chart/axis.py +337 -0
- pptx2/oxml/chart/chart.py +481 -0
- pptx2/oxml/chart/datalabel.py +253 -0
- pptx2/oxml/chart/legend.py +72 -0
- pptx2/oxml/chart/marker.py +61 -0
- pptx2/oxml/chart/plot.py +365 -0
- pptx2/oxml/chart/series.py +425 -0
- pptx2/oxml/chart/shared.py +220 -0
- pptx2/oxml/coreprops.py +288 -0
- pptx2/oxml/dml/__init__.py +0 -0
- pptx2/oxml/dml/color.py +135 -0
- pptx2/oxml/dml/effect.py +213 -0
- pptx2/oxml/dml/fill.py +316 -0
- pptx2/oxml/dml/line.py +12 -0
- pptx2/oxml/dml/three_d.py +110 -0
- pptx2/oxml/ns.py +135 -0
- pptx2/oxml/presentation.py +313 -0
- pptx2/oxml/shapes/__init__.py +19 -0
- pptx2/oxml/shapes/autoshape.py +467 -0
- pptx2/oxml/shapes/connector.py +107 -0
- pptx2/oxml/shapes/graphfrm.py +347 -0
- pptx2/oxml/shapes/groupshape.py +329 -0
- pptx2/oxml/shapes/picture.py +270 -0
- pptx2/oxml/shapes/shared.py +577 -0
- pptx2/oxml/simpletypes.py +1027 -0
- pptx2/oxml/slide.py +563 -0
- pptx2/oxml/table.py +650 -0
- pptx2/oxml/text.py +815 -0
- pptx2/oxml/theme.py +36 -0
- pptx2/oxml/xmlchemy.py +717 -0
- pptx2/package.py +222 -0
- pptx2/parts/__init__.py +0 -0
- pptx2/parts/chart.py +95 -0
- pptx2/parts/coreprops.py +167 -0
- pptx2/parts/diagram.py +37 -0
- pptx2/parts/embeddedpackage.py +93 -0
- pptx2/parts/image.py +275 -0
- pptx2/parts/media.py +37 -0
- pptx2/parts/presentation.py +136 -0
- pptx2/parts/slide.py +371 -0
- pptx2/presentation.py +408 -0
- pptx2/py.typed +0 -0
- pptx2/render.py +586 -0
- pptx2/section.py +272 -0
- pptx2/shapes/__init__.py +26 -0
- pptx2/shapes/autoshape.py +442 -0
- pptx2/shapes/base.py +1078 -0
- pptx2/shapes/connector.py +297 -0
- pptx2/shapes/freeform.py +337 -0
- pptx2/shapes/graphfrm.py +316 -0
- pptx2/shapes/group.py +264 -0
- pptx2/shapes/picture.py +422 -0
- pptx2/shapes/placeholder.py +468 -0
- pptx2/shapes/shapetree.py +2027 -0
- pptx2/shared.py +82 -0
- pptx2/skill/SKILL.md +450 -0
- pptx2/skill/__init__.py +78 -0
- pptx2/skill/__main__.py +64 -0
- pptx2/skill/references/animations.md +189 -0
- pptx2/skill/references/basics.md +421 -0
- pptx2/skill/references/charts.md +254 -0
- pptx2/skill/references/compose.md +234 -0
- pptx2/skill/references/design.md +366 -0
- pptx2/skill/references/effects.md +249 -0
- pptx2/skill/references/end-to-end-deck.md +231 -0
- pptx2/skill/references/geometry-and-arrows.md +334 -0
- pptx2/skill/references/lint.md +275 -0
- pptx2/skill/references/math.md +86 -0
- pptx2/skill/references/picture-effects.md +129 -0
- pptx2/skill/references/render.md +151 -0
- pptx2/skill/references/smart-art.md +75 -0
- pptx2/skill/references/space-aware-authoring.md +249 -0
- pptx2/skill/references/tables.md +244 -0
- pptx2/skill/references/theme.md +127 -0
- pptx2/skill/references/three-d.md +109 -0
- pptx2/skill/references/transitions.md +100 -0
- pptx2/slide.py +1244 -0
- pptx2/smart_art.py +220 -0
- pptx2/spec.py +633 -0
- pptx2/table.py +1181 -0
- pptx2/table_styles.py +184 -0
- pptx2/templates/default.pptx +0 -0
- pptx2/templates/docx-icon.emf +0 -0
- pptx2/templates/generic-icon.emf +0 -0
- pptx2/templates/notes.xml +23 -0
- pptx2/templates/notesMaster.xml +352 -0
- pptx2/templates/pptx-icon.emf +0 -0
- pptx2/templates/theme.xml +321 -0
- pptx2/templates/xlsx-icon.emf +0 -0
- pptx2/text/__init__.py +0 -0
- pptx2/text/fonts.py +482 -0
- pptx2/text/layout.py +374 -0
- pptx2/text/text.py +1272 -0
- pptx2/theme.py +721 -0
- pptx2/types.py +36 -0
- pptx2/util.py +263 -0
- python_pptx2-2.13.0.dist-info/METADATA +351 -0
- python_pptx2-2.13.0.dist-info/RECORD +175 -0
- python_pptx2-2.13.0.dist-info/WHEEL +5 -0
- python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
- python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
- python_pptx2-2.13.0.dist-info/top_level.txt +1 -0
pptx2/animation.py
ADDED
|
@@ -0,0 +1,2237 @@
|
|
|
1
|
+
"""High-level animation API for python-pptx.
|
|
2
|
+
|
|
3
|
+
.. warning::
|
|
4
|
+
|
|
5
|
+
**Experimental — playback is currently broken in PowerPoint.**
|
|
6
|
+
Animation timing XML produced by this module round-trips through
|
|
7
|
+
the OOXML schema, reads back correctly via the introspection API,
|
|
8
|
+
and converts cleanly to PDF via LibreOffice. But in PowerPoint
|
|
9
|
+
slideshow mode, animated shapes sit at 10–15% opacity for several
|
|
10
|
+
seconds and then snap to fully visible all at once, instead of
|
|
11
|
+
playing the requested animation. Decks containing entrance
|
|
12
|
+
animations on the same slide as a Morph transition can additionally
|
|
13
|
+
trigger PowerPoint's "Repair?" dialog on open.
|
|
14
|
+
|
|
15
|
+
Until this is resolved, prefer slide :class:`transitions
|
|
16
|
+
<pptx2.slide.SlideTransition>` (which round-trip and play
|
|
17
|
+
correctly) and treat the animation API as a code-shape preview
|
|
18
|
+
only. See ``IMPROVEMENT_PLAN.md`` (item 1) for the diagnostic plan.
|
|
19
|
+
|
|
20
|
+
Exposes entrance, exit, and emphasis preset animations that map to
|
|
21
|
+
PowerPoint's built-in animation library. Generated XML is valid
|
|
22
|
+
OOXML and persists/reads back correctly at the XML level via the
|
|
23
|
+
introspection API — but that is a *schema-validity* guarantee, not a
|
|
24
|
+
guarantee of PowerPoint open/save round-tripping or slideshow
|
|
25
|
+
playback (see the warning above).
|
|
26
|
+
|
|
27
|
+
Typical usage::
|
|
28
|
+
|
|
29
|
+
from pptx2.animation import Entrance, Exit, Emphasis, MotionPath, Trigger
|
|
30
|
+
|
|
31
|
+
# Fade a shape in on the next mouse click (default trigger)
|
|
32
|
+
Entrance.fade(slide, shape)
|
|
33
|
+
|
|
34
|
+
# Fly in from the bottom, starting with the previous effect
|
|
35
|
+
Entrance.fly_in(slide, shape, trigger=Trigger.WITH_PREVIOUS)
|
|
36
|
+
|
|
37
|
+
# Pulse emphasis
|
|
38
|
+
Emphasis.pulse(slide, shape)
|
|
39
|
+
|
|
40
|
+
# Fade exit
|
|
41
|
+
Exit.fade(slide, shape)
|
|
42
|
+
|
|
43
|
+
# Move along a straight line, two inches right and one inch down
|
|
44
|
+
from pptx2.util import Inches
|
|
45
|
+
MotionPath.line(slide, shape, Inches(2), Inches(1))
|
|
46
|
+
|
|
47
|
+
# Or pass an SVG-style path with a viewbox.
|
|
48
|
+
MotionPath.svg(slide, shape, "M 0 0 H 100 V 100", viewbox=(0, 0, 100, 100))
|
|
49
|
+
|
|
50
|
+
# Fade in each paragraph of a text frame, one after another
|
|
51
|
+
Entrance.fade(slide, text_frame, by_paragraph=True)
|
|
52
|
+
|
|
53
|
+
# Sequence multiple effects one after another with a single click
|
|
54
|
+
with slide.animations.sequence():
|
|
55
|
+
Entrance.fade(slide, title)
|
|
56
|
+
Entrance.fly_in(slide, body)
|
|
57
|
+
Emphasis.pulse(slide, badge)
|
|
58
|
+
|
|
59
|
+
# Via the slide proxy
|
|
60
|
+
slide.animations.add_entrance("fade", shape)
|
|
61
|
+
|
|
62
|
+
# Polymorphic dispatcher — useful when the kind is data-driven
|
|
63
|
+
# (e.g. from a YAML spec).
|
|
64
|
+
slide.animations.add("entrance", "fade", shape)
|
|
65
|
+
slide.animations.add("emphasis", "pulse", shape)
|
|
66
|
+
slide.animations.add("motion", "M 0 0 L 0.5 0 E", shape, duration=1500)
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
from __future__ import annotations
|
|
70
|
+
|
|
71
|
+
import math
|
|
72
|
+
from contextlib import contextmanager
|
|
73
|
+
from dataclasses import dataclass
|
|
74
|
+
from typing import TYPE_CHECKING, Any, Iterator, Optional, cast
|
|
75
|
+
|
|
76
|
+
from pptx2.enum.animation import PP_ANIM_TRIGGER
|
|
77
|
+
from pptx2.oxml.ns import nsdecls, qn
|
|
78
|
+
from pptx2.oxml import parse_xml
|
|
79
|
+
|
|
80
|
+
if TYPE_CHECKING:
|
|
81
|
+
from pptx2.shapes.base import BaseShape
|
|
82
|
+
from pptx2.slide import Slide
|
|
83
|
+
from pptx2.text.text import TextFrame
|
|
84
|
+
|
|
85
|
+
#: Short alias; application code reads ``Trigger.ON_CLICK`` more naturally.
|
|
86
|
+
Trigger = PP_ANIM_TRIGGER
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Easing curves
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
#: Named easings -> ``(accel, decel)`` fractions of the animation duration
|
|
94
|
+
#: spent in acceleration / deceleration phases. Each value is between 0.0
|
|
95
|
+
#: and 1.0. These four cover the common cases; pass an explicit
|
|
96
|
+
#: ``(accel, decel)`` tuple for anything else.
|
|
97
|
+
_EASING_PRESETS = {
|
|
98
|
+
"linear": (0.0, 0.0),
|
|
99
|
+
"ease_in": (0.5, 0.0),
|
|
100
|
+
"ease_out": (0.0, 0.5),
|
|
101
|
+
"ease_in_out": (0.3, 0.3),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _resolve_easing(easing) -> tuple[float, float]:
|
|
106
|
+
"""Resolve an ``easing`` argument to an ``(accel, decel)`` 2-tuple."""
|
|
107
|
+
if isinstance(easing, str):
|
|
108
|
+
try:
|
|
109
|
+
return _EASING_PRESETS[easing]
|
|
110
|
+
except KeyError:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"unknown easing preset %r; choose from %r or pass an "
|
|
113
|
+
"explicit (accel, decel) tuple"
|
|
114
|
+
% (easing, sorted(_EASING_PRESETS))
|
|
115
|
+
)
|
|
116
|
+
if (
|
|
117
|
+
isinstance(easing, tuple)
|
|
118
|
+
and len(easing) == 2
|
|
119
|
+
and all(isinstance(v, (int, float)) for v in easing)
|
|
120
|
+
):
|
|
121
|
+
accel, decel = float(easing[0]), float(easing[1])
|
|
122
|
+
if not (0.0 <= accel <= 1.0 and 0.0 <= decel <= 1.0 and accel + decel <= 1.0):
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"easing accel and decel must each be in [0, 1] and sum to ≤ 1"
|
|
125
|
+
)
|
|
126
|
+
return accel, decel
|
|
127
|
+
raise TypeError(
|
|
128
|
+
"easing must be a preset name (e.g. 'ease_in_out') or an "
|
|
129
|
+
"(accel, decel) 2-tuple of floats"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _apply_easing(group_elm, easing) -> None:
|
|
134
|
+
"""Stamp ``accel`` / ``decel`` onto every animation-duration ``<p:cTn>``.
|
|
135
|
+
|
|
136
|
+
Operates only on ``<p:cTn>`` elements whose ``dur`` attribute is a
|
|
137
|
+
positive integer greater than 1 — those are the "effect-level" timing
|
|
138
|
+
nodes that drive the actual animation, not the wrapper / 1-frame
|
|
139
|
+
visibility nodes.
|
|
140
|
+
"""
|
|
141
|
+
accel, decel = _resolve_easing(easing)
|
|
142
|
+
# ``int(x + 0.5)`` is round-half-up for the always-non-negative
|
|
143
|
+
# ``accel`` / ``decel`` values; behaves identically to ``round()`` here
|
|
144
|
+
# but is unambiguous regardless of banker's-rounding edge cases.
|
|
145
|
+
accel_pct = int(accel * 100000 + 0.5)
|
|
146
|
+
decel_pct = int(decel * 100000 + 0.5)
|
|
147
|
+
for ctn in group_elm.iter(qn("p:cTn")):
|
|
148
|
+
dur = ctn.get("dur")
|
|
149
|
+
try:
|
|
150
|
+
dur_i = int(dur) if dur is not None else 0
|
|
151
|
+
except ValueError:
|
|
152
|
+
continue
|
|
153
|
+
if dur_i <= 1:
|
|
154
|
+
continue
|
|
155
|
+
if accel_pct:
|
|
156
|
+
ctn.set("accel", str(accel_pct))
|
|
157
|
+
if decel_pct:
|
|
158
|
+
ctn.set("decel", str(decel_pct))
|
|
159
|
+
|
|
160
|
+
# Sentinel for "trigger not specified" — lets `sequence()` distinguish an
|
|
161
|
+
# explicit caller-supplied trigger from the default. Don't use ``None``
|
|
162
|
+
# because that's a valid attribute value elsewhere.
|
|
163
|
+
_TRIGGER_UNSET = object()
|
|
164
|
+
|
|
165
|
+
# presetClass attribute → human kind name. Used by AnimationEntry.kind
|
|
166
|
+
# for read-side introspection.
|
|
167
|
+
_PRESET_CLASS_TO_KIND: dict[str, str] = {
|
|
168
|
+
"entr": "entrance",
|
|
169
|
+
"exit": "exit",
|
|
170
|
+
"emph": "emphasis",
|
|
171
|
+
"path": "motion",
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# nodeType attribute on the wrapper cTn → trigger enum. Used by
|
|
175
|
+
# AnimationEntry.trigger.
|
|
176
|
+
_NODE_TYPE_TO_TRIGGER: dict[str, "PP_ANIM_TRIGGER"] = {
|
|
177
|
+
"clickEffect": PP_ANIM_TRIGGER.ON_CLICK,
|
|
178
|
+
"withEffect": PP_ANIM_TRIGGER.WITH_PREVIOUS,
|
|
179
|
+
"afterEffect": PP_ANIM_TRIGGER.AFTER_PREVIOUS,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Namespace helpers
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
_P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
|
187
|
+
_NS = {"p": _P_NS}
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# Preset metadata
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
# presetID values match PowerPoint's internal numbering
|
|
194
|
+
_ENTRANCE_PRESETS = {
|
|
195
|
+
"appear": (1, 0), # (presetID, presetSubtype)
|
|
196
|
+
"fade": (10, 0),
|
|
197
|
+
"fly_in": (2, 8), # default: from bottom (subtype 8)
|
|
198
|
+
"float_in": (22, 0),
|
|
199
|
+
"wipe": (8, 2), # default: left
|
|
200
|
+
"zoom": (18, 0),
|
|
201
|
+
"wheel": (20, 1), # 1 spoke
|
|
202
|
+
"random_bars": (12, 1), # horizontal
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_EXIT_PRESETS = {
|
|
206
|
+
"disappear": (1, 0),
|
|
207
|
+
"fade": (10, 0),
|
|
208
|
+
"fly_out": (2, 8),
|
|
209
|
+
"float_out": (22, 0),
|
|
210
|
+
"wipe": (8, 2),
|
|
211
|
+
"zoom": (18, 0),
|
|
212
|
+
"wheel": (20, 1),
|
|
213
|
+
"random_bars": (12, 1),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
_EMPHASIS_PRESETS = {
|
|
217
|
+
"pulse": (13, 0),
|
|
218
|
+
"spin": (5, 0),
|
|
219
|
+
"teeter": (6, 0),
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# Reverse mappings from (presetID, presetSubtype) → preset name, keyed by
|
|
223
|
+
# preset class. Used by AnimationEntry.preset for read-side introspection.
|
|
224
|
+
# Subtype matches are preferred but falling back to a "subtype-agnostic"
|
|
225
|
+
# match handles presets like ``fly_in`` where subtype encodes direction.
|
|
226
|
+
def _build_reverse_presets() -> dict[str, dict[tuple[int, int], str]]:
|
|
227
|
+
out: dict[str, dict[tuple[int, int], str]] = {
|
|
228
|
+
"entr": {(pid, sub): name for name, (pid, sub) in _ENTRANCE_PRESETS.items()},
|
|
229
|
+
"exit": {(pid, sub): name for name, (pid, sub) in _EXIT_PRESETS.items()},
|
|
230
|
+
"emph": {(pid, sub): name for name, (pid, sub) in _EMPHASIS_PRESETS.items()},
|
|
231
|
+
}
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
_REVERSE_PRESETS = _build_reverse_presets()
|
|
235
|
+
_REVERSE_PRESET_BY_ID: dict[str, dict[int, str]] = {
|
|
236
|
+
cls: {pid: name for (pid, _sub), name in entries.items()}
|
|
237
|
+
for cls, entries in _REVERSE_PRESETS.items()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
# animEffect filter strings for each preset name (entrance direction)
|
|
241
|
+
_EFFECT_FILTER = {
|
|
242
|
+
"fade": "fade",
|
|
243
|
+
"float_in": "fade",
|
|
244
|
+
"float_out": "fade",
|
|
245
|
+
"wipe": "wipe(dir=left)",
|
|
246
|
+
"zoom": "zoom(dir=in)",
|
|
247
|
+
"wheel": "wheel(spokes=1)",
|
|
248
|
+
"random_bars": "randomBar(dir=horz)",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
# FlyIn/FlyOut path templates (M start L end E)
|
|
252
|
+
_FLY_PATHS_IN = {
|
|
253
|
+
"bottom": "M 0 1 L 0 0 E",
|
|
254
|
+
"top": "M 0 -1 L 0 0 E",
|
|
255
|
+
"left": "M -1 0 L 0 0 E",
|
|
256
|
+
"right": "M 1 0 L 0 0 E",
|
|
257
|
+
}
|
|
258
|
+
_FLY_PATHS_OUT = {
|
|
259
|
+
"bottom": "M 0 0 L 0 1 E",
|
|
260
|
+
"top": "M 0 0 L 0 -1 E",
|
|
261
|
+
"left": "M 0 0 L -1 0 E",
|
|
262
|
+
"right": "M 0 0 L 1 0 E",
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# Internal XML builders
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _nsdecls_p() -> str:
|
|
271
|
+
return nsdecls("p")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _visibility_set_xml(ctn_id: int, spid: int, visible: bool) -> str:
|
|
275
|
+
"""Return XML for a `<p:set>` that shows or hides a shape."""
|
|
276
|
+
val = "visible" if visible else "hidden"
|
|
277
|
+
return (
|
|
278
|
+
"<p:set>\n"
|
|
279
|
+
" <p:cBhvr>\n"
|
|
280
|
+
f' <p:cTn id="{ctn_id}" dur="1" fill="hold"/>\n'
|
|
281
|
+
f' <p:tgtEl><p:spTgt spid="{spid}"/></p:tgtEl>\n'
|
|
282
|
+
" <p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst>\n"
|
|
283
|
+
" </p:cBhvr>\n"
|
|
284
|
+
f' <p:to><p:strVal val="{val}"/></p:to>\n'
|
|
285
|
+
"</p:set>\n"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _anim_effect_xml(ctn_id: int, spid: int, duration: int, filter_str: str, transition: str) -> str:
|
|
290
|
+
return (
|
|
291
|
+
f'<p:animEffect transition="{transition}" filter="{filter_str}">\n'
|
|
292
|
+
" <p:cBhvr>\n"
|
|
293
|
+
f' <p:cTn id="{ctn_id}" dur="{duration}"/>\n'
|
|
294
|
+
f' <p:tgtEl><p:spTgt spid="{spid}"/></p:tgtEl>\n'
|
|
295
|
+
" </p:cBhvr>\n"
|
|
296
|
+
"</p:animEffect>\n"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _anim_motion_xml(ctn_id: int, spid: int, duration: int, path: str) -> str:
|
|
301
|
+
return (
|
|
302
|
+
f'<p:animMotion origin="parent" path="{path}" pathEditMode="relative" rAng="0" ptsTypes="AE">\n'
|
|
303
|
+
" <p:cBhvr>\n"
|
|
304
|
+
f' <p:cTn id="{ctn_id}" dur="{duration}" fill="hold"/>\n'
|
|
305
|
+
f' <p:tgtEl><p:spTgt spid="{spid}"/></p:tgtEl>\n'
|
|
306
|
+
" </p:cBhvr>\n"
|
|
307
|
+
"</p:animMotion>\n"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _visibility_set_xml_for_paragraph(
|
|
312
|
+
ctn_id: int, spid: int, paragraph_idx: int, visible: bool
|
|
313
|
+
) -> str:
|
|
314
|
+
"""Return XML for a `<p:set>` targeting a single paragraph by index."""
|
|
315
|
+
val = "visible" if visible else "hidden"
|
|
316
|
+
return (
|
|
317
|
+
"<p:set>\n"
|
|
318
|
+
" <p:cBhvr>\n"
|
|
319
|
+
f' <p:cTn id="{ctn_id}" dur="1" fill="hold"/>\n'
|
|
320
|
+
f' <p:tgtEl><p:spTgt spid="{spid}">'
|
|
321
|
+
f'<p:txEl><p:pRg st="{paragraph_idx}" end="{paragraph_idx}"/></p:txEl>'
|
|
322
|
+
"</p:spTgt></p:tgtEl>\n"
|
|
323
|
+
" <p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst>\n"
|
|
324
|
+
" </p:cBhvr>\n"
|
|
325
|
+
f' <p:to><p:strVal val="{val}"/></p:to>\n'
|
|
326
|
+
"</p:set>\n"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _anim_effect_xml_for_paragraph(
|
|
331
|
+
ctn_id: int,
|
|
332
|
+
spid: int,
|
|
333
|
+
paragraph_idx: int,
|
|
334
|
+
duration: int,
|
|
335
|
+
filter_str: str,
|
|
336
|
+
transition: str,
|
|
337
|
+
) -> str:
|
|
338
|
+
"""Return XML for a `<p:animEffect>` targeting a single paragraph by index."""
|
|
339
|
+
return (
|
|
340
|
+
f'<p:animEffect transition="{transition}" filter="{filter_str}">\n'
|
|
341
|
+
" <p:cBhvr>\n"
|
|
342
|
+
f' <p:cTn id="{ctn_id}" dur="{duration}"/>\n'
|
|
343
|
+
f' <p:tgtEl><p:spTgt spid="{spid}">'
|
|
344
|
+
f'<p:txEl><p:pRg st="{paragraph_idx}" end="{paragraph_idx}"/></p:txEl>'
|
|
345
|
+
"</p:spTgt></p:tgtEl>\n"
|
|
346
|
+
" </p:cBhvr>\n"
|
|
347
|
+
"</p:animEffect>\n"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _anim_scale_xml(ctn_id: int, spid: int, duration: int, x: int = 133333, y: int = 133333) -> str:
|
|
352
|
+
"""Return XML for a `<p:animScale>` (used by Pulse emphasis)."""
|
|
353
|
+
return (
|
|
354
|
+
"<p:animScale>\n"
|
|
355
|
+
" <p:cBhvr>\n"
|
|
356
|
+
f' <p:cTn id="{ctn_id}" dur="{duration}" autoRev="1"/>\n'
|
|
357
|
+
f' <p:tgtEl><p:spTgt spid="{spid}"/></p:tgtEl>\n'
|
|
358
|
+
" </p:cBhvr>\n"
|
|
359
|
+
f' <p:by x="{x}" y="{y}"/>\n'
|
|
360
|
+
"</p:animScale>\n"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _anim_rot_xml(ctn_id: int, spid: int, duration: int, angle_deg: float = 360.0) -> str:
|
|
365
|
+
"""Return XML for a `<p:animRot>` (used by Spin emphasis)."""
|
|
366
|
+
ang = int(angle_deg * 60000)
|
|
367
|
+
# `by` is an ST_Angle *attribute* of <p:animRot> (CT_TLAnimateRotationBehavior
|
|
368
|
+
# allows only a <p:cBhvr> child). Emitting it as a <p:by> child — as
|
|
369
|
+
# <p:animScale> legitimately does — produces XML that PowerPoint rejects.
|
|
370
|
+
return (
|
|
371
|
+
f'<p:animRot by="{ang}">\n'
|
|
372
|
+
" <p:cBhvr>\n"
|
|
373
|
+
f' <p:cTn id="{ctn_id}" dur="{duration}" fill="hold"/>\n'
|
|
374
|
+
f' <p:tgtEl><p:spTgt spid="{spid}"/></p:tgtEl>\n'
|
|
375
|
+
" </p:cBhvr>\n"
|
|
376
|
+
"</p:animRot>\n"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _prune_empty_timing(sld: Any) -> None:
|
|
381
|
+
"""Drop the slide's timing content once it holds no time nodes.
|
|
382
|
+
|
|
383
|
+
An empty `<p:childTnLst>` violates the schema (CT_TimeNodeList requires
|
|
384
|
+
at least one time-node child), so whichever removal takes out the last
|
|
385
|
+
animation entry must remove the `p:tnLst` subtree with it. A `p:bldLst`
|
|
386
|
+
goes too — its build entries reference the effects just removed. An
|
|
387
|
+
extension list (`p:extLst`) is preserved: CT_SlideTiming allows a timing
|
|
388
|
+
element holding only extensions, and its content is not ours to drop.
|
|
389
|
+
Timing trees that still carry time nodes (remaining effects, or a
|
|
390
|
+
`p:video` node for a movie's play controls) are left untouched.
|
|
391
|
+
"""
|
|
392
|
+
timing = sld.find(qn("p:timing"))
|
|
393
|
+
if timing is None:
|
|
394
|
+
return
|
|
395
|
+
if sld.xpath("p:timing/p:tnLst/p:par/p:cTn/p:childTnLst/*"):
|
|
396
|
+
return
|
|
397
|
+
ext_lst_tag = qn("p:extLst")
|
|
398
|
+
for child in list(timing):
|
|
399
|
+
if child.tag != ext_lst_tag:
|
|
400
|
+
timing.remove(child)
|
|
401
|
+
if len(timing) == 0:
|
|
402
|
+
sld._remove_timing()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
# SlideAnimations – the object returned by slide.animations
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class SlideAnimations:
|
|
411
|
+
"""Manages the animation timeline for a single slide.
|
|
412
|
+
|
|
413
|
+
Returned by :attr:`pptx2.slide.Slide.animations`. Provides methods to
|
|
414
|
+
append entrance, exit, and emphasis effects to the slide's timing tree.
|
|
415
|
+
Existing animations (e.g. authored in PowerPoint) are left untouched;
|
|
416
|
+
new effects are appended after them.
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
def __init__(self, slide: Slide):
|
|
420
|
+
self._slide = slide
|
|
421
|
+
# Sequence-context state: when active, the first add_* call uses
|
|
422
|
+
# `_seq_start` as its trigger and subsequent calls default to
|
|
423
|
+
# AFTER_PREVIOUS so effects play one after another from a single click.
|
|
424
|
+
# `_seq_delay` is added to the first effect's `delay` so that
|
|
425
|
+
# `sequence(delay=N)` shifts the whole chain forward by N ms.
|
|
426
|
+
self._seq_active: bool = False
|
|
427
|
+
self._seq_start: PP_ANIM_TRIGGER = PP_ANIM_TRIGGER.ON_CLICK
|
|
428
|
+
self._seq_count: int = 0
|
|
429
|
+
self._seq_delay: int = 0
|
|
430
|
+
self._seq_delay_consumed: bool = False
|
|
431
|
+
# Group-context state. When active, the first call uses ``_grp_start``
|
|
432
|
+
# and every subsequent call within the block defaults to
|
|
433
|
+
# WITH_PREVIOUS — i.e. the whole cluster animates as one visual unit.
|
|
434
|
+
self._grp_active: bool = False
|
|
435
|
+
self._grp_start: PP_ANIM_TRIGGER = PP_ANIM_TRIGGER.AFTER_PREVIOUS
|
|
436
|
+
self._grp_count: int = 0
|
|
437
|
+
self._grp_delay: int = 0
|
|
438
|
+
self._grp_delay_consumed: bool = False
|
|
439
|
+
|
|
440
|
+
# -- introspection ------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
def __iter__(self) -> "Iterator[AnimationEntry]":
|
|
443
|
+
"""Iterate over the slide's top-level animation entries.
|
|
444
|
+
|
|
445
|
+
Yields one :class:`AnimationEntry` per click-group ``<p:par>``,
|
|
446
|
+
in document order. Effects authored inside PowerPoint as well
|
|
447
|
+
as those added via this API are reported.
|
|
448
|
+
"""
|
|
449
|
+
for top_par in self._top_level_pars():
|
|
450
|
+
yield AnimationEntry(top_par, self._slide)
|
|
451
|
+
|
|
452
|
+
def __len__(self) -> int:
|
|
453
|
+
return len(self._top_level_pars())
|
|
454
|
+
|
|
455
|
+
def __bool__(self) -> bool: # explicit so __len__ doesn't drive truthiness alone
|
|
456
|
+
return bool(self._top_level_pars())
|
|
457
|
+
|
|
458
|
+
def list(self) -> "list[AnimationEntry]":
|
|
459
|
+
"""Return a list of :class:`AnimationEntry` views, in document order.
|
|
460
|
+
|
|
461
|
+
Convenience for callers that prefer not to iterate.
|
|
462
|
+
"""
|
|
463
|
+
return list(self)
|
|
464
|
+
|
|
465
|
+
def clear(self) -> int:
|
|
466
|
+
"""Remove every animation from the slide.
|
|
467
|
+
|
|
468
|
+
Returns the number of top-level click-group entries removed.
|
|
469
|
+
Unlike :meth:`purge_orphans`, this drops **all** entries — useful
|
|
470
|
+
when iterating on animation design and you want to re-run the
|
|
471
|
+
build without the previous run's effects piling up.
|
|
472
|
+
"""
|
|
473
|
+
removed = 0
|
|
474
|
+
for top_par in list(self._top_level_pars()):
|
|
475
|
+
parent = top_par.getparent()
|
|
476
|
+
if parent is not None:
|
|
477
|
+
parent.remove(top_par)
|
|
478
|
+
removed += 1
|
|
479
|
+
_prune_empty_timing(self._slide._element)
|
|
480
|
+
return removed
|
|
481
|
+
|
|
482
|
+
def _top_level_pars(self) -> list[Any]:
|
|
483
|
+
"""Return the top-level click-group ``<p:par>`` elements."""
|
|
484
|
+
sld = self._slide._element
|
|
485
|
+
return list(
|
|
486
|
+
sld.xpath("p:timing/p:tnLst/p:par/p:cTn/p:childTnLst/p:par")
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# -- public API ----------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
def add(
|
|
492
|
+
self,
|
|
493
|
+
kind: str,
|
|
494
|
+
preset: str,
|
|
495
|
+
shape: "BaseShape | TextFrame",
|
|
496
|
+
**kwargs: Any,
|
|
497
|
+
) -> None:
|
|
498
|
+
"""Polymorphic dispatcher — add an animation of the given *kind*.
|
|
499
|
+
|
|
500
|
+
*kind* selects the animation family and routes to the matching
|
|
501
|
+
``add_*`` method:
|
|
502
|
+
|
|
503
|
+
* ``"entrance"`` → :meth:`add_entrance`
|
|
504
|
+
* ``"exit"`` → :meth:`add_exit`
|
|
505
|
+
* ``"emphasis"`` → :meth:`add_emphasis`
|
|
506
|
+
* ``"motion"`` → :meth:`add_motion` (here *preset* is the
|
|
507
|
+
OOXML motion-path string, e.g. ``"M 0 0 L 0.5 0 E"``)
|
|
508
|
+
|
|
509
|
+
Convenient when the animation kind is data-driven (e.g. read
|
|
510
|
+
from a YAML spec) rather than known at call sites::
|
|
511
|
+
|
|
512
|
+
slide.animations.add("entrance", "fade", title)
|
|
513
|
+
slide.animations.add("emphasis", "pulse", badge)
|
|
514
|
+
slide.animations.add("motion", "M 0 0 L 0.5 0 E", logo,
|
|
515
|
+
duration=2000)
|
|
516
|
+
|
|
517
|
+
For literal authoring the static
|
|
518
|
+
``Entrance.fade(slide, shape, ...)`` / ``Exit.fade(...)`` /
|
|
519
|
+
``Emphasis.pulse(...)`` helpers remain idiomatic and a touch
|
|
520
|
+
more readable.
|
|
521
|
+
"""
|
|
522
|
+
if kind == "entrance":
|
|
523
|
+
self.add_entrance(preset, shape, **kwargs)
|
|
524
|
+
return
|
|
525
|
+
if kind == "exit":
|
|
526
|
+
if not hasattr(shape, "shape_id"):
|
|
527
|
+
raise TypeError(
|
|
528
|
+
f"add(kind='exit') requires a BaseShape; got "
|
|
529
|
+
f"{type(shape).__name__!r}."
|
|
530
|
+
)
|
|
531
|
+
self.add_exit(preset, cast("BaseShape", shape), **kwargs)
|
|
532
|
+
return
|
|
533
|
+
if kind == "emphasis":
|
|
534
|
+
if not hasattr(shape, "shape_id"):
|
|
535
|
+
raise TypeError(
|
|
536
|
+
f"add(kind='emphasis') requires a BaseShape; got "
|
|
537
|
+
f"{type(shape).__name__!r}."
|
|
538
|
+
)
|
|
539
|
+
self.add_emphasis(preset, cast("BaseShape", shape), **kwargs)
|
|
540
|
+
return
|
|
541
|
+
if kind == "motion":
|
|
542
|
+
if not hasattr(shape, "shape_id"):
|
|
543
|
+
raise TypeError(
|
|
544
|
+
f"add(kind='motion') requires a BaseShape; got "
|
|
545
|
+
f"{type(shape).__name__!r}."
|
|
546
|
+
)
|
|
547
|
+
# For motion, *preset* carries the raw OOXML path string;
|
|
548
|
+
# validating ``E``-termination here keeps the error site
|
|
549
|
+
# close to the call site rather than deep in the XML
|
|
550
|
+
# builder.
|
|
551
|
+
if not preset or "E" not in preset:
|
|
552
|
+
raise ValueError(
|
|
553
|
+
"motion path must be a non-empty OOXML path string "
|
|
554
|
+
"ending in 'E' (e.g. 'M 0 0 L 0.5 0 E')"
|
|
555
|
+
)
|
|
556
|
+
self.add_motion(cast("BaseShape", shape), preset, **kwargs)
|
|
557
|
+
return
|
|
558
|
+
raise ValueError(
|
|
559
|
+
f"Unknown animation kind {kind!r}; choose from "
|
|
560
|
+
"'entrance', 'exit', 'emphasis', 'motion'."
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
def add_entrance(
|
|
564
|
+
self,
|
|
565
|
+
preset: str,
|
|
566
|
+
shape: BaseShape | TextFrame,
|
|
567
|
+
*,
|
|
568
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
569
|
+
delay: int = 0,
|
|
570
|
+
duration: int = 500,
|
|
571
|
+
direction: str = "bottom",
|
|
572
|
+
by_paragraph: bool = False,
|
|
573
|
+
easing: str | tuple[float, float] | None = None,
|
|
574
|
+
) -> None:
|
|
575
|
+
"""Append an entrance animation for *shape* to the slide timeline.
|
|
576
|
+
|
|
577
|
+
*preset* is one of: ``"appear"``, ``"fade"``, ``"fly_in"``,
|
|
578
|
+
``"float_in"``, ``"wipe"``, ``"zoom"``, ``"wheel"``,
|
|
579
|
+
``"random_bars"``.
|
|
580
|
+
|
|
581
|
+
*direction* is only used for ``"fly_in"``; accepted values are
|
|
582
|
+
``"bottom"`` (default), ``"top"``, ``"left"``, ``"right"``.
|
|
583
|
+
|
|
584
|
+
Pass ``by_paragraph=True`` to animate each paragraph of a text
|
|
585
|
+
frame separately. *shape* may then be either a |TextFrame| or
|
|
586
|
+
any shape that exposes a ``text_frame`` (e.g. an autoshape or
|
|
587
|
+
placeholder). The first paragraph fires on the supplied
|
|
588
|
+
*trigger*; subsequent paragraphs fire after the previous one.
|
|
589
|
+
Currently supports the ``"fade"``, ``"appear"``, ``"wipe"``,
|
|
590
|
+
``"zoom"``, ``"wheel"``, and ``"random_bars"`` presets — others
|
|
591
|
+
raise :class:`ValueError`.
|
|
592
|
+
"""
|
|
593
|
+
if preset not in _ENTRANCE_PRESETS:
|
|
594
|
+
raise ValueError(
|
|
595
|
+
f"Unknown entrance preset {preset!r}. "
|
|
596
|
+
f"Choose from: {sorted(_ENTRANCE_PRESETS)}"
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
if by_paragraph:
|
|
600
|
+
self._add_entrance_by_paragraph(
|
|
601
|
+
preset, shape, trigger=trigger, delay=delay, duration=duration
|
|
602
|
+
)
|
|
603
|
+
return
|
|
604
|
+
|
|
605
|
+
# When by_paragraph=False, *shape* must be a BaseShape (something
|
|
606
|
+
# with a shape_id). The union type is only widened to support
|
|
607
|
+
# the by_paragraph=True case, so guard explicitly here rather
|
|
608
|
+
# than relying on a duck-typed AttributeError later.
|
|
609
|
+
if not hasattr(shape, "shape_id"):
|
|
610
|
+
raise TypeError(
|
|
611
|
+
f"add_entrance requires a shape with a shape_id; got "
|
|
612
|
+
f"{type(shape).__name__!r}. Pass by_paragraph=True to "
|
|
613
|
+
"animate a TextFrame's paragraphs individually."
|
|
614
|
+
)
|
|
615
|
+
bshape = cast("BaseShape", shape)
|
|
616
|
+
preset_id, preset_subtype = _ENTRANCE_PRESETS[preset]
|
|
617
|
+
behaviors = self._entrance_behaviors(preset, bshape.shape_id, duration, direction)
|
|
618
|
+
self._append_effect(
|
|
619
|
+
bshape.shape_id, preset_id, "entr", preset_subtype, trigger, delay,
|
|
620
|
+
behaviors, easing=easing,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
def add_exit(
|
|
624
|
+
self,
|
|
625
|
+
preset: str,
|
|
626
|
+
shape: BaseShape,
|
|
627
|
+
*,
|
|
628
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
629
|
+
delay: int = 0,
|
|
630
|
+
duration: int = 500,
|
|
631
|
+
direction: str = "bottom",
|
|
632
|
+
) -> None:
|
|
633
|
+
"""Append an exit animation for *shape* to the slide timeline.
|
|
634
|
+
|
|
635
|
+
*preset* is one of: ``"disappear"``, ``"fade"``, ``"fly_out"``,
|
|
636
|
+
``"float_out"``, ``"wipe"``, ``"zoom"``, ``"wheel"``,
|
|
637
|
+
``"random_bars"``.
|
|
638
|
+
"""
|
|
639
|
+
if preset not in _EXIT_PRESETS:
|
|
640
|
+
raise ValueError(
|
|
641
|
+
f"Unknown exit preset {preset!r}. "
|
|
642
|
+
f"Choose from: {sorted(_EXIT_PRESETS)}"
|
|
643
|
+
)
|
|
644
|
+
preset_id, preset_subtype = _EXIT_PRESETS[preset]
|
|
645
|
+
behaviors = self._exit_behaviors(preset, shape.shape_id, duration, direction)
|
|
646
|
+
self._append_effect(
|
|
647
|
+
shape.shape_id, preset_id, "exit", preset_subtype, trigger, delay, behaviors
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
def add_emphasis(
|
|
651
|
+
self,
|
|
652
|
+
preset: str,
|
|
653
|
+
shape: BaseShape,
|
|
654
|
+
*,
|
|
655
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
656
|
+
delay: int = 0,
|
|
657
|
+
duration: int = 1000,
|
|
658
|
+
degrees: float = 360.0,
|
|
659
|
+
) -> None:
|
|
660
|
+
"""Append an emphasis animation for *shape* to the slide timeline.
|
|
661
|
+
|
|
662
|
+
*preset* is one of: ``"pulse"``, ``"spin"``, ``"teeter"``.
|
|
663
|
+
|
|
664
|
+
*degrees* controls the rotation angle for the ``"spin"`` preset
|
|
665
|
+
(default: 360 — one full clockwise revolution).
|
|
666
|
+
"""
|
|
667
|
+
if preset not in _EMPHASIS_PRESETS:
|
|
668
|
+
raise ValueError(
|
|
669
|
+
f"Unknown emphasis preset {preset!r}. "
|
|
670
|
+
f"Choose from: {sorted(_EMPHASIS_PRESETS)}"
|
|
671
|
+
)
|
|
672
|
+
preset_id, preset_subtype = _EMPHASIS_PRESETS[preset]
|
|
673
|
+
behaviors = self._emphasis_behaviors(preset, shape.shape_id, duration, degrees)
|
|
674
|
+
self._append_effect(
|
|
675
|
+
shape.shape_id, preset_id, "emph", preset_subtype, trigger, delay, behaviors
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
def add_motion(
|
|
679
|
+
self,
|
|
680
|
+
shape: BaseShape,
|
|
681
|
+
path: str,
|
|
682
|
+
*,
|
|
683
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
684
|
+
delay: int = 0,
|
|
685
|
+
duration: int = 2000,
|
|
686
|
+
) -> None:
|
|
687
|
+
"""Append a motion-path animation that moves *shape* along *path*.
|
|
688
|
+
|
|
689
|
+
*path* is an OOXML motion-path string (the same syntax PowerPoint
|
|
690
|
+
uses internally): ``"M x y L x y E"`` for a single straight
|
|
691
|
+
segment, ``"M x y C x1 y1 x2 y2 x y E"`` for a cubic bezier, etc.
|
|
692
|
+
Coordinates are normalized to the slide's width and height
|
|
693
|
+
(``0,0`` is the shape's starting position; ``1,0`` is one slide
|
|
694
|
+
width to the right). The terminating ``E`` is required.
|
|
695
|
+
|
|
696
|
+
Use :meth:`MotionPath.line` for a coordinate-aware convenience
|
|
697
|
+
wrapper, or :meth:`MotionPath.custom` to pass an arbitrary path.
|
|
698
|
+
"""
|
|
699
|
+
ids = self._reserve_ids(1)
|
|
700
|
+
behaviors = _anim_motion_xml(ids[0], shape.shape_id, duration, path)
|
|
701
|
+
# presetID 64 is PowerPoint's "Custom Path" path animation.
|
|
702
|
+
self._append_effect(
|
|
703
|
+
shape.shape_id, 64, "path", 0, trigger, delay, behaviors
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
@contextmanager
|
|
707
|
+
def sequence(
|
|
708
|
+
self,
|
|
709
|
+
*,
|
|
710
|
+
start: PP_ANIM_TRIGGER = PP_ANIM_TRIGGER.ON_CLICK,
|
|
711
|
+
delay: int = 0,
|
|
712
|
+
) -> Iterator[SlideAnimations]:
|
|
713
|
+
"""Group the contained animations into a single sequenced run.
|
|
714
|
+
|
|
715
|
+
Inside the ``with`` block, the first effect added (whose
|
|
716
|
+
``trigger`` was not explicitly set) fires on *start* and every
|
|
717
|
+
subsequent effect defaults to :attr:`Trigger.AFTER_PREVIOUS`,
|
|
718
|
+
producing a chain of effects that play one after another from a
|
|
719
|
+
single click.
|
|
720
|
+
|
|
721
|
+
Effects whose *trigger* is explicitly supplied still honour the
|
|
722
|
+
caller's choice — sequencing is opt-in per call.
|
|
723
|
+
|
|
724
|
+
Example::
|
|
725
|
+
|
|
726
|
+
with slide.animations.sequence(delay=200):
|
|
727
|
+
Entrance.fade(slide, title)
|
|
728
|
+
Entrance.fly_in(slide, body)
|
|
729
|
+
Emphasis.pulse(slide, badge)
|
|
730
|
+
|
|
731
|
+
Sequences cannot be nested — entering a sequence inside another
|
|
732
|
+
raises :class:`RuntimeError`.
|
|
733
|
+
"""
|
|
734
|
+
if self._seq_active:
|
|
735
|
+
raise RuntimeError("animation sequences cannot be nested")
|
|
736
|
+
if self._grp_active:
|
|
737
|
+
raise RuntimeError("cannot enter sequence() inside group()")
|
|
738
|
+
self._seq_active = True
|
|
739
|
+
self._seq_start = start
|
|
740
|
+
self._seq_delay = delay
|
|
741
|
+
self._seq_delay_consumed = False
|
|
742
|
+
self._seq_count = 0
|
|
743
|
+
try:
|
|
744
|
+
yield self
|
|
745
|
+
finally:
|
|
746
|
+
self._seq_active = False
|
|
747
|
+
self._seq_count = 0
|
|
748
|
+
self._seq_delay = 0
|
|
749
|
+
self._seq_delay_consumed = False
|
|
750
|
+
|
|
751
|
+
@contextmanager
|
|
752
|
+
def group(
|
|
753
|
+
self,
|
|
754
|
+
*,
|
|
755
|
+
start: PP_ANIM_TRIGGER = PP_ANIM_TRIGGER.AFTER_PREVIOUS,
|
|
756
|
+
delay: int = 0,
|
|
757
|
+
) -> Iterator["SlideAnimations"]:
|
|
758
|
+
"""Animate every effect added in the block as a single visual cluster.
|
|
759
|
+
|
|
760
|
+
The first effect added inside the ``with`` block uses *start*
|
|
761
|
+
(default :attr:`Trigger.AFTER_PREVIOUS`) and every subsequent
|
|
762
|
+
effect defaults to :attr:`Trigger.WITH_PREVIOUS`, so the whole
|
|
763
|
+
cluster animates as one unit. Pair this with a per-cluster
|
|
764
|
+
anchor delay to control the rhythm between clusters::
|
|
765
|
+
|
|
766
|
+
for i, card in enumerate(cards):
|
|
767
|
+
with slide.animations.group(delay=0 if i == 0 else 200):
|
|
768
|
+
Entrance.fade(slide, card.body)
|
|
769
|
+
Entrance.fade(slide, card.title)
|
|
770
|
+
Entrance.fade(slide, card.blurb)
|
|
771
|
+
|
|
772
|
+
``group()`` is the right primitive when sub-shapes belong to the
|
|
773
|
+
same visual unit (a card, a row, a panel) — emitting a single
|
|
774
|
+
``WITH_PREVIOUS`` cluster is much cheaper for PowerPoint to
|
|
775
|
+
render than the same number of independent click-groups.
|
|
776
|
+
|
|
777
|
+
Effects whose *trigger* is supplied explicitly still honour the
|
|
778
|
+
caller's choice; the group default only applies to unset triggers.
|
|
779
|
+
|
|
780
|
+
Cannot be nested or combined with :meth:`sequence` —
|
|
781
|
+
:class:`RuntimeError` is raised on either.
|
|
782
|
+
"""
|
|
783
|
+
if self._grp_active:
|
|
784
|
+
raise RuntimeError("animation groups cannot be nested")
|
|
785
|
+
if self._seq_active:
|
|
786
|
+
raise RuntimeError("cannot enter group() inside sequence()")
|
|
787
|
+
self._grp_active = True
|
|
788
|
+
self._grp_start = start
|
|
789
|
+
self._grp_delay = delay
|
|
790
|
+
self._grp_delay_consumed = False
|
|
791
|
+
self._grp_count = 0
|
|
792
|
+
try:
|
|
793
|
+
yield self
|
|
794
|
+
finally:
|
|
795
|
+
self._grp_active = False
|
|
796
|
+
self._grp_count = 0
|
|
797
|
+
self._grp_delay = 0
|
|
798
|
+
self._grp_delay_consumed = False
|
|
799
|
+
|
|
800
|
+
# -- behavior builders ---------------------------------------------------
|
|
801
|
+
|
|
802
|
+
def _entrance_behaviors(
|
|
803
|
+
self, preset: str, spid: int, duration: int, direction: str
|
|
804
|
+
) -> str:
|
|
805
|
+
ids = self._reserve_ids(3)
|
|
806
|
+
vis_xml = _visibility_set_xml(ids[0], spid, visible=True)
|
|
807
|
+
|
|
808
|
+
if preset == "appear":
|
|
809
|
+
return vis_xml
|
|
810
|
+
|
|
811
|
+
if preset == "fly_in":
|
|
812
|
+
path = _FLY_PATHS_IN.get(direction, _FLY_PATHS_IN["bottom"])
|
|
813
|
+
return vis_xml + _anim_motion_xml(ids[1], spid, duration, path)
|
|
814
|
+
|
|
815
|
+
if preset == "float_in":
|
|
816
|
+
return (
|
|
817
|
+
vis_xml
|
|
818
|
+
+ _anim_effect_xml(ids[1], spid, duration, "fade", "in")
|
|
819
|
+
+ _anim_motion_xml(ids[2], spid, duration, "M 0 0.25 L 0 0 E")
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
filter_str = _EFFECT_FILTER.get(preset, "fade")
|
|
823
|
+
return vis_xml + _anim_effect_xml(ids[1], spid, duration, filter_str, "in")
|
|
824
|
+
|
|
825
|
+
def _exit_behaviors(
|
|
826
|
+
self, preset: str, spid: int, duration: int, direction: str
|
|
827
|
+
) -> str:
|
|
828
|
+
ids = self._reserve_ids(3)
|
|
829
|
+
|
|
830
|
+
if preset == "disappear":
|
|
831
|
+
return _visibility_set_xml(ids[0], spid, visible=False)
|
|
832
|
+
|
|
833
|
+
if preset == "fly_out":
|
|
834
|
+
path = _FLY_PATHS_OUT.get(direction, _FLY_PATHS_OUT["bottom"])
|
|
835
|
+
vis_xml = _visibility_set_xml(ids[1], spid, visible=False)
|
|
836
|
+
return _anim_motion_xml(ids[0], spid, duration, path) + vis_xml
|
|
837
|
+
|
|
838
|
+
if preset == "float_out":
|
|
839
|
+
vis_xml = _visibility_set_xml(ids[2], spid, visible=False)
|
|
840
|
+
return (
|
|
841
|
+
_anim_effect_xml(ids[0], spid, duration, "fade", "out")
|
|
842
|
+
+ _anim_motion_xml(ids[1], spid, duration, "M 0 0 L 0 0.25 E")
|
|
843
|
+
+ vis_xml
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
filter_str = _EFFECT_FILTER.get(preset, "fade")
|
|
847
|
+
vis_xml = _visibility_set_xml(ids[1], spid, visible=False)
|
|
848
|
+
# For exit: animEffect first, then hide
|
|
849
|
+
return _anim_effect_xml(ids[0], spid, duration, filter_str, "out") + vis_xml
|
|
850
|
+
|
|
851
|
+
def _emphasis_behaviors(
|
|
852
|
+
self, preset: str, spid: int, duration: int, degrees: float = 360.0
|
|
853
|
+
) -> str:
|
|
854
|
+
ids = self._reserve_ids(1)
|
|
855
|
+
if preset == "pulse":
|
|
856
|
+
return _anim_scale_xml(ids[0], spid, duration)
|
|
857
|
+
if preset == "spin":
|
|
858
|
+
return _anim_rot_xml(ids[0], spid, duration, angle_deg=degrees)
|
|
859
|
+
if preset == "teeter":
|
|
860
|
+
# Teeter: oscillate rotation ~10 degrees either side. `by` is an
|
|
861
|
+
# attribute of <p:animRot>, not a child element (see _anim_rot_xml).
|
|
862
|
+
ang = int(10 * 60000)
|
|
863
|
+
return (
|
|
864
|
+
f'<p:animRot by="{ang}">\n'
|
|
865
|
+
" <p:cBhvr>\n"
|
|
866
|
+
f' <p:cTn id="{ids[0]}" dur="{duration}" autoRev="1"/>\n'
|
|
867
|
+
f' <p:tgtEl><p:spTgt spid="{spid}"/></p:tgtEl>\n'
|
|
868
|
+
" </p:cBhvr>\n"
|
|
869
|
+
"</p:animRot>\n"
|
|
870
|
+
)
|
|
871
|
+
return ""
|
|
872
|
+
|
|
873
|
+
# -- by-paragraph entrance ---------------------------------------------
|
|
874
|
+
|
|
875
|
+
# Subset of entrance presets where targeting an individual paragraph
|
|
876
|
+
# makes sense. Direction-aware presets (fly_in, float_in) are
|
|
877
|
+
# excluded because PowerPoint's per-paragraph wrappers don't support
|
|
878
|
+
# the motion-path component cleanly.
|
|
879
|
+
_PARAGRAPH_PRESETS = frozenset({
|
|
880
|
+
"appear", "fade", "wipe", "zoom", "wheel", "random_bars",
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
def _add_entrance_by_paragraph(
|
|
884
|
+
self,
|
|
885
|
+
preset: str,
|
|
886
|
+
target: BaseShape | TextFrame,
|
|
887
|
+
*,
|
|
888
|
+
trigger: PP_ANIM_TRIGGER,
|
|
889
|
+
delay: int,
|
|
890
|
+
duration: int,
|
|
891
|
+
) -> None:
|
|
892
|
+
"""Append one entrance effect per paragraph of a text frame.
|
|
893
|
+
|
|
894
|
+
Resolves *target* to a (shape, text_frame) pair, then emits a
|
|
895
|
+
chain of effects: the first uses *trigger*, the rest use
|
|
896
|
+
AFTER_PREVIOUS so the text reveals one paragraph at a time.
|
|
897
|
+
"""
|
|
898
|
+
if preset not in self._PARAGRAPH_PRESETS:
|
|
899
|
+
raise ValueError(
|
|
900
|
+
f"by_paragraph=True is not supported for preset {preset!r}. "
|
|
901
|
+
f"Choose from: {sorted(self._PARAGRAPH_PRESETS)}"
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
from pptx2.text.text import TextFrame as _TextFrame
|
|
905
|
+
|
|
906
|
+
if isinstance(target, _TextFrame):
|
|
907
|
+
text_frame = target
|
|
908
|
+
# Walk up the parent chain until we hit something with a
|
|
909
|
+
# `shape_id` (a |BaseShape|). TextFrames inside table cells
|
|
910
|
+
# have an intermediate `_Cell` parent that has no shape_id;
|
|
911
|
+
# those cells live inside a `GraphicFrame` further up. If
|
|
912
|
+
# nothing in the chain has a shape_id, the TextFrame isn't
|
|
913
|
+
# attached to a slide-level shape and we can't target it.
|
|
914
|
+
parent = target._parent # type: ignore[attr-defined]
|
|
915
|
+
seen: set[int] = set()
|
|
916
|
+
while parent is not None and not hasattr(parent, "shape_id"):
|
|
917
|
+
if id(parent) in seen:
|
|
918
|
+
parent = None # cycle guard
|
|
919
|
+
break
|
|
920
|
+
seen.add(id(parent))
|
|
921
|
+
parent = getattr(parent, "_parent", None)
|
|
922
|
+
if parent is None or not hasattr(parent, "shape_id"):
|
|
923
|
+
raise TypeError(
|
|
924
|
+
"by_paragraph=True requires a TextFrame whose parent "
|
|
925
|
+
"chain reaches a shape; "
|
|
926
|
+
f"{type(target._parent).__name__!r} has no shape_id " # type: ignore[attr-defined]
|
|
927
|
+
"ancestor (table-cell text frames are not yet supported)."
|
|
928
|
+
)
|
|
929
|
+
shape = cast("BaseShape", parent)
|
|
930
|
+
else:
|
|
931
|
+
text_frame = getattr(target, "text_frame", None)
|
|
932
|
+
if text_frame is None or not hasattr(target, "shape_id"):
|
|
933
|
+
raise TypeError(
|
|
934
|
+
"by_paragraph=True requires a TextFrame or a shape with "
|
|
935
|
+
f"a text_frame and shape_id; got {type(target).__name__!r}"
|
|
936
|
+
)
|
|
937
|
+
shape = cast("BaseShape", target)
|
|
938
|
+
|
|
939
|
+
spid: int = shape.shape_id
|
|
940
|
+
preset_id, preset_subtype = _ENTRANCE_PRESETS[preset]
|
|
941
|
+
# Resolve the first trigger now so subsequent effects can chain
|
|
942
|
+
# off it via AFTER_PREVIOUS. The default trigger inside a
|
|
943
|
+
# `sequence()` context is honoured via _resolve_default_trigger.
|
|
944
|
+
first_trigger = self._resolve_default_trigger(trigger)
|
|
945
|
+
for i, _para in enumerate(text_frame.paragraphs):
|
|
946
|
+
effect_trigger = first_trigger if i == 0 else PP_ANIM_TRIGGER.AFTER_PREVIOUS
|
|
947
|
+
effect_delay = delay if i == 0 else 0
|
|
948
|
+
behaviors = self._paragraph_entrance_behaviors(preset, spid, i, duration)
|
|
949
|
+
self._append_effect(
|
|
950
|
+
spid,
|
|
951
|
+
preset_id,
|
|
952
|
+
"entr",
|
|
953
|
+
preset_subtype,
|
|
954
|
+
effect_trigger,
|
|
955
|
+
effect_delay,
|
|
956
|
+
behaviors,
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
def _paragraph_entrance_behaviors(
|
|
960
|
+
self, preset: str, spid: int, paragraph_idx: int, duration: int
|
|
961
|
+
) -> str:
|
|
962
|
+
"""Return the behaviors XML for a paragraph-targeted entrance preset."""
|
|
963
|
+
ids = self._reserve_ids(2)
|
|
964
|
+
vis_xml = _visibility_set_xml_for_paragraph(
|
|
965
|
+
ids[0], spid, paragraph_idx, visible=True
|
|
966
|
+
)
|
|
967
|
+
if preset == "appear":
|
|
968
|
+
return vis_xml
|
|
969
|
+
filter_str = _EFFECT_FILTER.get(preset, "fade")
|
|
970
|
+
return vis_xml + _anim_effect_xml_for_paragraph(
|
|
971
|
+
ids[1], spid, paragraph_idx, duration, filter_str, "in"
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
# -- trigger / sequence resolution -------------------------------------
|
|
975
|
+
|
|
976
|
+
def _resolve_default_trigger(self, trigger: PP_ANIM_TRIGGER) -> PP_ANIM_TRIGGER:
|
|
977
|
+
"""Map the ``_TRIGGER_UNSET`` sentinel to a concrete trigger.
|
|
978
|
+
|
|
979
|
+
When called outside a sequence/group, an unset trigger falls back to
|
|
980
|
+
``Trigger.ON_CLICK``. Inside a sequence, the first effect uses
|
|
981
|
+
the sequence's ``start`` trigger and subsequent effects default
|
|
982
|
+
to ``Trigger.AFTER_PREVIOUS``. Inside a group, the first effect
|
|
983
|
+
uses the group's ``start`` trigger and subsequent effects default
|
|
984
|
+
to ``Trigger.WITH_PREVIOUS`` so the whole cluster animates as
|
|
985
|
+
one unit.
|
|
986
|
+
|
|
987
|
+
Counters bump on **every** call inside a block, even when the
|
|
988
|
+
caller supplied an explicit trigger. Otherwise an explicit
|
|
989
|
+
trigger on the first effect would let the *next* unset-trigger
|
|
990
|
+
effect get the "first effect" treatment instead of being
|
|
991
|
+
``WITH_PREVIOUS`` / ``AFTER_PREVIOUS`` as documented.
|
|
992
|
+
"""
|
|
993
|
+
if self._grp_active:
|
|
994
|
+
is_first = self._grp_count == 0
|
|
995
|
+
self._grp_count += 1
|
|
996
|
+
if trigger is not _TRIGGER_UNSET:
|
|
997
|
+
return trigger
|
|
998
|
+
return self._grp_start if is_first else PP_ANIM_TRIGGER.WITH_PREVIOUS
|
|
999
|
+
if self._seq_active:
|
|
1000
|
+
is_first = self._seq_count == 0
|
|
1001
|
+
self._seq_count += 1
|
|
1002
|
+
if trigger is not _TRIGGER_UNSET:
|
|
1003
|
+
return trigger
|
|
1004
|
+
return self._seq_start if is_first else PP_ANIM_TRIGGER.AFTER_PREVIOUS
|
|
1005
|
+
if trigger is not _TRIGGER_UNSET:
|
|
1006
|
+
return trigger
|
|
1007
|
+
return PP_ANIM_TRIGGER.ON_CLICK
|
|
1008
|
+
|
|
1009
|
+
def _consume_block_delay(self, delay: int) -> int:
|
|
1010
|
+
"""Add the active sequence/group ``delay`` to the first effect's *delay*.
|
|
1011
|
+
|
|
1012
|
+
``sequence(delay=N)`` and ``group(delay=N)`` shift the whole
|
|
1013
|
+
block by N ms, so the block-level delay is added to the first
|
|
1014
|
+
effect's per-call ``delay`` and never applied again within the
|
|
1015
|
+
same block. Returns *delay* unchanged when no block is active.
|
|
1016
|
+
"""
|
|
1017
|
+
if self._grp_active and not self._grp_delay_consumed:
|
|
1018
|
+
self._grp_delay_consumed = True
|
|
1019
|
+
return delay + self._grp_delay
|
|
1020
|
+
if self._seq_active and not self._seq_delay_consumed:
|
|
1021
|
+
self._seq_delay_consumed = True
|
|
1022
|
+
return delay + self._seq_delay
|
|
1023
|
+
return delay
|
|
1024
|
+
|
|
1025
|
+
# -- timing tree management ----------------------------------------------
|
|
1026
|
+
|
|
1027
|
+
def _append_effect(
|
|
1028
|
+
self,
|
|
1029
|
+
spid: int,
|
|
1030
|
+
preset_id: int,
|
|
1031
|
+
preset_class: str,
|
|
1032
|
+
preset_subtype: int,
|
|
1033
|
+
trigger: PP_ANIM_TRIGGER,
|
|
1034
|
+
delay: int,
|
|
1035
|
+
behaviors_xml: str,
|
|
1036
|
+
*,
|
|
1037
|
+
easing: str | tuple[float, float] | None = None,
|
|
1038
|
+
) -> None:
|
|
1039
|
+
"""Build the animation XML and insert it into the slide timing tree."""
|
|
1040
|
+
root_ctn = self._get_or_create_root_ctn()
|
|
1041
|
+
|
|
1042
|
+
trigger = self._resolve_default_trigger(trigger)
|
|
1043
|
+
delay = self._consume_block_delay(delay)
|
|
1044
|
+
grp_id, node_type, wrapper_delay = self._resolve_trigger(trigger)
|
|
1045
|
+
|
|
1046
|
+
indent_behaviors = "\n".join(
|
|
1047
|
+
" " + line for line in behaviors_xml.splitlines()
|
|
1048
|
+
) + "\n"
|
|
1049
|
+
|
|
1050
|
+
effect_par = (
|
|
1051
|
+
"<p:par>\n"
|
|
1052
|
+
f' <p:cTn id="0" presetID="{preset_id}"'
|
|
1053
|
+
f' presetClass="{preset_class}" presetSubtype="{preset_subtype}"'
|
|
1054
|
+
f' fill="hold" grpId="{grp_id}" nodeType="{node_type}">\n'
|
|
1055
|
+
" <p:stCondLst>\n"
|
|
1056
|
+
f' <p:cond delay="{delay}"/>\n'
|
|
1057
|
+
" </p:stCondLst>\n"
|
|
1058
|
+
" <p:childTnLst>\n"
|
|
1059
|
+
f"{indent_behaviors}"
|
|
1060
|
+
" </p:childTnLst>\n"
|
|
1061
|
+
" </p:cTn>\n"
|
|
1062
|
+
"</p:par>\n"
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
click_group = (
|
|
1066
|
+
"<p:par %s>\n"
|
|
1067
|
+
" <p:cTn fill=\"hold\">\n"
|
|
1068
|
+
" <p:stCondLst>\n"
|
|
1069
|
+
f' <p:cond delay="{wrapper_delay}"/>\n'
|
|
1070
|
+
" </p:stCondLst>\n"
|
|
1071
|
+
" <p:childTnLst>\n"
|
|
1072
|
+
+ "\n".join(" " + l for l in effect_par.splitlines()) + "\n"
|
|
1073
|
+
" </p:childTnLst>\n"
|
|
1074
|
+
" </p:cTn>\n"
|
|
1075
|
+
"</p:par>\n"
|
|
1076
|
+
) % _nsdecls_p()
|
|
1077
|
+
|
|
1078
|
+
group_elm = parse_xml(click_group.encode("utf-8"))
|
|
1079
|
+
# Fix IDs: assign proper sequential IDs to all p:cTn elements in our
|
|
1080
|
+
# new subtree now that we know which IDs are free.
|
|
1081
|
+
self._assign_ids(group_elm)
|
|
1082
|
+
if easing is not None:
|
|
1083
|
+
_apply_easing(group_elm, easing)
|
|
1084
|
+
root_ctn.append(group_elm)
|
|
1085
|
+
|
|
1086
|
+
def _get_or_create_root_ctn(self):
|
|
1087
|
+
"""Return the `p:childTnLst` of the root timing container.
|
|
1088
|
+
|
|
1089
|
+
Creates the full `p:timing/p:tnLst/p:par/p:cTn/p:childTnLst` skeleton
|
|
1090
|
+
if it doesn't already exist, without disturbing any existing timing.
|
|
1091
|
+
"""
|
|
1092
|
+
sld = self._slide._element
|
|
1093
|
+
return sld.get_or_add_childTnLst()
|
|
1094
|
+
|
|
1095
|
+
def _next_ctn_id(self) -> int:
|
|
1096
|
+
"""Return the next free ``p:cTn/@id`` integer for this slide."""
|
|
1097
|
+
sld = self._slide._element
|
|
1098
|
+
# BaseOxmlElement.xpath() pre-injects _nsmap; no namespaces kwarg needed
|
|
1099
|
+
id_strs = sld.xpath("p:timing//p:cTn/@id")
|
|
1100
|
+
if not id_strs:
|
|
1101
|
+
return 2 # 1 is reserved for the root cTn
|
|
1102
|
+
return max(int(s) for s in id_strs) + 1
|
|
1103
|
+
|
|
1104
|
+
def _reserve_ids(self, count: int) -> list[int]:
|
|
1105
|
+
"""Return `count` placeholder ints; actual IDs are assigned at insert time."""
|
|
1106
|
+
# We use 0-based placeholders; _assign_ids will replace them.
|
|
1107
|
+
return list(range(count))
|
|
1108
|
+
|
|
1109
|
+
def _assign_ids(self, group_elm) -> None:
|
|
1110
|
+
"""Walk `group_elm` and assign monotonically-increasing IDs to every `p:cTn`."""
|
|
1111
|
+
next_id = self._next_ctn_id()
|
|
1112
|
+
for ctn in group_elm.iter(qn("p:cTn")):
|
|
1113
|
+
ctn.set("id", str(next_id))
|
|
1114
|
+
next_id += 1
|
|
1115
|
+
|
|
1116
|
+
# ---- multi-shape sequence helpers --------------------------------------
|
|
1117
|
+
|
|
1118
|
+
def typewriter(
|
|
1119
|
+
self,
|
|
1120
|
+
shapes,
|
|
1121
|
+
*,
|
|
1122
|
+
preset: str = "wipe",
|
|
1123
|
+
delay_between_ms: int = 200,
|
|
1124
|
+
duration: int = 300,
|
|
1125
|
+
start: PP_ANIM_TRIGGER = PP_ANIM_TRIGGER.ON_CLICK,
|
|
1126
|
+
direction: str = "left",
|
|
1127
|
+
) -> None:
|
|
1128
|
+
"""One-line cascade entrance across an iterable of *shapes*.
|
|
1129
|
+
|
|
1130
|
+
Replaces the manual ``with self.sequence(): for s in shapes: ...``
|
|
1131
|
+
boilerplate. Each shape's entrance fires ``delay_between_ms``
|
|
1132
|
+
after the previous one, all under a single click trigger
|
|
1133
|
+
(``start``).
|
|
1134
|
+
|
|
1135
|
+
Default uses the ``"wipe"`` preset, which is the closest visual
|
|
1136
|
+
analogue to a typewriter reveal; pass any other entrance preset
|
|
1137
|
+
(``"fade"``, ``"appear"``, etc.) for the effect of your choice.
|
|
1138
|
+
|
|
1139
|
+
Example::
|
|
1140
|
+
|
|
1141
|
+
slide.animations.typewriter(
|
|
1142
|
+
[bullet1, bullet2, bullet3], delay_between_ms=200
|
|
1143
|
+
)
|
|
1144
|
+
"""
|
|
1145
|
+
shapes = list(shapes)
|
|
1146
|
+
if not shapes:
|
|
1147
|
+
return
|
|
1148
|
+
with self.sequence(start=start):
|
|
1149
|
+
for i, shape in enumerate(shapes):
|
|
1150
|
+
self.add_entrance(
|
|
1151
|
+
preset,
|
|
1152
|
+
shape,
|
|
1153
|
+
delay=0 if i == 0 else delay_between_ms,
|
|
1154
|
+
duration=duration,
|
|
1155
|
+
direction=direction,
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
# ---- orphan cleanup ----------------------------------------------------
|
|
1159
|
+
|
|
1160
|
+
def purge_orphans(self) -> int:
|
|
1161
|
+
"""Remove animation entries whose target shape no longer exists.
|
|
1162
|
+
|
|
1163
|
+
Walks the slide's timing tree and removes any top-level click-group
|
|
1164
|
+
``<p:par>`` that contains a ``spid`` reference to a shape that's no
|
|
1165
|
+
longer in the slide's shape tree. Use this after deleting shapes
|
|
1166
|
+
to clean up the timing tree (PowerPoint will silently "repair"
|
|
1167
|
+
a deck with orphan timing references, but a clean tree avoids
|
|
1168
|
+
that prompt).
|
|
1169
|
+
|
|
1170
|
+
Returns the number of orphan ``<p:par>`` entries removed.
|
|
1171
|
+
|
|
1172
|
+
This is also called automatically when a shape is removed via
|
|
1173
|
+
:meth:`BaseShape.delete`, but is exposed publicly for callers
|
|
1174
|
+
that delete shapes by other means.
|
|
1175
|
+
"""
|
|
1176
|
+
sld = self._slide._element
|
|
1177
|
+
# Collect live shape ids from the spTree.
|
|
1178
|
+
live_ids: set[int] = set()
|
|
1179
|
+
for cNvPr in sld.xpath("p:cSld/p:spTree//p:cNvPr"):
|
|
1180
|
+
try:
|
|
1181
|
+
live_ids.add(int(cNvPr.get("id")))
|
|
1182
|
+
except (TypeError, ValueError):
|
|
1183
|
+
continue
|
|
1184
|
+
|
|
1185
|
+
# The click-group <p:par> elements live as children of the root
|
|
1186
|
+
# timing's childTnLst: p:timing/p:tnLst/p:par/p:cTn/p:childTnLst/p:par.
|
|
1187
|
+
root_pars = sld.xpath("p:timing/p:tnLst/p:par/p:cTn/p:childTnLst/p:par")
|
|
1188
|
+
|
|
1189
|
+
removed = 0
|
|
1190
|
+
spTgt_tag = qn("p:spTgt")
|
|
1191
|
+
for top_par in list(root_pars):
|
|
1192
|
+
spTgts = list(top_par.iter(spTgt_tag))
|
|
1193
|
+
for spTgt in spTgts:
|
|
1194
|
+
spid_attr = spTgt.get("spid")
|
|
1195
|
+
try:
|
|
1196
|
+
spid = int(spid_attr) if spid_attr is not None else None
|
|
1197
|
+
except ValueError:
|
|
1198
|
+
continue
|
|
1199
|
+
if spid is not None and spid not in live_ids:
|
|
1200
|
+
parent = top_par.getparent()
|
|
1201
|
+
if parent is not None:
|
|
1202
|
+
parent.remove(top_par)
|
|
1203
|
+
removed += 1
|
|
1204
|
+
break # already removed; no need to check more spTgts
|
|
1205
|
+
if removed:
|
|
1206
|
+
_prune_empty_timing(sld)
|
|
1207
|
+
return removed
|
|
1208
|
+
|
|
1209
|
+
def _resolve_trigger(
|
|
1210
|
+
self, trigger: PP_ANIM_TRIGGER
|
|
1211
|
+
) -> tuple[int, str, str]:
|
|
1212
|
+
"""Return ``(grp_id, node_type, wrapper_delay)`` for *trigger*."""
|
|
1213
|
+
sld = self._slide._element
|
|
1214
|
+
click_grp_ids = sld.xpath("p:timing//p:cTn[@nodeType='clickEffect']/@grpId")
|
|
1215
|
+
current_max = max((int(g) for g in click_grp_ids), default=-1)
|
|
1216
|
+
|
|
1217
|
+
if trigger is PP_ANIM_TRIGGER.ON_CLICK:
|
|
1218
|
+
grp_id = current_max + 1
|
|
1219
|
+
return grp_id, "clickEffect", "indefinite"
|
|
1220
|
+
elif trigger is PP_ANIM_TRIGGER.WITH_PREVIOUS:
|
|
1221
|
+
grp_id = max(current_max, 0)
|
|
1222
|
+
return grp_id, "withEffect", "0"
|
|
1223
|
+
else: # AFTER_PREVIOUS
|
|
1224
|
+
grp_id = max(current_max, 0)
|
|
1225
|
+
return grp_id, "afterEffect", "0"
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
# ---------------------------------------------------------------------------
|
|
1229
|
+
# Read-side introspection view
|
|
1230
|
+
# ---------------------------------------------------------------------------
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
@dataclass(frozen=True)
|
|
1234
|
+
class AnimationEntry:
|
|
1235
|
+
"""Read-only view onto a single animation entry on a slide.
|
|
1236
|
+
|
|
1237
|
+
Yielded by iteration over :class:`SlideAnimations`. Exposes the
|
|
1238
|
+
fields most useful for debugging and copying animations between
|
|
1239
|
+
slides:
|
|
1240
|
+
|
|
1241
|
+
* :attr:`kind` — one of ``"entrance"``, ``"exit"``, ``"emphasis"``,
|
|
1242
|
+
``"motion"`` (or ``None`` for unknown preset classes)
|
|
1243
|
+
* :attr:`preset` — the preset name (``"fade"``, ``"fly_in"``,
|
|
1244
|
+
``"pulse"`` etc.) or ``None`` if the presetID isn't recognised
|
|
1245
|
+
* :attr:`trigger` — the :class:`PP_ANIM_TRIGGER` for this entry
|
|
1246
|
+
* :attr:`shape_id` — the target shape's id, or ``None`` if the
|
|
1247
|
+
entry has no ``<p:spTgt>`` (rare)
|
|
1248
|
+
* :attr:`duration` — milliseconds, from the inner cTn ``dur`` attr
|
|
1249
|
+
* :attr:`delay` — milliseconds, from the inner cTn's first
|
|
1250
|
+
``<p:cond delay="...">``
|
|
1251
|
+
* :attr:`shape` — looked up live from the slide's shape tree by id;
|
|
1252
|
+
may be ``None`` if the shape has been deleted (use
|
|
1253
|
+
:meth:`SlideAnimations.purge_orphans` to drop orphan entries).
|
|
1254
|
+
"""
|
|
1255
|
+
|
|
1256
|
+
_par_element: Any # the wrapping <p:par> click-group element
|
|
1257
|
+
_slide: Any
|
|
1258
|
+
|
|
1259
|
+
@property
|
|
1260
|
+
def trigger(self) -> Optional[PP_ANIM_TRIGGER]:
|
|
1261
|
+
# The nodeType lives on the inner effect cTn (same one carrying
|
|
1262
|
+
# presetID). The outer click-group cTn just wraps timing.
|
|
1263
|
+
ctn = self._inner_effect_ctn()
|
|
1264
|
+
if ctn is None:
|
|
1265
|
+
return None
|
|
1266
|
+
return _NODE_TYPE_TO_TRIGGER.get(ctn.get("nodeType"))
|
|
1267
|
+
|
|
1268
|
+
@property
|
|
1269
|
+
def kind(self) -> Optional[str]:
|
|
1270
|
+
cls = self._effect_attr("presetClass")
|
|
1271
|
+
return _PRESET_CLASS_TO_KIND.get(cls) if cls else None
|
|
1272
|
+
|
|
1273
|
+
@property
|
|
1274
|
+
def preset(self) -> Optional[str]:
|
|
1275
|
+
cls = self._effect_attr("presetClass")
|
|
1276
|
+
if cls == "path":
|
|
1277
|
+
return "custom" # MotionPath presets aren't named the same way
|
|
1278
|
+
pid = self._effect_attr("presetID")
|
|
1279
|
+
sub = self._effect_attr("presetSubtype")
|
|
1280
|
+
if cls is None or pid is None:
|
|
1281
|
+
return None
|
|
1282
|
+
try:
|
|
1283
|
+
pid_i = int(pid)
|
|
1284
|
+
sub_i = int(sub) if sub is not None else 0
|
|
1285
|
+
except ValueError:
|
|
1286
|
+
return None
|
|
1287
|
+
# Prefer exact (pid, subtype) match — handles fly_in's directional
|
|
1288
|
+
# subtypes — then fall back to the subtype-agnostic match for
|
|
1289
|
+
# presets like fade where subtype is always 0.
|
|
1290
|
+
by_pair = _REVERSE_PRESETS.get(cls, {})
|
|
1291
|
+
name = by_pair.get((pid_i, sub_i))
|
|
1292
|
+
if name is not None:
|
|
1293
|
+
return name
|
|
1294
|
+
return _REVERSE_PRESET_BY_ID.get(cls, {}).get(pid_i)
|
|
1295
|
+
|
|
1296
|
+
@property
|
|
1297
|
+
def shape_id(self) -> Optional[int]:
|
|
1298
|
+
spTgt = self._par_element.find(".//" + qn("p:spTgt"))
|
|
1299
|
+
if spTgt is None:
|
|
1300
|
+
return None
|
|
1301
|
+
spid = spTgt.get("spid")
|
|
1302
|
+
try:
|
|
1303
|
+
return int(spid) if spid is not None else None
|
|
1304
|
+
except (TypeError, ValueError):
|
|
1305
|
+
return None
|
|
1306
|
+
|
|
1307
|
+
@property
|
|
1308
|
+
def duration(self) -> Optional[int]:
|
|
1309
|
+
ctn = self._inner_effect_ctn()
|
|
1310
|
+
if ctn is None:
|
|
1311
|
+
return None
|
|
1312
|
+
# The wrapper cTn has dur="indefinite"; the actual animation
|
|
1313
|
+
# duration lives on a nested behaviour cTn. Find the deepest
|
|
1314
|
+
# cTn with a numeric dur attribute.
|
|
1315
|
+
best: Optional[int] = None
|
|
1316
|
+
for child in self._par_element.iter(qn("p:cTn")):
|
|
1317
|
+
dur = child.get("dur")
|
|
1318
|
+
if dur is None or dur == "indefinite":
|
|
1319
|
+
continue
|
|
1320
|
+
try:
|
|
1321
|
+
val = int(dur)
|
|
1322
|
+
except ValueError:
|
|
1323
|
+
continue
|
|
1324
|
+
# Skip the wrapper's "0" placeholder; pick the largest concrete
|
|
1325
|
+
# duration in the subtree, which corresponds to the visible
|
|
1326
|
+
# effect duration.
|
|
1327
|
+
if val > 0 and (best is None or val > best):
|
|
1328
|
+
best = val
|
|
1329
|
+
return best
|
|
1330
|
+
|
|
1331
|
+
@property
|
|
1332
|
+
def delay(self) -> int:
|
|
1333
|
+
ctn = self._inner_effect_ctn()
|
|
1334
|
+
if ctn is None:
|
|
1335
|
+
return 0
|
|
1336
|
+
cond = ctn.find(".//" + qn("p:stCondLst") + "/" + qn("p:cond"))
|
|
1337
|
+
if cond is None:
|
|
1338
|
+
return 0
|
|
1339
|
+
delay = cond.get("delay")
|
|
1340
|
+
try:
|
|
1341
|
+
return int(delay) if delay is not None and delay != "indefinite" else 0
|
|
1342
|
+
except ValueError:
|
|
1343
|
+
return 0
|
|
1344
|
+
|
|
1345
|
+
@property
|
|
1346
|
+
def shape(self) -> Any:
|
|
1347
|
+
"""Look up the live |BaseShape| for this entry on its slide.
|
|
1348
|
+
|
|
1349
|
+
Walks the slide's spTree elements directly to locate the shape
|
|
1350
|
+
with a matching id and only then constructs a proxy — so the
|
|
1351
|
+
cost is one proxy construction per access, not ``N`` (where
|
|
1352
|
+
``N`` is the number of shapes on the slide). Returns ``None``
|
|
1353
|
+
when the shape has been deleted. Callers iterating many
|
|
1354
|
+
entries on a dense slide can still build their own
|
|
1355
|
+
``shape_id`` → shape map from a single ``slide.shapes`` walk
|
|
1356
|
+
if they want to amortise across accesses.
|
|
1357
|
+
"""
|
|
1358
|
+
spid = self.shape_id
|
|
1359
|
+
if spid is None:
|
|
1360
|
+
return None
|
|
1361
|
+
shapes = self._slide.shapes
|
|
1362
|
+
for shape_elm in shapes._iter_member_elms():
|
|
1363
|
+
elm_id = getattr(shape_elm, "shape_id", None)
|
|
1364
|
+
if elm_id == spid:
|
|
1365
|
+
return shapes._shape_factory(shape_elm)
|
|
1366
|
+
return None
|
|
1367
|
+
|
|
1368
|
+
@property
|
|
1369
|
+
def element(self) -> Any:
|
|
1370
|
+
"""The underlying ``<p:par>`` element. Treat as read-only."""
|
|
1371
|
+
return self._par_element
|
|
1372
|
+
|
|
1373
|
+
def remove(self) -> None:
|
|
1374
|
+
"""Remove this animation entry from the slide."""
|
|
1375
|
+
parent = self._par_element.getparent()
|
|
1376
|
+
if parent is not None:
|
|
1377
|
+
parent.remove(self._par_element)
|
|
1378
|
+
_prune_empty_timing(self._slide._element)
|
|
1379
|
+
|
|
1380
|
+
# -- internal helpers --------------------------------------------------
|
|
1381
|
+
|
|
1382
|
+
def _effect_attr(self, name: str) -> Optional[str]:
|
|
1383
|
+
ctn = self._inner_effect_ctn()
|
|
1384
|
+
return ctn.get(name) if ctn is not None else None
|
|
1385
|
+
|
|
1386
|
+
def _inner_effect_ctn(self) -> Any:
|
|
1387
|
+
"""Return the inner cTn carrying presetID/presetClass attributes."""
|
|
1388
|
+
for ctn in self._par_element.iter(qn("p:cTn")):
|
|
1389
|
+
if ctn.get("presetID") is not None:
|
|
1390
|
+
return ctn
|
|
1391
|
+
return None
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
# ---------------------------------------------------------------------------
|
|
1395
|
+
# Convenience class API (Entrance.fade(slide, shape, ...) etc.)
|
|
1396
|
+
# ---------------------------------------------------------------------------
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
class Entrance:
|
|
1400
|
+
"""Convenience class for adding entrance animations.
|
|
1401
|
+
|
|
1402
|
+
All methods are class-methods that delegate to
|
|
1403
|
+
:class:`SlideAnimations`. The slide's ``.animations`` proxy is
|
|
1404
|
+
created on demand and discarded; it does not need to be retained.
|
|
1405
|
+
|
|
1406
|
+
Available presets: ``appear``, ``fade``, ``fly_in``, ``float_in``,
|
|
1407
|
+
``wipe``, ``zoom``, ``wheel``, ``random_bars``.
|
|
1408
|
+
"""
|
|
1409
|
+
|
|
1410
|
+
@classmethod
|
|
1411
|
+
def appear(
|
|
1412
|
+
cls,
|
|
1413
|
+
slide: Slide,
|
|
1414
|
+
shape: BaseShape,
|
|
1415
|
+
*,
|
|
1416
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1417
|
+
delay: int = 0,
|
|
1418
|
+
) -> None:
|
|
1419
|
+
"""Shape pops into view instantly (no duration)."""
|
|
1420
|
+
slide.animations.add_entrance("appear", shape, trigger=trigger, delay=delay)
|
|
1421
|
+
|
|
1422
|
+
@classmethod
|
|
1423
|
+
def fade(
|
|
1424
|
+
cls,
|
|
1425
|
+
slide: Slide,
|
|
1426
|
+
shape: BaseShape | TextFrame,
|
|
1427
|
+
*,
|
|
1428
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1429
|
+
delay: int = 0,
|
|
1430
|
+
duration: int = 500,
|
|
1431
|
+
by_paragraph: bool = False,
|
|
1432
|
+
) -> None:
|
|
1433
|
+
"""Shape fades in.
|
|
1434
|
+
|
|
1435
|
+
With ``by_paragraph=True``, *shape* may be a |TextFrame| or any
|
|
1436
|
+
shape with a ``text_frame``; one fade effect is added per
|
|
1437
|
+
paragraph and they reveal sequentially after the first click.
|
|
1438
|
+
"""
|
|
1439
|
+
slide.animations.add_entrance(
|
|
1440
|
+
"fade",
|
|
1441
|
+
shape,
|
|
1442
|
+
trigger=trigger,
|
|
1443
|
+
delay=delay,
|
|
1444
|
+
duration=duration,
|
|
1445
|
+
by_paragraph=by_paragraph,
|
|
1446
|
+
)
|
|
1447
|
+
|
|
1448
|
+
@classmethod
|
|
1449
|
+
def fly_in(
|
|
1450
|
+
cls,
|
|
1451
|
+
slide: Slide,
|
|
1452
|
+
shape: BaseShape,
|
|
1453
|
+
*,
|
|
1454
|
+
direction: str = "bottom",
|
|
1455
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1456
|
+
delay: int = 0,
|
|
1457
|
+
duration: int = 500,
|
|
1458
|
+
) -> None:
|
|
1459
|
+
"""Shape flies in from the given *direction* (bottom/top/left/right)."""
|
|
1460
|
+
slide.animations.add_entrance(
|
|
1461
|
+
"fly_in",
|
|
1462
|
+
shape,
|
|
1463
|
+
trigger=trigger,
|
|
1464
|
+
delay=delay,
|
|
1465
|
+
duration=duration,
|
|
1466
|
+
direction=direction,
|
|
1467
|
+
)
|
|
1468
|
+
|
|
1469
|
+
@classmethod
|
|
1470
|
+
def float_in(
|
|
1471
|
+
cls,
|
|
1472
|
+
slide: Slide,
|
|
1473
|
+
shape: BaseShape,
|
|
1474
|
+
*,
|
|
1475
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1476
|
+
delay: int = 0,
|
|
1477
|
+
duration: int = 500,
|
|
1478
|
+
) -> None:
|
|
1479
|
+
"""Shape fades and drifts upward into its final position."""
|
|
1480
|
+
slide.animations.add_entrance(
|
|
1481
|
+
"float_in", shape, trigger=trigger, delay=delay, duration=duration
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
@classmethod
|
|
1485
|
+
def wipe(
|
|
1486
|
+
cls,
|
|
1487
|
+
slide: Slide,
|
|
1488
|
+
shape: BaseShape,
|
|
1489
|
+
*,
|
|
1490
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1491
|
+
delay: int = 0,
|
|
1492
|
+
duration: int = 500,
|
|
1493
|
+
) -> None:
|
|
1494
|
+
"""Shape is revealed by a wipe from the left."""
|
|
1495
|
+
slide.animations.add_entrance(
|
|
1496
|
+
"wipe", shape, trigger=trigger, delay=delay, duration=duration
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1499
|
+
@classmethod
|
|
1500
|
+
def zoom(
|
|
1501
|
+
cls,
|
|
1502
|
+
slide: Slide,
|
|
1503
|
+
shape: BaseShape,
|
|
1504
|
+
*,
|
|
1505
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1506
|
+
delay: int = 0,
|
|
1507
|
+
duration: int = 500,
|
|
1508
|
+
) -> None:
|
|
1509
|
+
"""Shape zooms in from the center."""
|
|
1510
|
+
slide.animations.add_entrance(
|
|
1511
|
+
"zoom", shape, trigger=trigger, delay=delay, duration=duration
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
@classmethod
|
|
1515
|
+
def wheel(
|
|
1516
|
+
cls,
|
|
1517
|
+
slide: Slide,
|
|
1518
|
+
shape: BaseShape,
|
|
1519
|
+
*,
|
|
1520
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1521
|
+
delay: int = 0,
|
|
1522
|
+
duration: int = 500,
|
|
1523
|
+
) -> None:
|
|
1524
|
+
"""Shape spins into view like a wheel."""
|
|
1525
|
+
slide.animations.add_entrance(
|
|
1526
|
+
"wheel", shape, trigger=trigger, delay=delay, duration=duration
|
|
1527
|
+
)
|
|
1528
|
+
|
|
1529
|
+
@classmethod
|
|
1530
|
+
def random_bars(
|
|
1531
|
+
cls,
|
|
1532
|
+
slide: Slide,
|
|
1533
|
+
shape: BaseShape,
|
|
1534
|
+
*,
|
|
1535
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1536
|
+
delay: int = 0,
|
|
1537
|
+
duration: int = 500,
|
|
1538
|
+
) -> None:
|
|
1539
|
+
"""Shape appears through random horizontal bars."""
|
|
1540
|
+
slide.animations.add_entrance(
|
|
1541
|
+
"random_bars", shape, trigger=trigger, delay=delay, duration=duration
|
|
1542
|
+
)
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
class Exit:
|
|
1546
|
+
"""Convenience class for adding exit animations.
|
|
1547
|
+
|
|
1548
|
+
Available presets: ``disappear``, ``fade``, ``fly_out``,
|
|
1549
|
+
``float_out``, ``wipe``, ``zoom``, ``wheel``, ``random_bars``.
|
|
1550
|
+
"""
|
|
1551
|
+
|
|
1552
|
+
@classmethod
|
|
1553
|
+
def disappear(
|
|
1554
|
+
cls,
|
|
1555
|
+
slide: Slide,
|
|
1556
|
+
shape: BaseShape,
|
|
1557
|
+
*,
|
|
1558
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1559
|
+
delay: int = 0,
|
|
1560
|
+
) -> None:
|
|
1561
|
+
"""Shape vanishes instantly."""
|
|
1562
|
+
slide.animations.add_exit("disappear", shape, trigger=trigger, delay=delay)
|
|
1563
|
+
|
|
1564
|
+
@classmethod
|
|
1565
|
+
def fade(
|
|
1566
|
+
cls,
|
|
1567
|
+
slide: Slide,
|
|
1568
|
+
shape: BaseShape,
|
|
1569
|
+
*,
|
|
1570
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1571
|
+
delay: int = 0,
|
|
1572
|
+
duration: int = 500,
|
|
1573
|
+
) -> None:
|
|
1574
|
+
"""Shape fades out."""
|
|
1575
|
+
slide.animations.add_exit(
|
|
1576
|
+
"fade", shape, trigger=trigger, delay=delay, duration=duration
|
|
1577
|
+
)
|
|
1578
|
+
|
|
1579
|
+
@classmethod
|
|
1580
|
+
def fly_out(
|
|
1581
|
+
cls,
|
|
1582
|
+
slide: Slide,
|
|
1583
|
+
shape: BaseShape,
|
|
1584
|
+
*,
|
|
1585
|
+
direction: str = "bottom",
|
|
1586
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1587
|
+
delay: int = 0,
|
|
1588
|
+
duration: int = 500,
|
|
1589
|
+
) -> None:
|
|
1590
|
+
"""Shape flies out in the given *direction*."""
|
|
1591
|
+
slide.animations.add_exit(
|
|
1592
|
+
"fly_out",
|
|
1593
|
+
shape,
|
|
1594
|
+
trigger=trigger,
|
|
1595
|
+
delay=delay,
|
|
1596
|
+
duration=duration,
|
|
1597
|
+
direction=direction,
|
|
1598
|
+
)
|
|
1599
|
+
|
|
1600
|
+
@classmethod
|
|
1601
|
+
def float_out(
|
|
1602
|
+
cls,
|
|
1603
|
+
slide: Slide,
|
|
1604
|
+
shape: BaseShape,
|
|
1605
|
+
*,
|
|
1606
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1607
|
+
delay: int = 0,
|
|
1608
|
+
duration: int = 500,
|
|
1609
|
+
) -> None:
|
|
1610
|
+
"""Shape fades and drifts upward out of view."""
|
|
1611
|
+
slide.animations.add_exit(
|
|
1612
|
+
"float_out", shape, trigger=trigger, delay=delay, duration=duration
|
|
1613
|
+
)
|
|
1614
|
+
|
|
1615
|
+
@classmethod
|
|
1616
|
+
def wipe(
|
|
1617
|
+
cls,
|
|
1618
|
+
slide: Slide,
|
|
1619
|
+
shape: BaseShape,
|
|
1620
|
+
*,
|
|
1621
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1622
|
+
delay: int = 0,
|
|
1623
|
+
duration: int = 500,
|
|
1624
|
+
) -> None:
|
|
1625
|
+
"""Shape is wiped away from the left."""
|
|
1626
|
+
slide.animations.add_exit(
|
|
1627
|
+
"wipe", shape, trigger=trigger, delay=delay, duration=duration
|
|
1628
|
+
)
|
|
1629
|
+
|
|
1630
|
+
@classmethod
|
|
1631
|
+
def zoom(
|
|
1632
|
+
cls,
|
|
1633
|
+
slide: Slide,
|
|
1634
|
+
shape: BaseShape,
|
|
1635
|
+
*,
|
|
1636
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1637
|
+
delay: int = 0,
|
|
1638
|
+
duration: int = 500,
|
|
1639
|
+
) -> None:
|
|
1640
|
+
"""Shape zooms away to the center."""
|
|
1641
|
+
slide.animations.add_exit(
|
|
1642
|
+
"zoom", shape, trigger=trigger, delay=delay, duration=duration
|
|
1643
|
+
)
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
class Emphasis:
|
|
1647
|
+
"""Convenience class for adding emphasis animations.
|
|
1648
|
+
|
|
1649
|
+
Available presets: ``pulse``, ``spin``, ``teeter``.
|
|
1650
|
+
"""
|
|
1651
|
+
|
|
1652
|
+
@classmethod
|
|
1653
|
+
def pulse(
|
|
1654
|
+
cls,
|
|
1655
|
+
slide: Slide,
|
|
1656
|
+
shape: BaseShape,
|
|
1657
|
+
*,
|
|
1658
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1659
|
+
delay: int = 0,
|
|
1660
|
+
duration: int = 300,
|
|
1661
|
+
) -> None:
|
|
1662
|
+
"""Shape briefly grows and shrinks (pulse)."""
|
|
1663
|
+
slide.animations.add_emphasis(
|
|
1664
|
+
"pulse", shape, trigger=trigger, delay=delay, duration=duration
|
|
1665
|
+
)
|
|
1666
|
+
|
|
1667
|
+
@classmethod
|
|
1668
|
+
def spin(
|
|
1669
|
+
cls,
|
|
1670
|
+
slide: Slide,
|
|
1671
|
+
shape: BaseShape,
|
|
1672
|
+
*,
|
|
1673
|
+
degrees: float = 360.0,
|
|
1674
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1675
|
+
delay: int = 0,
|
|
1676
|
+
duration: int = 1000,
|
|
1677
|
+
) -> None:
|
|
1678
|
+
"""Shape spins by `degrees` (default: full 360-degree rotation)."""
|
|
1679
|
+
slide.animations.add_emphasis(
|
|
1680
|
+
"spin", shape, trigger=trigger, delay=delay, duration=duration, degrees=degrees
|
|
1681
|
+
)
|
|
1682
|
+
|
|
1683
|
+
@classmethod
|
|
1684
|
+
def teeter(
|
|
1685
|
+
cls,
|
|
1686
|
+
slide: Slide,
|
|
1687
|
+
shape: BaseShape,
|
|
1688
|
+
*,
|
|
1689
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1690
|
+
delay: int = 0,
|
|
1691
|
+
duration: int = 800,
|
|
1692
|
+
) -> None:
|
|
1693
|
+
"""Shape rocks back and forth (teeter)."""
|
|
1694
|
+
slide.animations.add_emphasis(
|
|
1695
|
+
"teeter", shape, trigger=trigger, delay=delay, duration=duration
|
|
1696
|
+
)
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
class MotionPath:
|
|
1700
|
+
"""Convenience class for adding motion-path animations.
|
|
1701
|
+
|
|
1702
|
+
A motion path moves a shape along a parametric path while playing.
|
|
1703
|
+
Coordinates are normalized to the slide's width and height: ``(0,0)``
|
|
1704
|
+
is the shape's starting position, ``(1,0)`` is one slide-width to
|
|
1705
|
+
the right, ``(0,1)`` is one slide-height down.
|
|
1706
|
+
|
|
1707
|
+
Example::
|
|
1708
|
+
|
|
1709
|
+
from pptx2.animation import MotionPath, Trigger
|
|
1710
|
+
from pptx2.util import Inches
|
|
1711
|
+
|
|
1712
|
+
# Slide it two inches to the right and one inch down
|
|
1713
|
+
MotionPath.line(slide, badge, Inches(2), Inches(1))
|
|
1714
|
+
|
|
1715
|
+
# Or hand-roll a path: a quarter-circle to the right
|
|
1716
|
+
MotionPath.custom(
|
|
1717
|
+
slide, badge, "M 0 0 C 0 -0.2 0.2 -0.2 0.2 0 E"
|
|
1718
|
+
)
|
|
1719
|
+
|
|
1720
|
+
# Built-in path presets:
|
|
1721
|
+
MotionPath.arc(slide, badge, Inches(2), 0, height=0.5)
|
|
1722
|
+
MotionPath.circle(slide, badge, Inches(1))
|
|
1723
|
+
MotionPath.zigzag(slide, badge, Inches(3), 0, segments=4)
|
|
1724
|
+
MotionPath.spiral(slide, badge, Inches(2), turns=2)
|
|
1725
|
+
"""
|
|
1726
|
+
|
|
1727
|
+
@classmethod
|
|
1728
|
+
def line(
|
|
1729
|
+
cls,
|
|
1730
|
+
slide: Slide,
|
|
1731
|
+
shape: BaseShape,
|
|
1732
|
+
dx: int,
|
|
1733
|
+
dy: int,
|
|
1734
|
+
*,
|
|
1735
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1736
|
+
delay: int = 0,
|
|
1737
|
+
duration: int = 2000,
|
|
1738
|
+
) -> None:
|
|
1739
|
+
"""Move *shape* in a straight line by ``(dx, dy)`` EMU.
|
|
1740
|
+
|
|
1741
|
+
*dx* and *dy* are absolute deltas in English Metric Units (EMU)
|
|
1742
|
+
— typically built with :func:`pptx2.util.Inches`,
|
|
1743
|
+
:func:`pptx2.util.Pt`, etc. They are normalized to the slide's
|
|
1744
|
+
size before being written into the motion-path attribute, so a
|
|
1745
|
+
path encoded against a 10-inch-wide slide still moves the right
|
|
1746
|
+
absolute distance on a wide-screen slide.
|
|
1747
|
+
"""
|
|
1748
|
+
slide_w, slide_h = _slide_dimensions_emu(slide)
|
|
1749
|
+
nx = float(dx) / slide_w
|
|
1750
|
+
ny = float(dy) / slide_h
|
|
1751
|
+
path = f"M 0 0 L {_fmt(nx)} {_fmt(ny)} E"
|
|
1752
|
+
slide.animations.add_motion(
|
|
1753
|
+
shape, path, trigger=trigger, delay=delay, duration=duration
|
|
1754
|
+
)
|
|
1755
|
+
|
|
1756
|
+
@classmethod
|
|
1757
|
+
def custom(
|
|
1758
|
+
cls,
|
|
1759
|
+
slide: Slide,
|
|
1760
|
+
shape: BaseShape,
|
|
1761
|
+
path: str,
|
|
1762
|
+
*,
|
|
1763
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1764
|
+
delay: int = 0,
|
|
1765
|
+
duration: int = 2000,
|
|
1766
|
+
) -> None:
|
|
1767
|
+
"""Move *shape* along an arbitrary OOXML motion *path* string.
|
|
1768
|
+
|
|
1769
|
+
*path* is a PowerPoint motion-path expression — the same syntax
|
|
1770
|
+
the ``<p:animMotion>`` element uses internally. Coordinates are
|
|
1771
|
+
slide-normalized: ``(0, 0)`` is the shape's starting position,
|
|
1772
|
+
``(1, 0)`` is one slide-width to the right, ``(0, 1)`` is one
|
|
1773
|
+
slide-height down. The terminating ``E`` (path end) is required,
|
|
1774
|
+
e.g. ``"M 0 0 L 0.5 0 E"`` for a horizontal half-slide hop.
|
|
1775
|
+
|
|
1776
|
+
For the more common SVG path syntax — absolute / relative
|
|
1777
|
+
commands, no terminator, pixel-style coordinates — use
|
|
1778
|
+
:meth:`svg` instead.
|
|
1779
|
+
"""
|
|
1780
|
+
if not path or "E" not in path:
|
|
1781
|
+
raise ValueError(
|
|
1782
|
+
"motion path must be a non-empty OOXML path string ending in 'E'"
|
|
1783
|
+
)
|
|
1784
|
+
slide.animations.add_motion(
|
|
1785
|
+
shape, path, trigger=trigger, delay=delay, duration=duration
|
|
1786
|
+
)
|
|
1787
|
+
|
|
1788
|
+
@classmethod
|
|
1789
|
+
def svg(
|
|
1790
|
+
cls,
|
|
1791
|
+
slide: Slide,
|
|
1792
|
+
shape: BaseShape,
|
|
1793
|
+
path: str,
|
|
1794
|
+
*,
|
|
1795
|
+
viewbox: tuple[float, float, float, float] | None = None,
|
|
1796
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1797
|
+
delay: int = 0,
|
|
1798
|
+
duration: int = 2000,
|
|
1799
|
+
) -> None:
|
|
1800
|
+
"""Move *shape* along an SVG-style motion path.
|
|
1801
|
+
|
|
1802
|
+
Accepts the standard SVG path mini-language with the commands
|
|
1803
|
+
most commonly seen in design-tool exports:
|
|
1804
|
+
|
|
1805
|
+
* ``M / m`` — moveto (absolute / relative)
|
|
1806
|
+
* ``L / l`` — lineto
|
|
1807
|
+
* ``H / h`` — horizontal lineto
|
|
1808
|
+
* ``V / v`` — vertical lineto
|
|
1809
|
+
* ``C / c`` — cubic bezier curveto
|
|
1810
|
+
* ``Q / q`` — quadratic bezier curveto
|
|
1811
|
+
* ``Z / z`` — closepath (returns to the most-recent moveto)
|
|
1812
|
+
|
|
1813
|
+
Coordinates are interpreted against *viewbox* (a 4-tuple
|
|
1814
|
+
``(min_x, min_y, width, height)``). When *viewbox* is ``None``
|
|
1815
|
+
the path is assumed to live in the unit square ``(0, 0, 1, 1)``,
|
|
1816
|
+
which mirrors PowerPoint's slide-normalised coordinate system —
|
|
1817
|
+
useful for paths hand-authored in the same coordinate space.
|
|
1818
|
+
|
|
1819
|
+
The first point of the SVG path becomes the shape's starting
|
|
1820
|
+
position (``M 0 0`` in OOXML terms) so ``MotionPath.svg(slide,
|
|
1821
|
+
shape, "M 0 0 L 100 0", viewbox=(0, 0, 100, 100))`` is
|
|
1822
|
+
equivalent to ``MotionPath.line(slide, shape, slide_w, 0)``.
|
|
1823
|
+
|
|
1824
|
+
The ``E`` terminator that OOXML expects is appended automatically;
|
|
1825
|
+
callers don't need to include one in *path*.
|
|
1826
|
+
"""
|
|
1827
|
+
normalised = _svg_to_ooxml_motion_path(path, viewbox=viewbox)
|
|
1828
|
+
slide.animations.add_motion(
|
|
1829
|
+
shape, normalised, trigger=trigger, delay=delay, duration=duration
|
|
1830
|
+
)
|
|
1831
|
+
|
|
1832
|
+
@classmethod
|
|
1833
|
+
def diagonal(
|
|
1834
|
+
cls,
|
|
1835
|
+
slide: Slide,
|
|
1836
|
+
shape: BaseShape,
|
|
1837
|
+
dx: int,
|
|
1838
|
+
dy: int,
|
|
1839
|
+
*,
|
|
1840
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1841
|
+
delay: int = 0,
|
|
1842
|
+
duration: int = 2000,
|
|
1843
|
+
) -> None:
|
|
1844
|
+
"""Move *shape* diagonally by ``(dx, dy)`` EMU.
|
|
1845
|
+
|
|
1846
|
+
Functionally equivalent to :meth:`line` — exposed as its own
|
|
1847
|
+
preset because diagonal motion is a common authoring intent and
|
|
1848
|
+
callers reading recipe code shouldn't have to puzzle out which
|
|
1849
|
+
direction a "line" travels.
|
|
1850
|
+
"""
|
|
1851
|
+
cls.line(
|
|
1852
|
+
slide, shape, dx, dy,
|
|
1853
|
+
trigger=trigger, delay=delay, duration=duration,
|
|
1854
|
+
)
|
|
1855
|
+
|
|
1856
|
+
@classmethod
|
|
1857
|
+
def circle(
|
|
1858
|
+
cls,
|
|
1859
|
+
slide: Slide,
|
|
1860
|
+
shape: BaseShape,
|
|
1861
|
+
radius: int,
|
|
1862
|
+
*,
|
|
1863
|
+
clockwise: bool = True,
|
|
1864
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1865
|
+
delay: int = 0,
|
|
1866
|
+
duration: int = 2000,
|
|
1867
|
+
) -> None:
|
|
1868
|
+
"""Move *shape* in a closed circle of *radius* EMU.
|
|
1869
|
+
|
|
1870
|
+
The shape's starting position sits on the rim at the 9 o'clock
|
|
1871
|
+
position; setting *clockwise=False* reverses the direction. The
|
|
1872
|
+
radius is normalized separately against the slide width and
|
|
1873
|
+
height, which keeps the path physically circular on widescreen
|
|
1874
|
+
and 4:3 slides alike.
|
|
1875
|
+
"""
|
|
1876
|
+
slide_w, slide_h = _slide_dimensions_emu(slide)
|
|
1877
|
+
rx = float(radius) / slide_w
|
|
1878
|
+
ry = float(radius) / slide_h
|
|
1879
|
+
# Build a closed cubic-bezier circle approximation. The 0.5523
|
|
1880
|
+
# constant (4/3 * tan(pi/8)) is the standard control-handle
|
|
1881
|
+
# length that yields a near-perfect circle from four cubics.
|
|
1882
|
+
k = 0.5522847498
|
|
1883
|
+
# Sign flip swaps direction without changing the start point.
|
|
1884
|
+
s = 1 if clockwise else -1
|
|
1885
|
+
path = (
|
|
1886
|
+
f"M 0 0 "
|
|
1887
|
+
f"C 0 {_fmt(-s * k * ry)} {_fmt(rx - k * rx)} {_fmt(-s * ry)} "
|
|
1888
|
+
f"{_fmt(rx)} {_fmt(-s * ry)} "
|
|
1889
|
+
f"C {_fmt(rx + k * rx)} {_fmt(-s * ry)} {_fmt(2 * rx)} "
|
|
1890
|
+
f"{_fmt(-s * (ry - k * ry))} {_fmt(2 * rx)} 0 "
|
|
1891
|
+
f"C {_fmt(2 * rx)} {_fmt(s * (ry - k * ry))} {_fmt(rx + k * rx)} "
|
|
1892
|
+
f"{_fmt(s * ry)} {_fmt(rx)} {_fmt(s * ry)} "
|
|
1893
|
+
f"C {_fmt(rx - k * rx)} {_fmt(s * ry)} 0 {_fmt(s * (ry - k * ry))} 0 0 E"
|
|
1894
|
+
)
|
|
1895
|
+
slide.animations.add_motion(
|
|
1896
|
+
shape, path, trigger=trigger, delay=delay, duration=duration
|
|
1897
|
+
)
|
|
1898
|
+
|
|
1899
|
+
@classmethod
|
|
1900
|
+
def arc(
|
|
1901
|
+
cls,
|
|
1902
|
+
slide: Slide,
|
|
1903
|
+
shape: BaseShape,
|
|
1904
|
+
dx: int,
|
|
1905
|
+
dy: int,
|
|
1906
|
+
*,
|
|
1907
|
+
height: float = 0.5,
|
|
1908
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1909
|
+
delay: int = 0,
|
|
1910
|
+
duration: int = 2000,
|
|
1911
|
+
) -> None:
|
|
1912
|
+
"""Move *shape* along a parabolic arc to ``(dx, dy)``.
|
|
1913
|
+
|
|
1914
|
+
*height* controls the arc's peak as a fraction of the chord
|
|
1915
|
+
length: ``0.5`` is a gentle hump, ``1.0`` a tall throw. Negative
|
|
1916
|
+
values flip the arc to the opposite side of the chord.
|
|
1917
|
+
|
|
1918
|
+
The peak is placed perpendicular to the chord, so the curve
|
|
1919
|
+
keeps its shape for any chord direction including pure vertical
|
|
1920
|
+
moves (``dx=0``).
|
|
1921
|
+
"""
|
|
1922
|
+
slide_w, slide_h = _slide_dimensions_emu(slide)
|
|
1923
|
+
nx = float(dx) / slide_w
|
|
1924
|
+
ny = float(dy) / slide_h
|
|
1925
|
+
# Control point offset perpendicular to the chord. The (ny, -nx)
|
|
1926
|
+
# vector has length equal to the chord, so multiplying it by
|
|
1927
|
+
# `height` gives a perpendicular offset of `height * chord_length`
|
|
1928
|
+
# — non-degenerate for any chord direction, including pure
|
|
1929
|
+
# vertical (where the previous `abs(nx) * height` collapsed to 0).
|
|
1930
|
+
cx = nx / 2 + height * ny
|
|
1931
|
+
cy = ny / 2 - height * nx
|
|
1932
|
+
path = f"M 0 0 Q {_fmt(cx)} {_fmt(cy)} {_fmt(nx)} {_fmt(ny)} E"
|
|
1933
|
+
slide.animations.add_motion(
|
|
1934
|
+
shape, path, trigger=trigger, delay=delay, duration=duration
|
|
1935
|
+
)
|
|
1936
|
+
|
|
1937
|
+
@classmethod
|
|
1938
|
+
def zigzag(
|
|
1939
|
+
cls,
|
|
1940
|
+
slide: Slide,
|
|
1941
|
+
shape: BaseShape,
|
|
1942
|
+
dx: int,
|
|
1943
|
+
dy: int,
|
|
1944
|
+
*,
|
|
1945
|
+
segments: int = 4,
|
|
1946
|
+
amplitude: float = 0.05,
|
|
1947
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1948
|
+
delay: int = 0,
|
|
1949
|
+
duration: int = 2000,
|
|
1950
|
+
) -> None:
|
|
1951
|
+
"""Move *shape* along a zigzag from origin to ``(dx, dy)``.
|
|
1952
|
+
|
|
1953
|
+
*segments* is the number of zigzag legs (must be ≥ 1).
|
|
1954
|
+
*amplitude* is the perpendicular swing as a fraction of the
|
|
1955
|
+
slide's smaller dimension.
|
|
1956
|
+
"""
|
|
1957
|
+
if segments < 1:
|
|
1958
|
+
raise ValueError("segments must be >= 1")
|
|
1959
|
+
slide_w, slide_h = _slide_dimensions_emu(slide)
|
|
1960
|
+
nx = float(dx) / slide_w
|
|
1961
|
+
ny = float(dy) / slide_h
|
|
1962
|
+
length = (nx * nx + ny * ny) ** 0.5 or 1.0
|
|
1963
|
+
# Perpendicular unit vector to (nx, ny).
|
|
1964
|
+
px, py = -ny / length, nx / length
|
|
1965
|
+
parts = ["M 0 0"]
|
|
1966
|
+
for i in range(1, segments + 1):
|
|
1967
|
+
t = i / segments
|
|
1968
|
+
mid_x = nx * t
|
|
1969
|
+
mid_y = ny * t
|
|
1970
|
+
swing = amplitude if i % 2 == 1 else -amplitude
|
|
1971
|
+
if i < segments:
|
|
1972
|
+
mid_x += px * swing
|
|
1973
|
+
mid_y += py * swing
|
|
1974
|
+
parts.append(f"L {_fmt(mid_x)} {_fmt(mid_y)}")
|
|
1975
|
+
parts.append("E")
|
|
1976
|
+
path = " ".join(parts)
|
|
1977
|
+
slide.animations.add_motion(
|
|
1978
|
+
shape, path, trigger=trigger, delay=delay, duration=duration
|
|
1979
|
+
)
|
|
1980
|
+
|
|
1981
|
+
@classmethod
|
|
1982
|
+
def spiral(
|
|
1983
|
+
cls,
|
|
1984
|
+
slide: Slide,
|
|
1985
|
+
shape: BaseShape,
|
|
1986
|
+
radius: int,
|
|
1987
|
+
*,
|
|
1988
|
+
turns: float = 2.0,
|
|
1989
|
+
clockwise: bool = True,
|
|
1990
|
+
trigger: PP_ANIM_TRIGGER = _TRIGGER_UNSET, # pyright: ignore[reportArgumentType]
|
|
1991
|
+
delay: int = 0,
|
|
1992
|
+
duration: int = 2500,
|
|
1993
|
+
) -> None:
|
|
1994
|
+
"""Move *shape* along an Archimedean spiral.
|
|
1995
|
+
|
|
1996
|
+
The spiral begins at the shape's starting position and unwinds
|
|
1997
|
+
outward, ending *radius* EMU away (along the +x axis for an
|
|
1998
|
+
integer *turns* count). Use a negative *turns* value to wind
|
|
1999
|
+
inward; *clockwise=False* reverses the rotation direction.
|
|
2000
|
+
"""
|
|
2001
|
+
if turns == 0:
|
|
2002
|
+
raise ValueError("turns must be non-zero")
|
|
2003
|
+
slide_w, slide_h = _slide_dimensions_emu(slide)
|
|
2004
|
+
rx = float(radius) / slide_w
|
|
2005
|
+
ry = float(radius) / slide_h
|
|
2006
|
+
# Sample enough points for a smooth spiral. 16 per turn is
|
|
2007
|
+
# visually indistinguishable from a true Archimedean spiral.
|
|
2008
|
+
steps = max(16, int(abs(turns) * 16))
|
|
2009
|
+
s = 1 if clockwise else -1
|
|
2010
|
+
parts = ["M 0 0"]
|
|
2011
|
+
for i in range(1, steps + 1):
|
|
2012
|
+
t = i / steps
|
|
2013
|
+
angle = 2 * math.pi * turns * t
|
|
2014
|
+
# Archimedean spiral: radius grows linearly while the angle
|
|
2015
|
+
# sweeps `turns` full revolutions. At t=1 with integer
|
|
2016
|
+
# turns this lands at (rx, 0) — one radius from the start.
|
|
2017
|
+
x = rx * t * math.cos(angle)
|
|
2018
|
+
y = s * ry * t * math.sin(angle)
|
|
2019
|
+
parts.append(f"L {_fmt(x)} {_fmt(y)}")
|
|
2020
|
+
parts.append("E")
|
|
2021
|
+
path = " ".join(parts)
|
|
2022
|
+
slide.animations.add_motion(
|
|
2023
|
+
shape, path, trigger=trigger, delay=delay, duration=duration
|
|
2024
|
+
)
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
# Match SVG path command letters (any letter, so unsupported ones can be
|
|
2028
|
+
# diagnosed) and signed/exponent-form numbers.
|
|
2029
|
+
_SVG_TOKEN_RE = __import__("re").compile(
|
|
2030
|
+
r"[A-Za-z]|-?\d*\.?\d+(?:[eE][+-]?\d+)?"
|
|
2031
|
+
)
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
def _svg_to_ooxml_motion_path(
|
|
2035
|
+
svg: str,
|
|
2036
|
+
*,
|
|
2037
|
+
viewbox: tuple[float, float, float, float] | None = None,
|
|
2038
|
+
) -> str:
|
|
2039
|
+
"""Convert an SVG path string into an OOXML motion-path expression.
|
|
2040
|
+
|
|
2041
|
+
Supports M/m, L/l, H/h, V/v, C/c, Q/q, Z/z. The resulting OOXML
|
|
2042
|
+
path is rebased so the first moveto becomes ``M 0 0`` (PowerPoint
|
|
2043
|
+
motion-paths are relative to the shape's starting position) and
|
|
2044
|
+
coordinates are mapped from *viewbox* into the unit square.
|
|
2045
|
+
|
|
2046
|
+
When *viewbox* is ``None`` the path is assumed to already live in
|
|
2047
|
+
the unit square ``(0, 0, 1, 1)`` — the OOXML coordinate system —
|
|
2048
|
+
which is convenient for hand-authored paths.
|
|
2049
|
+
"""
|
|
2050
|
+
if not svg or not svg.strip():
|
|
2051
|
+
raise ValueError("svg path must be a non-empty string")
|
|
2052
|
+
|
|
2053
|
+
if viewbox is None:
|
|
2054
|
+
vb_x, vb_y, vb_w, vb_h = 0.0, 0.0, 1.0, 1.0
|
|
2055
|
+
else:
|
|
2056
|
+
vb_x, vb_y, vb_w, vb_h = (float(v) for v in viewbox)
|
|
2057
|
+
if vb_w <= 0 or vb_h <= 0:
|
|
2058
|
+
raise ValueError(
|
|
2059
|
+
"viewbox width and height must be positive; got "
|
|
2060
|
+
f"{viewbox!r}"
|
|
2061
|
+
)
|
|
2062
|
+
|
|
2063
|
+
tokens = _SVG_TOKEN_RE.findall(svg)
|
|
2064
|
+
if not tokens:
|
|
2065
|
+
raise ValueError(f"no recognisable commands in svg path {svg!r}")
|
|
2066
|
+
|
|
2067
|
+
out_parts: list[str] = []
|
|
2068
|
+
# Track the SVG-coord cursor (cx, cy), the rebase origin (origin_x,
|
|
2069
|
+
# origin_y) — i.e. where the first moveto landed in SVG coords —
|
|
2070
|
+
# and the most-recent subpath start for ``Z`` closure.
|
|
2071
|
+
cx = cy = 0.0
|
|
2072
|
+
origin_x: float | None = None
|
|
2073
|
+
origin_y: float | None = None
|
|
2074
|
+
subpath_x = subpath_y = 0.0
|
|
2075
|
+
started = False
|
|
2076
|
+
|
|
2077
|
+
def _emit(letter: str, *coords: float) -> None:
|
|
2078
|
+
# Translate (cx, cy) in SVG coords to OOXML unit-square coords
|
|
2079
|
+
# relative to the rebase origin.
|
|
2080
|
+
out_parts.append(letter)
|
|
2081
|
+
for c in coords:
|
|
2082
|
+
out_parts.append(_fmt(c))
|
|
2083
|
+
|
|
2084
|
+
def _to_ooxml(x: float, y: float) -> tuple[float, float]:
|
|
2085
|
+
# Rebase to origin then map viewbox → unit square.
|
|
2086
|
+
assert origin_x is not None and origin_y is not None
|
|
2087
|
+
return (x - origin_x) / vb_w, (y - origin_y) / vb_h
|
|
2088
|
+
|
|
2089
|
+
i = 0
|
|
2090
|
+
cmd: str = ""
|
|
2091
|
+
while i < len(tokens):
|
|
2092
|
+
tok = tokens[i]
|
|
2093
|
+
if tok.isalpha():
|
|
2094
|
+
cmd = tok
|
|
2095
|
+
i += 1
|
|
2096
|
+
# H / V take a single number; Z takes none.
|
|
2097
|
+
if cmd in ("Z", "z"):
|
|
2098
|
+
# Close current subpath: SVG draws a line back to the
|
|
2099
|
+
# subpath origin.
|
|
2100
|
+
cx, cy = subpath_x, subpath_y
|
|
2101
|
+
ox, oy = _to_ooxml(cx, cy)
|
|
2102
|
+
_emit("L", ox, oy)
|
|
2103
|
+
continue
|
|
2104
|
+
try:
|
|
2105
|
+
n = float(tokens[i])
|
|
2106
|
+
except (IndexError, ValueError):
|
|
2107
|
+
raise ValueError(
|
|
2108
|
+
f"expected coordinate after {cmd!r} in svg path {svg!r}"
|
|
2109
|
+
)
|
|
2110
|
+
i += 1
|
|
2111
|
+
|
|
2112
|
+
if cmd in ("M", "m"):
|
|
2113
|
+
# First number is the new cursor; subsequent number-pairs
|
|
2114
|
+
# under the same M command are implicit linetos.
|
|
2115
|
+
try:
|
|
2116
|
+
m = float(tokens[i])
|
|
2117
|
+
except (IndexError, ValueError):
|
|
2118
|
+
raise ValueError(
|
|
2119
|
+
f"expected y after x in moveto in svg path {svg!r}"
|
|
2120
|
+
)
|
|
2121
|
+
i += 1
|
|
2122
|
+
new_x = n if cmd == "M" else cx + n
|
|
2123
|
+
new_y = m if cmd == "M" else cy + m
|
|
2124
|
+
if not started:
|
|
2125
|
+
origin_x, origin_y = new_x, new_y
|
|
2126
|
+
subpath_x, subpath_y = new_x, new_y
|
|
2127
|
+
cx, cy = new_x, new_y
|
|
2128
|
+
_emit("M", 0.0, 0.0)
|
|
2129
|
+
started = True
|
|
2130
|
+
else:
|
|
2131
|
+
cx, cy = new_x, new_y
|
|
2132
|
+
subpath_x, subpath_y = new_x, new_y
|
|
2133
|
+
ox, oy = _to_ooxml(cx, cy)
|
|
2134
|
+
_emit("M", ox, oy)
|
|
2135
|
+
# Implicit lineto continuation: M behaves as L for
|
|
2136
|
+
# subsequent coord pairs.
|
|
2137
|
+
cmd = "L" if cmd == "M" else "l"
|
|
2138
|
+
continue
|
|
2139
|
+
|
|
2140
|
+
if not started:
|
|
2141
|
+
# Any non-M command must be preceded by a moveto in valid SVG.
|
|
2142
|
+
raise ValueError(
|
|
2143
|
+
f"svg path must start with M/m; got {cmd!r} in {svg!r}"
|
|
2144
|
+
)
|
|
2145
|
+
|
|
2146
|
+
if cmd in ("L", "l"):
|
|
2147
|
+
try:
|
|
2148
|
+
m = float(tokens[i])
|
|
2149
|
+
except (IndexError, ValueError):
|
|
2150
|
+
raise ValueError(
|
|
2151
|
+
f"expected y after x in lineto in svg path {svg!r}"
|
|
2152
|
+
)
|
|
2153
|
+
i += 1
|
|
2154
|
+
cx = n if cmd == "L" else cx + n
|
|
2155
|
+
cy = m if cmd == "L" else cy + m
|
|
2156
|
+
ox, oy = _to_ooxml(cx, cy)
|
|
2157
|
+
_emit("L", ox, oy)
|
|
2158
|
+
elif cmd in ("H", "h"):
|
|
2159
|
+
cx = n if cmd == "H" else cx + n
|
|
2160
|
+
ox, oy = _to_ooxml(cx, cy)
|
|
2161
|
+
_emit("L", ox, oy)
|
|
2162
|
+
elif cmd in ("V", "v"):
|
|
2163
|
+
cy = n if cmd == "V" else cy + n
|
|
2164
|
+
ox, oy = _to_ooxml(cx, cy)
|
|
2165
|
+
_emit("L", ox, oy)
|
|
2166
|
+
elif cmd in ("C", "c"):
|
|
2167
|
+
# Cubic: x1 y1 x2 y2 x y
|
|
2168
|
+
try:
|
|
2169
|
+
pts = [n] + [float(tokens[i + k]) for k in range(5)]
|
|
2170
|
+
except (IndexError, ValueError):
|
|
2171
|
+
raise ValueError(
|
|
2172
|
+
f"cubic curve needs 6 coords in svg path {svg!r}"
|
|
2173
|
+
)
|
|
2174
|
+
i += 5
|
|
2175
|
+
x1, y1, x2, y2, x, y = pts
|
|
2176
|
+
if cmd == "c":
|
|
2177
|
+
x1 += cx; y1 += cy
|
|
2178
|
+
x2 += cx; y2 += cy
|
|
2179
|
+
x += cx; y += cy
|
|
2180
|
+
cx, cy = x, y
|
|
2181
|
+
o1x, o1y = _to_ooxml(x1, y1)
|
|
2182
|
+
o2x, o2y = _to_ooxml(x2, y2)
|
|
2183
|
+
ox, oy = _to_ooxml(x, y)
|
|
2184
|
+
_emit("C", o1x, o1y, o2x, o2y, ox, oy)
|
|
2185
|
+
elif cmd in ("Q", "q"):
|
|
2186
|
+
# Quadratic: x1 y1 x y
|
|
2187
|
+
try:
|
|
2188
|
+
pts = [n] + [float(tokens[i + k]) for k in range(3)]
|
|
2189
|
+
except (IndexError, ValueError):
|
|
2190
|
+
raise ValueError(
|
|
2191
|
+
f"quadratic curve needs 4 coords in svg path {svg!r}"
|
|
2192
|
+
)
|
|
2193
|
+
i += 3
|
|
2194
|
+
x1, y1, x, y = pts
|
|
2195
|
+
if cmd == "q":
|
|
2196
|
+
x1 += cx; y1 += cy
|
|
2197
|
+
x += cx; y += cy
|
|
2198
|
+
cx, cy = x, y
|
|
2199
|
+
o1x, o1y = _to_ooxml(x1, y1)
|
|
2200
|
+
ox, oy = _to_ooxml(x, y)
|
|
2201
|
+
_emit("Q", o1x, o1y, ox, oy)
|
|
2202
|
+
else:
|
|
2203
|
+
raise ValueError(
|
|
2204
|
+
f"unsupported svg path command {cmd!r}; supported: "
|
|
2205
|
+
"M/m L/l H/h V/v C/c Q/q Z/z"
|
|
2206
|
+
)
|
|
2207
|
+
|
|
2208
|
+
out_parts.append("E")
|
|
2209
|
+
return " ".join(out_parts)
|
|
2210
|
+
|
|
2211
|
+
|
|
2212
|
+
def _slide_dimensions_emu(slide: Slide) -> tuple[int, int]:
|
|
2213
|
+
"""Return the (width, height) of *slide*'s presentation in EMU.
|
|
2214
|
+
|
|
2215
|
+
Falls back to PowerPoint's default 10in × 7.5in if either dimension
|
|
2216
|
+
is missing (which should not happen for any well-formed deck).
|
|
2217
|
+
"""
|
|
2218
|
+
prs_part = slide.part.package.presentation_part
|
|
2219
|
+
presentation = prs_part.presentation
|
|
2220
|
+
width = presentation.slide_width or 9_144_000 # 10 inches
|
|
2221
|
+
height = presentation.slide_height or 6_858_000 # 7.5 inches
|
|
2222
|
+
return int(width), int(height)
|
|
2223
|
+
|
|
2224
|
+
|
|
2225
|
+
def _fmt(value: float) -> str:
|
|
2226
|
+
"""Format a motion-path coordinate as a plain decimal number.
|
|
2227
|
+
|
|
2228
|
+
``%g`` emits scientific notation for small magnitudes (``1.5e-17`` from
|
|
2229
|
+
sin/cos float noise), which PowerPoint's path parser cannot read — ``e``
|
|
2230
|
+
is not part of the number grammar and ``E`` is the End command — so the
|
|
2231
|
+
whole animation is dropped. Fixed-point with six decimals, trailing
|
|
2232
|
+
zeros stripped, and float-noise clamped to ``0``.
|
|
2233
|
+
"""
|
|
2234
|
+
if abs(value) < 5e-7:
|
|
2235
|
+
return "0"
|
|
2236
|
+
text = f"{value:.6f}".rstrip("0").rstrip(".")
|
|
2237
|
+
return "0" if text == "-0" else text
|