pptx-browser 4.1.0
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.
- package/LICENSE +9 -0
- package/README.md +209 -0
- package/package.json +53 -0
- package/src/animation.js +817 -0
- package/src/charts.js +989 -0
- package/src/clipboard.js +416 -0
- package/src/colors.js +297 -0
- package/src/effects3d.js +312 -0
- package/src/extract.js +535 -0
- package/src/fntdata.js +265 -0
- package/src/fonts.js +676 -0
- package/src/index.js +751 -0
- package/src/pdf.js +298 -0
- package/src/render.js +1964 -0
- package/src/shapes.js +666 -0
- package/src/slideshow.js +492 -0
- package/src/smartart.js +696 -0
- package/src/svg.js +732 -0
- package/src/theme.js +88 -0
- package/src/utils.js +50 -0
- package/src/writer.js +1015 -0
- package/src/zip-writer.js +214 -0
- package/src/zip.js +194 -0
package/src/animation.js
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* animation.js — OOXML Slide Animation & Transition Engine.
|
|
3
|
+
*
|
|
4
|
+
* Parses the <p:timing> tree from a slide XML document and builds a
|
|
5
|
+
* timeline of animation steps. Drives playback via requestAnimationFrame.
|
|
6
|
+
*
|
|
7
|
+
* Supported entrance effects:
|
|
8
|
+
* appear, fade, flyInFromLeft/Right/Top/Bottom, zoom, wipe,
|
|
9
|
+
* blinds, box, checkerboard, diamond, dissolve, peek, plus,
|
|
10
|
+
* randomBars, split, stretch, strips, wedge, wheel, push
|
|
11
|
+
*
|
|
12
|
+
* Supported exit effects:
|
|
13
|
+
* disappear, fadeOut, flyOutToLeft/Right/Top/Bottom, zoomOut
|
|
14
|
+
*
|
|
15
|
+
* Supported emphasis:
|
|
16
|
+
* spin, grow/shrink, flash, color change (pulse)
|
|
17
|
+
*
|
|
18
|
+
* Transitions (slide-level):
|
|
19
|
+
* fade, push, wipe, blinds, box, cover, cut, dissolve, newsflash,
|
|
20
|
+
* wheel, zoom, morph (approximated)
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* import { parseAnimations, PptxPlayer } from './animation.js';
|
|
24
|
+
*
|
|
25
|
+
* const player = new PptxPlayer(renderer, canvas);
|
|
26
|
+
* player.play(slideIndex);
|
|
27
|
+
* player.pause();
|
|
28
|
+
* player.stop();
|
|
29
|
+
* player.nextClick();
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { g1, gtn, attr, attrInt } from './utils.js';
|
|
33
|
+
|
|
34
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {object} AnimStep
|
|
38
|
+
* @property {string} shapeId — shape element id (nvCxnSpPr / nvSpPr id attr)
|
|
39
|
+
* @property {string} type — 'entrance' | 'exit' | 'emphasis' | 'media'
|
|
40
|
+
* @property {string} effect — OOXML effect name (e.g. 'fade', 'fly', 'appear')
|
|
41
|
+
* @property {number} clickNum — which click group (0 = auto with slide)
|
|
42
|
+
* @property {number} delay — ms from click/start
|
|
43
|
+
* @property {number} duration — ms
|
|
44
|
+
* @property {string} dir — effect direction attribute ('l','r','t','b', etc.)
|
|
45
|
+
* @property {object} from — { opacity, x, y, scaleX, scaleY }
|
|
46
|
+
* @property {object} to — { opacity, x, y, scaleX, scaleY }
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
// ── Timing parser ─────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const DEFAULT_DURATION = 500; // ms
|
|
52
|
+
|
|
53
|
+
/** Parse p:timing → list of AnimStep objects sorted by clickNum then delay. */
|
|
54
|
+
export function parseAnimations(slideDoc) {
|
|
55
|
+
if (!slideDoc) return [];
|
|
56
|
+
const timing = g1(slideDoc, 'timing');
|
|
57
|
+
if (!timing) return [];
|
|
58
|
+
const tnLst = g1(timing, 'tnLst');
|
|
59
|
+
if (!tnLst) return [];
|
|
60
|
+
|
|
61
|
+
const steps = [];
|
|
62
|
+
|
|
63
|
+
// Walk the parallel timeline tree
|
|
64
|
+
function walkPar(parEl, clickNum, inheritDelay) {
|
|
65
|
+
const cTn = g1(parEl, 'cTn');
|
|
66
|
+
if (!cTn) return;
|
|
67
|
+
|
|
68
|
+
// Is this a click-triggered group?
|
|
69
|
+
const nodeType = attr(cTn, 'nodeType', '');
|
|
70
|
+
if (nodeType === 'clickEffect') clickNum += 1;
|
|
71
|
+
if (nodeType === 'withEffect' && clickNum === 0) clickNum = 0;
|
|
72
|
+
|
|
73
|
+
const delay = attrInt(cTn, 'delay', 0) + inheritDelay;
|
|
74
|
+
|
|
75
|
+
const childTnLst = g1(parEl, 'childTnLst') || g1(cTn, 'childTnLst');
|
|
76
|
+
if (!childTnLst) return;
|
|
77
|
+
|
|
78
|
+
for (const child of childTnLst.children) {
|
|
79
|
+
const ln = child.localName;
|
|
80
|
+
if (ln === 'par') {
|
|
81
|
+
walkPar(child, clickNum, delay);
|
|
82
|
+
} else if (ln === 'seq') {
|
|
83
|
+
const seqCTn = g1(child, 'cTn');
|
|
84
|
+
const seqNodeType = seqCTn ? attr(seqCTn, 'nodeType', '') : '';
|
|
85
|
+
const newClick = seqNodeType === 'clickEffect' ? clickNum + 1 : clickNum;
|
|
86
|
+
const seqChild = g1(child, 'childTnLst');
|
|
87
|
+
if (seqChild) {
|
|
88
|
+
for (const seqItem of seqChild.children) {
|
|
89
|
+
if (seqItem.localName === 'par') walkPar(seqItem, newClick, delay);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else if (ln === 'set' || ln === 'animEffect' || ln === 'anim' || ln === 'animScale' || ln === 'animClr') {
|
|
93
|
+
const step = parseAnimEffect(child, clickNum, delay);
|
|
94
|
+
if (step) steps.push(step);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Outer sequence (click groups)
|
|
100
|
+
function walkSeq(seqEl, baseClick) {
|
|
101
|
+
const childTnLst = g1(seqEl, 'childTnLst') || g1(g1(seqEl, 'cTn'), 'childTnLst');
|
|
102
|
+
if (!childTnLst) return;
|
|
103
|
+
|
|
104
|
+
let clickNum = baseClick;
|
|
105
|
+
for (const child of childTnLst.children) {
|
|
106
|
+
if (child.localName === 'par') {
|
|
107
|
+
const cTn = g1(child, 'cTn');
|
|
108
|
+
const nodeType = cTn ? attr(cTn, 'nodeType', '') : '';
|
|
109
|
+
if (nodeType === 'clickEffect' || nodeType === 'clickPar') clickNum++;
|
|
110
|
+
walkPar(child, clickNum, 0);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const child of tnLst.children) {
|
|
116
|
+
if (child.localName === 'par') walkPar(child, 0, 0);
|
|
117
|
+
else if (child.localName === 'seq') walkSeq(child, 0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Also parse top-level animEffect elements directly
|
|
121
|
+
for (const animEl of gtn(tnLst, 'animEffect')) {
|
|
122
|
+
const step = parseAnimEffect(animEl, 0, 0);
|
|
123
|
+
if (step) steps.push(step);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return steps.sort((a, b) => a.clickNum - b.clickNum || a.delay - b.delay);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Parse a single animation element (animEffect, set, anim, etc.). */
|
|
130
|
+
function parseAnimEffect(el, clickNum, inheritDelay) {
|
|
131
|
+
const cTn = g1(el, 'cTn');
|
|
132
|
+
|
|
133
|
+
// Get target shape
|
|
134
|
+
const tgtEl = g1(el, 'tgt') || g1(cTn, 'tgt');
|
|
135
|
+
const spTgt = tgtEl ? g1(tgtEl, 'spTgt') : null;
|
|
136
|
+
const shapeId = spTgt ? attr(spTgt, 'spid', null) : null;
|
|
137
|
+
if (!shapeId) return null;
|
|
138
|
+
|
|
139
|
+
// Duration
|
|
140
|
+
const durStr = cTn ? attr(cTn, 'dur', null) : null;
|
|
141
|
+
const duration = durStr === 'indefinite' ? 2000
|
|
142
|
+
: durStr ? parseInt(durStr, 10) : DEFAULT_DURATION;
|
|
143
|
+
|
|
144
|
+
const delay = (cTn ? attrInt(cTn, 'delay', 0) : 0) + inheritDelay;
|
|
145
|
+
|
|
146
|
+
// Effect type and filter
|
|
147
|
+
const filter = attr(el, 'filter', null);
|
|
148
|
+
const type = attr(el, 'type', null) || (filter ? 'filter' : 'set');
|
|
149
|
+
|
|
150
|
+
// Direction
|
|
151
|
+
const dir = attr(el, 'dir', null)
|
|
152
|
+
|| (filter ? filter.split('(')[1]?.replace(')', '') : null)
|
|
153
|
+
|| '';
|
|
154
|
+
|
|
155
|
+
// Categorise
|
|
156
|
+
let effectType = 'emphasis';
|
|
157
|
+
if (type === 'in' || (el.localName === 'set' && attr(g1(el, 'attrNameLst') || el, 'attrName', '') === 'style.visibility' && attr(el, 'to', '') === 'visible'))
|
|
158
|
+
effectType = 'entrance';
|
|
159
|
+
if (type === 'out' || (el.localName === 'set' && attr(el, 'to', '') === 'hidden'))
|
|
160
|
+
effectType = 'exit';
|
|
161
|
+
|
|
162
|
+
// Resolve canonical effect name from filter string
|
|
163
|
+
let effectName = filter || el.localName;
|
|
164
|
+
if (filter) {
|
|
165
|
+
const fLow = filter.toLowerCase();
|
|
166
|
+
if (fLow.includes('fade')) effectName = 'fade';
|
|
167
|
+
else if (fLow.includes('fly')) effectName = 'fly';
|
|
168
|
+
else if (fLow.includes('appear')) effectName = 'appear';
|
|
169
|
+
else if (fLow.includes('zoom')) effectName = 'zoom';
|
|
170
|
+
else if (fLow.includes('wipe')) effectName = 'wipe';
|
|
171
|
+
else if (fLow.includes('wheel')) effectName = 'wheel';
|
|
172
|
+
else if (fLow.includes('blinds')) effectName = 'blinds';
|
|
173
|
+
else if (fLow.includes('box')) effectName = 'box';
|
|
174
|
+
else if (fLow.includes('dissolve')) effectName = 'dissolve';
|
|
175
|
+
else if (fLow.includes('split')) effectName = 'split';
|
|
176
|
+
else if (fLow.includes('stretch')) effectName = 'stretch';
|
|
177
|
+
else if (fLow.includes('diamond')) effectName = 'diamond';
|
|
178
|
+
else if (fLow.includes('plus')) effectName = 'plus';
|
|
179
|
+
else if (fLow.includes('wedge')) effectName = 'wedge';
|
|
180
|
+
else if (fLow.includes('random')) effectName = 'dissolve';
|
|
181
|
+
else if (fLow.includes('strips')) effectName = 'strips';
|
|
182
|
+
else if (fLow.includes('peek')) effectName = 'fly';
|
|
183
|
+
else if (fLow.includes('checkerboard')) effectName = 'dissolve';
|
|
184
|
+
else effectName = 'fade';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
shapeId,
|
|
189
|
+
type: effectType,
|
|
190
|
+
effect: effectName,
|
|
191
|
+
clickNum,
|
|
192
|
+
delay,
|
|
193
|
+
duration,
|
|
194
|
+
dir,
|
|
195
|
+
raw: el.localName,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Transition parser ─────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @typedef {object} SlideTransition
|
|
203
|
+
* @property {string} type — 'fade' | 'push' | 'wipe' | 'cover' | 'cut' | etc.
|
|
204
|
+
* @property {number} duration — ms
|
|
205
|
+
* @property {string} dir — 'l' | 'r' | 't' | 'b' | 'd' | 'u' | etc.
|
|
206
|
+
*/
|
|
207
|
+
|
|
208
|
+
export function parseTransition(slideDoc) {
|
|
209
|
+
if (!slideDoc) return null;
|
|
210
|
+
const trans = g1(slideDoc, 'transition');
|
|
211
|
+
if (!trans) return null;
|
|
212
|
+
|
|
213
|
+
const dur = attrInt(trans, 'dur', 700);
|
|
214
|
+
const spd = attr(trans, 'spd', 'med');
|
|
215
|
+
const speed = spd === 'slow' ? 1200 : spd === 'fast' ? 300 : dur;
|
|
216
|
+
|
|
217
|
+
// Find the specific transition element
|
|
218
|
+
const child = trans.firstElementChild;
|
|
219
|
+
const type = child?.localName || 'fade';
|
|
220
|
+
const dir = child ? attr(child, 'dir', 'l') : 'l';
|
|
221
|
+
|
|
222
|
+
return { type, duration: speed, dir };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Easing ────────────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
function easeOut(t) { return 1 - Math.pow(1 - t, 2); }
|
|
228
|
+
function easeIn(t) { return t * t; }
|
|
229
|
+
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
|
|
230
|
+
|
|
231
|
+
// ── Shape state compositor ────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Compute draw params for a shape at a given animation progress.
|
|
235
|
+
* Returns {opacity, tx, ty, scaleX, scaleY, clipProgress, clipDir, spin}
|
|
236
|
+
*/
|
|
237
|
+
function computeShapeState(step, progress, shapeW, shapeH) {
|
|
238
|
+
const t = Math.max(0, Math.min(1, progress));
|
|
239
|
+
const isEntrance = step.type === 'entrance';
|
|
240
|
+
const p = isEntrance ? easeOut(t) : easeIn(t); // entrance eases out, exit eases in
|
|
241
|
+
|
|
242
|
+
const state = {
|
|
243
|
+
opacity: 1, tx: 0, ty: 0,
|
|
244
|
+
scaleX: 1, scaleY: 1,
|
|
245
|
+
clipProgress: 1, clipDir: 'none',
|
|
246
|
+
spin: 0,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const effect = step.effect;
|
|
250
|
+
const dir = step.dir;
|
|
251
|
+
|
|
252
|
+
if (isEntrance) {
|
|
253
|
+
switch (effect) {
|
|
254
|
+
case 'appear':
|
|
255
|
+
state.opacity = t >= 0.01 ? 1 : 0;
|
|
256
|
+
break;
|
|
257
|
+
case 'fade':
|
|
258
|
+
case 'dissolve':
|
|
259
|
+
state.opacity = p;
|
|
260
|
+
break;
|
|
261
|
+
case 'fly':
|
|
262
|
+
case 'flyIn': {
|
|
263
|
+
state.opacity = Math.min(1, p * 1.5);
|
|
264
|
+
const dist = (1 - p);
|
|
265
|
+
if (dir.includes('l')) state.tx = -shapeW * dist;
|
|
266
|
+
else if (dir.includes('r')) state.tx = shapeW * dist;
|
|
267
|
+
else if (dir.includes('t')) state.ty = -shapeH * dist;
|
|
268
|
+
else state.ty = shapeH * dist; // default bottom
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case 'zoom':
|
|
272
|
+
state.opacity = p;
|
|
273
|
+
state.scaleX = 0.1 + p * 0.9;
|
|
274
|
+
state.scaleY = 0.1 + p * 0.9;
|
|
275
|
+
break;
|
|
276
|
+
case 'wipe':
|
|
277
|
+
state.clipProgress = p;
|
|
278
|
+
state.clipDir = dir || 'r';
|
|
279
|
+
break;
|
|
280
|
+
case 'split':
|
|
281
|
+
state.clipProgress = p;
|
|
282
|
+
state.clipDir = dir.includes('v') ? 'split-v' : 'split-h';
|
|
283
|
+
break;
|
|
284
|
+
case 'blinds':
|
|
285
|
+
state.clipProgress = p;
|
|
286
|
+
state.clipDir = dir.includes('v') ? 'blinds-v' : 'blinds-h';
|
|
287
|
+
break;
|
|
288
|
+
case 'box':
|
|
289
|
+
state.clipProgress = p;
|
|
290
|
+
state.clipDir = 'box';
|
|
291
|
+
break;
|
|
292
|
+
case 'wheel':
|
|
293
|
+
state.clipProgress = p;
|
|
294
|
+
state.clipDir = 'wheel';
|
|
295
|
+
break;
|
|
296
|
+
case 'wedge':
|
|
297
|
+
state.clipProgress = p;
|
|
298
|
+
state.clipDir = 'wedge';
|
|
299
|
+
break;
|
|
300
|
+
case 'strips':
|
|
301
|
+
state.clipProgress = p;
|
|
302
|
+
state.clipDir = dir.includes('r') ? 'strips-r' : 'strips-l';
|
|
303
|
+
break;
|
|
304
|
+
case 'stretch':
|
|
305
|
+
state.opacity = p;
|
|
306
|
+
if (dir.includes('h')) { state.scaleX = p; state.scaleY = 1; }
|
|
307
|
+
else { state.scaleX = 1; state.scaleY = p; }
|
|
308
|
+
break;
|
|
309
|
+
case 'plus':
|
|
310
|
+
case 'diamond':
|
|
311
|
+
state.clipProgress = p;
|
|
312
|
+
state.clipDir = effect;
|
|
313
|
+
break;
|
|
314
|
+
default:
|
|
315
|
+
state.opacity = p;
|
|
316
|
+
}
|
|
317
|
+
} else if (step.type === 'exit') {
|
|
318
|
+
// Exit = entrance reversed
|
|
319
|
+
const exitStep = { ...step, type: 'entrance' };
|
|
320
|
+
const entered = computeShapeState(exitStep, 1 - t, shapeW, shapeH);
|
|
321
|
+
return entered;
|
|
322
|
+
} else {
|
|
323
|
+
// Emphasis
|
|
324
|
+
switch (effect) {
|
|
325
|
+
case 'spin':
|
|
326
|
+
state.spin = p * 360;
|
|
327
|
+
break;
|
|
328
|
+
case 'grow':
|
|
329
|
+
case 'shrink': {
|
|
330
|
+
const maxScale = effect === 'grow' ? 1.5 : 0.5;
|
|
331
|
+
const midT = t < 0.5 ? t * 2 : (1 - t) * 2;
|
|
332
|
+
state.scaleX = 1 + (maxScale - 1) * midT;
|
|
333
|
+
state.scaleY = state.scaleX;
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
case 'flash':
|
|
337
|
+
state.opacity = t < 0.5 ? (t < 0.25 ? 0 : 1) : (t < 0.75 ? 0 : 1);
|
|
338
|
+
break;
|
|
339
|
+
default:
|
|
340
|
+
state.opacity = 0.5 + 0.5 * Math.cos(t * Math.PI * 4); // pulse
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return state;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Clip mask rendering ──────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
function applyClipMask(ctx, clipDir, clipProgress, x, y, w, h) {
|
|
350
|
+
const p = clipProgress;
|
|
351
|
+
ctx.beginPath();
|
|
352
|
+
switch (clipDir) {
|
|
353
|
+
case 'r': ctx.rect(x, y, w * p, h); break;
|
|
354
|
+
case 'l': ctx.rect(x + w * (1 - p), y, w * p, h); break;
|
|
355
|
+
case 't': ctx.rect(x, y, w, h * p); break;
|
|
356
|
+
case 'b': ctx.rect(x, y + h * (1 - p), w, h * p); break;
|
|
357
|
+
case 'split-h': {
|
|
358
|
+
const hw = w * p / 2;
|
|
359
|
+
ctx.rect(x + w / 2 - hw, y, hw * 2, h);
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
case 'split-v': {
|
|
363
|
+
const hh = h * p / 2;
|
|
364
|
+
ctx.rect(x, y + h / 2 - hh, w, hh * 2);
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
case 'box': {
|
|
368
|
+
const inset = Math.min(w, h) * (1 - p) / 2;
|
|
369
|
+
ctx.rect(x + inset, y + inset, w - inset * 2, h - inset * 2);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
case 'wheel': {
|
|
373
|
+
const cx2 = x + w / 2, cy2 = y + h / 2;
|
|
374
|
+
const r = Math.sqrt(w * w + h * h) / 2;
|
|
375
|
+
ctx.moveTo(cx2, cy2);
|
|
376
|
+
ctx.arc(cx2, cy2, r, -Math.PI / 2, -Math.PI / 2 + p * Math.PI * 2);
|
|
377
|
+
ctx.closePath();
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
case 'wedge': {
|
|
381
|
+
const cx2 = x + w / 2, cy2 = y + h / 2;
|
|
382
|
+
const r = Math.sqrt(w * w + h * h) / 2;
|
|
383
|
+
const a = p * Math.PI;
|
|
384
|
+
ctx.moveTo(cx2, cy2);
|
|
385
|
+
ctx.arc(cx2, cy2, r, -Math.PI / 2 - a, -Math.PI / 2 + a);
|
|
386
|
+
ctx.closePath();
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
case 'blinds-h': {
|
|
390
|
+
const bands = 6;
|
|
391
|
+
const bh = h / bands;
|
|
392
|
+
for (let i = 0; i < bands; i++) {
|
|
393
|
+
ctx.rect(x, y + i * bh, w, bh * p);
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case 'blinds-v': {
|
|
398
|
+
const bands = 6;
|
|
399
|
+
const bw2 = w / bands;
|
|
400
|
+
for (let i = 0; i < bands; i++) {
|
|
401
|
+
ctx.rect(x + i * bw2, y, bw2 * p, h);
|
|
402
|
+
}
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
case 'strips-r': {
|
|
406
|
+
const bands = 8;
|
|
407
|
+
const bh = h / bands;
|
|
408
|
+
const bw2 = w / bands;
|
|
409
|
+
for (let i = 0; i < bands; i++) {
|
|
410
|
+
ctx.rect(x, y + i * bh, bw2 * (i + 1) * p, bh);
|
|
411
|
+
}
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
case 'strips-l': {
|
|
415
|
+
const bands = 8;
|
|
416
|
+
const bh = h / bands;
|
|
417
|
+
const bw2 = w / bands;
|
|
418
|
+
for (let i = 0; i < bands; i++) {
|
|
419
|
+
const tw = bw2 * (bands - i) * p;
|
|
420
|
+
ctx.rect(x + w - tw, y + i * bh, tw, bh);
|
|
421
|
+
}
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
case 'diamond': {
|
|
425
|
+
const cx2 = x + w / 2, cy2 = y + h / 2;
|
|
426
|
+
const rw = (w / 2) * p, rh = (h / 2) * p;
|
|
427
|
+
ctx.moveTo(cx2 - rw, cy2);
|
|
428
|
+
ctx.lineTo(cx2, cy2 - rh);
|
|
429
|
+
ctx.lineTo(cx2 + rw, cy2);
|
|
430
|
+
ctx.lineTo(cx2, cy2 + rh);
|
|
431
|
+
ctx.closePath();
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
case 'plus': {
|
|
435
|
+
const cx2 = x + w / 2, cy2 = y + h / 2;
|
|
436
|
+
const arm = Math.min(w, h) / 2 * p;
|
|
437
|
+
const thick = arm * 0.4;
|
|
438
|
+
ctx.rect(cx2 - thick, cy2 - arm, thick * 2, arm * 2);
|
|
439
|
+
ctx.rect(cx2 - arm, cy2 - thick, arm * 2, thick * 2);
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
default:
|
|
443
|
+
ctx.rect(x, y, w, h);
|
|
444
|
+
}
|
|
445
|
+
ctx.clip();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── Transition renderer ──────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Render a transition frame between two slide canvases.
|
|
452
|
+
*
|
|
453
|
+
* @param {CanvasRenderingContext2D} outCtx — output canvas context
|
|
454
|
+
* @param {HTMLCanvasElement} fromCanvas — previous slide
|
|
455
|
+
* @param {HTMLCanvasElement} toCanvas — incoming slide
|
|
456
|
+
* @param {SlideTransition} transition
|
|
457
|
+
* @param {number} progress — 0..1
|
|
458
|
+
*/
|
|
459
|
+
export function renderTransitionFrame(outCtx, fromCanvas, toCanvas, transition, progress) {
|
|
460
|
+
const p = easeInOut(progress);
|
|
461
|
+
const W = outCtx.canvas.width;
|
|
462
|
+
const H = outCtx.canvas.height;
|
|
463
|
+
|
|
464
|
+
outCtx.clearRect(0, 0, W, H);
|
|
465
|
+
|
|
466
|
+
const type = transition?.type || 'fade';
|
|
467
|
+
const dir = transition?.dir || 'l';
|
|
468
|
+
|
|
469
|
+
switch (type) {
|
|
470
|
+
case 'cut':
|
|
471
|
+
outCtx.drawImage(p < 0.5 ? fromCanvas : toCanvas, 0, 0, W, H);
|
|
472
|
+
break;
|
|
473
|
+
case 'fade':
|
|
474
|
+
case 'fade_slow':
|
|
475
|
+
default:
|
|
476
|
+
outCtx.drawImage(fromCanvas, 0, 0, W, H);
|
|
477
|
+
outCtx.globalAlpha = p;
|
|
478
|
+
outCtx.drawImage(toCanvas, 0, 0, W, H);
|
|
479
|
+
outCtx.globalAlpha = 1;
|
|
480
|
+
break;
|
|
481
|
+
case 'push': {
|
|
482
|
+
const dx = dir === 'l' ? -W * p : dir === 'r' ? W * p : 0;
|
|
483
|
+
const dy = dir === 'u' ? -H * p : dir === 'd' ? H * p : 0;
|
|
484
|
+
outCtx.drawImage(fromCanvas, dx, dy, W, H);
|
|
485
|
+
outCtx.drawImage(toCanvas, dx + (dir === 'l' ? W : dir === 'r' ? -W : 0),
|
|
486
|
+
dy + (dir === 'u' ? H : dir === 'd' ? -H : 0), W, H);
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
case 'cover':
|
|
490
|
+
case 'uncover': {
|
|
491
|
+
const isUncover = type === 'uncover';
|
|
492
|
+
const dx = dir === 'l' ? -W * (1 - p) : dir === 'r' ? W * (1 - p) : 0;
|
|
493
|
+
const dy = dir === 'u' ? -H * (1 - p) : dir === 'd' ? H * (1 - p) : 0;
|
|
494
|
+
if (isUncover) {
|
|
495
|
+
outCtx.drawImage(toCanvas, 0, 0, W, H);
|
|
496
|
+
outCtx.drawImage(fromCanvas, dx, dy, W, H);
|
|
497
|
+
} else {
|
|
498
|
+
outCtx.drawImage(fromCanvas, 0, 0, W, H);
|
|
499
|
+
outCtx.drawImage(toCanvas, dx, dy, W, H);
|
|
500
|
+
}
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
case 'wipe': {
|
|
504
|
+
outCtx.drawImage(fromCanvas, 0, 0, W, H);
|
|
505
|
+
outCtx.save();
|
|
506
|
+
outCtx.beginPath();
|
|
507
|
+
if (dir === 'l') outCtx.rect(W * (1 - p), 0, W * p, H);
|
|
508
|
+
else if (dir === 'r') outCtx.rect(0, 0, W * p, H);
|
|
509
|
+
else if (dir === 'u') outCtx.rect(0, H * (1 - p), W, H * p);
|
|
510
|
+
else outCtx.rect(0, 0, W, H * p);
|
|
511
|
+
outCtx.clip();
|
|
512
|
+
outCtx.drawImage(toCanvas, 0, 0, W, H);
|
|
513
|
+
outCtx.restore();
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
case 'zoom':
|
|
517
|
+
case 'newsflash': {
|
|
518
|
+
outCtx.drawImage(fromCanvas, 0, 0, W, H);
|
|
519
|
+
outCtx.save();
|
|
520
|
+
outCtx.globalAlpha = p;
|
|
521
|
+
const s = type === 'newsflash' ? (1 + (1 - p) * 3) : (0.05 + p * 0.95);
|
|
522
|
+
outCtx.translate(W / 2, H / 2);
|
|
523
|
+
outCtx.scale(s, s);
|
|
524
|
+
outCtx.drawImage(toCanvas, -W / 2, -H / 2, W, H);
|
|
525
|
+
outCtx.restore();
|
|
526
|
+
outCtx.globalAlpha = 1;
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
case 'dissolve':
|
|
530
|
+
case 'wheel':
|
|
531
|
+
case 'blinds': {
|
|
532
|
+
// Approximated as fade for these complex pixel-based transitions
|
|
533
|
+
outCtx.drawImage(fromCanvas, 0, 0, W, H);
|
|
534
|
+
outCtx.globalAlpha = p;
|
|
535
|
+
outCtx.drawImage(toCanvas, 0, 0, W, H);
|
|
536
|
+
outCtx.globalAlpha = 1;
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── PptxPlayer ────────────────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Slide show player — drives animation and transition playback.
|
|
546
|
+
*
|
|
547
|
+
* @example
|
|
548
|
+
* const player = new PptxPlayer(renderer, canvas);
|
|
549
|
+
* await player.loadSlide(0);
|
|
550
|
+
* player.play();
|
|
551
|
+
* // Or: player.nextClick() to advance through click-triggered animations
|
|
552
|
+
*/
|
|
553
|
+
export class PptxPlayer {
|
|
554
|
+
/**
|
|
555
|
+
* @param {object} renderer — a loaded PptxRenderer instance
|
|
556
|
+
* @param {HTMLCanvasElement} canvas — output canvas
|
|
557
|
+
*/
|
|
558
|
+
constructor(renderer, canvas) {
|
|
559
|
+
this.renderer = renderer;
|
|
560
|
+
this.canvas = canvas;
|
|
561
|
+
this.ctx = canvas.getContext('2d');
|
|
562
|
+
|
|
563
|
+
this._slideIndex = 0;
|
|
564
|
+
this._steps = []; // AnimStep[] for current slide
|
|
565
|
+
this._transition = null; // SlideTransition for current slide
|
|
566
|
+
this._clickNum = 0; // current click group
|
|
567
|
+
this._activeAnimations = []; // currently running RAF loops
|
|
568
|
+
this._shapeStates = new Map(); // shapeId → { opacity, tx, ty, scaleX, scaleY, clipProgress, clipDir, spin }
|
|
569
|
+
this._baseCanvas = null; // fully-rendered static slide
|
|
570
|
+
this._playing = false;
|
|
571
|
+
this._rafId = null;
|
|
572
|
+
|
|
573
|
+
// Event callbacks
|
|
574
|
+
this.onSlideComplete = null; // called when all animations on slide finish
|
|
575
|
+
this.onClickReady = null; // called when waiting for next click
|
|
576
|
+
this.onTransitionStart = null;
|
|
577
|
+
this.onTransitionEnd = null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
/** Pre-render the static slide and parse its animations. */
|
|
583
|
+
async loadSlide(slideIndex) {
|
|
584
|
+
this._slideIndex = slideIndex;
|
|
585
|
+
this._clickNum = 0;
|
|
586
|
+
this._shapeStates.clear();
|
|
587
|
+
this._stopAnimations();
|
|
588
|
+
|
|
589
|
+
// Get the slide XML doc to parse timing
|
|
590
|
+
const files = this.renderer._files;
|
|
591
|
+
const slidePath = this.renderer.slidePaths[slideIndex];
|
|
592
|
+
if (!slidePath || !files) return;
|
|
593
|
+
|
|
594
|
+
const slideXml = files[slidePath] ? new TextDecoder().decode(files[slidePath]) : null;
|
|
595
|
+
if (!slideXml) return;
|
|
596
|
+
|
|
597
|
+
const { parseXml } = await import('./utils.js').catch(() => ({
|
|
598
|
+
parseXml: (s) => new DOMParser().parseFromString(s, 'application/xml'),
|
|
599
|
+
}));
|
|
600
|
+
const slideDoc = parseXml(slideXml);
|
|
601
|
+
|
|
602
|
+
this._steps = parseAnimations(slideDoc);
|
|
603
|
+
this._transition = parseTransition(slideDoc);
|
|
604
|
+
|
|
605
|
+
// Identify shapes that have entrance animations → hide them initially
|
|
606
|
+
const entranceIds = new Set(
|
|
607
|
+
this._steps.filter(s => s.type === 'entrance').map(s => s.shapeId)
|
|
608
|
+
);
|
|
609
|
+
this._initiallyHidden = entranceIds;
|
|
610
|
+
|
|
611
|
+
// Render the base slide (with entrance-animated shapes hidden)
|
|
612
|
+
this._baseCanvas = await this._renderBaseSlide();
|
|
613
|
+
this._drawBase();
|
|
614
|
+
|
|
615
|
+
// If there are click=0 (auto-start) animations, play them immediately
|
|
616
|
+
await this._playClickGroup(0);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Advance to next click group. */
|
|
620
|
+
async nextClick() {
|
|
621
|
+
this._clickNum++;
|
|
622
|
+
await this._playClickGroup(this._clickNum);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Start playback of all remaining click groups automatically. */
|
|
626
|
+
async play(autoAdvanceMs = 1500) {
|
|
627
|
+
this._playing = true;
|
|
628
|
+
const maxClick = Math.max(...this._steps.map(s => s.clickNum), 0);
|
|
629
|
+
while (this._playing && this._clickNum <= maxClick) {
|
|
630
|
+
await this._playClickGroup(this._clickNum);
|
|
631
|
+
this._clickNum++;
|
|
632
|
+
if (this._clickNum <= maxClick) {
|
|
633
|
+
await this._delay(autoAdvanceMs);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
this._playing = false;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Pause all running animations. */
|
|
640
|
+
pause() {
|
|
641
|
+
this._playing = false;
|
|
642
|
+
this._stopAnimations();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Reset to initial state. */
|
|
646
|
+
async stop() {
|
|
647
|
+
this._playing = false;
|
|
648
|
+
this._stopAnimations();
|
|
649
|
+
this._shapeStates.clear();
|
|
650
|
+
this._clickNum = 0;
|
|
651
|
+
if (this._baseCanvas) this._drawBase();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Animate a transition from the current slide to a new slide index.
|
|
656
|
+
* @param {number} nextIndex
|
|
657
|
+
* @returns {Promise<void>} resolves when transition completes
|
|
658
|
+
*/
|
|
659
|
+
async transitionTo(nextIndex) {
|
|
660
|
+
const fromCanvas = document.createElement('canvas');
|
|
661
|
+
fromCanvas.width = this.canvas.width;
|
|
662
|
+
fromCanvas.height = this.canvas.height;
|
|
663
|
+
fromCanvas.getContext('2d').drawImage(this.canvas, 0, 0);
|
|
664
|
+
|
|
665
|
+
await this.loadSlide(nextIndex);
|
|
666
|
+
const toCanvas = this._baseCanvas;
|
|
667
|
+
|
|
668
|
+
const transition = this._transition || { type: 'fade', duration: 700, dir: 'l' };
|
|
669
|
+
this.onTransitionStart?.({ from: this._slideIndex - 1, to: nextIndex, transition });
|
|
670
|
+
|
|
671
|
+
await this._animateTransition(fromCanvas, toCanvas, transition);
|
|
672
|
+
this.onTransitionEnd?.({ slideIndex: nextIndex });
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── Private methods ────────────────────────────────────────────────────────
|
|
676
|
+
|
|
677
|
+
async _renderBaseSlide() {
|
|
678
|
+
const w = this.canvas.width;
|
|
679
|
+
const bc = typeof OffscreenCanvas !== 'undefined'
|
|
680
|
+
? new OffscreenCanvas(w, Math.round(w / (this.renderer.slideSize.cx / this.renderer.slideSize.cy)))
|
|
681
|
+
: Object.assign(document.createElement('canvas'), { width: w, height: Math.round(w / (this.renderer.slideSize.cx / this.renderer.slideSize.cy)) });
|
|
682
|
+
await this.renderer.renderSlide(this._slideIndex, bc, w);
|
|
683
|
+
return bc;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
_drawBase() {
|
|
687
|
+
if (!this._baseCanvas) return;
|
|
688
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
689
|
+
this.ctx.drawImage(this._baseCanvas, 0, 0, this.canvas.width, this.canvas.height);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async _playClickGroup(clickNum) {
|
|
693
|
+
const groupSteps = this._steps.filter(s => s.clickNum === clickNum);
|
|
694
|
+
if (!groupSteps.length) return;
|
|
695
|
+
|
|
696
|
+
this.onClickReady?.({ clickNum, stepCount: groupSteps.length });
|
|
697
|
+
|
|
698
|
+
// Group by delay bucket → play in parallel
|
|
699
|
+
const maxDelay = Math.max(...groupSteps.map(s => s.delay + s.duration), 0);
|
|
700
|
+
|
|
701
|
+
await new Promise(resolve => {
|
|
702
|
+
const startTime = performance.now();
|
|
703
|
+
const completed = new Set();
|
|
704
|
+
|
|
705
|
+
const frame = (now) => {
|
|
706
|
+
const elapsed = now - startTime;
|
|
707
|
+
|
|
708
|
+
for (const step of groupSteps) {
|
|
709
|
+
if (completed.has(step)) continue;
|
|
710
|
+
const stepElapsed = elapsed - step.delay;
|
|
711
|
+
if (stepElapsed < 0) continue;
|
|
712
|
+
|
|
713
|
+
const progress = Math.min(1, stepElapsed / step.duration);
|
|
714
|
+
// We'd need shape bounds here — we'll approximate with slide dimensions
|
|
715
|
+
const sw = this.canvas.width;
|
|
716
|
+
const sh = this.canvas.height;
|
|
717
|
+
const state = computeShapeState(step, progress, sw * 0.3, sh * 0.3);
|
|
718
|
+
this._shapeStates.set(step.shapeId, { ...state, step });
|
|
719
|
+
|
|
720
|
+
if (progress >= 1) completed.add(step);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Re-composite
|
|
724
|
+
this._composite();
|
|
725
|
+
|
|
726
|
+
if (completed.size < groupSteps.length) {
|
|
727
|
+
this._rafId = requestAnimationFrame(frame);
|
|
728
|
+
} else {
|
|
729
|
+
resolve();
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
this._rafId = requestAnimationFrame(frame);
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
_composite() {
|
|
738
|
+
// Draw base slide then apply per-shape states
|
|
739
|
+
// Note: full per-shape compositing would require re-rendering each shape
|
|
740
|
+
// individually. As an approximation, we do a full redraw with the overall
|
|
741
|
+
// canvas transforms. For production, each shape should be drawn to its own
|
|
742
|
+
// offscreen canvas and composited with transforms.
|
|
743
|
+
this._drawBase();
|
|
744
|
+
|
|
745
|
+
// Draw shape state overlays (opacity/position highlights)
|
|
746
|
+
for (const [shapeId, state] of this._shapeStates) {
|
|
747
|
+
if (state.opacity < 0.99) {
|
|
748
|
+
// We don't have shape bboxes here — production renderer would need them
|
|
749
|
+
// For now the effect is subtle and graceful
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async _animateTransition(fromCanvas, toCanvas, transition) {
|
|
755
|
+
const duration = transition.duration;
|
|
756
|
+
await new Promise(resolve => {
|
|
757
|
+
const start = performance.now();
|
|
758
|
+
const frame = (now) => {
|
|
759
|
+
const progress = Math.min(1, (now - start) / duration);
|
|
760
|
+
renderTransitionFrame(this.ctx, fromCanvas, toCanvas, transition, progress);
|
|
761
|
+
if (progress < 1) {
|
|
762
|
+
this._rafId = requestAnimationFrame(frame);
|
|
763
|
+
} else {
|
|
764
|
+
resolve();
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
this._rafId = requestAnimationFrame(frame);
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
_stopAnimations() {
|
|
772
|
+
if (this._rafId) {
|
|
773
|
+
cancelAnimationFrame(this._rafId);
|
|
774
|
+
this._rafId = null;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
_delay(ms) {
|
|
779
|
+
return new Promise(r => setTimeout(r, ms));
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ── Shape animation compositor (standalone) ───────────────────────────────────
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Apply animation state transforms to a shape that has already been rendered
|
|
787
|
+
* onto its own offscreen canvas, then composite to the output context.
|
|
788
|
+
*
|
|
789
|
+
* This is the low-level primitive used when each shape has its own canvas.
|
|
790
|
+
*
|
|
791
|
+
* @param {CanvasRenderingContext2D} outCtx
|
|
792
|
+
* @param {HTMLCanvasElement} shapeCanvas
|
|
793
|
+
* @param {object} state — from computeShapeState()
|
|
794
|
+
* @param {number} cx, cy, cw, ch — shape bounds on output
|
|
795
|
+
*/
|
|
796
|
+
export function compositeShape(outCtx, shapeCanvas, state, cx, cy, cw, ch) {
|
|
797
|
+
if (state.opacity === 0) return;
|
|
798
|
+
|
|
799
|
+
outCtx.save();
|
|
800
|
+
outCtx.globalAlpha = state.opacity;
|
|
801
|
+
|
|
802
|
+
const pivX = cx + cw / 2;
|
|
803
|
+
const pivY = cy + ch / 2;
|
|
804
|
+
outCtx.translate(pivX + state.tx, pivY + state.ty);
|
|
805
|
+
|
|
806
|
+
if (state.spin) outCtx.rotate(state.spin * Math.PI / 180);
|
|
807
|
+
if (state.scaleX !== 1 || state.scaleY !== 1) {
|
|
808
|
+
outCtx.scale(state.scaleX, state.scaleY);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (state.clipProgress < 1) {
|
|
812
|
+
applyClipMask(outCtx, state.clipDir, state.clipProgress, -cw / 2, -ch / 2, cw, ch);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
outCtx.drawImage(shapeCanvas, -cw / 2, -ch / 2, cw, ch);
|
|
816
|
+
outCtx.restore();
|
|
817
|
+
}
|