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