返回 oh-my-ppt
pptx-animation-map.ts
根目录 / src / main / animation / pptx-animation-map.ts
1 import type { DataAnimFrom, DataAnimType } from './data-anim-schema'
2
3 export type PptxPresetClass = 'entr' | 'emph' | 'exit'
4 export type PptxMotion = 'fromTop' | 'fromBottom' | 'fromLeft' | 'fromRight' | 'fromTrace'
5
6 export interface PptxAnimationPreset {
7 presetId: number
8 presetClass: PptxPresetClass
9 presetSubtype?: number
10 motion?: PptxMotion
11 scale?: boolean
12 scaleFrom?: number
13 scaleTo?: number
14 rotateFrom?: number
15 rotateTo?: number
16 fade?: boolean
17 effectFilter?: 'wipe'
18 transition?: 'in' | 'out'
19 }
20
21 const SIMPLE_ENTRANCE_DELTA_THRESHOLD = 30
22
23 const parseNumericMotionDelta = (
24 from: string | undefined,
25 to: string | undefined,
26 axis: 'x' | 'y'
27 ): number | undefined => {
28 const base = axis === 'x' ? '#ppt_x' : '#ppt_y'
29 if (to !== base || !from?.startsWith(base)) return undefined
30 const match = from.match(new RegExp(`^${base}([+-]\\d+(?:\\.\\d+)?)$`))
31 if (!match) return undefined
32 const value = Number(match[1])
33 return Number.isFinite(value) ? value : undefined
34 }
35
36 const EMPHASIS_PRESETS = [
37 { type: 'grow-shrink-soft', scaleFrom: 95000, scaleTo: 104000 },
38 { type: 'grow-shrink', scaleFrom: 90000, scaleTo: 108000 },
39 { type: 'grow-shrink-strong', scaleFrom: 85000, scaleTo: 112000 },
40 { type: 'pulse-soft', scaleFrom: 100000, scaleTo: 103000 },
41 { type: 'pulse', scaleFrom: 100000, scaleTo: 106000 },
42 { type: 'pulse-strong', scaleFrom: 100000, scaleTo: 110000 }
43 ] as const satisfies ReadonlyArray<{
44 type: DataAnimType
45 scaleFrom: number
46 scaleTo: number
47 }>
48
49 const ENTRANCE_SCALE_PRESETS = [
50 { type: 'zoom-in', scaleFrom: 75000, scaleTo: 100000 },
51 { type: 'scale-in', scaleFrom: 85000, scaleTo: 100000 },
52 { type: 'spin-in', scaleFrom: 92000, scaleTo: 100000 }
53 ] as const satisfies ReadonlyArray<{
54 type: DataAnimType
55 scaleFrom: number
56 scaleTo: number
57 }>
58
59 const EXIT_SCALE_PRESETS = [
60 { type: 'exit-scale', scaleFrom: 100000, scaleTo: 85000 },
61 { type: 'exit-zoom', scaleFrom: 100000, scaleTo: 75000 }
62 ] as const satisfies ReadonlyArray<{
63 type: DataAnimType
64 scaleFrom: number
65 scaleTo: number
66 }>
67
68 export const PPTX_ANIMATION_PRESETS: Record<DataAnimType, PptxAnimationPreset> = {
69 fade: { presetId: 10, presetClass: 'entr', fade: true },
70 'fade-up': {
71 presetId: 2,
72 presetClass: 'entr',
73 presetSubtype: 8,
74 motion: 'fromBottom',
75 fade: true
76 },
77 'fade-down': {
78 presetId: 2,
79 presetClass: 'entr',
80 presetSubtype: 1,
81 motion: 'fromTop',
82 fade: true
83 },
84 'fade-left': {
85 presetId: 2,
86 presetClass: 'entr',
87 presetSubtype: 3,
88 motion: 'fromRight',
89 fade: true
90 },
91 'fade-right': {
92 presetId: 2,
93 presetClass: 'entr',
94 presetSubtype: 2,
95 motion: 'fromLeft',
96 fade: true
97 },
98 'scale-in': { presetId: 31, presetClass: 'entr', scale: true, fade: true },
99 'slide-up': {
100 presetId: 2,
101 presetClass: 'entr',
102 presetSubtype: 8,
103 motion: 'fromBottom',
104 fade: true
105 },
106 'slide-down': {
107 presetId: 2,
108 presetClass: 'entr',
109 presetSubtype: 1,
110 motion: 'fromTop',
111 fade: true
112 },
113 'slide-left': {
114 presetId: 2,
115 presetClass: 'entr',
116 presetSubtype: 3,
117 motion: 'fromRight',
118 fade: true
119 },
120 'slide-right': {
121 presetId: 2,
122 presetClass: 'entr',
123 presetSubtype: 2,
124 motion: 'fromLeft',
125 fade: true
126 },
127 'fly-in': {
128 presetId: 2,
129 presetClass: 'entr',
130 motion: 'fromTrace',
131 fade: true
132 },
133 wipe: {
134 presetId: 5,
135 presetClass: 'entr',
136 effectFilter: 'wipe'
137 },
138 'zoom-in': {
139 presetId: 31,
140 presetClass: 'entr',
141 scale: true,
142 scaleFrom: 75000,
143 scaleTo: 100000,
144 fade: true
145 },
146 'spin-in': {
147 presetId: 31,
148 presetClass: 'entr',
149 scale: true,
150 scaleFrom: 92000,
151 scaleTo: 100000,
152 rotateFrom: -720000,
153 rotateTo: 0,
154 fade: true
155 },
156 'grow-shrink-soft': {
157 presetId: 6,
158 presetClass: 'emph',
159 scale: true,
160 scaleFrom: 95000,
161 scaleTo: 104000
162 },
163 'grow-shrink': {
164 presetId: 6,
165 presetClass: 'emph',
166 scale: true,
167 scaleFrom: 90000,
168 scaleTo: 108000
169 },
170 'grow-shrink-strong': {
171 presetId: 6,
172 presetClass: 'emph',
173 scale: true,
174 scaleFrom: 85000,
175 scaleTo: 112000
176 },
177 'pulse-soft': {
178 presetId: 6,
179 presetClass: 'emph',
180 scale: true,
181 scaleFrom: 100000,
182 scaleTo: 103000
183 },
184 pulse: {
185 presetId: 6,
186 presetClass: 'emph',
187 scale: true,
188 scaleFrom: 100000,
189 scaleTo: 106000
190 },
191 'pulse-strong': {
192 presetId: 6,
193 presetClass: 'emph',
194 scale: true,
195 scaleFrom: 100000,
196 scaleTo: 110000
197 },
198 'exit-fade': {
199 presetId: 10,
200 presetClass: 'exit',
201 fade: true,
202 transition: 'out'
203 },
204 'exit-scale': {
205 presetId: 31,
206 presetClass: 'exit',
207 scale: true,
208 scaleFrom: 100000,
209 scaleTo: 85000,
210 fade: true,
211 transition: 'out'
212 },
213 'exit-zoom': {
214 presetId: 31,
215 presetClass: 'exit',
216 scale: true,
217 scaleFrom: 100000,
218 scaleTo: 75000,
219 fade: true,
220 transition: 'out'
221 },
222 'exit-wipe': {
223 presetId: 5,
224 presetClass: 'exit',
225 effectFilter: 'wipe',
226 transition: 'out'
227 },
228 'exit-fly': {
229 presetId: 2,
230 presetClass: 'exit',
231 motion: 'fromTrace',
232 fade: true,
233 transition: 'out'
234 },
235 path: { presetId: 10, presetClass: 'entr' }
236 }
237
238 export const getPptxAnimationPreset = (
239 type: DataAnimType
240 ): PptxAnimationPreset | undefined => PPTX_ANIMATION_PRESETS[type]
241
242 export const resolveTraceMotion = (from: DataAnimFrom | undefined): Exclude<PptxMotion, 'fromTrace'> => {
243 switch (from) {
244 case 'left':
245 return 'fromLeft'
246 case 'right':
247 return 'fromRight'
248 case 'top':
249 return 'fromTop'
250 case 'bottom':
251 case 'center':
252 default:
253 return 'fromBottom'
254 }
255 }
256
257 export const wipeFilterForFrom = (from: DataAnimFrom | undefined): string => {
258 switch (from) {
259 case 'right':
260 return 'wipe(l)'
261 case 'top':
262 return 'wipe(d)'
263 case 'bottom':
264 return 'wipe(u)'
265 case 'left':
266 case 'center':
267 default:
268 return 'wipe(r)'
269 }
270 }
271
272 export const mapPptxPresetToDataAnimType = (args: {
273 presetId?: string
274 presetSubtype?: string
275 presetClass?: string
276 hasScale: boolean
277 hasRotation?: boolean
278 scaleFrom?: number
279 scaleTo?: number
280 effectFilter?: string
281 motionXFrom?: string
282 motionXTo?: string
283 motionYFrom?: string
284 motionYTo?: string
285 }): DataAnimType => {
286 const hasLinearMotionDelta =
287 (args.motionXFrom !== undefined &&
288 args.motionXTo !== undefined &&
289 args.motionXFrom !== args.motionXTo) ||
290 (args.motionYFrom !== undefined &&
291 args.motionYTo !== undefined &&
292 args.motionYFrom !== args.motionYTo)
293 const simpleEntranceDeltaX = parseNumericMotionDelta(args.motionXFrom, args.motionXTo, 'x')
294 const simpleEntranceDeltaY = parseNumericMotionDelta(args.motionYFrom, args.motionYTo, 'y')
295 if (args.presetClass === 'exit') {
296 if (args.effectFilter?.startsWith('wipe') || args.presetId === '5') return 'exit-wipe'
297 if (args.hasScale) {
298 if (args.scaleFrom !== undefined && args.scaleTo !== undefined) {
299 // exit-scale/exit-zoom: projection-based bucketing by nearest scale values
300 // External PPTX with scaleTo=80000 will be arbitrarily mapped to either
301 // exit-scale (85000) or exit-zoom (75000). These labels are approximations,
302 // not identity-preserving round-trip values.
303 return EXIT_SCALE_PRESETS.reduce(
304 (best, preset) => {
305 const distance =
306 Math.abs(preset.scaleFrom - args.scaleFrom!) + Math.abs(preset.scaleTo - args.scaleTo!)
307 return distance < best.distance ? { type: preset.type, distance } : best
308 },
309 { type: 'exit-scale' as DataAnimType, distance: Number.POSITIVE_INFINITY }
310 ).type
311 }
312 return 'exit-scale'
313 }
314 if (args.presetId === '2') return 'exit-fly'
315 return 'exit-fade'
316 }
317 if (args.presetClass === 'emph' && args.hasScale) {
318 if (args.scaleFrom === undefined || args.scaleTo === undefined) return 'pulse'
319 return EMPHASIS_PRESETS.reduce(
320 (best, preset) => {
321 const distance =
322 Math.abs(preset.scaleFrom - args.scaleFrom!) + Math.abs(preset.scaleTo - args.scaleTo!)
323 return distance < best.distance ? { type: preset.type, distance } : best
324 },
325 { type: 'pulse' as DataAnimType, distance: Number.POSITIVE_INFINITY }
326 ).type
327 }
328 if (args.effectFilter?.startsWith('wipe') || args.presetId === '5') return 'wipe'
329 if (args.hasScale) {
330 if (args.hasRotation) return 'spin-in'
331 if (args.scaleFrom !== undefined && args.scaleTo !== undefined) {
332 return ENTRANCE_SCALE_PRESETS.reduce(
333 (best, preset) => {
334 const distance =
335 Math.abs(preset.scaleFrom - args.scaleFrom!) + Math.abs(preset.scaleTo - args.scaleTo!)
336 return distance < best.distance ? { type: preset.type, distance } : best
337 },
338 { type: 'scale-in' as DataAnimType, distance: Number.POSITIVE_INFINITY }
339 ).type
340 }
341 return 'scale-in'
342 }
343 // presetID 10: Fade vs Path projection is heuristic-based and not universally safe
344 // hasLinearMotionDelta only checks if motion channels differ, which may incorrectly
345 // classify Fade with minor position drift as 'path'. This mapping is best-effort
346 // and should be validated with representative third-party PPTX samples before
347 // claiming safe import of arbitrary external presentations.
348 if (args.presetId === '10') return hasLinearMotionDelta ? 'path' : 'fade'
349 if (args.presetId === '2') {
350 if (
351 !args.presetSubtype &&
352 (args.motionXFrom !== undefined ||
353 args.motionXTo !== undefined ||
354 args.motionYFrom !== undefined ||
355 args.motionYTo !== undefined)
356 ) {
357 return 'fly-in'
358 }
359 switch (args.presetSubtype) {
360 case '1':
361 return Math.abs(simpleEntranceDeltaY || 0) >= SIMPLE_ENTRANCE_DELTA_THRESHOLD
362 ? 'slide-down'
363 : 'fade-down'
364 case '2':
365 return Math.abs(simpleEntranceDeltaX || 0) >= SIMPLE_ENTRANCE_DELTA_THRESHOLD
366 ? 'slide-right'
367 : 'fade-right'
368 case '3':
369 case '4':
370 return Math.abs(simpleEntranceDeltaX || 0) >= SIMPLE_ENTRANCE_DELTA_THRESHOLD
371 ? 'slide-left'
372 : 'fade-left'
373 case '8':
374 return Math.abs(simpleEntranceDeltaY || 0) >= SIMPLE_ENTRANCE_DELTA_THRESHOLD
375 ? 'slide-up'
376 : 'fade-up'
377 default:
378 return 'fade-up'
379 }
380 }
381 return 'fade'
382 }
383
384 export const mapPptxPresetToDataAnimFrom = (args: {
385 presetId?: string
386 presetClass?: string
387 presetSubtype?: string
388 effectFilter?: string
389 motionXFrom?: string
390 motionXTo?: string
391 motionYFrom?: string
392 motionYTo?: string
393 }): DataAnimFrom | undefined => {
394 const inferMotionDirection = (): DataAnimFrom | undefined => {
395 const xAway = args.presetClass === 'exit' ? args.motionXTo : args.motionXFrom
396 const yAway = args.presetClass === 'exit' ? args.motionYTo : args.motionYFrom
397 if (xAway?.includes('#ppt_x-#ppt_w/2')) return 'left'
398 if (xAway?.includes('#ppt_x+#ppt_w/2')) return 'right'
399 if (yAway?.includes('#ppt_y-#ppt_h/2')) return 'top'
400 if (yAway?.includes('#ppt_y+#ppt_h/2')) return 'bottom'
401 return undefined
402 }
403 if (args.effectFilter?.startsWith('wipe')) {
404 if (args.effectFilter.includes('(l)') || args.effectFilter.includes('(left)')) return 'right'
405 if (args.effectFilter.includes('(r)') || args.effectFilter.includes('(right)')) return 'left'
406 if (args.effectFilter.includes('(u)') || args.effectFilter.includes('(up)')) return 'bottom'
407 if (args.effectFilter.includes('(d)') || args.effectFilter.includes('(down)')) return 'top'
408 }
409 const motionDirection = inferMotionDirection()
410 if (motionDirection) return motionDirection
411 if (args.presetId === '5' && args.presetSubtype) {
412 switch (args.presetSubtype) {
413 case '1':
414 return 'left'
415 case '2':
416 return 'right'
417 case '3':
418 return 'bottom'
419 case '4':
420 return 'top'
421 default:
422 return undefined
423 }
424 }
425 switch (args.presetSubtype) {
426 case '1':
427 return 'top'
428 case '2':
429 return 'left'
430 case '3':
431 case '4':
432 return 'right'
433 case '8':
434 return 'bottom'
435 default:
436 return undefined
437 }
438 }
439
439 lines TYPESCRIPT