| 1 | import * as cheerio from 'cheerio' |
| 2 | import { describe, expect, it } from 'vitest' |
| 3 | import { |
| 4 | buildSlideTimingXml, |
| 5 | type PptxTargetAnimation |
| 6 | } from '@arcsin1/html2pptx/animation' |
| 7 | |
| 8 | const makeAnim = (overrides: Partial<PptxTargetAnimation> = {}): PptxTargetAnimation => ({ |
| 9 | spid: 2, |
| 10 | type: 'fade-up', |
| 11 | trigger: 'load', |
| 12 | duration: 500, |
| 13 | delay: 0, |
| 14 | order: 0, |
| 15 | ...overrides |
| 16 | }) |
| 17 | |
| 18 | const parseTimingXml = (xml: string) => { |
| 19 | if (!xml) return null |
| 20 | return cheerio.load(xml, { xmlMode: true }, false) |
| 21 | } |
| 22 | |
| 23 | describe('OOXML timing tree structure', () => { |
| 24 | it('has exactly one tmRoot containing one mainSeq', () => { |
| 25 | const xml = buildSlideTimingXml([makeAnim({ spid: 7, type: 'fade' })]) |
| 26 | const $ = parseTimingXml(xml) |
| 27 | expect($).not.toBeNull() |
| 28 | |
| 29 | const root = $('p\\:par p\\:cTn[nodeType="tmRoot"]') |
| 30 | expect(root.length).toBe(1) |
| 31 | |
| 32 | const mainSeq = root.find('p\\:cTn[nodeType="mainSeq"]') |
| 33 | expect(mainSeq.length).toBe(1) |
| 34 | expect(mainSeq.parents('p\\:cTn[nodeType="tmRoot"]').length).toBeGreaterThanOrEqual(1) |
| 35 | }) |
| 36 | |
| 37 | it('every effect node has exactly one stCondLst child', () => { |
| 38 | const xml = buildSlideTimingXml([ |
| 39 | makeAnim({ spid: 3, type: 'fade' }), |
| 40 | makeAnim({ spid: 4, type: 'pulse', trigger: 'click', order: 1 }) |
| 41 | ]) |
| 42 | const $ = parseTimingXml(xml) |
| 43 | expect($).not.toBeNull() |
| 44 | |
| 45 | // Find all par-level cTn that contain presetID (effect containers) |
| 46 | $('p\\:cTn[presetID]').each((_, el) => { |
| 47 | const ctn = $(el) |
| 48 | const stCond = ctn.children('p\\:stCondLst') |
| 49 | expect(stCond.length, `effect node id=${ctn.attr('id')} has stCondLst`).toBe(1) |
| 50 | }) |
| 51 | }) |
| 52 | |
| 53 | it('every effect node has exactly one childTnLst parent wrapping its children', () => { |
| 54 | const xml = buildSlideTimingXml([ |
| 55 | makeAnim({ spid: 3, type: 'fade' }), |
| 56 | makeAnim({ spid: 4, type: 'scale-in', order: 1 }) |
| 57 | ]) |
| 58 | const $ = parseTimingXml(xml) |
| 59 | expect($).not.toBeNull() |
| 60 | |
| 61 | $('p\\:cTn[presetID]').each((_, el) => { |
| 62 | const ctn = $(el) |
| 63 | const childList = ctn.children('p\\:childTnLst') |
| 64 | expect(childList.length, `effect node id=${ctn.attr('id')} has childTnLst`).toBe(1) |
| 65 | }) |
| 66 | }) |
| 67 | |
| 68 | it('starts the main animation sequence immediately so load animations do not wait for a click', () => { |
| 69 | const xml = buildSlideTimingXml([ |
| 70 | makeAnim({ spid: 3, type: 'fade-up', trigger: 'load' }), |
| 71 | makeAnim({ spid: 4, type: 'exit-fade', trigger: 'click', order: 1 }) |
| 72 | ]) |
| 73 | const $ = parseTimingXml(xml) |
| 74 | expect($).not.toBeNull() |
| 75 | |
| 76 | expect(xml).not.toContain('<p:cond delay="indefinite"/>') |
| 77 | expect($('p\\:cTn[nodeType="withEffect"]').length).toBeGreaterThan(0) |
| 78 | expect($('p\\:cTn[nodeType="clickEffect"]').length).toBeGreaterThan(0) |
| 79 | }) |
| 80 | |
| 81 | it('emphasis effects contain a p:seq with two child animScale elements', () => { |
| 82 | const xml = buildSlideTimingXml([makeAnim({ spid: 3, type: 'pulse' })]) |
| 83 | const $ = parseTimingXml(xml) |
| 84 | expect($).not.toBeNull() |
| 85 | |
| 86 | // The timing tree has multiple <p:seq> elements: |
| 87 | // - One for the main sequence wrapper (mainSeq) |
| 88 | // - One nested inside the effect for emphasis rebound |
| 89 | // Find the seq that contains animScale (the emphasis rebound seq) |
| 90 | const reboundSeq = $('p\\:seq').filter((_, el) => $(el).find('p\\:animScale').length > 0) |
| 91 | expect(reboundSeq.length).toBeGreaterThanOrEqual(1) |
| 92 | |
| 93 | // The rebound seq should contain exactly 2 animScale children |
| 94 | const scales = reboundSeq.find('p\\:animScale') |
| 95 | expect(scales.length).toBe(2) |
| 96 | }) |
| 97 | |
| 98 | it('emphasis rebound: first animScale uses fill="hold", second uses fill="remove"', () => { |
| 99 | const xml = buildSlideTimingXml([makeAnim({ spid: 5, type: 'pulse', duration: 800 })]) |
| 100 | const $ = parseTimingXml(xml) |
| 101 | expect($).not.toBeNull() |
| 102 | |
| 103 | const scales = $('p\\:animScale') |
| 104 | expect(scales.length).toBe(2) |
| 105 | |
| 106 | const first = $(scales[0]) |
| 107 | const firstTn = first.find('p\\:cTn[dur][fill]').first() |
| 108 | expect(firstTn.attr('fill')).toBe('hold') |
| 109 | expect(Number(firstTn.attr('dur'))).toBeGreaterThan(0) |
| 110 | |
| 111 | const second = $(scales[1]) |
| 112 | const secondTn = second.find('p\\:cTn[dur][fill]').first() |
| 113 | expect(secondTn.attr('fill')).toBe('remove') |
| 114 | expect(Number(secondTn.attr('dur'))).toBeGreaterThan(0) |
| 115 | }) |
| 116 | |
| 117 | it('emphasis rebound: second animScale transitions peak back to identity (x=100000, y=100000)', () => { |
| 118 | const xml = buildSlideTimingXml([makeAnim({ spid: 5, type: 'pulse', duration: 600 })]) |
| 119 | const $ = parseTimingXml(xml) |
| 120 | expect($).not.toBeNull() |
| 121 | |
| 122 | const scales = $('p\\:animScale') |
| 123 | const second = $(scales[1]) |
| 124 | const toEl = second.find('p\\:to') |
| 125 | expect(toEl.length).toBe(1) |
| 126 | expect(toEl.attr('x')).toBe('100000') |
| 127 | expect(toEl.attr('y')).toBe('100000') |
| 128 | }) |
| 129 | |
| 130 | it('emphasis rebound: second phase duration equals first phase (half total each)', () => { |
| 131 | const totalDur = 1000 |
| 132 | const xml = buildSlideTimingXml([makeAnim({ spid: 5, type: 'grow-shrink', duration: totalDur })]) |
| 133 | const $ = parseTimingXml(xml) |
| 134 | expect($).not.toBeNull() |
| 135 | |
| 136 | const scales = $('p\\:animScale') |
| 137 | const dur1 = Number($(scales[0]).find('p\\:cTn[dur][fill]').first().attr('dur')) |
| 138 | const dur2 = Number($(scales[1]).find('p\\:cTn[dur][fill]').first().attr('dur')) |
| 139 | expect(dur1 + dur2).toBeLessThanOrEqual(totalDur) |
| 140 | // Should be roughly half each (floor division may lose 1ms) |
| 141 | expect(Math.abs(dur1 - dur2)).toBeLessThanOrEqual(1) |
| 142 | }) |
| 143 | |
| 144 | it('emphasis seq is a child of the effect childTnLst (not top-level)', () => { |
| 145 | const xml = buildSlideTimingXml([makeAnim({ spid: 5, type: 'pulse-strong' })]) |
| 146 | const $ = parseTimingXml(xml) |
| 147 | expect($).not.toBeNull() |
| 148 | |
| 149 | // Find the emphasis rebound seq: a p:seq whose direct children include |
| 150 | // a p:cTn that directly contains p:childTnLst > p:animScale |
| 151 | const reboundSeq = $('p\\:seq').filter((_, el) => { |
| 152 | const $seq = $(el) |
| 153 | return $seq.children('p\\:cTn').children('p\\:childTnLst').children('p\\:animScale').length > 0 |
| 154 | }) |
| 155 | expect(reboundSeq.length).toBe(1) |
| 156 | |
| 157 | // The seq must be inside a childTnLst that belongs to the effect par |
| 158 | const parentChildTn = reboundSeq.closest('p\\:childTnLst') |
| 159 | expect(parentChildTn.length).toBe(1) |
| 160 | // That childTnLst must be inside a cTn that has presetID |
| 161 | const parentCtn = parentChildTn.closest('p\\:cTn[presetID]') |
| 162 | expect(parentCtn.length).toBe(1) |
| 163 | }) |
| 164 | |
| 165 | it('non-emphasis scale animations do not use rebound p:seq', () => { |
| 166 | const xml = buildSlideTimingXml([makeAnim({ spid: 3, type: 'scale-in' })]) |
| 167 | const $ = parseTimingXml(xml) |
| 168 | expect($).not.toBeNull() |
| 169 | |
| 170 | // A rebound seq is a p:seq whose direct children include p:cTn > p:childTnLst > p:animScale |
| 171 | // Non-emphasis scale animations should NOT have this structure |
| 172 | const reboundSeq = $('p\\:seq').filter((_, el) => { |
| 173 | return $(el).children('p\\:cTn').children('p\\:childTnLst').children('p\\:animScale').length > 0 |
| 174 | }) |
| 175 | expect(reboundSeq.length).toBe(0) |
| 176 | |
| 177 | // But there should still be exactly one animScale total |
| 178 | const allScales = $('p\\:animScale') |
| 179 | expect(allScales.length).toBe(1) |
| 180 | }) |
| 181 | |
| 182 | it('path animation generates two anim channels (x, y) and no animEffect', () => { |
| 183 | const xml = buildSlideTimingXml([ |
| 184 | makeAnim({ spid: 8, type: 'path', path: 'M 0 0 L 120 30' }) |
| 185 | ]) |
| 186 | const $ = parseTimingXml(xml) |
| 187 | expect($).not.toBeNull() |
| 188 | |
| 189 | const animNodes = $('p\\:anim[calcmode="lin"]') |
| 190 | expect(animNodes.length).toBe(2) |
| 191 | |
| 192 | const xAnim = animNodes.filter((_, el) => $(el).find('p\\:attrName').text() === 'ppt_x') |
| 193 | const yAnim = animNodes.filter((_, el) => $(el).find('p\\:attrName').text() === 'ppt_y') |
| 194 | expect(xAnim.length).toBe(1) |
| 195 | expect(yAnim.length).toBe(1) |
| 196 | |
| 197 | // No animEffect for path (P1-4) |
| 198 | expect($('p\\:animEffect').length).toBe(0) |
| 199 | }) |
| 200 | |
| 201 | it('fade animation generates exactly one animEffect with filter="fade"', () => { |
| 202 | const xml = buildSlideTimingXml([makeAnim({ spid: 3, type: 'fade' })]) |
| 203 | const $ = parseTimingXml(xml) |
| 204 | expect($).not.toBeNull() |
| 205 | |
| 206 | const effects = $('p\\:animEffect') |
| 207 | expect(effects.length).toBe(1) |
| 208 | expect(effects.attr('filter')).toBe('fade') |
| 209 | }) |
| 210 | |
| 211 | it('entrance animation channels use smooth ease-out timing', () => { |
| 212 | const xml = buildSlideTimingXml([makeAnim({ spid: 3, type: 'fade-up', duration: 600 })]) |
| 213 | const $ = parseTimingXml(xml) |
| 214 | expect($).not.toBeNull() |
| 215 | |
| 216 | const timedNodes = $('p\\:cTn[dur="600"][accel][decel]') |
| 217 | expect(timedNodes.length).toBeGreaterThanOrEqual(2) |
| 218 | timedNodes.each((_, el) => { |
| 219 | expect($(el).attr('accel')).toBe('0') |
| 220 | expect($(el).attr('decel')).toBe('70000') |
| 221 | }) |
| 222 | }) |
| 223 | |
| 224 | it('exit animation channels use smooth ease-in timing', () => { |
| 225 | const xml = buildSlideTimingXml([ |
| 226 | makeAnim({ spid: 3, type: 'exit-fly', trigger: 'click', duration: 600 }) |
| 227 | ]) |
| 228 | const $ = parseTimingXml(xml) |
| 229 | expect($).not.toBeNull() |
| 230 | |
| 231 | const timedNodes = $('p\\:cTn[dur="600"][accel][decel]') |
| 232 | expect(timedNodes.length).toBeGreaterThanOrEqual(2) |
| 233 | timedNodes.each((_, el) => { |
| 234 | expect($(el).attr('accel')).toBe('70000') |
| 235 | expect($(el).attr('decel')).toBe('0') |
| 236 | }) |
| 237 | }) |
| 238 | |
| 239 | it('emphasis rebound uses smooth ease-in-out timing on both phases', () => { |
| 240 | const xml = buildSlideTimingXml([makeAnim({ spid: 5, type: 'pulse', duration: 600 })]) |
| 241 | const $ = parseTimingXml(xml) |
| 242 | expect($).not.toBeNull() |
| 243 | |
| 244 | const scaleTimingNodes = $('p\\:animScale p\\:cTn[dur][accel][decel]') |
| 245 | expect(scaleTimingNodes.length).toBe(2) |
| 246 | scaleTimingNodes.each((_, el) => { |
| 247 | expect($(el).attr('accel')).toBe('20000') |
| 248 | expect($(el).attr('decel')).toBe('60000') |
| 249 | }) |
| 250 | }) |
| 251 | |
| 252 | it('build list entries match unique target spids exactly', () => { |
| 253 | const xml = buildSlideTimingXml([ |
| 254 | makeAnim({ spid: 7, type: 'fade-up', delay: 100 }), |
| 255 | makeAnim({ spid: 7, type: 'pulse', trigger: 'click', order: 1 }), |
| 256 | makeAnim({ spid: 8, type: 'fade', order: 2 }) |
| 257 | ]) |
| 258 | const $ = parseTimingXml(xml) |
| 259 | expect($).not.toBeNull() |
| 260 | |
| 261 | const buildEntries = $('p\\:bldP') |
| 262 | expect(buildEntries.length).toBe(2) // spid 7 and spid 8 |
| 263 | const spids = buildEntries.map((_, el) => $(el).attr('spid')).get() |
| 264 | expect(spids).toContain('7') |
| 265 | expect(spids).toContain('8') |
| 266 | }) |
| 267 | |
| 268 | it('tmRoot contains exactly one bldLst', () => { |
| 269 | const xml = buildSlideTimingXml([makeAnim({ spid: 7, type: 'fade' })]) |
| 270 | const $ = parseTimingXml(xml) |
| 271 | expect($).not.toBeNull() |
| 272 | |
| 273 | const bldLst = $('p\\:bldLst') |
| 274 | expect(bldLst.length).toBe(1) |
| 275 | |
| 276 | // bldLst must be a direct child of timing |
| 277 | const timingParent = bldLst.closest('p\\:timing') |
| 278 | expect(timingParent.length).toBe(1) |
| 279 | }) |
| 280 | }) |
| 281 | |
| 282 | describe('Decimal path precision in timing XML', () => { |
| 283 | it('emits fractional deltas for decimal path coordinates', () => { |
| 284 | // delta = end - start: 120.5 - 0.5 = 120 (x), 30.25 - 0 = 30.25 (y) |
| 285 | const xml = buildSlideTimingXml([ |
| 286 | makeAnim({ spid: 8, type: 'path', path: 'M 0.5 0 L 120.5 30.25' }) |
| 287 | ]) |
| 288 | const $ = parseTimingXml(xml) |
| 289 | expect($).not.toBeNull() |
| 290 | |
| 291 | const toValues = $('p\\:tav[tm="100000"] p\\:strVal') |
| 292 | const vals = toValues.map((_, el) => $(el).attr('val') || '').get() |
| 293 | // x delta = 120.5 - 0.5 = 120 (integer, no fraction) |
| 294 | expect(vals).toContain('#ppt_x+120') |
| 295 | // y delta = 30.25 - 0 = 30.25 (fractional) |
| 296 | expect(vals).toContain('#ppt_y+30.25') |
| 297 | }) |
| 298 | }) |
| 299 |