html2pptx-local-mcp 1.1.17

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.
@@ -0,0 +1,724 @@
1
+ // src/animation-injector.js
2
+ //
3
+ // Post-processes a generated PPTX ZIP by injecting <p:transition> and <p:timing>
4
+ // elements into each slide's XML, based on data-anim-* attributes found in the
5
+ // source HTML.
6
+ //
7
+ // DSL (declarative, AOS-style):
8
+ //
9
+ // <section class="slide"
10
+ // data-transition="fade"
11
+ // data-transition-duration="800">
12
+ // ...
13
+ // <h1 data-anim="flyin"
14
+ // data-anim-direction="left"
15
+ // data-anim-duration="1000"
16
+ // data-anim-delay="200"
17
+ // data-anim-trigger="afterPrevious">Title</h1>
18
+ // </section>
19
+ //
20
+ // Design goals:
21
+ // - Zero-effect on HTML that has no data-anim-* (backward compatible)
22
+ // - Works as a PostProcess on the ZIP produced by PptxGenJS, no upstream changes
23
+ // - Keeps the authored text / shape editable — we only append <p:timing> nodes
24
+ //
25
+ // This is a deliberately minimal MVP that supports slide transitions and a
26
+ // handful of entrance animations. Extend `ENTRANCE_PRESETS` and `TRANSITION_PRESETS`
27
+ // to grow the catalogue.
28
+
29
+ /* ----------------------------------------------------------------------
30
+ * PRESETS — OOXML <p:transition> children
31
+ * See http://officeopenxml.com/prSlide-transitions.php
32
+ * ---------------------------------------------------------------------- */
33
+ // Each factory returns the inner XML for the given <p:transition>.
34
+ // Called with a `direction` parameter from data-transition-direction (optional).
35
+ // Modern transitions (morph / vortex / ferris / gallery / etc.) require the
36
+ // Office 2016+ extension list and use the p16 namespace. We emit them as
37
+ // <p:extLst> children so they render in PowerPoint 2016+ and gracefully
38
+ // fall back to no-transition in older viewers.
39
+ const TRANSITION_PRESETS = {
40
+ none: () => '',
41
+ fade: () => '<p:fade/>',
42
+ push: (dir = 'left') => `<p:push dir="${dirShort(dir, 'l')}"/>`,
43
+ wipe: (dir = 'left') => `<p:wipe dir="${dirShort(dir, 'l')}"/>`,
44
+ cover: (dir = 'left') => `<p:cover dir="${dirShort(dir, 'l')}"/>`,
45
+ uncover: (dir = 'left') => `<p:pull dir="${dirShort(dir, 'l')}"/>`,
46
+ split: (dir = 'out') => `<p:split orient="horz" dir="${dir === 'in' ? 'in' : 'out'}"/>`,
47
+ cut: () => '<p:cut/>',
48
+ dissolve: () => '<p:dissolve/>',
49
+ zoom: () => '<p:fade/>', // legacy fallback
50
+ random: () => '<p:random/>',
51
+
52
+ // --- Office 2016+ modern transitions via extLst ---
53
+ // ref: [MS-PPT] §2.5.129 / ECMA-376 Part 4 §13.11
54
+ morph: () => modernTransitionExt('morph', 'byObject'),
55
+ vortex: (dir = 'left') => modernTransitionExt('vortex', null, { dir: dirShort(dir, 'l') }),
56
+ ferris: (dir = 'left') => modernTransitionExt('ferris', null, { dir: dirShort(dir, 'l') }),
57
+ gallery: (dir = 'left') => modernTransitionExt('gallery', null, { dir: dirShort(dir, 'l') }),
58
+ conveyor: (dir = 'left') => modernTransitionExt('conveyor', null, { dir: dirShort(dir, 'l') }),
59
+ flash: () => modernTransitionExt('flash'),
60
+ prism: (dir = 'left') => modernTransitionExt('prism', null, { dir: dirShort(dir, 'l') }),
61
+ glitter: (dir = 'left') => modernTransitionExt('glitter', null, { dir: dirShort(dir, 'l') }),
62
+ honeycomb: () => modernTransitionExt('honeycomb'),
63
+ warp: (dir = 'in') => modernTransitionExt('warp', null, { dir: dir === 'in' ? 'in' : 'out' }),
64
+ window: (dir = 'in') => modernTransitionExt('window', null, { dir: dir === 'in' ? 'in' : 'out' }),
65
+ orbit: () => modernTransitionExt('orbit'),
66
+ shred: (dir = 'in') => modernTransitionExt('shred', null, { dir: dir === 'in' ? 'in' : 'out' }),
67
+ switch: (dir = 'left') => modernTransitionExt('switch', null, { dir: dirShort(dir, 'l') }),
68
+ flip: (dir = 'left') => modernTransitionExt('flip', null, { dir: dirShort(dir, 'l') }),
69
+ cube: (dir = 'left') => modernTransitionExt('cube', null, { dir: dirShort(dir, 'l') }),
70
+ doors: (dir = 'horz') => modernTransitionExt('doors', null, { dir: dir === 'horz' ? 'horz' : 'vert' }),
71
+ box: (dir = 'in') => modernTransitionExt('box', null, { dir: dir === 'in' ? 'in' : 'out' }),
72
+ rotate: () => modernTransitionExt('rotate'),
73
+ revealSmoothly: () => modernTransitionExt('revealSmoothly'),
74
+ };
75
+
76
+ function modernTransitionExt(name, option = null, attrs = {}) {
77
+ const attrStr = Object.entries(attrs)
78
+ .map(([k, v]) => `${k}="${escapeXmlAttr(v)}"`)
79
+ .join(' ');
80
+ const optionAttr = option ? ` option="${escapeXmlAttr(option)}"` : '';
81
+ return [
82
+ '<p:extLst>',
83
+ `<p:ext uri="{E01B4FDE-8C12-4F1D-8B9C-1E2C5E4AB7E1}">`,
84
+ `<p14:${name} xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main"${attrStr ? ' ' + attrStr : ''}${optionAttr}/>`,
85
+ '</p:ext>',
86
+ '</p:extLst>',
87
+ ].join('');
88
+ }
89
+
90
+ function dirShort(dir, fallback) {
91
+ const m = { left: 'l', right: 'r', up: 'u', down: 'd' };
92
+ return m[dir] || fallback;
93
+ }
94
+
95
+ import { renderPresetChildTimeline } from './animation-renderers.js';
96
+
97
+ function escapeXmlAttr(s) {
98
+ return String(s)
99
+ .replace(/&/g, '&amp;')
100
+ .replace(/"/g, '&quot;')
101
+ .replace(/</g, '&lt;')
102
+ .replace(/>/g, '&gt;');
103
+ }
104
+
105
+ /* ----------------------------------------------------------------------
106
+ * PRESETS — Entrance/Emphasis/Exit animations
107
+ *
108
+ * presetID values follow ECMA-376 §19.5 and the Microsoft animation schema
109
+ * https://learn.microsoft.com/en-us/office/open-xml/presentation/working-with-animation
110
+ *
111
+ * Where the spec is ambiguous we use the mapping PowerPoint 2016+ writes
112
+ * out when you pick the effect manually.
113
+ * ---------------------------------------------------------------------- */
114
+ const ENTRANCE_PRESETS = {
115
+ // --- Basic ---
116
+ appear: { presetID: 1, presetClass: 'entr', presetSubtype: 0, directional: false },
117
+ fadein: { presetID: 10, presetClass: 'entr', presetSubtype: 0, directional: false },
118
+ flyin: { presetID: 2, presetClass: 'entr', presetSubtype: 4, directional: 'cardinal' },
119
+ floatin: { presetID: 42, presetClass: 'entr', presetSubtype: 0, directional: 'cardinal' },
120
+ split: { presetID: 3, presetClass: 'entr', presetSubtype: 26, directional: 'inout' },
121
+ wipe: { presetID: 4, presetClass: 'entr', presetSubtype: 4, directional: 'cardinal' },
122
+ zoom: { presetID: 23, presetClass: 'entr', presetSubtype: 0, directional: 'inout' },
123
+ bounce: { presetID: 26, presetClass: 'entr', presetSubtype: 0, directional: false },
124
+ swivel: { presetID: 18, presetClass: 'entr', presetSubtype: 1, directional: false },
125
+ // --- Subtle ---
126
+ dissolvein: { presetID: 9, presetClass: 'entr', presetSubtype: 0, directional: false },
127
+ expand: { presetID: 13, presetClass: 'entr', presetSubtype: 0, directional: false },
128
+ fadeswivel: { presetID: 45, presetClass: 'entr', presetSubtype: 0, directional: false },
129
+ zoomcenter: { presetID: 23, presetClass: 'entr', presetSubtype: 16, directional: false },
130
+ // --- Moderate ---
131
+ ascend: { presetID: 39, presetClass: 'entr', presetSubtype: 0, directional: false },
132
+ basiczoom: { presetID: 23, presetClass: 'entr', presetSubtype: 0, directional: false },
133
+ centerrevolve: { presetID: 12, presetClass: 'entr', presetSubtype: 0, directional: false },
134
+ compress: { presetID: 21, presetClass: 'entr', presetSubtype: 0, directional: false },
135
+ descend: { presetID: 40, presetClass: 'entr', presetSubtype: 0, directional: false },
136
+ fadedzoom: { presetID: 23, presetClass: 'entr', presetSubtype: 16, directional: false },
137
+ gridout: { presetID: 4, presetClass: 'entr', presetSubtype: 1, directional: false },
138
+ risefall: { presetID: 49, presetClass: 'entr', presetSubtype: 0, directional: false },
139
+ riseup: { presetID: 43, presetClass: 'entr', presetSubtype: 0, directional: false },
140
+ spinner: { presetID: 17, presetClass: 'entr', presetSubtype: 0, directional: false },
141
+ stretch: { presetID: 46, presetClass: 'entr', presetSubtype: 4, directional: 'cardinal' },
142
+ // --- Exciting ---
143
+ boomerang: { presetID: 25, presetClass: 'entr', presetSubtype: 0, directional: false },
144
+ credits: { presetID: 28, presetClass: 'entr', presetSubtype: 0, directional: false },
145
+ curveup: { presetID: 15, presetClass: 'entr', presetSubtype: 0, directional: false },
146
+ flipin: { presetID: 20, presetClass: 'entr', presetSubtype: 0, directional: false },
147
+ float: { presetID: 42, presetClass: 'entr', presetSubtype: 0, directional: false },
148
+ foldin: { presetID: 7, presetClass: 'entr', presetSubtype: 0, directional: false },
149
+ glidein: { presetID: 19, presetClass: 'entr', presetSubtype: 0, directional: false },
150
+ pinwheel: { presetID: 38, presetClass: 'entr', presetSubtype: 0, directional: false },
151
+ spiral: { presetID: 27, presetClass: 'entr', presetSubtype: 0, directional: false },
152
+ thread: { presetID: 30, presetClass: 'entr', presetSubtype: 0, directional: false },
153
+ whip: { presetID: 32, presetClass: 'entr', presetSubtype: 0, directional: false },
154
+
155
+ // ================================================================
156
+ // EMPHASIS
157
+ // ================================================================
158
+ pulse: { presetID: 1, presetClass: 'emph', presetSubtype: 0, directional: false },
159
+ spin: { presetID: 8, presetClass: 'emph', presetSubtype: 0, directional: false },
160
+ grow: { presetID: 6, presetClass: 'emph', presetSubtype: 0, directional: false },
161
+ shrink: { presetID: 6, presetClass: 'emph', presetSubtype: 1, directional: false },
162
+ flash: { presetID: 14, presetClass: 'emph', presetSubtype: 0, directional: false },
163
+ colorpulse: { presetID: 2, presetClass: 'emph', presetSubtype: 0, directional: false },
164
+ teeter: { presetID: 3, presetClass: 'emph', presetSubtype: 0, directional: false },
165
+ shimmer: { presetID: 24, presetClass: 'emph', presetSubtype: 0, directional: false },
166
+ blink: { presetID: 20, presetClass: 'emph', presetSubtype: 0, directional: false },
167
+ bold: { presetID: 9, presetClass: 'emph', presetSubtype: 0, directional: false },
168
+ wave: { presetID: 37, presetClass: 'emph', presetSubtype: 0, directional: false },
169
+ desaturate: { presetID: 13, presetClass: 'emph', presetSubtype: 0, directional: false },
170
+ darken: { presetID: 11, presetClass: 'emph', presetSubtype: 0, directional: false },
171
+ lighten: { presetID: 17, presetClass: 'emph', presetSubtype: 0, directional: false },
172
+ transparent: { presetID: 19, presetClass: 'emph', presetSubtype: 0, directional: false },
173
+ colorwave: { presetID: 36, presetClass: 'emph', presetSubtype: 0, directional: false },
174
+ brushon: { presetID: 35, presetClass: 'emph', presetSubtype: 0, directional: false },
175
+ wobble: { presetID: 21, presetClass: 'emph', presetSubtype: 0, directional: false },
176
+
177
+ // ================================================================
178
+ // EXIT
179
+ // ================================================================
180
+ disappear: { presetID: 1, presetClass: 'exit', presetSubtype: 0, directional: false },
181
+ fadeout: { presetID: 10, presetClass: 'exit', presetSubtype: 0, directional: false },
182
+ flyout: { presetID: 2, presetClass: 'exit', presetSubtype: 4, directional: 'cardinal' },
183
+ floatout: { presetID: 42, presetClass: 'exit', presetSubtype: 0, directional: 'cardinal' },
184
+ zoomout: { presetID: 23, presetClass: 'exit', presetSubtype: 0, directional: 'inout' },
185
+ splitout: { presetID: 3, presetClass: 'exit', presetSubtype: 26, directional: 'inout' },
186
+ wipeout: { presetID: 4, presetClass: 'exit', presetSubtype: 4, directional: 'cardinal' },
187
+ shrinkout: { presetID: 6, presetClass: 'exit', presetSubtype: 1, directional: false },
188
+ dissolveout: { presetID: 9, presetClass: 'exit', presetSubtype: 0, directional: false },
189
+ peekout: { presetID: 24, presetClass: 'exit', presetSubtype: 4, directional: 'cardinal' },
190
+ bounceout: { presetID: 26, presetClass: 'exit', presetSubtype: 0, directional: false },
191
+ swivelout: { presetID: 18, presetClass: 'exit', presetSubtype: 0, directional: false },
192
+ spiralout: { presetID: 27, presetClass: 'exit', presetSubtype: 0, directional: false },
193
+ flipout: { presetID: 20, presetClass: 'exit', presetSubtype: 0, directional: false },
194
+ foldout: { presetID: 7, presetClass: 'exit', presetSubtype: 0, directional: false },
195
+ glideout: { presetID: 19, presetClass: 'exit', presetSubtype: 0, directional: false },
196
+ boomerangout: { presetID: 25, presetClass: 'exit', presetSubtype: 0, directional: false },
197
+ contract: { presetID: 13, presetClass: 'exit', presetSubtype: 0, directional: false },
198
+ };
199
+
200
+ // Cardinal direction → subtype mapping (OOXML presetSubtype values).
201
+ // Used by flyin / floatin / wipe / flyout where "from the left", etc. matters.
202
+ const CARDINAL_SUBTYPES = {
203
+ left: 4,
204
+ right: 8,
205
+ up: 1,
206
+ down: 2,
207
+ topleft: 5,
208
+ topright: 9,
209
+ bottomleft: 6,
210
+ bottomright: 10,
211
+ };
212
+
213
+ // In/Out direction → subtype for split / zoom / zoomout.
214
+ const INOUT_SUBTYPES = {
215
+ in: 16,
216
+ out: 32,
217
+ };
218
+
219
+ /* ----------------------------------------------------------------------
220
+ * MOTION PATHS — <p:animMotion path="..." />
221
+ *
222
+ * Each entry returns the SVG-style path string expected by PowerPoint.
223
+ * Coordinates are in the 0..1 space (relative to the slide). The value
224
+ * accepted by PowerPoint is a mini SVG path DSL with "M"/"L"/"C"/"E" commands.
225
+ *
226
+ * Docs: https://learn.microsoft.com/en-us/office/open-xml/presentation/working-with-animation
227
+ * ---------------------------------------------------------------------- */
228
+ const MOTION_PATHS = {
229
+ line: 'M 0 0 L 0.2 0 E',
230
+ lineright: 'M 0 0 L 0.3 0 E',
231
+ lineleft: 'M 0 0 L -0.3 0 E',
232
+ lineup: 'M 0 0 L 0 -0.3 E',
233
+ linedown: 'M 0 0 L 0 0.3 E',
234
+ arc: 'M 0 0 C 0.1 -0.1 0.2 -0.1 0.3 0 E',
235
+ arcup: 'M 0 0 C 0.1 -0.2 0.2 -0.2 0.3 0 E',
236
+ arcdown: 'M 0 0 C 0.1 0.2 0.2 0.2 0.3 0 E',
237
+ curve: 'M 0 0 C 0.05 -0.1 0.15 0.1 0.3 0 E',
238
+ scurve: 'M 0 0 C 0.1 -0.15 0.2 0.15 0.3 0 E',
239
+ circle: 'M 0 0 C 0.1 0 0.2 0.1 0.2 0.2 C 0.2 0.3 0.1 0.4 0 0.4 C -0.1 0.4 -0.2 0.3 -0.2 0.2 C -0.2 0.1 -0.1 0 0 0 E',
240
+ square: 'M 0 0 L 0.2 0 L 0.2 0.2 L 0 0.2 L 0 0 E',
241
+ diamond: 'M 0 0 L 0.15 -0.15 L 0.3 0 L 0.15 0.15 L 0 0 E',
242
+ triangle: 'M 0 0 L 0.15 -0.2 L 0.3 0 L 0 0 E',
243
+ bounce: 'M 0 0 C 0.05 -0.15 0.1 0 0.1 0 C 0.15 -0.08 0.2 0 0.2 0 C 0.25 -0.04 0.3 0 0.3 0 E',
244
+ loop: 'M 0 0 C 0.1 -0.2 0.3 -0.2 0.2 0 C 0.1 0.15 -0.1 0.15 0 0 L 0.3 0 E',
245
+ zigzag: 'M 0 0 L 0.05 -0.1 L 0.1 0.1 L 0.15 -0.1 L 0.2 0.1 L 0.25 0 E',
246
+ wave: 'M 0 0 C 0.05 -0.1 0.1 -0.1 0.15 0 C 0.2 0.1 0.25 0.1 0.3 0 E',
247
+ spiral: 'M 0 0 C 0.05 -0.05 0.1 0 0.05 0.05 C -0.05 0.1 -0.1 0 0 -0.05 L 0.2 -0.05 E',
248
+ figure8: 'M 0 0 C -0.1 -0.1 -0.1 0.1 0 0 C 0.1 -0.1 0.1 0.1 0 0 E',
249
+ pointyright: 'M 0 0 L 0.1 -0.05 L 0.2 0 L 0.1 0.05 L 0 0 E',
250
+ decayingwave: 'M 0 0 C 0.03 -0.12 0.06 0.12 0.09 -0.08 C 0.12 0.08 0.15 -0.04 0.18 0.04 C 0.21 0 0.24 0 0.3 0 E',
251
+ };
252
+
253
+ function resolveSubtype(preset, direction) {
254
+ if (!preset.directional || !direction) return preset.presetSubtype;
255
+ if (preset.directional === 'cardinal' && CARDINAL_SUBTYPES[direction] != null) {
256
+ return CARDINAL_SUBTYPES[direction];
257
+ }
258
+ if (preset.directional === 'inout' && INOUT_SUBTYPES[direction] != null) {
259
+ return INOUT_SUBTYPES[direction];
260
+ }
261
+ return preset.presetSubtype;
262
+ }
263
+
264
+ /* ----------------------------------------------------------------------
265
+ * extractAnimationPlan
266
+ * Walk the source HTML and collect per-slide animation descriptors.
267
+ *
268
+ * Returns:
269
+ * [
270
+ * {
271
+ * slideIndex: 0,
272
+ * transition: { type, duration } | null,
273
+ * entries: [{ shapeId, type, direction, duration, delay, trigger, presetInfo }, ...]
274
+ * },
275
+ * ...
276
+ * ]
277
+ *
278
+ * Note: shapeId matching is the hard part. PptxGenJS generates shape IDs in the
279
+ * order it processes elements. We assign `data-pptx-shape-index` to each text/
280
+ * shape element before PPTX generation and read that back here.
281
+ * ---------------------------------------------------------------------- */
282
+ export function extractAnimationPlan(rootElement) {
283
+ const plans = [];
284
+ const slides = rootElement.querySelectorAll('section.slide');
285
+
286
+ slides.forEach((slide, slideIndex) => {
287
+ const plan = { slideIndex, transition: null, entries: [] };
288
+
289
+ // --- transition ---
290
+ const tType = slide.getAttribute('data-transition');
291
+ if (tType && TRANSITION_PRESETS[tType] != null) {
292
+ plan.transition = {
293
+ type: tType,
294
+ duration: parseInt(slide.getAttribute('data-transition-duration'), 10) || 800,
295
+ direction: slide.getAttribute('data-transition-direction') || null,
296
+ };
297
+ }
298
+
299
+ // --- element animations ---
300
+ const animNodes = slide.querySelectorAll('[data-anim]');
301
+ animNodes.forEach((node, localIndex) => {
302
+ const type = node.getAttribute('data-anim');
303
+ const preset = ENTRANCE_PRESETS[type];
304
+ if (!preset) {
305
+ console.warn(`[animation-injector] Unknown data-anim value: "${type}"`);
306
+ return;
307
+ }
308
+ const direction = node.getAttribute('data-anim-direction');
309
+ const presetSubtype = resolveSubtype(preset, direction);
310
+
311
+ plan.entries.push({
312
+ localIndex,
313
+ type,
314
+ direction,
315
+ duration: parseInt(node.getAttribute('data-anim-duration'), 10) || 500,
316
+ delay: parseInt(node.getAttribute('data-anim-delay'), 10) || 0,
317
+ // Default to "afterPrevious" so animations auto-cascade in the
318
+ // slideshow instead of requiring a click per element. Authors that
319
+ // want click-gated reveals can set data-anim-trigger="onClick".
320
+ trigger: node.getAttribute('data-anim-trigger') || 'afterPrevious',
321
+ presetID: preset.presetID,
322
+ presetClass: preset.presetClass,
323
+ presetSubtype,
324
+ kind: 'preset',
325
+ shapeIndex: parseInt(node.getAttribute('data-pptx-shape-index'), 10),
326
+ });
327
+ });
328
+
329
+ // --- text-level animations (per character / word / paragraph reveal) ---
330
+ // Carried on a normal element animated with data-anim="fadein" (or any
331
+ // other entrance effect). Adding data-anim-text="bychar" makes PowerPoint
332
+ // apply the effect to each text run in sequence.
333
+ // data-anim-text="bychar" — letter by letter
334
+ // data-anim-text="byword" — word by word
335
+ // data-anim-text="byparagraph" — line/paragraph by line
336
+ // Optional pacing (inter-letter delay, in ms):
337
+ // data-anim-text-pace="80"
338
+ plan.entries.forEach((entry) => {
339
+ if (entry.kind !== 'preset') return;
340
+ // Find the originating DOM node to read its text-build attributes
341
+ const animNodes = slide.querySelectorAll('[data-anim]');
342
+ const node = animNodes[entry.localIndex];
343
+ if (!node) return;
344
+ const build = node.getAttribute('data-anim-text');
345
+ const pace = parseInt(node.getAttribute('data-anim-text-pace'), 10);
346
+ if (build) {
347
+ entry.textBuild = build; // 'bychar' | 'byword' | 'byparagraph'
348
+ if (Number.isFinite(pace) && pace >= 0) entry.textPaceMs = pace;
349
+ }
350
+ });
351
+
352
+ // --- motion paths ---
353
+ const motionNodes = slide.querySelectorAll('[data-anim-motion]');
354
+ motionNodes.forEach((node, localIndex) => {
355
+ const pathName = node.getAttribute('data-anim-motion');
356
+ const customPath = node.getAttribute('data-anim-motion-path');
357
+ const path = customPath || MOTION_PATHS[pathName];
358
+ if (!path) {
359
+ console.warn(`[animation-injector] Unknown data-anim-motion value: "${pathName}"`);
360
+ return;
361
+ }
362
+ plan.entries.push({
363
+ localIndex: plan.entries.length + localIndex,
364
+ type: `motion:${pathName || 'custom'}`,
365
+ duration: parseInt(node.getAttribute('data-anim-duration'), 10) || 1000,
366
+ delay: parseInt(node.getAttribute('data-anim-delay'), 10) || 0,
367
+ // Default to "afterPrevious" so animations auto-cascade in the
368
+ // slideshow instead of requiring a click per element. Authors that
369
+ // want click-gated reveals can set data-anim-trigger="onClick".
370
+ trigger: node.getAttribute('data-anim-trigger') || 'afterPrevious',
371
+ kind: 'motion',
372
+ motionPath: path,
373
+ shapeIndex: parseInt(node.getAttribute('data-pptx-shape-index'), 10),
374
+ });
375
+ });
376
+
377
+ if (plan.transition || plan.entries.length > 0) {
378
+ plans.push(plan);
379
+ }
380
+ });
381
+
382
+ return plans;
383
+ }
384
+
385
+ /* ----------------------------------------------------------------------
386
+ * buildTransitionXml — minimal <p:transition> snippet
387
+ * ---------------------------------------------------------------------- */
388
+ function buildTransitionXml(transition) {
389
+ if (!transition) return '';
390
+ const factory = TRANSITION_PRESETS[transition.type];
391
+ const inner = typeof factory === 'function' ? factory(transition.direction) : '';
392
+ const dur = Math.max(100, Math.min(5000, transition.duration));
393
+ // spd attribute: 'slow' | 'med' | 'fast' — derive from duration
394
+ let spd = 'med';
395
+ if (dur < 500) spd = 'fast';
396
+ else if (dur > 1500) spd = 'slow';
397
+ return `<p:transition spd="${spd}" advClick="1">${inner}</p:transition>`;
398
+ }
399
+
400
+ /* ----------------------------------------------------------------------
401
+ * buildTimingXml — minimal <p:timing> block for a list of entries
402
+ * Schema reference: ECMA-376 §19.5.76
403
+ * ---------------------------------------------------------------------- */
404
+ function buildTimingXml(entries) {
405
+ if (!entries.length) return '';
406
+
407
+ // One <p:par> per top-level sequence. Each entry is wrapped in its own
408
+ // <p:par> inside the main sequence, with presetID applied.
409
+ const parList = entries
410
+ .map((e, idx) => {
411
+ // nodeType depends on trigger
412
+ // onClick → tn id=... nodeType="clickEffect"
413
+ // withPrevious → tn id=... nodeType="withEffect"
414
+ // afterPrevious → tn id=... nodeType="afterEffect"
415
+ const nodeType =
416
+ e.trigger === 'withPrevious'
417
+ ? 'withEffect'
418
+ : e.trigger === 'afterPrevious'
419
+ ? 'afterEffect'
420
+ : 'clickEffect';
421
+
422
+ const durMs = e.duration;
423
+ const delay = e.delay;
424
+ const shapeSpid = e.shapeSpid || 0;
425
+ const baseId = 100 + idx * 10;
426
+
427
+ // Motion-path entries use <p:animMotion> instead of <p:animEffect>.
428
+ if (e.kind === 'motion') {
429
+ return `
430
+ <p:par>
431
+ <p:cTn id="${baseId}" presetID="0" presetClass="path" presetSubtype="0" fill="hold" grpId="0" nodeType="${nodeType}">
432
+ <p:stCondLst><p:cond delay="${delay}"/></p:stCondLst>
433
+ <p:childTnLst>
434
+ <p:animMotion origin="layout" path="${escapeXmlAttr(e.motionPath)}" pathEditMode="relative" rAng="0" ptsTypes="">
435
+ <p:cBhvr>
436
+ <p:cTn id="${baseId + 1}" dur="${durMs}" fill="hold"/>
437
+ <p:tgtEl><p:spTgt spid="${shapeSpid}"/></p:tgtEl>
438
+ <p:attrNameLst><p:attrName>ppt_x</p:attrName><p:attrName>ppt_y</p:attrName></p:attrNameLst>
439
+ </p:cBhvr>
440
+ </p:animMotion>
441
+ </p:childTnLst>
442
+ </p:cTn>
443
+ </p:par>`;
444
+ }
445
+
446
+ // When textBuild is set, the shape's text reveals piece-by-piece via
447
+ // <p:iterate type="lt|wd|el"> ... <p:tmAbs val="<paceMs>"/></p:iterate>
448
+ // on the outer <p:cTn>. The same preset-specific animation below then
449
+ // fires once per piece.
450
+ const buildMap = { bychar: 'lt', byword: 'wd', byparagraph: 'el' };
451
+ const iterType = e.textBuild ? buildMap[e.textBuild] : null;
452
+ const paceMs = Number.isFinite(e.textPaceMs) ? e.textPaceMs : 80;
453
+ const iterateXml = iterType
454
+ ? `<p:iterate type="${iterType}"><p:tmAbs val="${paceMs}"/></p:iterate>`
455
+ : '';
456
+
457
+ // Preset-specific animation body (see animation-renderers.js). Each
458
+ // renderer knows the right <p:set>/<p:anim>/<p:animEffect> children
459
+ // for its effect — this replaces the previous "opacity 0→1 for
460
+ // everything" stub that made every preset look like fadein.
461
+ const childTimeline = renderPresetChildTimeline(e.type, {
462
+ baseId,
463
+ durMs,
464
+ delayMs: delay,
465
+ spid: shapeSpid,
466
+ direction: e.direction,
467
+ presetClass: e.presetClass,
468
+ });
469
+
470
+ return `
471
+ <p:par>
472
+ <p:cTn id="${baseId}" presetID="${e.presetID}" presetClass="${e.presetClass}" presetSubtype="${e.presetSubtype}" fill="hold" grpId="0" nodeType="${nodeType}">
473
+ <p:stCondLst><p:cond delay="${delay}"/></p:stCondLst>
474
+ ${iterateXml}
475
+ <p:childTnLst>${childTimeline}
476
+ </p:childTnLst>
477
+ </p:cTn>
478
+ </p:par>`;
479
+ })
480
+ .join('');
481
+
482
+ return `
483
+ <p:timing>
484
+ <p:tnLst>
485
+ <p:par>
486
+ <p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot">
487
+ <p:childTnLst>
488
+ <p:seq concurrent="1" nextAc="seek">
489
+ <p:cTn id="2" dur="indefinite" nodeType="mainSeq">
490
+ <p:childTnLst>${parList}
491
+ </p:childTnLst>
492
+ </p:cTn>
493
+ <p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst>
494
+ <p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst>
495
+ </p:seq>
496
+ </p:childTnLst>
497
+ </p:cTn>
498
+ </p:par>
499
+ </p:tnLst>
500
+ </p:timing>`;
501
+ }
502
+
503
+ /* ----------------------------------------------------------------------
504
+ * injectIntoSlideXml — mutate an existing slideN.xml string
505
+ * Inserts <p:transition> and <p:timing> immediately before </p:cSld>-sibling
506
+ * elements (they are siblings of <p:cSld>, not inside it).
507
+ *
508
+ * Structure expected:
509
+ * <p:sld ...>
510
+ * <p:cSld>...</p:cSld>
511
+ * [<p:clrMapOvr>...</p:clrMapOvr>]
512
+ * [<p:transition>...</p:transition>] ← we insert here
513
+ * [<p:timing>...</p:timing>] ← and here
514
+ * </p:sld>
515
+ * ---------------------------------------------------------------------- */
516
+ export function injectIntoSlideXml(slideXml, { transition, entries }) {
517
+ let xml = slideXml;
518
+
519
+ // Resolve shape ids: walk <p:sp> / <p:pic> elements in order. The Nth one
520
+ // corresponds to shapeIndex === N. Extract the numeric id from <p:cNvPr id="..."/>.
521
+ const shapeRegex = /<p:(sp|pic|grpSp|graphicFrame)\b[^>]*>\s*<p:nvSpPr>\s*<p:cNvPr id="(\d+)"/g;
522
+ const altRegex = /<p:cNvPr id="(\d+)"/g;
523
+ const ids = [];
524
+ let m;
525
+ while ((m = altRegex.exec(xml)) !== null) ids.push(parseInt(m[1], 10));
526
+ // Skip first id (group root) — typical PptxGenJS output starts with an id=1 root group.
527
+ const shapeIds = ids.slice(1);
528
+
529
+ const resolvedEntries = entries
530
+ .map((e) => ({
531
+ ...e,
532
+ shapeSpid:
533
+ Number.isFinite(e.shapeIndex) && shapeIds[e.shapeIndex] != null
534
+ ? shapeIds[e.shapeIndex]
535
+ : shapeIds[0] || 0,
536
+ }))
537
+ .filter((e) => e.shapeSpid > 0);
538
+
539
+ const transitionXml = buildTransitionXml(transition);
540
+ const timingXml = buildTimingXml(resolvedEntries);
541
+
542
+ const insertion = `${transitionXml}${timingXml}`;
543
+ if (!insertion) return xml;
544
+
545
+ // Remove any existing p:timing/p:transition that PptxGenJS may have put in
546
+ xml = xml.replace(/<p:transition\b[^<]*(?:<[^/][^>]*>[\s\S]*?<\/p:transition>|\/?>)/g, '');
547
+ xml = xml.replace(/<p:timing\b[\s\S]*?<\/p:timing>/g, '');
548
+
549
+ // Insert right before </p:sld>
550
+ xml = xml.replace('</p:sld>', `${insertion}</p:sld>`);
551
+ return xml;
552
+ }
553
+
554
+ /* ----------------------------------------------------------------------
555
+ * applyPlansToZip — given a loaded JSZip PPTX and a list of plans, mutate
556
+ * slides/slideN.xml in place.
557
+ * ---------------------------------------------------------------------- */
558
+ export async function applyPlansToZip(zip, plans) {
559
+ for (const plan of plans) {
560
+ const path = `ppt/slides/slide${plan.slideIndex + 1}.xml`;
561
+ const entry = zip.file(path);
562
+ if (!entry) {
563
+ console.warn(`[animation-injector] Slide not found: ${path}`);
564
+ continue;
565
+ }
566
+ const xml = await entry.async('string');
567
+ const newXml = injectIntoSlideXml(xml, plan);
568
+ zip.file(path, newXml);
569
+ }
570
+ return zip;
571
+ }
572
+
573
+ /* ----------------------------------------------------------------------
574
+ * preProcessHtml — assigns a sequential data-pptx-shape-index to every
575
+ * element that will become a PPTX shape. Must be called BEFORE the main
576
+ * HTML→PPTX conversion so that exportToPptx picks the same order.
577
+ *
578
+ * The heuristic: any element that has data-anim (we need it animated) AND
579
+ * is one of [text container, image, shape div] gets a shape index equal to
580
+ * its position in the slide's top-down traversal order.
581
+ * ---------------------------------------------------------------------- */
582
+ export function preProcessHtml(rootElement) {
583
+ const slides = rootElement.querySelectorAll('section.slide');
584
+ slides.forEach((slide) => {
585
+ let counter = 0;
586
+ // NOTE: this traversal intentionally matches exportToPptx's own order —
587
+ // depth-first, document order. Elements without bg/text are skipped by
588
+ // the main converter, so we also skip them here.
589
+ const all = slide.querySelectorAll('*');
590
+ all.forEach((el) => {
591
+ // Always index elements that explicitly request an animation.
592
+ const wantsAnim =
593
+ el.hasAttribute('data-anim') || el.hasAttribute('data-anim-motion');
594
+ const style = el.getAttribute('style') || '';
595
+ const hasTextOrBg = /position:|background|border|color|font-size/i.test(style);
596
+ if (wantsAnim || hasTextOrBg) {
597
+ el.setAttribute('data-pptx-shape-index', String(counter));
598
+ counter += 1;
599
+ }
600
+ });
601
+ });
602
+ }
603
+
604
+ /* ----------------------------------------------------------------------
605
+ * Public helpers
606
+ * ---------------------------------------------------------------------- */
607
+ export const SUPPORTED_TRANSITIONS = Object.keys(TRANSITION_PRESETS);
608
+ export const SUPPORTED_ANIMATIONS = Object.keys(ENTRANCE_PRESETS);
609
+ export const SUPPORTED_MOTION_PATHS = Object.keys(MOTION_PATHS);
610
+
611
+ /**
612
+ * Machine-readable catalogue of every animation/transition the injector
613
+ * supports. Used by the MCP `html2pptx_animation_catalog` tool and the
614
+ * public `GET /api/animations/catalog` endpoint. Designed so an AI agent
615
+ * can pick the right animation without reading the source.
616
+ */
617
+ export function getAnimationCatalog() {
618
+ const entrance = [];
619
+ const emphasis = [];
620
+ const exit = [];
621
+ Object.entries(ENTRANCE_PRESETS).forEach(([name, preset]) => {
622
+ const directions =
623
+ preset.directional === 'cardinal'
624
+ ? Object.keys(CARDINAL_SUBTYPES)
625
+ : preset.directional === 'inout'
626
+ ? Object.keys(INOUT_SUBTYPES)
627
+ : [];
628
+ const entry = { name, directions };
629
+ if (preset.presetClass === 'entr') entrance.push(entry);
630
+ else if (preset.presetClass === 'emph') emphasis.push(entry);
631
+ else if (preset.presetClass === 'exit') exit.push(entry);
632
+ });
633
+
634
+ return {
635
+ version: 3,
636
+ dsl: {
637
+ element: {
638
+ 'data-anim': 'Animation name (e.g. "fadein", "flyin", "bounce") — see animations.entrance/emphasis/exit',
639
+ 'data-anim-direction': 'Optional direction (cardinal: left/right/up/down [+ corners], or in/out for zoom-style)',
640
+ 'data-anim-duration': 'Duration in ms (100–5000, default 500)',
641
+ 'data-anim-delay': 'Delay in ms before the animation starts (default 0)',
642
+ 'data-anim-trigger': 'When to start: onClick (default) | withPrevious | afterPrevious',
643
+ 'data-anim-motion': 'Motion path name (e.g. "line", "arc", "loop") — see motionPaths',
644
+ 'data-anim-motion-path': 'OPTIONAL custom SVG-style path override, e.g. "M 0 0 L 0.3 -0.2 E" (overrides data-anim-motion preset)',
645
+ 'data-anim-text': 'Apply effect piece-by-piece: "bychar" | "byword" | "byparagraph"',
646
+ 'data-anim-text-pace': 'Inter-piece delay in ms for text builds (default 80)',
647
+ },
648
+ slide: {
649
+ 'data-transition': 'Slide transition name (e.g. "fade", "push", "morph", "vortex", "cube")',
650
+ 'data-transition-direction': 'Optional direction for directional transitions (left/right/up/down or in/out or horz/vert)',
651
+ 'data-transition-duration': 'Duration in ms (100–5000, default 800)',
652
+ },
653
+ },
654
+ transitions: Object.keys(TRANSITION_PRESETS).map((name) => ({
655
+ name,
656
+ directional: ['push', 'wipe', 'cover', 'uncover', 'vortex', 'ferris', 'gallery', 'conveyor', 'prism', 'glitter', 'switch', 'flip', 'cube'].includes(name)
657
+ ? 'cardinal'
658
+ : ['split', 'warp', 'window', 'shred', 'box'].includes(name)
659
+ ? 'inout'
660
+ : name === 'doors'
661
+ ? 'horzvert'
662
+ : false,
663
+ modern: ['morph', 'vortex', 'ferris', 'gallery', 'conveyor', 'flash', 'prism', 'glitter', 'honeycomb', 'warp', 'window', 'orbit', 'shred', 'switch', 'flip', 'cube', 'doors', 'box', 'rotate', 'revealSmoothly'].includes(name),
664
+ })),
665
+ animations: { entrance, emphasis, exit },
666
+ motionPaths: Object.keys(MOTION_PATHS),
667
+ triggers: ['onClick', 'withPrevious', 'afterPrevious'],
668
+ examples: [
669
+ {
670
+ title: 'Fade-in title',
671
+ html: '<h1 data-anim="fadein" data-anim-duration="1000">Hello</h1>',
672
+ },
673
+ {
674
+ title: 'Fly in from the left after previous',
675
+ html: '<p data-anim="flyin" data-anim-direction="left" data-anim-delay="300" data-anim-trigger="afterPrevious">Body</p>',
676
+ },
677
+ {
678
+ title: 'Slide with fade transition',
679
+ html: '<section class="slide" data-transition="fade" data-transition-duration="600">...</section>',
680
+ },
681
+ {
682
+ title: 'Staggered list',
683
+ html: [
684
+ '<ul>',
685
+ ' <li data-anim="fadein" data-anim-trigger="withPrevious">A</li>',
686
+ ' <li data-anim="fadein" data-anim-trigger="afterPrevious" data-anim-delay="200">B</li>',
687
+ ' <li data-anim="fadein" data-anim-trigger="afterPrevious" data-anim-delay="400">C</li>',
688
+ '</ul>',
689
+ ].join('\n'),
690
+ },
691
+ {
692
+ title: 'Motion path — element arcs across the slide',
693
+ html: '<div data-anim-motion="arcup" data-anim-duration="1500">Shape</div>',
694
+ },
695
+ {
696
+ title: 'Custom motion path (SVG-style)',
697
+ html: '<div data-anim-motion="custom" data-anim-motion-path="M 0 0 L 0.4 -0.2 L 0 0 E">Shape</div>',
698
+ },
699
+ {
700
+ title: 'Typewriter — reveal letter by letter',
701
+ html: '<h1 data-anim="fadein" data-anim-text="bychar" data-anim-text-pace="60" data-anim-duration="40">Hello, world</h1>',
702
+ },
703
+ {
704
+ title: 'Word-by-word reveal',
705
+ html: '<p data-anim="fadein" data-anim-text="byword" data-anim-text-pace="180">Three things to know</p>',
706
+ },
707
+ {
708
+ title: 'Morph transition (PowerPoint 2016+)',
709
+ html: '<section class="slide" data-transition="morph" data-transition-duration="1000">...</section>',
710
+ },
711
+ {
712
+ title: 'Cube transition',
713
+ html: '<section class="slide" data-transition="cube" data-transition-direction="left">...</section>',
714
+ },
715
+ ],
716
+ notes: [
717
+ 'HTML authored without data-anim/data-transition/data-anim-motion attributes renders identically to the current html2pptx output (fully backward compatible).',
718
+ 'Modern transitions (morph, vortex, ferris, cube, etc.) require PowerPoint 2016+ and emit p14-namespaced ext markers. Older viewers ignore them gracefully.',
719
+ 'Motion paths use a compact SVG-style DSL: M (move), L (line), C (cubic bezier), E (end). Coordinates are 0..1 slide-relative.',
720
+ 'Text builds (data-anim-text) emit a <p:iterate> timing so PowerPoint reveals the text piece-by-piece at the given pace.',
721
+ 'PowerPoint (Mac, Windows, Web), Keynote, and LibreOffice honour most timings. Google Slides may ignore some effects.',
722
+ ],
723
+ };
724
+ }