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.
Files changed (175) hide show
  1. pptx2/__init__.py +152 -0
  2. pptx2/_color.py +75 -0
  3. pptx2/_slide_importer.py +597 -0
  4. pptx2/_svg.py +155 -0
  5. pptx2/_template_applier.py +292 -0
  6. pptx2/_textstyle.py +187 -0
  7. pptx2/accessibility.py +365 -0
  8. pptx2/action.py +270 -0
  9. pptx2/animation.py +2237 -0
  10. pptx2/api.py +49 -0
  11. pptx2/audit.py +258 -0
  12. pptx2/chart/__init__.py +0 -0
  13. pptx2/chart/analytics.py +381 -0
  14. pptx2/chart/axis.py +543 -0
  15. pptx2/chart/category.py +200 -0
  16. pptx2/chart/chart.py +670 -0
  17. pptx2/chart/data.py +864 -0
  18. pptx2/chart/datalabel.py +406 -0
  19. pptx2/chart/legend.py +86 -0
  20. pptx2/chart/marker.py +70 -0
  21. pptx2/chart/palettes.py +129 -0
  22. pptx2/chart/plot.py +462 -0
  23. pptx2/chart/point.py +101 -0
  24. pptx2/chart/quick_layouts.py +325 -0
  25. pptx2/chart/series.py +334 -0
  26. pptx2/chart/xlsx.py +272 -0
  27. pptx2/chart/xmlwriter.py +1845 -0
  28. pptx2/compose/__init__.py +28 -0
  29. pptx2/compose/from_spec.py +1094 -0
  30. pptx2/design/__init__.py +8 -0
  31. pptx2/design/components.py +607 -0
  32. pptx2/design/figures.py +389 -0
  33. pptx2/design/layout.py +370 -0
  34. pptx2/design/recipes.py +1967 -0
  35. pptx2/design/style.py +209 -0
  36. pptx2/design/tokens.py +915 -0
  37. pptx2/diagrams.py +754 -0
  38. pptx2/dml/__init__.py +0 -0
  39. pptx2/dml/chtfmt.py +40 -0
  40. pptx2/dml/color.py +496 -0
  41. pptx2/dml/effect.py +909 -0
  42. pptx2/dml/fill.py +691 -0
  43. pptx2/dml/line.py +287 -0
  44. pptx2/dml/picture.py +212 -0
  45. pptx2/dml/three_d.py +381 -0
  46. pptx2/enum/__init__.py +0 -0
  47. pptx2/enum/action.py +71 -0
  48. pptx2/enum/animation.py +31 -0
  49. pptx2/enum/base.py +218 -0
  50. pptx2/enum/chart.py +574 -0
  51. pptx2/enum/dml.py +740 -0
  52. pptx2/enum/lang.py +685 -0
  53. pptx2/enum/presentation.py +133 -0
  54. pptx2/enum/shapes.py +1029 -0
  55. pptx2/enum/text.py +230 -0
  56. pptx2/exc.py +42 -0
  57. pptx2/formats.py +139 -0
  58. pptx2/geometry.py +420 -0
  59. pptx2/inherit.py +109 -0
  60. pptx2/lint.py +2256 -0
  61. pptx2/math.py +177 -0
  62. pptx2/media.py +197 -0
  63. pptx2/opc/__init__.py +0 -0
  64. pptx2/opc/constants.py +332 -0
  65. pptx2/opc/oxml.py +188 -0
  66. pptx2/opc/package.py +762 -0
  67. pptx2/opc/packuri.py +109 -0
  68. pptx2/opc/serialized.py +296 -0
  69. pptx2/opc/shared.py +20 -0
  70. pptx2/opc/spec.py +45 -0
  71. pptx2/oxml/__init__.py +555 -0
  72. pptx2/oxml/action.py +53 -0
  73. pptx2/oxml/chart/__init__.py +0 -0
  74. pptx2/oxml/chart/axis.py +337 -0
  75. pptx2/oxml/chart/chart.py +481 -0
  76. pptx2/oxml/chart/datalabel.py +253 -0
  77. pptx2/oxml/chart/legend.py +72 -0
  78. pptx2/oxml/chart/marker.py +61 -0
  79. pptx2/oxml/chart/plot.py +365 -0
  80. pptx2/oxml/chart/series.py +425 -0
  81. pptx2/oxml/chart/shared.py +220 -0
  82. pptx2/oxml/coreprops.py +288 -0
  83. pptx2/oxml/dml/__init__.py +0 -0
  84. pptx2/oxml/dml/color.py +135 -0
  85. pptx2/oxml/dml/effect.py +213 -0
  86. pptx2/oxml/dml/fill.py +316 -0
  87. pptx2/oxml/dml/line.py +12 -0
  88. pptx2/oxml/dml/three_d.py +110 -0
  89. pptx2/oxml/ns.py +135 -0
  90. pptx2/oxml/presentation.py +313 -0
  91. pptx2/oxml/shapes/__init__.py +19 -0
  92. pptx2/oxml/shapes/autoshape.py +467 -0
  93. pptx2/oxml/shapes/connector.py +107 -0
  94. pptx2/oxml/shapes/graphfrm.py +347 -0
  95. pptx2/oxml/shapes/groupshape.py +329 -0
  96. pptx2/oxml/shapes/picture.py +270 -0
  97. pptx2/oxml/shapes/shared.py +577 -0
  98. pptx2/oxml/simpletypes.py +1027 -0
  99. pptx2/oxml/slide.py +563 -0
  100. pptx2/oxml/table.py +650 -0
  101. pptx2/oxml/text.py +815 -0
  102. pptx2/oxml/theme.py +36 -0
  103. pptx2/oxml/xmlchemy.py +717 -0
  104. pptx2/package.py +222 -0
  105. pptx2/parts/__init__.py +0 -0
  106. pptx2/parts/chart.py +95 -0
  107. pptx2/parts/coreprops.py +167 -0
  108. pptx2/parts/diagram.py +37 -0
  109. pptx2/parts/embeddedpackage.py +93 -0
  110. pptx2/parts/image.py +275 -0
  111. pptx2/parts/media.py +37 -0
  112. pptx2/parts/presentation.py +136 -0
  113. pptx2/parts/slide.py +371 -0
  114. pptx2/presentation.py +408 -0
  115. pptx2/py.typed +0 -0
  116. pptx2/render.py +586 -0
  117. pptx2/section.py +272 -0
  118. pptx2/shapes/__init__.py +26 -0
  119. pptx2/shapes/autoshape.py +442 -0
  120. pptx2/shapes/base.py +1078 -0
  121. pptx2/shapes/connector.py +297 -0
  122. pptx2/shapes/freeform.py +337 -0
  123. pptx2/shapes/graphfrm.py +316 -0
  124. pptx2/shapes/group.py +264 -0
  125. pptx2/shapes/picture.py +422 -0
  126. pptx2/shapes/placeholder.py +468 -0
  127. pptx2/shapes/shapetree.py +2027 -0
  128. pptx2/shared.py +82 -0
  129. pptx2/skill/SKILL.md +450 -0
  130. pptx2/skill/__init__.py +78 -0
  131. pptx2/skill/__main__.py +64 -0
  132. pptx2/skill/references/animations.md +189 -0
  133. pptx2/skill/references/basics.md +421 -0
  134. pptx2/skill/references/charts.md +254 -0
  135. pptx2/skill/references/compose.md +234 -0
  136. pptx2/skill/references/design.md +366 -0
  137. pptx2/skill/references/effects.md +249 -0
  138. pptx2/skill/references/end-to-end-deck.md +231 -0
  139. pptx2/skill/references/geometry-and-arrows.md +334 -0
  140. pptx2/skill/references/lint.md +275 -0
  141. pptx2/skill/references/math.md +86 -0
  142. pptx2/skill/references/picture-effects.md +129 -0
  143. pptx2/skill/references/render.md +151 -0
  144. pptx2/skill/references/smart-art.md +75 -0
  145. pptx2/skill/references/space-aware-authoring.md +249 -0
  146. pptx2/skill/references/tables.md +244 -0
  147. pptx2/skill/references/theme.md +127 -0
  148. pptx2/skill/references/three-d.md +109 -0
  149. pptx2/skill/references/transitions.md +100 -0
  150. pptx2/slide.py +1244 -0
  151. pptx2/smart_art.py +220 -0
  152. pptx2/spec.py +633 -0
  153. pptx2/table.py +1181 -0
  154. pptx2/table_styles.py +184 -0
  155. pptx2/templates/default.pptx +0 -0
  156. pptx2/templates/docx-icon.emf +0 -0
  157. pptx2/templates/generic-icon.emf +0 -0
  158. pptx2/templates/notes.xml +23 -0
  159. pptx2/templates/notesMaster.xml +352 -0
  160. pptx2/templates/pptx-icon.emf +0 -0
  161. pptx2/templates/theme.xml +321 -0
  162. pptx2/templates/xlsx-icon.emf +0 -0
  163. pptx2/text/__init__.py +0 -0
  164. pptx2/text/fonts.py +482 -0
  165. pptx2/text/layout.py +374 -0
  166. pptx2/text/text.py +1272 -0
  167. pptx2/theme.py +721 -0
  168. pptx2/types.py +36 -0
  169. pptx2/util.py +263 -0
  170. python_pptx2-2.13.0.dist-info/METADATA +351 -0
  171. python_pptx2-2.13.0.dist-info/RECORD +175 -0
  172. python_pptx2-2.13.0.dist-info/WHEEL +5 -0
  173. python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
  174. python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
  175. 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