pptd-utils 2026.10.2.dev3__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.
pptd_utils/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # pptd_utils
2
+
3
+ Offline PPTD toolkit — full-spec `.pptd` → `.pptx` converter + slide image export (collage / single slide). Pure Python, no browser required.
4
+
5
+ Built against the PPTD v2 spec (`skills/cowork-ppt/reference/pptd.md`, relative to the repo root).
6
+
7
+ ## Install / dependencies
8
+
9
+ ```
10
+ pip install python-pptx Pillow PyYAML
11
+ ```
12
+
13
+ Optional (graceful fallback if missing):
14
+
15
+ | Package | Unlocks | Fallback |
16
+ |---|---|---|
17
+ | `latex2mathml` + `mathml2omml` | native equations (OMML) from `\( ... \)` | literal text |
18
+ | `fontTools` | customFonts (Google Fonts woff2 → embedded TTF) | fonts skipped |
19
+
20
+ Slide images additionally need **LibreOffice** (`soffice` on PATH).
21
+
22
+ ## CLI
23
+
24
+ Install from PyPI:
25
+
26
+ ```bash
27
+ python -m pip install pptd-utils
28
+ ```
29
+
30
+ Commands write beside the input path. `all` is the default; selections accept comma-separated 1-based slide numbers.
31
+
32
+ ```bash
33
+ pptd check deck.pptd
34
+ pptd pptx deck.pptd
35
+ pptd png deck.pptd # all slides -> deck-png/
36
+ pptd png 1,2,3 deck.pptd # selected slides -> deck-png/
37
+ pptd png collage deck.pptd # contact sheet -> deck-collage.png
38
+ pptd pdf deck.pptd # all slides -> deck.pdf
39
+ pptd pdf 1,2,3 deck.pptd # selected slides -> deck.pdf
40
+ ```
41
+
42
+ PNG and PDF export require LibreOffice (`soffice` on `PATH`).
43
+
44
+ ## Python API
45
+
46
+ ```python
47
+ from pptd_utils import convert, build, verify
48
+ from pptd_utils import slides_image
49
+
50
+ convert("deck.pptd", "out.pptx") # build + verify + print summary
51
+ build("deck.pptd", "out.pptx") # -> (n_slides, n_fonts)
52
+ verify("deck.pptd", "out.pptx") # structural self-check (asserts)
53
+
54
+ slides_image.render_collage("deck.pptd", "collage.png",
55
+ tile_w=480, gap=20, bg="#1F2430")
56
+ slides_image.render_slide("deck.pptd", 3, "slide3.png")
57
+ slides_image.render_slide_images("deck.pptd", "outdir/",
58
+ slides=[1, 3, 5]) # -> [(n, Path), ...]
59
+ ```
60
+
61
+ ## Supported features (PPTD v2)
62
+
63
+ | Area | Coverage |
64
+ |---|---|
65
+ | text | full HTML tag set (strong/em/u/s/sup/sub/a/br/ul/ol/span style attrs), LaTeX runs, gradient + shadow text, letterSpacing, lineHeight / lineHeightPx, bullets/numbering, vertical text, hyperlinks, highlight |
66
+ | shapes | all 130 preset shapeNames (adjustments), custom SVG paths (`M/L/H/V/C/S/Q/A/Z`, multi-subpath holes), rotation/flip/opacity, gradient/radial fills |
67
+ | lines | sharp / smooth / round (fillet) curves, arrows (head/tail), dash styles |
68
+ | images | fit cover/contain/fill, crop (inset + negative outset padding), cropShape (any preset or custom path), opacity, border, shadow |
69
+ | icons | Font Awesome 6/7 free (fas/far/fab), solid + gradient fill, outline, shadow |
70
+ | tables | rowSpan/colSpan merges, theme tableStyles + inline, per-cell style chains, BorderSpec (per-side / null-clear), gradient cell fills, fontSize auto-fit |
71
+ | charts — native (editable) | bar (stacked/clustered/horizontal), line (smooth/dash/markers), area, scatter, bubble, pie/donut, radar, secondary axes, dataLabels + numberFormat, embedded workbooks |
72
+ | charts — raster (Pillow PNG) | candlestick, waterfall, heatmap (linear/diverging + colorbar), treemap, sunburst, sankey, stream stacks, mixed scatter+cartesian |
73
+ | animations | 21 effects (entrance/emphasis/exit incl. motion-path) → full `p:timing` tree, click/withPrevious/afterPrevious, repeat, easing |
74
+ | transitions | fade |
75
+ | fonts | customFonts embedding (see optional deps) |
76
+ | misc | page backgrounds (solid/gradient/image), notes, themes ($refs), elementId → animation targeting |
77
+
78
+ ## How it works
79
+
80
+ - `pptx` generation: python-pptx + hand-built DrawingML for fills/effects/ordering; charts created via real chart parts (editable in PowerPoint) then plot XML rewritten; unsupported chart types rasterized at 2× and placed as pictures.
81
+ - images: each slide isolated into a one-slide pptx, rendered by LibreOffice headless, composed by Pillow.
82
+ (`ponytail:` one soffice run per slide — switch to single PDF + PyMuPDF for large decks.)
83
+
84
+ ## Test
85
+
86
+ ```bash
87
+ python3 tests/run.py # converts full-spec fixture + minimal + ml-smp, asserts structure
88
+ ```
89
+
90
+ Fixture `tests/fixtures/full/` exercises every feature area — use it as a syntax reference for your own decks.
91
+
92
+ ## Notes
93
+
94
+ - Downloads (FA fonts, URL images/fonts) cache under `~/.cache/pptd_utils/`.
95
+ - Defaults follow the spec: MiSans font family, 18pt body, black text.
pptd_utils/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """pptd_utils — offline PPTD toolkit: .pptd -> .pptx converter + slide images (PPTD v2 spec).
2
+
3
+ python-pptx + Pillow only; optional: latex2mathml+mathml2omml (LaTeX->OMML),
4
+ fontTools+brotli (customFont embedding).
5
+ """
6
+ __version__ = "0.1.0"
7
+
8
+ from .main import build, verify, convert
9
+
10
+ __all__ = ["__version__", "build", "verify", "convert"]
pptd_utils/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .main import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,134 @@
1
+ """Generated from pptd.md shapes.md: shapeName -> adjustment defaults (OOXML units)."""
2
+
3
+ SHAPES = {
4
+ "accentBorderCallout1": [18750, -8333, 112500, -38333],
5
+ "accentBorderCallout2": [18750, -8333, 18750, -16667, 112500, -46667],
6
+ "accentBorderCallout3": [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333],
7
+ "accentCallout1": [18750, -8333, 112500, -38333],
8
+ "accentCallout2": [18750, -8333, 18750, -16667, 112500, -46667],
9
+ "accentCallout3": [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333],
10
+ "arc": [16200000, 0],
11
+ "bentArrow": [25000, 25000, 25000, 43750],
12
+ "bentUpArrow": [25000, 25000, 25000],
13
+ "bevel": [12500],
14
+ "blockArc": [10800000, 0, 25000],
15
+ "borderCallout1": [18750, -8333, 112500, -38333],
16
+ "borderCallout2": [18750, -8333, 18750, -16667, 112500, -46667],
17
+ "borderCallout3": [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333],
18
+ "bracePair": [8333],
19
+ "bracketPair": [16667],
20
+ "callout1": [18750, -8333, 112500, -38333],
21
+ "callout2": [18750, -8333, 18750, -16667, 112500, -46667],
22
+ "callout3": [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333],
23
+ "can": [25000],
24
+ "chevron": [50000],
25
+ "chord": [2700000, 16200000],
26
+ "circularArrow": [12500, 1142319, 20457681, 10800000, 12500],
27
+ "cloud": [],
28
+ "cloudCallout": [-20833, 62500],
29
+ "corner": [50000, 50000],
30
+ "cube": [25000],
31
+ "curvedDownArrow": [25000, 50000, 25000],
32
+ "curvedLeftArrow": [25000, 50000, 25000],
33
+ "curvedRightArrow": [25000, 50000, 25000],
34
+ "curvedUpArrow": [25000, 50000, 25000],
35
+ "decagon": [105146],
36
+ "diagStripe": [50000],
37
+ "diamond": [],
38
+ "dodecagon": [],
39
+ "donut": [25000],
40
+ "doubleWave": [6250, 0],
41
+ "downArrow": [50000, 50000],
42
+ "downArrowCallout": [25000, 25000, 25000, 64977],
43
+ "ellipse": [],
44
+ "ellipseRibbon": [25000, 50000, 12500],
45
+ "ellipseRibbon2": [25000, 50000, 12500],
46
+ "foldedCorner": [],
47
+ "frame": [12500],
48
+ "funnel": [],
49
+ "gear6": [15000, 3526],
50
+ "gear9": [10000, 1763],
51
+ "halfFrame": [33333, 33333],
52
+ "heart": [],
53
+ "heptagon": [102572, 105210],
54
+ "hexagon": [25000, 115470],
55
+ "homePlate": [50000],
56
+ "horizontalScroll": [12500],
57
+ "irregularSeal1": [],
58
+ "irregularSeal2": [],
59
+ "leftArrow": [50000, 50000],
60
+ "leftArrowCallout": [25000, 25000, 25000, 64977],
61
+ "leftBrace": [8333, 50000],
62
+ "leftBracket": [8333],
63
+ "leftCircularArrow": [12500, -1142319, 1142319, 10800000, 12500],
64
+ "leftRightArrow": [50000, 50000],
65
+ "leftRightArrowCallout": [25000, 25000, 25000, 48123],
66
+ "leftRightCircularArrow": [12500, 1142319, 20457681, 11942319, 12500],
67
+ "leftRightRibbon": [50000, 50000, 16667],
68
+ "leftRightUpArrow": [25000, 25000, 25000],
69
+ "leftUpArrow": [25000, 25000, 25000],
70
+ "lightningBolt": [],
71
+ "lineInv": [],
72
+ "mathDivide": [23520, 5880, 11760],
73
+ "mathEqual": [23520, 11760],
74
+ "mathMinus": [23520],
75
+ "mathMultiply": [23520],
76
+ "mathNotEqual": [23520, 6600000, 11760],
77
+ "mathPlus": [23520],
78
+ "moon": [50000],
79
+ "noSmoking": [18750],
80
+ "nonIsoscelesTrapezoid": [25000, 25000],
81
+ "notchedRightArrow": [50000, 50000],
82
+ "octagon": [29289],
83
+ "parallelogram": [25000],
84
+ "pentagon": [105146, 110557],
85
+ "pie": [0, 16200000],
86
+ "pieWedge": [],
87
+ "plaque": [16667],
88
+ "plus": [25000],
89
+ "quadArrow": [22500, 22500, 22500],
90
+ "quadArrowCallout": [18515, 18515, 18515, 48123],
91
+ "rect": [],
92
+ "ribbon": [16667, 50000],
93
+ "ribbon2": [16667, 50000],
94
+ "rightArrow": [50000, 50000],
95
+ "rightArrowCallout": [25000, 25000, 25000, 64977],
96
+ "rightBrace": [8333, 50000],
97
+ "rightBracket": [8333],
98
+ "round1Rect": [16667],
99
+ "round2DiagRect": [16667, 0],
100
+ "round2SameRect": [16667, 0],
101
+ "roundRect": [16667],
102
+ "rtTriangle": [],
103
+ "smileyFace": [4653],
104
+ "snip1Rect": [16667],
105
+ "snip2DiagRect": [0, 16667],
106
+ "snip2SameRect": [16667, 0],
107
+ "snipRoundRect": [16667, 16667],
108
+ "star10": [42533, 105146],
109
+ "star12": [37500],
110
+ "star16": [37500],
111
+ "star24": [37500],
112
+ "star32": [37500],
113
+ "star4": [12500],
114
+ "star5": [19098, 105146, 110557],
115
+ "star6": [28868, 115470],
116
+ "star7": [34601, 102572, 105210],
117
+ "star8": [37500],
118
+ "stripedRightArrow": [50000, 50000],
119
+ "sun": [25000],
120
+ "swooshArrow": [25000, 16667],
121
+ "teardrop": [100000],
122
+ "trapezoid": [25000],
123
+ "triangle": [50000],
124
+ "upArrow": [50000, 50000],
125
+ "upArrowCallout": [25000, 25000, 25000, 64977],
126
+ "upDownArrow": [50000, 50000, 50000, 50000],
127
+ "upDownArrowCallout": [25000, 25000, 25000, 48123],
128
+ "uturnArrow": [25000, 25000, 25000, 43750, 75000],
129
+ "verticalScroll": [12500],
130
+ "wave": [12500, 0],
131
+ "wedgeEllipseCallout": [-20833, 62500],
132
+ "wedgeRectCallout": [-20833, 62500],
133
+ "wedgeRoundRectCallout": [-20833, 62500, 16667],
134
+ }
pptd_utils/anim.py ADDED
@@ -0,0 +1,367 @@
1
+ """Animations (pptd 6.) -> OOXML p:timing tree.
2
+
3
+ PowerPoint timing model: mainSeq of click groups; each group holds "step"
4
+ pars launched with computed delays (withPrevious joins current step,
5
+ afterPrevious chains a new step after the previous one ends).
6
+ """
7
+ from lxml import etree
8
+ from pptx.oxml.ns import qn
9
+
10
+ from .xmlutil import sub
11
+
12
+ P = "{http://schemas.openxmlformats.org/presentationml/2006/main}"
13
+
14
+ # presetID, presetClass, default ms
15
+ EFFECTS = {
16
+ "appear": (1, "entr", 1),
17
+ "fade-in": (10, "entr", 500),
18
+ "fly-in": (2, "entr", 500),
19
+ "zoom-in": (23, "entr", 500),
20
+ "wipe-in": (22, "entr", 500),
21
+ "float-in": (42, "entr", 500),
22
+ "peek-in": (12, "entr", 500),
23
+ "rise-in": (33, "entr", 1000),
24
+ "pulse": (26, "emph", 600),
25
+ "grow-shrink": (6, "emph", 2000),
26
+ "spin": (8, "emph", 2000),
27
+ "teeter": (32, "emph", 1000),
28
+ "fill-color": (22, "emph", 2000),
29
+ "transparency": (38, "emph", 2000),
30
+ "color-pulse": (36, "emph", 2000),
31
+ "disappear": (1, "exit", 1),
32
+ "fade-out": (10, "exit", 500),
33
+ "fly-out": (2, "exit", 500),
34
+ "zoom-out": (23, "exit", 500),
35
+ "wipe-out": (22, "exit", 500),
36
+ "float-out": (42, "exit", 500),
37
+ "motion-path": (0, "path", 2000),
38
+ }
39
+ DIR_SUBTYPE = {"up": 4, "down": 1, "left": 8, "right": 2}
40
+
41
+
42
+ class _Ids:
43
+ def __init__(self):
44
+ self.n = 0
45
+
46
+ def next(self):
47
+ self.n += 1
48
+ return self.n
49
+
50
+
51
+ def build_timing(slide, animations, shape_ids, slide_w=None, slide_h=None):
52
+ if not animations:
53
+ return
54
+ slide_w = slide_w or 960
55
+ slide_h = slide_h or 540
56
+ sld = slide._element
57
+ for e in sld.findall(qn("p:timing")):
58
+ sld.remove(e)
59
+ ids = _Ids()
60
+ groups = _group(animations)
61
+ timing = etree.SubElement(sld, qn("p:timing"))
62
+ tnLst = sub(timing, "p:tnLst")
63
+ root_par = sub(tnLst, "p:par")
64
+ ctn = sub(root_par, "p:cTn", {"id": str(ids.next()), "dur": "indefinite",
65
+ "restart": "never", "nodeType": "tmRoot"})
66
+ child = sub(ctn, "p:childTnLst")
67
+ seq = sub(child, "p:seq", {"concurrent": "1", "nextAc": "seek"})
68
+ seq_ctn = sub(seq, "p:cTn", {"id": str(ids.next()), "dur": "indefinite",
69
+ "nodeType": "mainSeq"})
70
+ seq_child = sub(seq_ctn, "p:childTnLst")
71
+ bldLst_shapes = []
72
+
73
+ for gi, group in enumerate(groups):
74
+ auto = group["auto"] # first anim with/after previous -> plays on entry
75
+ gpar = sub(seq_child, "p:par")
76
+ gctn = sub(gpar, "p:cTn", {"id": str(ids.next()), "dur": "indefinite",
77
+ "restart": "whenNotActive", "fill": "hold",
78
+ "nodeType": "clickGroup" if not auto else "afterGroup",
79
+ "presetID": "2", "presetClass": "entr"})
80
+ gcond = sub(gctn, "p:stCondLst")
81
+ sub(gcond, "p:cond", {"delay": "0" if auto else "indefinite"})
82
+ gchild = sub(gctn, "p:childTnLst")
83
+ t = 0 # cumulative time within group (ms)
84
+ for step in group["steps"]:
85
+ sp = sub(gchild, "p:par")
86
+ spctn = sub(sp, "p:cTn", {"id": str(ids.next()),
87
+ "fill": "hold", "nodeType": "clickEffect",
88
+ "presetID": "2", "presetClass": "entr"})
89
+ spcond = sub(spctn, "p:stCondLst")
90
+ sub(spcond, "p:cond", {"delay": str(int(step["start"]))})
91
+ spchild = sub(spctn, "p:childTnLst")
92
+ for anim in step["anims"]:
93
+ _effect(spchild, anim, shape_ids, ids, t=step["start"],
94
+ slide_w=slide_w, slide_h=slide_h)
95
+ bldLst_shapes.append(anim["elementId"])
96
+ t = step["end"]
97
+ # seq prev/next conditions
98
+ prev = sub(seq, "p:prevCondLst")
99
+ c = sub(prev, "p:cond", {"evt": "onPrev", "delay": "0"})
100
+ tgt = sub(c, "p:tgtEl")
101
+ sub(tgt, "p:sldTgt")
102
+ nxt = sub(seq, "p:nextCondLst")
103
+ c = sub(nxt, "p:cond", {"evt": "onNext", "delay": "0"})
104
+ tgt = sub(c, "p:tgtEl")
105
+ sub(tgt, "p:sldTgt")
106
+
107
+ if bldLst_shapes:
108
+ bl = sub(timing, "p:bldLst")
109
+ for sid in dict.fromkeys(bldLst_shapes):
110
+ spid = shape_ids.get(sid)
111
+ if spid:
112
+ sub(bl, "p:bldP", {"spid": str(spid), "grpId": "0"})
113
+
114
+
115
+ def _group(anims):
116
+ """Group into click groups and time-shifted steps."""
117
+ groups = []
118
+ cur_group = None
119
+ cur_step = None
120
+ t = 0
121
+ for a in anims:
122
+ trigger = a.get("trigger", "onClick")
123
+ dur = _dur(a)
124
+ delay = float(a.get("delayMs", 0) or 0)
125
+ if cur_group is None or trigger == "onClick":
126
+ cur_group = {"auto": trigger in ("withPrevious", "afterPrevious")
127
+ and not groups, "steps": []}
128
+ groups.append(cur_group)
129
+ t = 0
130
+ cur_step = None
131
+ if cur_step is None or trigger == "onClick":
132
+ start = t + delay
133
+ cur_step = {"start": start, "anims": [a],
134
+ "end": start + dur}
135
+ cur_group["steps"].append(cur_step)
136
+ elif trigger == "withPrevious":
137
+ start = t + delay
138
+ cur_step["anims"].append(a)
139
+ cur_step["end"] = max(cur_step["end"], start + dur)
140
+ else: # afterPrevious
141
+ start = cur_step["end"] + delay
142
+ cur_step = {"start": start, "anims": [a],
143
+ "end": start + dur}
144
+ cur_group["steps"].append(cur_step)
145
+ t = cur_step["end"]
146
+ return groups
147
+
148
+
149
+ def _dur(a):
150
+ e = a.get("effect")
151
+ if e in ("appear", "disappear"):
152
+ return 1
153
+ d = EFFECTS.get(e, (0, "entr", 500))[2]
154
+ return float(a.get("durationMs") or d or 500)
155
+
156
+
157
+ def _effect(parent, anim, shape_ids, ids, t=0, slide_w=960, slide_h=540):
158
+ spid = shape_ids.get(anim["elementId"])
159
+ if spid is None:
160
+ return
161
+ effect = anim.get("effect")
162
+ if effect not in EFFECTS:
163
+ return
164
+ pid, pclass, _ = EFFECTS[effect]
165
+ dur = int(_dur(anim))
166
+ delay = int(float(anim.get("delayMs", 0) or 0))
167
+ direction = anim.get("direction", "up")
168
+ repeat = int(anim.get("repeat", 1) or 1)
169
+ easing = anim.get("easing", "linear")
170
+
171
+ par = sub(parent, "p:par")
172
+ attrs = {"id": str(ids.next()), "presetID": str(pid),
173
+ "presetClass": pclass, "presetSubtype": "0",
174
+ "fill": "hold", "grpId": "0",
175
+ "nodeType": _node_type(anim)}
176
+ if effect in ("fly-in", "wipe-in", "peek-in", "fly-out", "wipe-out"):
177
+ attrs["presetSubtype"] = str(DIR_SUBTYPE.get(direction, 4))
178
+ if repeat > 1:
179
+ attrs["repeatCount"] = str(repeat)
180
+ if easing == "ease-in":
181
+ attrs["accel"] = "100000"
182
+ elif easing == "ease-out":
183
+ attrs["decel"] = "100000"
184
+ elif easing == "ease-in-out":
185
+ attrs["accel"] = "50000"
186
+ attrs["decel"] = "50000"
187
+ if effect in ("appear", "disappear"):
188
+ attrs["dur"] = "1"
189
+ ctn = sub(par, "p:cTn", attrs)
190
+ st = sub(ctn, "p:stCondLst")
191
+ sub(st, "p:cond", {"delay": str(delay)})
192
+ child = sub(ctn, "p:childTnLst")
193
+
194
+ def cbhvr(dur_ms, attr=None, additive=None):
195
+ cb = etree.Element(qn("p:cBhvr"))
196
+ if additive:
197
+ cb.set("additive", additive)
198
+ c = sub(cb, "p:cTn", {"id": str(ids.next()), "dur": str(max(1, int(dur_ms)))})
199
+ tg = sub(cb, "p:tgtEl")
200
+ sub(tg, "p:spTgt", {"spid": str(spid)})
201
+ if attr:
202
+ al = sub(cb, "p:attrNameLst")
203
+ sub(al, "p:attrName", text=attr)
204
+ return cb
205
+
206
+ def set_vis(visible, dur_ms=1, delay_ms=0):
207
+ s = etree.Element(qn("p:set"))
208
+ cb = cbhvr(dur_ms)
209
+ cb.find(qn("p:cTn")).set("fill", "hold")
210
+ if delay_ms:
211
+ cb.find(qn("p:cTn")).find(qn("p:stCondLst"))
212
+ c = cb.find(qn("p:cTn"))
213
+ st = sub(c, "p:stCondLst")
214
+ sub(st, "p:cond", {"delay": str(delay_ms)})
215
+ al = sub(cb, "p:attrNameLst")
216
+ sub(al, "p:attrName", text="style.visibility")
217
+ s.append(cb)
218
+ to = sub(s, "p:to")
219
+ sub(to, "p:strVal", {"val": "visible" if visible else "hidden"})
220
+ return s
221
+
222
+ def anim_effect(trans, filt, dur_ms):
223
+ ae = etree.Element(qn("p:animEffect"), {"transition": trans, "filter": filt})
224
+ ae.append(cbhvr(dur_ms))
225
+ return ae
226
+
227
+ def motion_xy(axis, frm, to, dur_ms):
228
+ an = etree.Element(qn("p:anim"), {"calcmode": "lin", "valueType": "num"})
229
+ cb = cbhvr(dur_ms, attr="ppt_%s" % axis, additive="base")
230
+ an.append(cb)
231
+ tav = sub(an, "p:tavLst")
232
+ for tm, v in ((0, frm), (100000, to)):
233
+ tv = sub(tav, "p:tav", {"tm": str(tm)})
234
+ val = sub(tv, "p:val")
235
+ sub(val, "p:strVal", {"val": v})
236
+ return an
237
+
238
+ if effect == "appear":
239
+ child.append(set_vis(True))
240
+ elif effect == "disappear":
241
+ child.append(set_vis(False))
242
+ elif effect == "fade-in":
243
+ child.append(set_vis(True))
244
+ child.append(anim_effect("in", "fade", dur))
245
+ elif effect == "fade-out":
246
+ child.append(anim_effect("out", "fade", dur))
247
+ s = set_vis(False, delay_ms=dur)
248
+ child.append(s)
249
+ elif effect in ("fly-in", "float-in", "rise-in", "peek-in"):
250
+ child.append(set_vis(True))
251
+ dist = "0.1+#ppt_h/2" if effect in ("float-in",) else None
252
+ if effect in ("fly-in", "float-in", "rise-in"):
253
+ frm_y = {"up": "1+#ppt_h/2", "down": "0-#ppt_h/2",
254
+ "left": "#ppt_y", "right": "#ppt_y"}[direction if effect != "rise-in" else "up"]
255
+ frm_x = {"up": "#ppt_x", "down": "#ppt_x", "left": "1+#ppt_w/2",
256
+ "right": "0-#ppt_w/2"}[direction if effect != "rise-in" else "up"]
257
+ if effect == "float-in":
258
+ frm_y = "0.1+#ppt_h/2" if direction == "up" else "-0.1-#ppt_h/2"
259
+ frm_x = "#ppt_x"
260
+ child.append(motion_xy("x", frm_x, "#ppt_x", dur))
261
+ child.append(motion_xy("y", frm_y, "#ppt_y", dur))
262
+ else: # peek-in: wipe without fade
263
+ filt = "wipe(%s)" % {"up": "up", "down": "down", "left": "right",
264
+ "right": "left"}.get(direction, "up")
265
+ child.append(anim_effect("in", filt, dur))
266
+ elif effect in ("fly-out", "float-out"):
267
+ to_y = {"up": "0-#ppt_h/2", "down": "1+#ppt_h/2"}.get(direction, "1+#ppt_h/2")
268
+ child.append(motion_xy("y", "#ppt_y", to_y if direction in ("up", "down") else "#ppt_y", dur))
269
+ if direction in ("left", "right"):
270
+ to_x = "0-#ppt_w/2" if direction == "left" else "1+#ppt_w/2"
271
+ child.append(motion_xy("x", "#ppt_x", to_x, dur))
272
+ s = set_vis(False, delay_ms=dur)
273
+ child.append(s)
274
+ elif effect in ("zoom-in", "zoom-out"):
275
+ child.append(set_vis(True))
276
+ scale = etree.Element(qn("p:animScale"))
277
+ cb = cbhvr(dur)
278
+ scale.append(cb)
279
+ if effect == "zoom-in":
280
+ f = sub(scale, "p:from", {"x": "0", "y": "0"})
281
+ t2 = sub(scale, "p:to", {"x": "100000", "y": "100000"})
282
+ else:
283
+ f = sub(scale, "p:from", {"x": "100000", "y": "100000"})
284
+ t2 = sub(scale, "p:to", {"x": "0", "y": "0"})
285
+ child.append(scale)
286
+ elif effect in ("wipe-in", "wipe-out"):
287
+ filt = "wipe(%s)" % {"up": "up", "down": "down", "left": "right",
288
+ "right": "left"}.get(direction, "up")
289
+ child.append(set_vis(True))
290
+ child.append(anim_effect("in" if effect == "wipe-in" else "out", filt, dur))
291
+ elif effect == "pulse":
292
+ scale = etree.Element(qn("p:animScale"))
293
+ cb = cbhvr(dur)
294
+ cb.find(qn("p:cTn")).set("autoRev", "1")
295
+ scale.append(cb)
296
+ sub(scale, "p:from", {"x": "100000", "y": "100000"})
297
+ sub(scale, "p:to", {"x": "110000", "y": "110000"})
298
+ child.append(scale)
299
+ elif effect == "grow-shrink":
300
+ scale = etree.Element(qn("p:animScale"))
301
+ scale.append(cbhvr(dur))
302
+ sub(scale, "p:to", {"x": "150000", "y": "150000"})
303
+ child.append(scale)
304
+ elif effect == "spin":
305
+ rot = etree.Element(qn("p:animRot"), {"by": "21600000"})
306
+ rot.append(cbhvr(dur))
307
+ child.append(rot)
308
+ elif effect == "teeter":
309
+ rot = etree.Element(qn("p:animRot"), {"by": "60000"})
310
+ cb = cbhvr(dur)
311
+ cb.find(qn("p:cTn")).set("autoRev", "1")
312
+ cb.find(qn("p:cTn")).set("repeatCount", "3")
313
+ rot.append(cb)
314
+ child.append(rot)
315
+ elif effect in ("fill-color", "color-pulse"):
316
+ ac = etree.Element(qn("p:animColor"), {"colorType": "rgb"})
317
+ cb = cbhvr(dur, attr="fillcolor")
318
+ if effect == "color-pulse":
319
+ cb.find(qn("p:cTn")).set("autoRev", "1")
320
+ ac.append(cb)
321
+ to = sub(ac, "p:to")
322
+ color = str(anim.get("color", "#FF0000")).lstrip("#")[:6]
323
+ a = sub(to, "a:srgbClr", {"val": color.upper()})
324
+ child.append(ac)
325
+ elif effect == "transparency":
326
+ an = etree.Element(qn("p:anim"), {"calcmode": "lin", "valueType": "num"})
327
+ cb = cbhvr(dur, attr="style.opacity")
328
+ an.append(cb)
329
+ tav = sub(an, "p:tavLst")
330
+ tv0 = sub(tav, "p:tav", {"tm": "0"})
331
+ v0 = sub(tv0, "p:val")
332
+ sub(v0, "p:fltVal", {"val": "1"})
333
+ tv1 = sub(tav, "p:tav", {"tm": "100000"})
334
+ v1 = sub(tv1, "p:val")
335
+ sub(v1, "p:fltVal", {"val": str(anim.get("amount", 0))})
336
+ child.append(an)
337
+ elif effect == "motion-path":
338
+ am = etree.Element(qn("p:animMotion"), {
339
+ "origin": "parent",
340
+ "path": _motion_path(anim.get("path", ""),
341
+ slide_w, slide_h),
342
+ "pathEditMode": "relative"})
343
+ am.append(cbhvr(dur))
344
+ child.append(am)
345
+
346
+
347
+ def _motion_path(path, sw, sh):
348
+ """px path -> animMotion slide-fraction path (M/L/C pairs)."""
349
+ import re
350
+ path = re.sub(r"([A-Za-z])([-+0-9.])", r"\1 \2", str(path))
351
+ out = []
352
+ idx = 0
353
+ for tok in re.finditer(r"[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?|[^\s,]+", path):
354
+ t = tok.group(0)
355
+ try:
356
+ v = float(t)
357
+ out.append(f"{(v / sw) if idx % 2 == 0 else (v / sh):.6f}")
358
+ idx += 1
359
+ except ValueError:
360
+ out.append(t)
361
+ return " ".join(out)
362
+
363
+
364
+ def _node_type(anim):
365
+ return {"onClick": "clickEffect", "withPrevious": "withEffect",
366
+ "afterPrevious": "afterEffect"}.get(anim.get("trigger", "onClick"),
367
+ "clickEffect")