Appearance
Animations & Transitions
Status
Phase 1 implemented (2026-06-26). Transitions are a full typed model both ways; animations are opaque spid-aware preservation on read plus preset-template authoring on write. The write emitters reproduce the PowerPoint-authored oracles byte-for-byte. The caveat this closed came in as gitbrent/PptxGenJS#1431; see CHANGELOG.md for the landed surface. Code: src/read/api/transition.ts, src/read/api/animation.ts, src/read/api/slide.ts (read accessors); src/types/index.ts, src/slide.ts, src/gen/anim/ (write). Tests: test/read/animations-transitions.test.js, test/regression/slide-content/animations-transitions.test.js, and two test/schema-cases.js validator fixtures.
This doc records the agreed read/write scope so the fixtures + oracles could be authored against a fixed target. Per AGENTS.md ("OOXML And PowerPoint Work" → fixture-gated work), implementation was blocked on the PowerPoint-authored fixtures + oracles enumerated in §7; those are the precondition, not synthetic XML.
Authorability was proven 2026-06-26: desktop PowerPoint COM bakes both p:transition and p:timing into slide XML non-interactively (probe test/read/fixtures/authoring/author-anim-probe.ps1). See the memory animations-transitions-fixture-authorability.
The §7 fixture gate was satisfied (2026-06-26) ahead of implementation. All three decks + oracles are authored, verified clean via COM, and documented in test/read/fixtures/README.md:
slide-transition.pptx/.oracle.json: fade/push(dir)/wipe(dir)/cut/dissolve across fast/med/slow buckets, bare vsmc:AlternateContent(p14:dur) forms,advClickvsadvTm; the oracle embeds the full 158-row probedPpEntryEffect → {element, ns, variant, modernOnly}table (the write-side preset table: 159 valid ints incl.0; e.g.1537 → dissolve, and 95 modernp14/p15/p159-only effects with a<p:fade/>fallback).slide-animation-basic.pptx/.oracle.json: one entrance Fade-on-click; pins thep:timing/p:bldLsttree, the(presetID 10, entr, subtype 0)triple, andspid 2.slide-animation-rich.pptx/.oracle.json: four shapes spanningentr/emph/exit×clickEffect/afterEffect/withEffect(emphasis Grow/ShrinkpresetID 6via probedMsoAnimEffect=59); the source for the write-side preset templates and the spid enumerate/remap/prune tests.
Decisions (locked)
Two constructs, two subsystems. The constructs differ in one decisive way: a transition is self-contained; an animation references shapes by spid (<p:spTgt spid="N"/> and <p:bldP spid="N"/>). That coupling is the crux of the whole design.
| Read / round-trip | Write / authoring | |
|---|---|---|
| Transition | Full typed model (slide.transition) | Full typed model (slide transition prop) |
| Animation | Opaque, spid-aware preservation | Preset-template effects (fixed set) |
- Direction: both preserve (read→write keeps these intact, including across the import paths) and author (a from-scratch API adds them to generated slides).
- Transitions get a full typed model both ways: the schema is small and bounded (§3), so semantic modeling is cheap and high-value.
- Animations are modeled opaquely. The
p:timingtree isCT_TimeNodeList: dozens of node types, deeply recursive; even two trivial effects produce ~90 lines (§4). We do not build a semantic AST. On read we preserve the DOM and track only thespidreferences; on write we emit known-good templates captured from PowerPoint for a fixed preset set. Authoring without a semantic builder is only possible via preset templates: that is the agreed authoring mechanism, consistent with the opaque internal model.
Architecture context (why the two sides differ)
- Read (
src/read/) is a lazy-parse, DOM-preserving model.Partkeeps its original bytes andserialize()returns them byte-identically unlessmarkDirty()was called (src/read/opc/part.ts). Typed accessors parse on demand over the live DOM; edits go throughgetOrAddChild/setAttr+markDirty()(pattern:Shape.resolvedFill,TextFrame.text). Consequence: an unmodified slide already round-trips itsp:transition/p:timingbyte-identically for free. The work is (a) a typed transition accessor and (b) keepingspidreferences coherent when ids change. - Write (
src/gen/anim/) is string concatenation from the object model.makeXmlSlideemitsspTree → clrMapOvr → timing; ap:timingbuilder already exists for media looping (slideTimingToXml, ~L2310) and is the exact structural template for animation emit. Shape ids are deterministic (idx + 2), sospidtargeting from the authoring API is tractable.
OOXML target
Transition (CT_SlideTransition, ECMA-376 + p14 extension)
Position in CT_Slide: cSld → clrMapOvr → **transition** → timing → extLst. So p:transition is emitted between p:clrMapOvr and p:timing: today makeXmlSlide puts nothing there.
Base ECMA-376 p:transition:
- Attributes:
spd(ST_TransitionSpeed=slow|med|fast, defaultfast),advClick(bool, defaulttrue),advTm(unsignedInt, ms, advance after time). - Child: exactly one transition-type element (choice of 21:
blinds,checker,circle,dissolve,comb,cover,cut,diamond,fade,newsflash,plus,pull,push,random,randomBar,split,strips,wedge,wheel,zoom), then optionalsndAc(sound), thenextLst. Each type element carries its own variant attrs (e.g.<p:push dir="u"/>,<p:wipe dir="d"/>).
Exact duration is not in base ECMA: spd is only a coarse bucket. PowerPoint 2010+ writes the precise milliseconds as p14:dur inside an mc:AlternateContent wrapper, and emits a base-only mc:Fallback. Probe output:
xml
<mc:AlternateContent xmlns:mc="…/markup-compatibility/2006">
<mc:Choice xmlns:p14="…/office/powerpoint/2010/main" Requires="p14">
<p:transition spd="slow" p14:dur="1500"><p:dissolve/></p:transition>
</mc:Choice>
<mc:Fallback>
<p:transition spd="slow"><p:dissolve/></p:transition>
</mc:Fallback>
</mc:AlternateContent>Newer transition types (Morph, etc.) live entirely in p14/p15 and only appear inside mc:Choice. Write target: match PowerPoint, emit the mc:AlternateContent form when a precise duration is requested (Choice with p14:dur + base Fallback); a plain p:transition is acceptable when only a speed bucket is given. Read target: handle both the bare p:transition and the mc:AlternateContent-wrapped form, preferring the p14 Choice for duration.
spd ↔ duration mapping and the PpEntryEffect-int ↔ transition-element mapping are PowerPoint specifics, not in the XSD. There are 159 valid PpEntryEffect ints (probed); the family<<8 intuition is wrong (1537 → dissolve, not fade). A required build artifact is a probed PpEntryEffect → {element, variant attrs, p14 variant?} table (author once by iterating accepted ints and dumping XML); this becomes the write-side preset table and is captured in the transition fixture's oracle.
Animation (p:timing / p:bldLst): opaque
Position: last child before extLst. Structure (from the probe): timing → tnLst → par → cTn(nodeType="tmRoot") → childTnLst → seq(mainSeq) → … with one nested par/cTn per effect carrying presetID / presetClass (e.g. entr) / presetSubtype / nodeType (clickEffect|afterEffect|withEffect) and cBhvr/tgtEl/spTgt @spid. A sibling <p:bldLst> holds one <p:bldP spid= grpId=> per animated shape.
We treat this subtree as opaque XML. The only structured data we extract is the set of referenced spids (the "spid-aware" contract, §5). The known preset effects we author (write side) are stored as verbatim templates keyed by (presetID, presetClass, presetSubtype, nodeType), parameterized only by spid, delay, and dur.
Read model design
slide.transition: typed accessor (src/read/api/slide.ts):- getter: parse
p:transition(bare ormc:AlternateContent) →{ type, speed, durationMs?, advanceOnClick, advanceAfterMs?, variant? }ornull. - setter: write/replace via DOM helpers +
markDirty(), emitting themc:AlternateContentform whendurationMsis set, inserted at the schema-correct slot (beforep:timing).
- getter: parse
- Animation preservation: no semantic accessor. Unmodified slides keep the
p:timing/p:bldLstDOM untouched (free byte-identical round-trip). Add an internal spid index helper that, given the slide DOM, enumerates everyp:spTgt/@spidandp:bldP/@spid. Expose minimally:slide.hasAnimations(bool) and an internalanimationSpids()for the import paths. The raw tree is never reconstructed from a model: it is carried as-is or pruned in place.
Write model design
- Transition: slide-level
transition?: TransitionPropsonSlide(src/types/index.ts); aslideTransitionToXml(slide)emitter (src/gen/anim/transition.ts) inserted inmakeXmlSlidebetweenp:clrMapOvrandslideTimingToXml(...). Requires declaringxmlns:mcon the slide root (or locally) for the AlternateContent form. Reuse the probed preset table for element + variant. - Animation (preset templates): authoring API targets shapes by their generated id (
idx + 2). For each requested effect emit the verbatim template (entrance/exit/emphasis from the fixed set) withspid/delay/durfilled in, assembling them under onemainSeqand appending matching<p:bldP>entries. This extendsslideTimingToXmlso a slide can carry both looping media and build animations in the single allowedp:timing. The supported preset set == the set we author fixtures + oracles for; adding a preset later means adding a fixture + template, not a new code path.
spid-awareness (the crux)
Animations dangle if a referenced spid disappears or is renumbered: PowerPoint then shows a repair prompt. The opaque model still must keep references coherent. Three operations, all purely structural (no semantic parse):
- enumerate: collect
spids fromp:spTgtandp:bldP(read side helper). - remap: given an
oldSpid → newSpidmap, rewrite all matching@spid. Needed by any id-reassigning op. - prune: when a shape is removed, drop its
p:bldPand the effect nodes whosespTgttargets it, so no dangling reference survives. - flatten: the whole-slide counterpart to prune:
slide.flattenAnimations()removes the entire<p:timing>block, flattening the slide to its final static state (every shape shown at once). Gated onhasAnimations, so a purely media-loop timing (nop:bldP/presetID) is preserved. Removes staging only; it never deletes shapes (flattenAnimationsinsrc/read/api/animation.ts).
Interaction with import paths (src/read/api/presentation.ts)
importSlidecopy mode: whole slide part copied byte-identically; transition + timing already survive. No change needed.importSlidepreserve/restyle modes: rebind to destination master; shape ids are not generally renumbered, so timing usually survives, but this must be verified by fixture and remap applied if ids do change.importShape: lifts a singlep:spand reassigns its id; slide-scoped timing is not part of the shape subtree. Default behaviour drops the build (a lifted shape lands static). Phase 2 (implemented): the opt-in{ carryAnimation: true }carries that shape's effect click-group(s) +p:bldPinto the destination timing with an spid remap and ap:cTn-id renumber, never leaving a dangling reference on either side (carryShapeAnimations).
Public API surface (shipped)
ts
// Read (src/read): typed transition; animation stays opaque.
slide.transition // -> TransitionInfo | null (get/set)
slide.hasAnimations // -> boolean
slide.flattenAnimations() // -> boolean (strip <p:timing>; flatten to final state)
// Write (core): slide-level transition + preset animations.
interface TransitionProps {
type: TransitionType // 'fade' | 'push' | 'wipe' | 'cut' | 'dissolve' | …
durationMs?: number // exact ms (emits p14:dur + AlternateContent)
speed?: 'slow' | 'med' | 'fast'
advanceOnClick?: boolean // default true
advanceAfterMs?: number // advTm (auto-advance)
variant?: Record<string, string> // e.g. { dir: 'u' } for push/wipe
}
pptx.addSlide({ /* … */ }).transition = { type: 'fade', durationMs: 1500 }
// Preset build animations target shapes added to the slide.
interface AnimationProps {
preset: PresetEffect // fixed set, e.g. 'fadeIn' | 'flyIn' | 'appear' | 'fadeOut'
trigger?: 'onClick' | 'withPrevious' | 'afterPrevious'
delayMs?: number
durationMs?: number
}Simplified for readability: AnimationProps also carries shapeIndex/objectName (target-shape selection) and other fields; TransitionProps/AnimationProps in src/types/index.ts are the authoritative definitions.
Fixtures + oracles (the gate)
Author with desktop PowerPoint COM (skill powerpoint-fixture-authoring; clear Resiliency\DocumentRecovery first). Each .pptx pairs with an oracle JSON (verbatim XML + extracted fields), like embedded-fonts.oracle.json.
slide-transition.pptx: several slides, distinct transitions (at leastfade,push(withdir),wipe,cut,dissolve) covering: speed buckets, exactp14:dur, advance-on-click vsadvTmtimed. Oracle pins each slide's full transition XML (Choice + Fallback) and the decoded fields, plus the probedPpEntryEffect → elementtable used by the write side.slide-animation-basic.pptx: one slide, single entrance (fade-on-click). Oracle pins thep:timingtree +p:bldLst+ the referencedspidand the(presetID, presetClass, presetSubtype)triple.slide-animation-rich.pptx: multiple shapes/effects/triggers (click + after-previous + with-previous; an exit and an emphasis). Exercises spid enumerate/remap/prune and is the source of the write-side preset templates.
Update test/read/fixtures/README.md (provenance, hash, purpose, PowerPoint check date). Until a fixture exists, it is the blocking precondition: see Evidence and fixtures.
Out of scope / phasing
- No semantic animation AST. No general
p:timingbuilder, no editing of arbitrary effects, no animation paths/triggers beyond the preset set. - Phase 1: transitions (full, both ways); animation opaque preserve + spid enumerate/remap/prune; preset-template authoring for the fixtured effect set.
- Phase 2: all three capabilities implemented (2026-06-26). Capabilities: carry a shape's build animation through
importShape(remap into destination timing) [A, done]; expand the preset set [B, done]; transition sounds (sndAc, which pulls in audio rels) [C, done]. The three PowerPoint-authored oracle decks that gate this work are authored + verified clean (see PHASE2-FIXTURE-PLAN.mdandtest/read/fixtures/README.md):- [B, implemented]
slide-animation-presets.pptx(+presetTemplatesoracle): verbatim write-side templates for the new presetsappear/wipe/spin/flyOut(and a byte-for-byte reconfirmation offadeIn/flyIn/grow/fadeOut). ThePresetEffecttype andANIM_PRESETS(src/gen/anim/animation.ts) now carry all eight; the regression case "emits every preset … byte-for-byte" pins the full timing tree against the oracle and atest/schema-cases.jsfixture validates the new templates. Note: opacity-based emphasis "Pulse" is not reachable via COMAddEffect, so the emphasis pair is grow + spin. - [C, implemented]
slide-transition-sound.pptx(+soundRelsoracle):p:sndAc/p:stSnd/p:sndembedded start sound, looped (stSnd loop="1"), and stop-previous (p:endSnd); the ECMAaudiorel →ppt/media/*.wavpart +wav=audio/x-wavDefault. Built-in sounds embed identically to a custom import (recorded, not committed: license-clean). Write:slide.transition.sound(TransitionSoundProps,data/path/name/loop, orstopPrevious) emits thesndAc; an export-time pass (registerTransitionSounds,src/presentation.ts) registers the audio rel + media part and the cross-deck media dedup collapses identical sound bytes to one part. Read:slide.transition.sounddecodes thesndAcinto aTransitionSoundInfo. The rId is ts-pptx's own (not PowerPoint'srId2), so the regression compares thesndAcrId-normalized.avContentType('wav')was corrected toaudio/x-wav. - [A, implemented]
import-animation-merge.pptx(+mergeMaporacle): PowerPoint's ground truth for cross-slide copy: the pasted shape takes the next free spid, the carried build'sspTgt/bldPare renumbered onto it and appended after the host's build.importShape/importShapesgained an opt-in{ carryAnimation: true }:carryShapeAnimations(src/read/api/animation.ts) copies the lifted shape's mainSeq click-group(s) +<p:bldP>into the destinationp:timing(built from scratch when the host has none), remapping spids to the shape's new id and renumbering<p:cTn>ids to stay collision-free. The destination timing is ts-pptx's own construction, so the test asserts themergeMapsemantics (new spid, appended-after-host, no dangling refs, schema-valid) rather than byte-for-byte.
- [B, implemented]
- Open questions: (a) preset animations as a per-shape option vs a slide-level ordered list (ordering/sequence semantics favor a slide-level list); (b) whether to always emit the
mc:AlternateContentform or only whendurationMsis set; (c) exactspd↔durationMsbucketing when both are given.