| 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 |