返回 oh-my-ppt
ooxml-animation.test.ts
根目录 / tests / unit / html-pptx / ooxml-animation.test.ts
1 import { describe, expect, it } from 'vitest'
2 import { buildSlideXml } from '@arcsin1/html2pptx/ooxml'
3 import type { HtmlToPptxSlide } from '@arcsin1/html2pptx'
4
5 describe('buildSlideXml animation export', () => {
6 it('writes only a slide fade for the safe editable-export animation mode', () => {
7 const slide: HtmlToPptxSlide = {
8 texts: [{ text: 'Visible in every preview', x: 1, y: 1, w: 5, h: 1, fontSize: 24 }],
9 shapes: [],
10 images: [],
11 tables: [],
12 transitionType: 'fade',
13 transitionDurationMs: 350
14 }
15
16 const xml = buildSlideXml(slide, new Map(), 1)
17
18 expect(xml).toContain('<p:transition')
19 expect(xml).not.toContain('<p:timing>')
20 })
21
22 it('exports automatic and click animations together', () => {
23 const slide: HtmlToPptxSlide = {
24 texts: [
25 { text: 'Automatic', x: 1, y: 1, w: 4, h: 0.8, fontSize: 24 },
26 { text: 'Click', x: 1, y: 2, w: 4, h: 0.8, fontSize: 24 }
27 ],
28 shapes: [],
29 images: [],
30 tables: [],
31 animationTraces: [
32 {
33 type: 'fade-up',
34 trigger: 'load',
35 duration: 500,
36 delay: 0,
37 order: 0,
38 x: 100,
39 y: 100,
40 w: 400,
41 h: 80
42 },
43 {
44 type: 'exit-fade',
45 trigger: 'click',
46 duration: 600,
47 delay: 0,
48 order: 1,
49 x: 100,
50 y: 200,
51 w: 400,
52 h: 80
53 }
54 ]
55 }
56
57 const xml = buildSlideXml(slide, new Map(), 1)
58
59 expect(xml).toContain('nodeType="withEffect"')
60 expect(xml).toContain('nodeType="clickEffect"')
61 })
62
63 it('binds one data-anim container trace to every exported shape inside it', () => {
64 const slide: HtmlToPptxSlide = {
65 texts: [
66 { text: 'Title', x: 1, y: 0.5, w: 6, h: 0.8, fontSize: 36 },
67 { text: 'Body', x: 1.2, y: 2, w: 5.5, h: 0.8, fontSize: 20 }
68 ],
69 shapes: [],
70 images: [],
71 tables: [],
72 animationTraces: [
73 {
74 type: 'fade-up',
75 trigger: 'load',
76 duration: 500,
77 delay: 100,
78 order: 0,
79 x: 100,
80 y: 40,
81 w: 800,
82 h: 320
83 }
84 ]
85 }
86
87 const xml = buildSlideXml(slide, new Map(), 1)
88
89 expect(xml).toContain('<p:timing>')
90 expect(xml).toContain('<p:bldP spid="2" grpId="0"/>')
91 expect(xml).toContain('<p:bldP spid="3" grpId="0"/>')
92 expect(xml).toContain('<p:spTgt spid="2"/>')
93 expect(xml).toContain('<p:spTgt spid="3"/>')
94 })
95
96 it('keeps transition and timing after clrMapOvr for valid slide child ordering', () => {
97 const slide: HtmlToPptxSlide = {
98 texts: [{ text: 'Slide', x: 1, y: 1, w: 5, h: 1, fontSize: 24 }],
99 shapes: [],
100 images: [],
101 tables: [],
102 transitionType: 'fade',
103 transitionDurationMs: 600,
104 animationTraces: [
105 {
106 type: 'fade',
107 trigger: 'load',
108 duration: 400,
109 delay: 0,
110 order: 0,
111 x: 100,
112 y: 100,
113 w: 700,
114 h: 150
115 }
116 ]
117 }
118
119 const xml = buildSlideXml(slide, new Map(), 1)
120
121 expect(xml.indexOf('<p:cSld>')).toBeLessThan(xml.indexOf('<p:clrMapOvr>'))
122 expect(xml.indexOf('<p:clrMapOvr>')).toBeLessThan(xml.indexOf('<p:transition'))
123 expect(xml.indexOf('<p:transition')).toBeLessThan(xml.indexOf('<p:timing>'))
124 })
125
126 it('can target overlay images such as formula screenshots', () => {
127 const dataUri = 'data:image/png;base64,abc'
128 const slide: HtmlToPptxSlide = {
129 texts: [],
130 shapes: [],
131 images: [],
132 tables: [],
133 overlayImages: [
134 {
135 dataUri,
136 mimeType: 'image/png',
137 x: 2,
138 y: 2,
139 w: 2,
140 h: 1
141 }
142 ],
143 animationTraces: [
144 {
145 type: 'fade',
146 trigger: 'load',
147 duration: 400,
148 delay: 0,
149 order: 0,
150 x: 240,
151 y: 240,
152 w: 240,
153 h: 120
154 }
155 ]
156 }
157
158 const xml = buildSlideXml(slide, new Map([[dataUri, { rId: 'rId1', mediaFile: 'image1.png' }]]), 1)
159
160 expect(xml).toContain('<p:pic>')
161 expect(xml).toContain('<p:bldP spid="2" grpId="0"/>')
162 expect(xml).toContain('<p:spTgt spid="2"/>')
163 })
164
165 it('exports fly-in direction as native movement timing', () => {
166 const slide: HtmlToPptxSlide = {
167 texts: [{ text: 'Fly', x: 1, y: 1, w: 3, h: 1, fontSize: 24 }],
168 shapes: [],
169 images: [],
170 tables: [],
171 animationTraces: [
172 {
173 type: 'fly-in',
174 trigger: 'load',
175 from: 'left',
176 duration: 500,
177 delay: 0,
178 order: 0,
179 x: 100,
180 y: 100,
181 w: 300,
182 h: 100
183 }
184 ]
185 }
186
187 const xml = buildSlideXml(slide, new Map(), 1)
188
189 expect(xml).toContain('presetID="2" presetClass="entr"')
190 expect(xml).toContain('<p:attrName>ppt_x</p:attrName>')
191 expect(xml).toContain('val="#ppt_x-#ppt_w/2"')
192 expect(xml).toContain('val="#ppt_x"')
193 })
194
195 it('exports wipe and exit animations instead of dropping extended data-anim types', () => {
196 const slide: HtmlToPptxSlide = {
197 texts: [
198 { text: 'Wipe', x: 1, y: 1, w: 3, h: 1, fontSize: 24 },
199 { text: 'Exit', x: 1, y: 2.2, w: 3, h: 1, fontSize: 24 }
200 ],
201 shapes: [],
202 images: [],
203 tables: [],
204 animationTraces: [
205 {
206 type: 'wipe',
207 trigger: 'load',
208 from: 'right',
209 duration: 500,
210 delay: 0,
211 order: 0,
212 x: 100,
213 y: 100,
214 w: 300,
215 h: 100
216 },
217 {
218 type: 'exit-fly',
219 trigger: 'click',
220 from: 'bottom',
221 duration: 500,
222 delay: 0,
223 order: 1,
224 x: 100,
225 y: 220,
226 w: 300,
227 h: 100
228 }
229 ]
230 }
231
232 const xml = buildSlideXml(slide, new Map(), 1)
233
234 expect(xml).toContain('filter="wipe(l)"')
235 expect(xml).toContain('presetClass="exit"')
236 expect(xml).toContain('nodeType="clickEffect"')
237 expect(xml).toContain('val="#ppt_y+#ppt_h/2"')
238 })
239
240 it('exports symmetry-completion candidates instead of dropping them', () => {
241 const slide: HtmlToPptxSlide = {
242 texts: [
243 { text: 'Down', x: 1, y: 1, w: 3, h: 1, fontSize: 24 },
244 { text: 'Right', x: 1, y: 2.2, w: 3, h: 1, fontSize: 24 },
245 { text: 'Exit Wipe', x: 1, y: 3.4, w: 3, h: 1, fontSize: 24 }
246 ],
247 shapes: [],
248 images: [],
249 tables: [],
250 animationTraces: [
251 {
252 type: 'slide-down',
253 trigger: 'load',
254 from: 'top',
255 duration: 500,
256 delay: 0,
257 order: 0,
258 x: 100,
259 y: 100,
260 w: 300,
261 h: 100
262 },
263 {
264 type: 'slide-right',
265 trigger: 'load',
266 from: 'left',
267 duration: 500,
268 delay: 0,
269 order: 1,
270 x: 100,
271 y: 220,
272 w: 300,
273 h: 100
274 },
275 {
276 type: 'exit-wipe',
277 trigger: 'click',
278 from: 'top',
279 duration: 500,
280 delay: 0,
281 order: 2,
282 x: 100,
283 y: 340,
284 w: 300,
285 h: 100
286 }
287 ]
288 }
289
290 const xml = buildSlideXml(slide, new Map(), 1)
291
292 expect(xml).toContain('presetSubtype="1"')
293 expect(xml).toContain('presetSubtype="2"')
294 expect(xml).toContain('filter="wipe(d)"')
295 expect(xml).toContain('presetClass="exit"')
296 })
297
298 it('exports exit scale primitives as distinct native exit scale ranges', () => {
299 const slide: HtmlToPptxSlide = {
300 texts: [
301 { text: 'Soft Exit', x: 1, y: 1, w: 3, h: 1, fontSize: 24 },
302 { text: 'Zoom Exit', x: 1, y: 2.2, w: 3, h: 1, fontSize: 24 }
303 ],
304 shapes: [],
305 images: [],
306 tables: [],
307 animationTraces: [
308 {
309 type: 'exit-scale',
310 trigger: 'click',
311 duration: 500,
312 delay: 0,
313 order: 0,
314 x: 100,
315 y: 100,
316 w: 300,
317 h: 100
318 },
319 {
320 type: 'exit-zoom',
321 trigger: 'click',
322 duration: 500,
323 delay: 0,
324 order: 1,
325 x: 100,
326 y: 220,
327 w: 300,
328 h: 100
329 }
330 ]
331 }
332
333 const xml = buildSlideXml(slide, new Map(), 1)
334
335 expect(xml).toContain('presetID="31" presetClass="exit"')
336 expect(xml).toContain('transition="out"')
337 expect(xml).toContain('<p:from x="100000" y="100000"/>')
338 expect(xml).toContain('<p:to x="85000" y="85000"/>')
339 expect(xml).toContain('<p:to x="75000" y="75000"/>')
340 })
341
342 it('exports spin-in with native rotation timing in addition to scale timing', () => {
343 const slide: HtmlToPptxSlide = {
344 texts: [{ text: 'Spin', x: 1, y: 1, w: 3, h: 1, fontSize: 24 }],
345 shapes: [],
346 images: [],
347 tables: [],
348 animationTraces: [
349 {
350 type: 'spin-in',
351 trigger: 'load',
352 duration: 500,
353 delay: 0,
354 order: 0,
355 x: 100,
356 y: 100,
357 w: 300,
358 h: 100
359 }
360 ]
361 }
362
363 const xml = buildSlideXml(slide, new Map(), 1)
364
365 expect(xml).toContain('presetID="31" presetClass="entr"')
366 expect(xml).toContain('<p:animScale>')
367 expect(xml).toContain('<p:animRot')
368 expect(xml).toContain('from="-720000"')
369 expect(xml).toContain('to="0"')
370 })
371
372 it('exports constrained path motion as linear PPTX channels', () => {
373 const slide: HtmlToPptxSlide = {
374 texts: [{ text: 'Path', x: 1, y: 1, w: 3, h: 1, fontSize: 24 }],
375 shapes: [],
376 images: [],
377 tables: [],
378 animationTraces: [
379 {
380 type: 'path',
381 trigger: 'load',
382 path: 'M 0 0 L 120 30',
383 duration: 500,
384 delay: 0,
385 order: 0,
386 x: 100,
387 y: 100,
388 w: 300,
389 h: 100
390 }
391 ]
392 }
393
394 const xml = buildSlideXml(slide, new Map(), 1)
395
396 expect(xml).toContain('<p:attrName>ppt_x</p:attrName>')
397 expect(xml).toContain('<p:strVal val="#ppt_x+120"/>')
398 expect(xml).toContain('<p:attrName>ppt_y</p:attrName>')
399 expect(xml).toContain('<p:strVal val="#ppt_y+30"/>')
400 })
401 })
402
402 lines TYPESCRIPT