返回 oh-my-ppt
animation-import.ts
根目录 / src / main / io / pptx-import / animation-import.ts
1 import { unzipSync } from 'fflate'
2 import * as cheerio from 'cheerio'
3 import type { DataAnimFrom, DataAnimPptxTrigger, DataAnimType } from '../../animation/data-anim-schema'
4 import {
5 mapPptxPresetToDataAnimFrom,
6 mapPptxPresetToDataAnimType
7 } from '../../animation/pptx-animation-map'
8
9 export type ImportedAnimationType = DataAnimType
10 export type ImportedAnimationTrigger = DataAnimPptxTrigger
11 export type ImportedAnimationFrom = DataAnimFrom
12
13 export type ImportedElementAnimation = {
14 id: number
15 type: ImportedAnimationType
16 trigger: ImportedAnimationTrigger
17 clickGroup?: string
18 from?: ImportedAnimationFrom
19 path?: string
20 duration: number
21 delay: number
22 sourceId: string
23 sourceName?: string
24 x?: number
25 y?: number
26 w?: number
27 h?: number
28 }
29
30 export type SlideAnimationPlan = {
31 animations: ImportedElementAnimation[]
32 byName: Map<string, ImportedElementAnimation[]>
33 }
34
35 type ParsedSlideShapeTarget = {
36 spid: string
37 name?: string
38 x?: number
39 y?: number
40 w?: number
41 h?: number
42 }
43
44 export const normalizePptxShapeName = (value: unknown): string =>
45 String(value || '').replace(/\s+/g, ' ').trim()
46
47 const clampMs = (value: unknown, fallback: number): number => {
48 const n = Number(value)
49 return Math.round(Math.max(100, Math.min(5000, Number.isFinite(n) ? n : fallback)))
50 }
51
52 const parseNumericDelay = (value: string | undefined): number => {
53 if (!value || value === 'indefinite') return 0
54 const n = Number(value)
55 return Number.isFinite(n) ? Math.max(0, Math.min(30000, Math.round(n))) : 0
56 }
57
58 const readXmlAttrNumber = (value: string | undefined): number | undefined => {
59 const n = Number(value)
60 return Number.isFinite(n) ? n : undefined
61 }
62
63 const readMotionChannelValue = (
64 $: cheerio.CheerioAPI,
65 ctn: cheerio.Cheerio<any>,
66 attrName: 'ppt_x' | 'ppt_y',
67 tm: '0' | '100000'
68 ): string | undefined => {
69 const motionNode = ctn
70 .find('p\\:anim')
71 .filter((_, node) => {
72 const el = $(node)
73 return el.find('p\\:attrName').first().text() === attrName
74 })
75 .first()
76 if (!motionNode.length) return undefined
77 return (
78 motionNode
79 .find(`p\\:tav[tm="${tm}"] p\\:strVal`)
80 .first()
81 .attr('val') || undefined
82 )
83 }
84
85 const parseDeltaFromMotionExpression = (
86 value: string | undefined,
87 axis: 'x' | 'y'
88 ): number | undefined => {
89 const raw = String(value || '').trim()
90 if (!raw) return undefined
91 const prefix = axis === 'x' ? '#ppt_x' : '#ppt_y'
92 if (raw === prefix) return 0
93 const match = raw.match(new RegExp(`^${prefix}([+-]\\d+(?:\\.\\d+)?)$`))
94 if (!match) return undefined
95 const delta = Number(match[1])
96 return Number.isFinite(delta) ? delta : undefined
97 }
98
99 const buildLinearPathFromMotion = (args: {
100 motionXFrom?: string
101 motionXTo?: string
102 motionYFrom?: string
103 motionYTo?: string
104 }): string | undefined => {
105 if (args.motionXFrom !== '#ppt_x' || args.motionYFrom !== '#ppt_y') return undefined
106 const deltaX = parseDeltaFromMotionExpression(args.motionXTo, 'x')
107 const deltaY = parseDeltaFromMotionExpression(args.motionYTo, 'y')
108 if (deltaX === undefined || deltaY === undefined) return undefined
109 return `M 0 0 L ${deltaX} ${deltaY}`
110 }
111
112 const readSlideEmuSize = (
113 files: Record<string, Uint8Array>
114 ): { cx: number; cy: number } | null => {
115 const presentation = files['ppt/presentation.xml']
116 if (!presentation) return null
117 const $ = cheerio.load(Buffer.from(presentation).toString('utf-8'), { xmlMode: true })
118 const slideSize = $('p\\:sldSz').first()
119 const cx = readXmlAttrNumber(slideSize.attr('cx'))
120 const cy = readXmlAttrNumber(slideSize.attr('cy'))
121 return cx && cy ? { cx, cy } : null
122 }
123
124 const collectSlideShapeTargets = (
125 $: cheerio.CheerioAPI,
126 slideEmuSize: { cx: number; cy: number } | null,
127 slideSize: { width: number; height: number }
128 ): Map<string, ParsedSlideShapeTarget> => {
129 const targets = new Map<string, ParsedSlideShapeTarget>()
130 $('p\\:cNvPr').each((_, node) => {
131 const item = $(node)
132 const spid = item.attr('id')
133 if (!spid || spid === '1') return
134 const name = normalizePptxShapeName(item.attr('name'))
135 const container = item.closest('p\\:sp,p\\:pic,p\\:graphicFrame,p\\:grpSp,p\\:cxnSp')
136 const xfrm = container.find('a\\:xfrm').first()
137 const off = xfrm.find('a\\:off').first()
138 const ext = xfrm.find('a\\:ext').first()
139 const xEmu = readXmlAttrNumber(off.attr('x'))
140 const yEmu = readXmlAttrNumber(off.attr('y'))
141 const wEmu = readXmlAttrNumber(ext.attr('cx'))
142 const hEmu = readXmlAttrNumber(ext.attr('cy'))
143 const box =
144 slideEmuSize && xEmu !== undefined && yEmu !== undefined && wEmu !== undefined && hEmu !== undefined
145 ? {
146 x: (xEmu / slideEmuSize.cx) * slideSize.width,
147 y: (yEmu / slideEmuSize.cy) * slideSize.height,
148 w: (wEmu / slideEmuSize.cx) * slideSize.width,
149 h: (hEmu / slideEmuSize.cy) * slideSize.height
150 }
151 : {}
152 targets.set(spid, {
153 spid,
154 name: name || undefined,
155 ...box
156 })
157 })
158 return targets
159 }
160
161 export const parsePptxSlideAnimationPlan = (
162 slideXml: string,
163 slideEmuSize: { cx: number; cy: number } | null,
164 slideSize: { width: number; height: number }
165 ): SlideAnimationPlan => {
166 const $ = cheerio.load(slideXml, { xmlMode: true })
167 const targets = collectSlideShapeTargets($, slideEmuSize, slideSize)
168 const animations: ImportedElementAnimation[] = []
169 let id = 0
170
171 // Collect grpId values from clickEffect nodes to validate withEffect grouping.
172 // External PPTX files may assign grpId to withEffect for unrelated reasons,
173 // so we only promote withEffect→click when the same grpId appears on a
174 // clickEffect sibling in the same slide.
175 const clickGrpIds = new Set<string>()
176 $('[nodeType="clickEffect"][grpId]').each((_, node) => {
177 const gid = $(node).attr('grpId')
178 if (gid && gid !== '0') clickGrpIds.add(gid)
179 })
180
181 $('[presetID]').each((_, node) => {
182 const ctn = $(node)
183 const nodeType = ctn.attr('nodeType')
184 const grpId = ctn.attr('grpId')
185 const presetId = ctn.attr('presetID')
186 const presetSubtype = ctn.attr('presetSubtype')
187 const presetClass = ctn.attr('presetClass')
188 const effectFilter = ctn.find('p\\:animEffect').first().attr('filter')
189 const scaleNode = ctn.find('p\\:animScale').first()
190 const rotationNode = ctn.find('p\\:animRot').first()
191 const scaleFrom = readXmlAttrNumber(scaleNode.find('p\\:from').first().attr('x'))
192 const scaleTo = readXmlAttrNumber(scaleNode.find('p\\:to').first().attr('x'))
193 const motionXFrom = readMotionChannelValue($, ctn, 'ppt_x', '0')
194 const motionXTo = readMotionChannelValue($, ctn, 'ppt_x', '100000')
195 const motionYFrom = readMotionChannelValue($, ctn, 'ppt_y', '0')
196 const motionYTo = readMotionChannelValue($, ctn, 'ppt_y', '100000')
197 const type = mapPptxPresetToDataAnimType({
198 presetId,
199 presetSubtype,
200 presetClass,
201 hasScale: scaleNode.length > 0,
202 hasRotation: rotationNode.length > 0,
203 scaleFrom,
204 scaleTo,
205 effectFilter,
206 motionXFrom,
207 motionXTo,
208 motionYFrom,
209 motionYTo
210 })
211 const from = mapPptxPresetToDataAnimFrom({
212 presetId,
213 presetSubtype,
214 presetClass,
215 effectFilter,
216 motionXFrom,
217 motionXTo,
218 motionYFrom,
219 motionYTo
220 })
221 const trigger: ImportedAnimationTrigger =
222 nodeType === 'clickEffect' ||
223 (nodeType === 'withEffect' && grpId && grpId !== '0' && clickGrpIds.has(grpId))
224 ? 'click'
225 : 'load'
226 const delay = parseNumericDelay(
227 ctn.children('p\\:stCondLst').find('p\\:cond').first().attr('delay')
228 )
229 const allDurs = ctn
230 .find('p\\:cTn[dur]')
231 .map((__, child) => Number($(child).attr('dur')))
232 .get()
233 .filter((value) => Number.isFinite(value) && value > 1)
234 // Emphasis animations export two half-duration phases (rebound).
235 // Sum all dur values to recover the total duration for roundtrip fidelity.
236 const isEmphasis = presetClass === 'emph'
237 const duration = isEmphasis
238 ? allDurs.reduce((sum, d) => sum + d, 0) || 500
239 : allDurs[0] ?? 500
240 const spids = [
241 ...new Set(
242 ctn
243 .find('p\\:spTgt')
244 .map((__, target) => $(target).attr('spid'))
245 .get()
246 .filter(Boolean)
247 )
248 ]
249 for (const spid of spids) {
250 const target = targets.get(spid)
251 id += 1
252 const animation: ImportedElementAnimation = {
253 id,
254 type,
255 trigger,
256 from,
257 path:
258 type === 'path'
259 ? buildLinearPathFromMotion({ motionXFrom, motionXTo, motionYFrom, motionYTo })
260 : undefined,
261 duration: clampMs(duration, 500),
262 delay,
263 sourceId: spid,
264 sourceName: target?.name,
265 x: target?.x,
266 y: target?.y,
267 w: target?.w,
268 h: target?.h
269 }
270 if (trigger === 'click' && grpId && grpId !== '0') {
271 animation.clickGroup = grpId
272 }
273 animations.push(animation)
274 }
275 })
276
277 const byName = new Map<string, ImportedElementAnimation[]>()
278 for (const animation of animations) {
279 const name = normalizePptxShapeName(animation.sourceName)
280 if (!name) continue
281 const list = byName.get(name) || []
282 list.push(animation)
283 byName.set(name, list)
284 }
285 return { animations, byName }
286 }
287
288 export const readPptxAnimationPlans = (
289 buffer: Buffer,
290 slideCountOrIndexes: number | number[],
291 slideSize: { width: number; height: number }
292 ): SlideAnimationPlan[] => {
293 const slideIndexes = Array.isArray(slideCountOrIndexes)
294 ? slideCountOrIndexes
295 : Array.from({ length: slideCountOrIndexes }, (_, index) => index)
296 try {
297 const files = unzipSync(new Uint8Array(buffer))
298 const slideEmuSize = readSlideEmuSize(files)
299 return slideIndexes.map((slideIndex) => {
300 const slideXml = files[`ppt/slides/slide${slideIndex + 1}.xml`]
301 if (!slideXml) return { animations: [], byName: new Map() }
302 return parsePptxSlideAnimationPlan(
303 Buffer.from(slideXml).toString('utf-8'),
304 slideEmuSize,
305 slideSize
306 )
307 })
308 } catch {
309 return slideIndexes.map(() => ({ animations: [], byName: new Map() }))
310 }
311 }
312
312 lines TYPESCRIPT