Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot
2026-07-28 00:02:47 +08:00
parent a202b5a179
commit 11ba867f79
49 changed files with 10978 additions and 1101 deletions
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-07-27T03:14:35Z"
"syncedAt": "2026-07-27T16:02:46Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "3f2cf7e1a024ab0b5a0ee16e02f0a258173947ae",
"commit": "1f65c7646eb57660e4eb38899b8197346d0d93c1",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-07-27T03:11:20Z"
"syncedAt": "2026-07-27T16:00:00Z"
}
+2 -2
View File
@@ -100,7 +100,7 @@ Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring this
---
Drop in your source material, and what you get back isn't a static layout you can edit — it's **a complete deck with real PowerPoint behavior**: native slide transitions, opt-in entrance animations (off by default), speaker notes that can become audio narration and even video, charts and tables that can ship as real data-backed PowerPoint objects, and it can follow your own PPT template — present it as-is, and keep refining. How to use each capability → [Getting Started](./docs/getting-started.md).
Drop in your source material, and what you get back isn't a static layout you can edit — it's **a complete deck with real PowerPoint behavior**: native slide transitions, opt-in entrance / emphasis / motion-path / exit animations (off by default), speaker notes that can become audio narration and even video, charts and tables that can ship as real data-backed PowerPoint objects, and it can follow your own PPT template — present it as-is, and keep refining. How to use each capability → [Getting Started](./docs/getting-started.md).
## Product Positioning
@@ -315,7 +315,7 @@ AI: Sure. Let's confirm the design spec:
The AI handles everything — content analysis, visual design, SVG generation, and PPTX export.
> **Output:** The SVG pipeline has one PPTX export route: PPT Master's converter reads `svg_output/` and writes a directly editable native DrawingML deck to `exports/<name>_<timestamp>.pptx`. Step 7 still always runs `finalize_svg.py`, producing self-contained files in `svg_final/` for visual inspection or manual insertion as SVG pictures; PowerPoint's manual **Convert to Shape** command is outside the supported contract. A copy of `svg_output/` is always snapshotted to `backup/<timestamp>/svg_output/` for re-export / archival. By default charts and tables export as individually editable SVG-derived DrawingML shapes, which prioritize cross-app visual consistency. Pass `--native-charts-and-tables` to replace eligible groups with PowerPoint-native Chart/Table objects backed by data, which provide **Edit Data** and object-specific controls but may render differently across apps; this variant is saved as `exports/<name>_<timestamp>_native_charts_tables.pptx`. Both routes are editable—the distinction is the PowerPoint object model, not editability itself.
> **Output:** The SVG pipeline has one PPTX converter: it reads `svg_output/` and writes a directly editable native DrawingML deck to `exports/<name>_<timestamp>.pptx`. The normal delivery flow runs `finalize_svg.py`, produces self-contained previews in `svg_final/`, and snapshots `svg_output/` to `backup/<timestamp>/svg_output/`; PowerPoint's manual **Convert to Shape** command is outside the supported contract. Explicit disposable few-page tests may instead use [quick-test mode](./skills/ppt-master/workflows/profiles/quick-test.md), which writes only the authored SVGs and one PPTX—no planning, preview, notes, validation, or backup artifacts. By default charts and tables export as individually editable SVG-derived DrawingML shapes, which prioritize cross-app visual consistency. Pass `--native-charts-and-tables` to replace eligible groups with PowerPoint-native Chart/Table objects backed by data, which provide **Edit Data** and object-specific controls but may render differently across apps; this variant is saved as `exports/<name>_<timestamp>_native_charts_tables.pptx`. Both routes are editable—the distinction is the PowerPoint object model, not editability itself.
> **Already have a `.pptx` you want to reuse?** Hand the AI that deck plus your material and ask it to "fill this deck with the new content" — it fills text, table, and chart data into your existing design and exports only the pages you pick, staying natively editable. See the [FAQ](./docs/faq.md) and [template-fill workflow](./skills/ppt-master/workflows/template-fill-pptx.md).
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"commit": "de0af38a07706eee03b97f6186d8e0ffba595892",
"commit": "5260a14f8467e52d7fc945c5efead3e2090f0f86",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-07-27T03:11:20Z"
"syncedAt": "2026-07-27T16:00:00Z"
}
@@ -1,15 +1,44 @@
# Page Transitions & Per-Element Animations
Execution contract for generated-PPTX **page transitions** and **per-element
entrance animations**. This file owns defaults, sidecar semantics, anchor
object animations**. This file owns defaults, sidecar semantics, anchor
selection, validation, and package read-back.
## Capability Menu — Open Here
Motion here is several separate capabilities, not one dial. Two of them are
decided **upstream, while pages are still being authored** — read this menu
before the page plan is frozen, not only when a deck is already exported.
| What the deck needs | Reach for | Decided at |
|---|---|---|
| Reveal content in step with the narration | Per-element object animation — `-a auto` deck-wide, or an `animations.json` sidecar for specific order, effects, timing, and triggers | Post-processing; §2, §4, [`customize-animations`](../workflows/stages/customize-animations.md) |
| A continuous action — slide-in, flip, camera push-in, progressive reveal, camera pan | **Morph: author the action as two static pages plus `-t morph`.** There is no keyframe timeline anywhere in this pipeline; the difference between two ordinary editable slides *is* the animation | **Page authoring (Step 6)** — §3.1 |
| A static full-bleed page that should stop looking frozen | One slow `path_*` motion on the background group only, `with-previous`, 410 s | Post-processing; §4.1, one sidecar entry |
| Carousel, counting numerals, parallax depth, click-to-reveal flip card | Four recurring recipes assembled from the mechanisms above | §4.2 — the carousel and odometer both need paired pages |
| Kiosk or unattended playback | `--auto-advance <seconds>`, optionally with `-t none` | Export; §3 |
| Nothing should move | `-t none`, and leave per-element animation at its default `none` | Export; §1 |
**Hard rule — morph is an authoring decision, not an export flag**: `-t morph`
tweens only objects it can match across consecutive slides, and matching is by
object identity — same image filename, same group `id`, unchanged container
dimensions. A deck that reaches export without paired pages cannot gain morph
motion by adding the flag; it degrades silently to a cross-fade. Resolve this
while `svg_output/` is still being authored, or accept that the sequence stays
static.
**Reference — not a constraint**: per-element animation stays off by default
(§1). Auto-firing element builds on every page are an unsolicited "AI deck"
tell; each capability above earns its place per page, not per deck.
---
## 1. Defaults
| Layer | Default | Why |
|---|---|---|
| Page transition | CLI: `fade`, 0.4s | Calm baseline that suits most decks; the public Python builder retains its legacy 0.5s default |
| Per-element animation | **`none` (off)** | A page appears as a whole. Auto-firing element builds are an unsolicited "AI deck" tell, so element entrance is opt-in. Turn it on with `-a auto` (or another effect): effects map from group id (chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade); image-like ids (`hero` / `figure-` / `image` / `img-` / `kpi`) cycle a richer visual pool (zoom / dissolve / circle / box / diamond / wheel) so multiple images vary across the deck; unmatched ids cycle a small fade/wipe/fly/zoom pool |
| Per-element animation | **`none` (off)** | A page appears as a whole. Auto-firing element builds are an unsolicited "AI deck" tell, so object animation is opt-in. Turn on the content-aware canonical entrance policy with `-a auto`, or select one PowerPoint-native `entrance_*`, `emphasis_*`, `path_*`, or `exit_*` key explicitly |
To regenerate a deck with different settings, rerun `svg_to_pptx.py` against the same `svg_output/` — no need to rerun the LLM. `-s final` is reserved for diagnostic comparison and is not a supported release source. To turn per-element animation on for the whole deck, pass `-a auto`.
@@ -21,12 +50,9 @@ Per-element animation is off by default. To enable it deck-wide, pass `-a auto`
Run the [`customize-animations`](../workflows/stages/customize-animations.md) post-processing stage when the user asks to tune animation order, effects, timing, or object-level reveals.
**Hard rule — semantic anchors before sidecar**: for custom object-level
animation, do not scaffold or choreograph directly from the SVG's pre-existing
`<g>` list. First derive reveal units from page meaning and narration, audit
every page, and rewrite coarse or fragmented ordinary Slide-local groups
without changing visible output. Only the post-regroup top-level ids are valid
custom-animation anchors.
**Hard rule — semantic anchors before sidecar**: derive reveal units from page
meaning and narration, then regroup coarse/fragmented Slide-local content
without changing its appearance. Only post-regroup top-level ids are valid.
```bash
# Inspect the real anchors after the semantic regrouping pass
@@ -56,9 +82,10 @@ Single-slide sidecar excerpt (repeat the complete slide block for every SVG in `
"transition": { "effect": "fade", "duration": 0.4 },
"animation": { "effect": "auto", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" },
"groups": {
"title": { "effect": "fade", "order": 1 },
"chart": { "effect": "wipe", "order": 2, "duration": 0.6 },
"insight": { "effect": "fly", "order": 3, "delay": 0.2 }
"title": { "effect": "entrance_fade", "order": 1 },
"chart": { "effect": "entrance_wipe", "effect_options": { "direction": "left" }, "order": 2, "duration": 0.6 },
"details-button": { "effect": "none" },
"insight": { "effect": "entrance_fly", "effect_options": { "direction": "up_right" }, "order": 3, "delay": 0.2, "trigger_shape": "details-button" }
}
}
}
@@ -69,19 +96,53 @@ Rules:
- `slides` keys match SVG stems (`03_market.svg``03_market`).
- `groups` keys match top-level `<g id="...">` anchors.
- `effect: none` removes that group from the entrance sequence.
- `effect: none` removes that group from the object-animation sequence.
- `order` changes animation order only; it does not change slide layering.
- `delay` is seconds before that group starts in `after-previous` mode.
- `duration` overrides the per-group schedule duration. `appear` remains a 1ms visibility flip; its configured duration spaces the next `after-previous` row.
- `trigger_shape` is a group-only reference to another unique, triggerable
top-level group. It maps to PowerPoint **Trigger → On Click of**, makes only
that row interactive, and uses `delay` as `TriggerDelayTime`.
- `duration` overrides the per-group schedule duration. `entrance_appear`
remains a 1ms visibility flip, and instantaneous native emphasis presets
retain their PowerPoint-authored duration; the configured value still spaces
the next `after-previous` row.
- `effect_options` requires an explicit canonical `effect` in the same block
and accepts only parameters PowerPoint exposes for that effect:
| Option | Applies to |
|---|---|
| `direction` | Directional Fly/Crawl/Wipe/Peek/Strips/Split/Stretch/Zoom and related entrance/exit effects |
| `amount` | Wheel spokes (`1`, `2`, `3`, `4`, `8`), emphasis Spin degrees, or Transparency ratio |
| `color` | Color-capable emphasis effects; `#RRGGBB` or `theme:<scheme-color>` |
| `font_name`, `size` | Change Font and Grow/Shrink |
| `relative` | Motion paths (`true` = shape-relative, `false` = fixed slide path) |
- Any animation/group block may set `repeat_count` or `repeat_duration`
(mutually exclusive), `auto_reverse`, `rewind`, `accelerate`, `decelerate`,
`bounce_end`, `restart`, `after_effect`, and `sound`. Ratios are `0..1`;
`bounce_end` requires an interpolated behavior and cannot combine with
`decelerate`; `restart` is `always`, `when-not-active`, or `never`;
`after_effect` is `none`, `dim` (with `color`), `hide`, or
`hide-on-next-click`; `sound` is a project-relative or absolute `.m4a`,
`.mp3`, or `.wav` path.
- `Speed` and smooth start/end are not duplicate sidecar fields: they are
derived from `duration` and `accelerate`/`decelerate`.
- This is the complete parameter surface for the generated top-level-group
target model. PowerPoint paragraph/text-range build fields are intentionally
absent because grouped SVG content is not emitted as paragraph builds; media
play/pause/stop commands remain in the audio/video workflows.
- Run `python3 skills/ppt-master/scripts/pptx_animations.py --describe
<canonical_effect>` for that effect's exact option values and full parameter
contract.
- `--animation none` overrides the sidecar and disables all per-element animation.
- An explicit sidecar group may override the legacy chrome-name heuristic, but it cannot override `data-pptx-layer` or an explicit static role/placeholder marker.
- Unknown effects, modes, or triggers and invalid numeric/order fields fail validation; no fallback effect is substituted.
**Declared inheritance for omitted sidecar fields**:
- The whole `animations.json` artifact is optional. When absent, normal exporter CLI resolution applies.
- In any existing sparse sidecar, an omitted slide transition/animation property inherits the matching `defaults.transition` / `defaults.animation` property; when that defaults property is also absent, normal exporter CLI resolution applies. Explicit CLI overrides still win. Current authoring writes each slide's complete transition and animation blocks.
- A group override inherits `effect` and `duration` from its resolved slide animation; omitted `order` and `delay` use the exporter's sidecar resolution.
**Inheritance**: the sidecar is optional. Sparse legacy slides inherit
`defaults.transition` / `defaults.animation`, then CLI resolution; explicit CLI
flags win. Groups inherit the resolved slide duration, timing modifiers,
after-effect, and sound. `effect_options` remains coupled to an explicit effect;
`trigger_shape` is never inherited; omitted `order`/`delay` use exporter
defaults. New authoring writes complete slide blocks.
---
@@ -101,15 +162,58 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --auto-advance 5
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -t none --auto-advance 5
```
Available effects: `fade`, `push`, `wipe`, `split`, `strips`, `cover`, `random`.
The native registry covers PowerPoint's complete Subtle, Exciting, and Dynamic
Content gallery: 48 canonical keys. New selection, sidecars, plans, conversion
traces, and writers use only those keys. Run `pptx_animations.py --list` for
the categorized identifiers.
Eight old low-level names remain accepted only as compatibility inputs. They
desugar to a native key plus native `effect_options`: for example, `diamond`
becomes `shape` with `shape: diamond`, and `wedge` becomes `clock` with
`style: wedge`. They are never selected for new output.
Effects expose their real PowerPoint Effect Options through
`transition.effect_options`. Common examples include Push/Wipe direction,
Morph by object/word/character, Reveal through black, Shape geometry, Page
Curl direction/pages, Glitter pattern/direction, and Fly Through bounce. Run
`pptx_animations.py --describe-transition <effect>` for the exact
effect-specific contract; unknown or inapplicable options fail validation.
`none` removes the visual effect. Effects that require newer Office namespaces
carry a real PowerPoint effect in `mc:Choice` and a `fade` fallback for older
consumers; validation requires the requested primary effect and never accepts
the fallback as a silent substitute.
Flags:
- `-t/--transition` — effect name, or `none` for no visual transition. Default: `fade`. `none` does not remove an explicitly configured automatic advance.
- `-t/--transition` — native effect name, compatibility input, or `none` for no visual transition. Default: `fade`. `none` does not remove an explicitly configured automatic advance.
- `--transition-duration` — seconds, default `0.4`.
- `--auto-advance` — seconds; click remains enabled, so the slide advances on click or when the timer expires. Omit for presenter-controlled advance.
**Hard rule — no silent downgrade**: an unknown transition effect or invalid/non-finite duration fails export. It is never replaced by `fade`. Recorded narration keeps the resolved visual transition; `-t none --recorded-narration ...` writes narration-driven advance timing without restoring a visual effect.
**Hard rule — no silent downgrade**: an unknown transition effect, unsupported Effect Option, or invalid/non-finite duration fails export. It is never replaced by `fade`. Recorded narration keeps the resolved visual transition; `-t none --recorded-narration ...` writes narration-driven advance timing without restoring a visual effect.
### 3.1 Morph — author an action as the difference between two pages
Morph tweens objects it can match across consecutive slides. That makes it a general mechanism, not just a transition: **any continuous action can be authored as two static pages plus `-t morph`**, with no keyframe timeline anywhere. Duplicate the page, change one property on one object, and PowerPoint interpolates the rest.
| Change between the two pages | Reads as |
|---|---|
| Object sits off-canvas, then on-canvas | Slide-in, drawer pull, card extending |
| Object rotates | Flip, turn, hinge |
| Image container scales up | Camera push-in |
| Scrim opacity drops, or a cut contour grows | Progressive reveal |
| Same wide image at two `x` offsets | Camera pan (see image-layout-patterns `#87`) |
Chain three or more pages to build a sequence — extend, hold, retract — where each page is still an ordinary editable slide.
**Hard rule — matching is by object identity**: keep the same image filename, the same group `id`, and container dimensions that do not change between the pages. Rename the file or resize the frame and morph silently degrades to a cross-fade with none of the motion. This is the most common reason a morph sequence "does nothing".
**Give text somewhere to come from.** Morph tweens objects present on both pages; text that only exists on the second page can only fade in. The standard fix, used in essentially every morph-driven deck: place the *next* page's copy on the current page just outside the canvas (below), and the *previous* page's copy just outside the opposite edge (above). Each block then slides through the frame instead of blinking, and the deck reads as one continuous surface being scrolled. Objects parked outside the canvas are not rendered but must still exist on both pages with the same identity.
**When morph refuses to match**: PowerPoint pairs objects of the same kind first, so two different shape types, or a shape and a picture, will cross-fade instead of tweening. Authored decks force the pairing by giving both objects an identical custom shape name. The exporter does read a per-object name — `data-pptx-shape-name` on the wrapper — but that attribute is currently specified as **importer metadata** for mirror/preserve packages ([`svg-effects.md`](./svg-effects.md) §6.6), not as an authoring control for generated pages. Until that contract is widened, keep morph pairs the same object kind with matching geometry and `id`, and do not introduce the attribute on generated pages to force a match.
**Not supported — Slide Zoom / Summary Zoom.** Click-to-jump navigation built on PowerPoint's Zoom objects (the "click a portrait, zoom into that section" pattern) has no exporter path. Build click-driven navigation with `trigger_shape` on ordinary object animations instead, or with plain hyperlinks.
**No 3D**: perspective rotation, extrusion, and shear are outside the SVG contract — `skewX` / `skewY` and shear matrices fail closed ([`svg-effects.md`](./svg-effects.md) §6.8). Build the same impression with 2D means — offset, scale, overlap, and per-facet lightness (image-layout-patterns `#91`) — rather than attempting a 3D tilt.
---
@@ -118,56 +222,98 @@ Flags:
Off by default — enable deck-wide with `-a auto` (or another effect). Once enabled, three Start modes are available — these mirror PowerPoint's animation-pane "Start" dropdown:
- **`on-click`** — entering a slide → first click reveals the first semantic group; each subsequent click reveals the next group in z-order. Suits live presentations where the speaker paces reveals. Forbidden with `--recorded-narration` because video-ready exports need click-free playback.
- **`with-previous`** — all groups start together on slide entry, playing their entrance animation in parallel. Stagger ignored.
- **`with-previous`** — all groups start together on slide entry, playing their object animation in parallel. Stagger ignored.
- **`after-previous`** (default) — first group fires on slide entry, subsequent groups cascade after the previous one finishes, with `--animation-stagger` extra spacing. Suits kiosk playback, recorded walkthroughs, or anyone who wants visual flow without clicking.
Enable with `-a auto`, select a canonical effect with
`--animation entrance_fade`, and choose Start behavior with
`--animation-trigger on-click|with-previous|after-previous`.
PowerPoint's separate **Trigger → On Click of** behavior uses group-only
`trigger_shape`. It links that row to another top-level group while unlinked
rows keep the slide Start mode; it is not a fourth deck-wide Start mode.
The registry exposes two layers:
- **203 PowerPoint-native object presets**: 53 `entrance_*` presets, 33
`emphasis_*` effects, 64 `path_*` motion paths, and 53 `exit_*` effects.
Examples include `entrance_bounce`, `emphasis_spin`, `path_circle`, and
`exit_faded_zoom`. Each native key carries the complete PowerPoint-authored
behavior tree, not a generic filter approximation.
- **29 legacy compatibility inputs**, listed by `--list`; new output never
selects them.
Run the registry command for the exact categorized key list:
```bash
# Default behavior (no flags): page transitions only, no per-element builds
python3 skills/ppt-master/scripts/svg_to_pptx.py <project>
# Enable per-element animation deck-wide (auto effect + after-previous cascade)
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -a auto
# Enable with a single effect (cascades via the after-previous trigger)
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation fade
# Enable and switch to on-click for live presentations (presenter controls pacing)
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -a auto --animation-trigger on-click
# Custom pacing
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation mixed \
--animation-stagger 0.7 --animation-duration 0.5
# All groups animate in unison on slide entry
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -a auto --animation-trigger with-previous
python3 skills/ppt-master/scripts/pptx_animations.py --list
```
29 single effects: `appear`, `fade`, `fly`, `fly_left`, `fly_right`, `fly_top`, `cut`, `zoom`, `wipe`, `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `split`, `blinds`, `checkerboard`, `dissolve`, `random_bars`, `peek`, `wheel`, `box`, `circle`, `diamond`, `plus`, `strips`, `wedge`, `stretch`, `expand`, `swivel`. Plus three auto-vary modes:
Compatibility names normalize before selection and writing: for example,
`fade` resolves to `entrance_fade`; every old Fly direction name resolves to
`entrance_fly`; every old Wipe direction name resolves to `entrance_wipe`; and
`cut` resolves to `entrance_appear` because current PowerPoint has no separate
Cut object effect. Directional aliases preserve their old direction through
`effect_options`; legacy `wheel` maps to `entrance_wheel` with four spokes.
These names are accepted only as compatibility inputs.
Automatic selection, new sidecars, conversion traces, and writers use
canonical keys.
These names preserve the established filter / `presetID` / `presetSubtype` tuples documented in [`pptx-animations.md`](../scripts/docs/pptx-animations.md#3-compatibility-contract). `fly` remains the bottom-up variant. `wipe` preserves its historical tuple; use `wipe_left`, `wipe_right`, `wipe_up`, or `wipe_down` when motion should follow the layout explicitly. `cut` is a legacy public key; compatibility promises its existing tuple, not a semantic interpretation inferred from an external preset-id table.
The native keys mirror the object-capable `MsoAnimEffect` surface. The four
media commands—play, pause, stop, and play from bookmark—are not object effects
for SVG groups and remain owned by the audio/video workflows.
- `auto` (recommended when enabling) — map effect from the group's SVG id. Information-dense elements get a single stable effect: `chart` / `table` / `legend` / `timeline` / `track``wipe`; `card-*` / `pillar-*` / `item-*` / `step-*` / `stage-*` / `tier-*` / `principle-*``fly`; `title` / `chapter-*` / `section-*` / `cover-*` / `tagline` / `subtitle``fade`; `takeaway` / `callout` / `quote` / `source` / `conclusion` / `note``fade`. Image-like ids `hero` / `figure-*` / `image` / `img-*` / `kpi` instead cycle a richer visual pool (`zoom` / `dissolve` / `circle` / `box` / `diamond` / `wheel`) so multiple images vary across the deck. Unmatched ids cycle through `fade` / `wipe` / `fly` / `zoom`.
- `mixed` (legacy) — deterministic. The first animated group on each slide uses `fade`; later groups cycle through a 16-effect pool (`blinds` / `checkerboard` / `dissolve` / `fly` / `cut` / `random_bars` / `box` / `split` / `strips` / `wedge` / `wheel` / `wipe` / `expand` / `fade` / `swivel` / `zoom`) across the deck. Kept for backward compatibility.
- `random` — samples from the legacy 16-effect pool. Resolution is seeded from the effective deck input, so the same input produces the same choices; `--conversion-trace` records every resolved effect when diagnostics are enabled.
- `auto` maps semantic ids to canonical entrances: charts/tables/timelines use
`entrance_wipe`; cards/steps use `entrance_fly`; titles/takeaways use
`entrance_fade`; image-like ids cycle a richer pool; unmatched ids cycle
fade/wipe/fly/zoom.
- `mixed` (legacy mode name) — deterministic. The first animated group on each
slide uses `entrance_fade`; later groups cycle through a 16-effect canonical
PowerPoint entrance pool across the deck. The mode name remains compatible;
it no longer selects hand-authored compatibility rows.
- `random` — samples from the same canonical PowerPoint entrance pool.
Resolution is seeded from the effective deck input, so the same input
produces the same choices; `--conversion-trace` records every resolved effect
when diagnostics are enabled.
`appear` is excluded from every variation pool because it has no visible motion.
`entrance_appear` is excluded from every variation pool because it has no
visible motion.
Flags:
Flags: `-a/--animation` selects effect/mode; `--animation-trigger` selects Start;
`--animation-duration` and `--animation-stagger` control base timing;
`--animation-config` selects a sidecar; `--no-animations` disables page/object
motion but preserves narration audio and recorded advance timing.
- `-a/--animation` — effect name, `auto`, `mixed`, `random`, or `none`. Default: `none` (per-element animation off; pass `auto` to enable).
- `--animation-trigger` — Start mode (matches PowerPoint): `on-click`, `with-previous`, or `after-previous` (default).
- `--animation-duration` — per-element entrance seconds, default `0.4`.
- `--animation-stagger` — gap between elements in `after-previous` mode (seconds, default `0.5`). Ignored otherwise.
- `--animation-config` — explicit sidecar path. Narrated export defaults to `<project>/narration_animations.json`; other export defaults to `<project>/animations.json` when present.
- `--no-animations` — ignore animation sidecars and disable both object animations and page-transition motion. Narration audio and recorded slide-advance timing remain active.
> Note: `--recorded-narration` rejects `on-click` and `trigger_shape`; use its default `narration_animations.json`, pass `--animation-config animations.json` for the canonical presentation animation, or pass `--no-animations`.
> Note: `--recorded-narration` rejects `on-click`; use its default `narration_animations.json`, pass `--animation-config animations.json` for the canonical presentation animation, or pass `--no-animations`.
### 4.1 Slow ambient motion — the page that breathes
Most object animation exists to reveal content on click. There is a second, quieter use: **one long, slow motion on the background that never waits for a click**, so a static page stops looking frozen. It is the highest-impact-per-effort motion available and it costs one sidecar entry.
The recipe: a `path_left` (or `path_right`) motion on the **background image group only**, `with-previous` so it starts unprompted, and a duration of **410 s** — an order of magnitude longer than the reveal default. Set the travel distance so the image is still fully covering the canvas at both ends; a background that drifts past its own edge exposes the slide beneath it.
It pairs naturally with a fixed foreground: with image-layout-patterns `#90`, the scrim and its cut contour stay locked while the world moves behind the cuts, which reads as looking through windows rather than as a sliding photo. The same logic applies to `#82` and `#12`.
**Restraint is the whole technique**: one moving object per page, background only, never body copy or data. Two ambient motions on one page cancel each other out and the page reads as unstable.
### 4.2 Recurring recipes
Four combinations that recur constantly in authored decks. Each is built from
mechanisms already defined above — none needs a new capability.
**Carousel** (morph, §3.1) — hold a fixed row of card frames and rotate the *content* through them: on each page every image advances one position, so the card at centre changes while the frames stay put. Morph then slides the images between frames and the row appears to scroll. Requires identical frame geometry and `id`s on every page; only the image assignments change. Scales to any number of images with one page each.
**Odometer / counting numerals** (morph or motion path) — build a vertical strip of digits 09 and show one through a fixed window: a masked opening, or a background-filled rectangle above and below ([`image-layout-patterns.md`](./image-layout-patterns.md) `#95`). Shift the strip so the target digit lands in the window, then either morph between two pages or run a `path_up` motion on the strip. Give each digit column a 0.1 s stagger so they settle in sequence rather than in lockstep.
**Parallax depth** (morph) — move a background layer a *short* distance and a foreground layer a longer one between two pages. The differing travel is read as depth. Keep both layers' z-order identical on both pages; a layer that changes stacking between pages breaks the tween and the transition jumps.
**Flip-card / click-to-reveal** (`trigger_shape`, §4) — pair a face group and a back group at the same position, give the face an exit and the back an entrance, and set the back's `trigger_shape` to the face's id. Clicking the face plays both. This is the supported route for click-driven interaction; PowerPoint's Zoom objects are not (§3.1).
---
## 5. Anchor Logic — Top-Level `<g id="...">`
Per-element animations are anchored on **top-level `<g id="...">` content groups** in the SVG (e.g. `<g id="cover-title">`, `<g id="card-1">`). IDs must be unique within the page. One group produces one animation-pane entrance row; whether that row needs a click depends on the selected Start mode. Nested implementation groups may remain anonymous because the sidecar does not target them.
Per-element animations are anchored on **top-level `<g id="...">` content groups** in the SVG (e.g. `<g id="cover-title">`, `<g id="card-1">`). IDs must be unique within the page. One group produces one animation-pane row; whether that row needs a click depends on the selected Start mode. Nested implementation groups may remain anonymous because the sidecar does not target them.
**Hard rule — existing groups are not custom-animation intent**: the
pre-existing SVG hierarchy is implementation evidence, not an authoritative
@@ -178,12 +324,16 @@ split coarse wrappers and merge fragmented atoms when needed, then use
uses for group-select / group-move. Do not split or merge units to hit a target
count.
**Chrome groups skip the cascade automatically.** Explicit SVG role and placeholder semantics are authoritative. A group with `data-pptx-layer` or an explicit static role/placeholder marker can never animate. For marker-free legacy SVGs only, top-level groups whose id tokens look like page chrome (background, header/footer, decorations, watermark, page number, nav, logo, dividing rule) are excluded and appear with the slide. An explicit `animations.json` group entry may override this id-name heuristic, but never an explicit structural marker. Examples that auto-skip by legacy id: `<g id="background">`, `<g id="bg-texture">`, `<g id="cover-footer">`, `<g id="p03-header">`, `<g id="bottom-decor">`, `<g id="watermark">`, `<g id="nav">`, `<g id="logo-area">`, `<g id="column-rule">`. Examples that still animate: `<g id="card-1">`, `<g id="cover-title">`, `<g id="step-discover">`, `<g id="timeline-track">`. Do not strip the `<g>` wrapper to avoid animation — keep it for PowerPoint group selection and use `effect: none` when the content should remain static.
**Chrome stays static.** `data-pptx-layer` and explicit static
role/placeholder markers are absolute. For marker-free legacy SVGs, chrome-like
ids (background, header/footer, decor, watermark, page number, nav, logo, rule)
are skipped; an explicit sidecar entry may override only this name heuristic.
Keep wrappers and use `effect: none` for static content.
**Fallback for flat SVGs** (no top-level `<g>` wrappers, only raw `<rect>` / `<text>` / `<path>` at the root):
- ≤ 8 visible top-level primitives → each becomes one anchor (capped to avoid 70+ atom cascades on dense pages).
- > 8 → animation is skipped on that slide. The slide still renders, just without entrance animation.
- > 8 → animation is skipped on that slide. The slide still renders, just without object animation.
Executors should wrap logical sections in `<g id>` regardless of whether you plan to animate. [`shared-standards-core.md`](./shared-standards-core.md) requires it.
@@ -191,61 +341,53 @@ Executors should wrap logical sections in `<g id>` regardless of whether you pla
## 6. Validation and Read-Back
Animation configuration is strict. Export fails on an unknown effect, mode, or trigger; a boolean or non-finite duration/delay/stagger; a non-positive duration; a negative delay/stagger; a non-positive or non-integer order; a missing slide/group reference; or any attempt to animate a structural layer. These errors never downgrade to another effect or silently omit a requested target.
Animation configuration is strict. Export fails on an unknown effect, mode, or
trigger; invalid timing/order values; a missing slide/group/`trigger_shape`
reference; a self-trigger; or any attempt to animate or trigger from a
structural layer. These errors never downgrade or silently omit a target.
Generated export reads each slide's timing tree back and checks row count/order, trigger, shape target, resolved effect tuple, duration, and timeline offset. Package validation then checks root timing placement, unique and valid `p:cTn` ids, and every `p:spTgt` reference. The writer does not emit `p:bldP` for groups or pictures. Direct-PPTX preserve mode tolerates unchanged legacy group/picture `p:bldP` rows from earlier PPT Master exports; new generated packages remain strict.
Generated export reads each slide's timing tree back and checks row count/order,
trigger, trigger shape, shape target, preset class, resolved effect tuple, native behavior
signature, duration, and timeline offset. Package validation then checks root
timing placement, unique and valid `p:cTn` ids, and every `p:spTgt` reference.
The writer does not emit `p:bldP` for groups or pictures. Direct-PPTX preserve
mode tolerates unchanged legacy group/picture `p:bldP` rows from earlier PPT
Master exports; new generated packages remain strict.
Narration injection merges audio timing into an existing direct `p:sld/p:timing` DOM and preserves entrance rows. A source timing tree nested in `mc:AlternateContent` or another non-root container fails safely instead of being rewritten or duplicated. Direct-PPTX routes fingerprint source object-animation timing before and after their allowed edits, then run structural package validation; they do not author or normalize animation effects.
Narration injection preserves animation and updates both p14 Choice/Fallback
when bounce timing is present; unsupported nested timing fails safely.
Direct-PPTX routes fingerprint source
object-animation timing before and after their allowed edits, then run
structural package validation; they do not author or normalize animation
effects.
---
## 7. Video Adaptation Contract
Custom animation remains the semantic source for video motion. A downstream
video renderer must consume a resolved conversion trace through
`video_motion_plan.py`, not infer motion from delay values or read an unresolved
sidecar directly.
The plan locks object identity, object order, source effect, semantic direction,
and timing anchors. Video-only adaptation may refine easing, travel distance,
opacity, scale, mask feather, blur, motion blur, and overshoot. Unsupported
effect families must fail visibly rather than silently becoming generic fades.
See [`video-motion-plan.md`](../scripts/docs/video-motion-plan.md) for the schema
and renderer contract.
Video renderers consume the resolved conversion trace through
`video_motion_plan.py`, never a raw sidecar or delay-only inference. The plan
locks identity, order, effect, direction, and timing; video may refine only its
declared renderer parameters. Unsupported families fail visibly. See
[`video-motion-plan.md`](../scripts/docs/video-motion-plan.md).
---
## 8. Limitations
- **Native DrawingML output only.** Page transitions and per-element animations are authored on the PPTX produced by the project converter from `svg_output/`. `svg_final/` remains a static SVG visual preview, not an animated or alternate PPTX route.
- **PowerPoint OOXML scope.** Effects preserve their established filter / `presetID` / `presetSubtype` tuples and are validated against the serialized PowerPoint package. Rendering in Keynote, LibreOffice, WPS, or other applications is outside the unconditional compatibility guarantee.
- **Manual SVG shape conversion is unsupported.** Inserting an `svg_final/` page as an SVG picture does not establish element animation anchors; use the native PPTX when editable animated shapes are required.
- **Source extension preservation.** Direct-PPTX routes preserve unknown transition `AlternateContent` when configured to keep the source. When advance timing changes, Choice and Fallback receive the same `advClick` / `advTm` values.
- Generated animation belongs to the native PPTX built from `svg_output/`.
`svg_final/` is a static preview, and inserting it as one SVG picture does
not create object anchors.
- PowerPoint OOXML is the compatibility target; other presentation apps may
reinterpret individual native behavior trees.
- Direct-PPTX routes preserve unknown transition `AlternateContent`; timing
edits keep Choice and Fallback advance attributes synchronized.
---
## 9. Quick Reference
## 9. Implementation References
| Goal | Command |
|---|---|
| Remove visual transition | `-t none` |
| Change transition effect | `-t push` (or any from the list above) |
| Slower transition | `--transition-duration 0.8` |
| Auto-play | `--auto-advance 5` |
| Disable element animation | `-a none` |
| Narrated export with synchronized animation | `--recorded-narration audio` |
| Narrated export with canonical animation | `--recorded-narration audio --animation-config animations.json` |
| Narrated export without animation motion | `--recorded-narration audio --no-animations` |
| Switch to on-click trigger | `-a auto --animation-trigger on-click` |
| Use a single effect instead of auto | `--animation fade` |
| All groups animate together | `-a auto --animation-trigger with-previous` |
| Slower per-element reveal | `-a auto --animation-duration 0.5` |
| Wider gap in after-previous | `-a auto --animation-stagger 0.7` |
| Derive effect-aware video motion | `video_motion_plan.py <project>/validation/<output_stem>.trace.json --force` |
See also:
- [`scripts/docs/svg-pipeline.md`](../scripts/docs/svg-pipeline.md) for the full `svg_to_pptx.py` reference.
- [`pptx-transitions.md`](../scripts/docs/pptx-transitions.md) for the shared OOXML writer, MCE preservation, and read-back contract.
- [`pptx-animations.md`](../scripts/docs/pptx-animations.md) for the exact effect tuples, timing-tree rules, and animation package validator.
- [`video-motion-plan.md`](../scripts/docs/video-motion-plan.md) for the resolved animation-to-video enhancement contract.
See [`svg-pipeline.md`](../scripts/docs/svg-pipeline.md),
[`pptx-transitions.md`](../scripts/docs/pptx-transitions.md),
[`pptx-animations.md`](../scripts/docs/pptx-animations.md), and
[`video-motion-plan.md`](../scripts/docs/video-motion-plan.md).
@@ -27,7 +27,7 @@ Always-loaded Executor authority for flat SVG page authoring and behavior shared
## 1. Effect Capability Discovery
**Reference — not a constraint**: Scan this menu for treatments that support the locked style and hierarchy. After selecting one, load [`svg-effects.md`](./svg-effects.md) before authoring it.
**Reference — not a constraint**: Scan this menu for treatments that support the locked style and hierarchy. After selecting one, load [`svg-effects.md`](./svg-effects.md) before authoring it — except the cross-page motion row, which loads [`animations.md`](./animations.md) §3.1.
| Visual need | Available construction |
|---|---|
@@ -36,9 +36,12 @@ Always-loaded Executor authority for flat SVG page authoring and behavior shared
| Image integration | scrim, vignette, brand wash, clipping, faux glass |
| Line / type | dash/cap/join, markers, gradient stroke; tracking, outline, alpha/gradient text |
| Space / constructed style | transform/reuse, curves/arcs, hand-drawn, ink/Riso, halftone, isometric, paper cut |
| Continuous action across pages | Paired pages that differ in one property, exported with morph |
**Hard rule — discovery does not expand compatibility**: Follow `svg-effects.md` syntax and fallbacks; unsupported blur, blend, mask, dense texture, or skew remains baked/alternative-only.
**Default — resolve cross-page motion here, while pages are still being authored (may override when the deck has no continuous action to express)**: object animation and page transitions are post-processing decisions, but morph is not. It tweens only objects it can match across consecutive slides — same image filename, same group `id`, unchanged container dimensions — so a sequence that should read as one continuous action (slide-in, flip, camera push-in, progressive reveal, camera pan) must be authored as paired pages in `svg_output/` now. A deck that reaches export without them cannot gain the motion by adding a flag; it degrades silently to a cross-fade. Adding pages is a §IX roster change and returns to Strategist for Design Spec repair first.
---
## 2. Design Parameter Confirmation (Mandatory Step)
@@ -1,10 +1,10 @@
# Image-Text Layout Patterns
A vocabulary registry of ways images can be placed on a slide. The point of this file is to **expand the mental list of options** so that when you reach for an image layout, you do not default to the same three patterns (left/right, top/bottom, full-bleed cover).
A vocabulary registry of ways images can be placed on a slide. The point of this file is to **expand the mental list of options** so that when you reach for an image layout, you do not default to the same three patterns (left/right, top/bottom, full-bleed cover). Start at **High-Yield Patterns** below: it routes the common page situations to the constructions that most visibly raise a deck's quality, at no asset cost.
Every entry has a name plus a short technical hint. Common techniques get a single line. Less obvious or easily forgotten techniques get a short paragraph — not a full tutorial, but enough that a model unfamiliar with the project can implement it without guessing. This is a registry, not a teaching document; no use-case prescriptions, no decision tables.
> **Numbers are stable identifiers, not sequence.** The file is split into **Part 1 — Primary Structures** (#1#19, #38#56, #73#81) and **Part 2 — Modifier Layers** (#20#37, #57#72). Numbers jump within each Part because Primary structures were grouped first; existing references to `#38`, `#48`, etc. anywhere in the project still resolve correctly.
> **Numbers are stable identifiers, not sequence.** The file is split into **Part 1 — Primary Structures** (#1#19, #38#56, #73#81, #88, #92#94) and **Part 2 — Modifier Layers** (#20#37, #57#72, #82#87, #89#91, #95#99). Numbers jump within each Part because Primary structures were grouped first; existing references to `#38`, `#48`, etc. anywhere in the project still resolve correctly. **High-Yield Patterns** below is a router over those same numbers, not a third part — read it first, then jump to the entry it names.
---
@@ -20,6 +20,35 @@ Anything that must be editable, numerically accurate, contain Chinese, or be sty
---
## High-Yield Patterns — Open Here
The patterns below are what separate a deck that looks designed from a deck that looks assembled. Nearly all of them are **one `<image>` plus geometry** — no extra asset, no generation cost, no second render — and they are the SVG equivalents of what PowerPoint users reach for under Merge Shapes. They sit late in the file only because the numbering is historical; they are the first place to look, not the last.
**Default — resolve each page against this table before falling back to the plain structures in Part 1 (may override when the content genuinely wants a plain split, an equal grid, or bare whitespace):**
| Page situation | Reach for | Produces |
|---|---|---|
| One ordinary photo must carry a cover or a chapter divider | `#90` scrim with shapes cut out + `#86` contour echo | Three elements turn a stock image into a designed page; the cut contour is where the page's character comes from |
| The supplied image does not fit the canvas | `#89` same image twice — sharp cutout over a receded copy | Subject at full fidelity in any aspect ratio; no stretching, no letterbox bars, no second asset |
| Several peer images belong to one frame | `#92` split tiling — one parent cut into interlocking cells | Edges interlock exactly; the group still reads as one object |
| One image should appear inside several detached containers | `#82` one image shattered across separated shapes | Merge-Shapes look; the photo runs continuously behind the gaps |
| A panel needs a real opening onto what is behind it | `#83` panel with a hole punched through it | True subtraction — survives a gradient, a texture, or a second image behind the panel |
| A photo row needs depth without 3D | `#94` embracing arc row, or `#93` containers arrayed along a curve | A perspective wall reproduced in 2D from scale + vertical offset alone |
| A flat scrim reads as a sheet of paint over the photo | `#98` grid scrim with per-cell opacity | The overlay reads as panelled glass or a contact sheet, felt rather than drawn |
| A busy photo has no clear focus | `#99` selective desaturation, or `#96` cutout subject re-laid over its own photo | Focus without cropping; the subject can then overlap a title, a panel, or a grid line |
| Text needs legibility but a solid scrim would kill the photo | `#97` frosted-glass panel | The photo's colour and composition stay visible through the panel |
| An image grid looks like a stock template | `#88` non-rectangular tessellation with 13 cells left empty | The empty cells are where the title and body copy live |
| A subject should escape its container | `#85` subject breaking out + `#96` | Depth with no shadow at all |
| One place should be recognized across consecutive pages | `#87` one image panned across pages | The deck reads as one continuous scene; with `-t morph` the flip becomes a camera pan |
**Hard rule — registration is what makes this family work**: in `#82`, `#85`, `#87`, `#89`, `#96`, and `#97`, the image stays anchored to the *union* of its containers, or the two copies stay in exact register. A few pixels of drift reads as a printing error, and giving each container its own image collapses the page into an ordinary tile grid. `#84` is the one pattern that breaks registration on purpose, and it only reads as a decision because the others establish the expectation.
**Skip-detection signal** — if every page's `Layout pattern` resolves to a bare `#2` / `#3` / `#5` / `#6` with no Modifier id, this table was not consulted. Re-open it before finalizing `design_spec.md §VIII`.
Each entry above is specified in full at its own number below; the table routes, it does not restate.
---
# Part 1 — Primary Structures
Pick one or more of these as the page's bones. Cross-primary combinations are encouraged (see Composition Guidance).
@@ -44,9 +73,21 @@ Pick one or more of these as the page's bones. Cross-primary combinations are en
9. **3×3 grid with central image** — nine cells; center cell holds the image, the other 8 hold text blocks, color swatches, or small data widgets.
93. **Containers arrayed along a curve (fan, arc, ring)** — N image containers distributed along an arc or wave, each rotated to sit square to the curve at its own position. Reads as motion and hierarchy at once, and it is the backbone of fan spreads, ring layouts, dial/roulette pages, and arched photo rows.
**Geometry** — place container `i` of `n` on a circle of radius `r` about `(cx, cy)`:
```
θᵢ = θ_start + i × (θ_span / (n 1))
xᵢ = cx + r·cos(θᵢ) yᵢ = cy + r·sin(θᵢ)
rotationᵢ = θᵢ + 90° (tangent-aligned; drop this for upright containers)
```
Use `transform="rotate(rotationᵢ xᵢ yᵢ)"` on each container group. A `θ_span` of 60120° reads as a fan; 360° with `θ_start = -90°` gives an evenly spaced ring. For a wave instead of an arc, sample the wave's own path and use its local tangent as the rotation.
**Two things to get right**: keep radius and angular step *constant* — an eyeballed fan reads as a mistake, not a flourish; and when containers are tangent-aligned, images inside must not inherit the rotation blindly (a sideways face is the failure mode). Counter-rotate the image inside its container, or keep the containers upright and let only their positions follow the curve.
10. **Centered image with radial callouts pointing outward** — image (often circular via `clipPath`) at canvas center; multiple `<line>` leader lines + small `<circle>` endpoints + offset text labels in surrounding space.
11. **Diagonal split with directional gradient (not hard polygon cut)** — full-bleed `<image>` (do NOT hard-clip) + overlay `<rect fill="url(#grad)">` whose `<linearGradient>` axis runs along the desired diagonal + a `<line>` on the diagonal to make the divider visible. The gradient does the "splitting" softly; hard polygon clipping produces ugly stair-step edges on text panels.
11. **Diagonal split with directional gradient (not hard polygon cut)** — full-bleed `<image>` + overlay `<rect fill="url(#grad)">` whose gradient axis runs along the diagonal, plus a `<line>` to make the divider read. Do NOT hard-clip: polygon cuts give stair-stepped edges on text panels.
12. **Faded image as backdrop with oversized overlay text** — `<image>` + heavy semi-transparent `<rect fill="bg-color" fill-opacity="0.50.7">` over it + huge `<text>` (80120px) on top. Image becomes texture; text is the subject.
@@ -54,7 +95,7 @@ Pick one or more of these as the page's bones. Cross-primary combinations are en
14. **Horizontal banner strip cutting through mid-section** — `<image y=middle width=1280 height=200280>` with edge fades; text blocks above and below the band.
15. **Multi-image montage with bold text spanning across**multiple `<image>` tiled with 24px gaps + large `<text>` (60100px) in a darkened band spanning the full montage. The band uses `<rect fill-opacity="0.50.7">` to keep text legible across all underlying images.
15. **Multi-image montage with bold text spanning across** — `<image>` tiled with 24px gaps + large `<text>` (60100px) in a `<rect fill-opacity="0.50.7">` band spanning the montage, so the text stays legible across every tile beneath it.
16. **Negative-space dominant — small image, mostly whitespace** — image and text together occupy less than 40% of the canvas; rest is empty.
@@ -88,7 +129,13 @@ This is the family that opens up the largest design space and the one AI is most
## Multi-Image Compositions
47. **Small multiples — 36 same-kind images in an evenly spaced row** — each in identical container, each with identical caption block underneath (title + one-line description). This is **not** a generic grid: the identical framing is itself the message — readers compare across panels because the structure is the same. Useful for style comparisons, time-series snapshots, product variations.
94. **Embracing arc row (2D substitute for a 3D perspective wall)** — a row of images or cards where the centre element is largest and each step outward shrinks and drops, so the tops trace an arc and the row appears to curve toward the viewer. This is what PowerPoint decks build with 3D rotation (perspective left / right, X-axis 330° / 30°) for logo walls, certificate rows, and photo shelves — and it is reproducible in 2D, which matters because 3D transforms are outside the SVG contract ([`svg-effects.md`](./svg-effects.md) §6.8).
**Construction**: for element `k` steps from the centre, apply `scale = 0.88ᵏ` and offset `y` downward so every element's *top* edge lands on one shallow arc; keep the horizontal step constant. Mirror the sequence left and right of the centre. Add a soft ground shadow or a reflection fading downward to seat the row. Bottom-aligning instead of top-arcing gives the flatter "shelf" variant.
The depth cue is entirely **scale + vertical offset + consistent light**; do not reach for skew or a fake 3D tilt, which fail closed on export. Three to seven elements is the working range — beyond that the outermost ones shrink into illegibility.
47. **Small multiples — 36 same-kind images in an evenly spaced row** — identical containers, identical caption blocks (title + one line). Not a generic grid: the identical framing *is* the message, because readers compare across panels only when the structure is constant.
48. **Side-by-side comparison (before/after, A/B, then/now)** — two `<image>` of equal size in 50/50 split with thin divider `<line>` and "before" / "after" labels.
@@ -98,37 +145,52 @@ This is the family that opens up the largest design space and the one AI is most
51. **Mosaic** — irregular tile sizes packed together with or without thin gaps; each image clipped to its tile's rect.
52. **Image strip / filmstrip**horizontal sequence of `<image>` elements with thin gaps; same height, varying widths allowed.
92. **Split tiling — one parent shape cut into interlocking cells** — the most-used construction in real image-heavy decks, and the counterpart to #82. Take one parent shape (circle, annulus, rounded rect, trapezoid, wave band), lay cutting lines across it (long bars, evenly distributed or fanned at different angles), and split it into cells. Each cell then holds a *different* image. Because every cell comes from one parent, the edges interlock exactly — no gaps, no overlaps, and the group still reads as one object.
53. **Vertical image stack** — column of `<image>` aligned by width, shared annotations on one side.
| Parent + cutters | Result |
|---|---|
| Circle + 2 crossed bars | Quadrant wheel |
| Annulus + radial bars | Ring segments |
| Wave band + vertical bars | Rhythmic strip |
| Trapezoid + slanted bars | Perspective row |
**Authoring**: compute each cell's contour and write it as its own `<path>` clip — the geometry is deterministic, so derive the cells rather than eyeballing them. `shape_boolean_svg.py fragment` returns exactly these interlocking regions as separately addressable paths. Give every cell the same stroke (2px, background color) so the cuts read as designed seams.
**Choosing between #92 and #82**: same construction, opposite content rule. One image across all cells (#82) says "these fragments are one thing"; a different image per cell (#92) says "these are peers, cut from one frame". Mixing them destroys both readings. Distinct from #50 / #51, where cells are independent rectangles that never shared a parent.
5253. **Filmstrip / stack** — a sequence of `<image>` with thin consistent gaps: horizontal, equal height and varying widths (**#52**), or vertical, aligned by width with shared annotations down one side (**#53**).
54. **Overlapping image stack** — `<image>` elements with overlapping `x/y` positions; each subsequent one in front (z-order by document order); often combined with slight rotation for layered photo-print look.
55. **Diptych split — two images abutting at 50/50** vertical or horizontal split with optional thin divider `<line>`.
5556. **Diptych / triptych** — two images abutting 50/50, vertical or horizontal (**#55**), or three side-by-side at equal or 2:1:2 widths (**#56**), with an optional thin divider `<line>`. Distinct from #26, where the panels live inside one image file, and from #48, where the pairing carries a before/after argument.
56. **Image triptych**three independent `<image>` side-by-side, equal widths or 2:1:2 etc. (distinct from #26 baked-in triptych, where the three scenes are inside one image file).
88. **Non-rectangular tessellation (honeycomb, diamond, chevron array)** — a tiled field of hexagons, diamonds, or slanted parallelograms, each cell holding its own image via `clipPath` (#23) and separated by a consistent 23px stroke in the background color, which reads as the grid's mortar. The non-rectangular counterpart to #50 / #51.
**Geometry**: a flat-top hexagon of width `w` and height `h` is `M x+0.25w,y L x+0.75w,y L x+w,y+0.5h L x+0.75w,y+h L x+0.25w,y+h L x,y+0.5h Z`. Tile it by stepping `0.75w` horizontally and offsetting alternate columns by `0.5h` vertically.
**Leave cells deliberately empty**: fill 13 tiles with a flat or gradient deck color instead of a photo. A fully-populated honeycomb reads as a stock template, and the empty cells are where the title and body copy live. Keep the identical stroke on the empty cells so they read as designed rather than as a missing image.
## Imported Deck Patterns (image-led promotional pages)
These patterns come from polished image-text decks where photos define the slide skeleton instead of sitting inside generic cards. Treat them as layout vocabulary for travel, product, venue, hospitality, real-estate, event, and brochure-style decks.
73. **Full-bleed poster image + side title stack** full-slide image, title stack anchored to the left or lower-left third, no title card. Use native text directly over a calm image region with a subtle scrim only when needed. The title stack can mix huge Latin / display text, local-language title, and small brand/date line.
73. **Full-bleed poster image + side title stack** — title stack on the left or lower-left third, no title card; scrim only where the image is busy.
74. **TOC image-navigation cards** — 35 equal vertical image cards across the page. Each card gets a same-color translucent overlay, large chapter number, chapter title, and one-line summary. The TOC becomes a visual preview of the deck, not a text list.
74. **TOC image-navigation cards** — 35 vertical image cards, each with a translucent overlay, chapter number, title, one-line summary. A visual preview of the deck, not a text list.
75. **Asymmetric dual-image chapter banner**two images occupy the upper half: one smaller panel and one wide dominant panel, usually left-small / right-wide. The chapter title lives in the lower half with an oversized section number as a background anchor.
75. **Asymmetric dual-image chapter banner** — one small + one wide image across the upper half; chapter title below, anchored by an oversized section number.
76. **Mid-page image belt with native text inset** a wide image strip cuts through the middle 4560% of the slide. Put the key text inside a darker or calmer region of the strip, using native text and a small label, while the top area carries the page heading.
76. **Mid-page image belt with native text inset** — wide image strip through the middle 4560%, key text inside its calm region, heading above.
77. **Photo mosaic with a text cell** an irregular grid where one grid cell is deliberately reserved for copy and the other cells are photos. The missing photo cell creates hierarchy; do not fill every grid slot just because a grid exists.
77. **Photo mosaic with a text cell** — irregular grid with one cell reserved for copy. The missing photo is the hierarchy; do not fill every slot just because a grid exists.
78. **Ambient banner + evidence photo + text panel** one wide atmospheric image spans the upper portion, a smaller concrete/evidence photo sits below, and a solid color or tinted panel carries the copy on the side. Useful when one image sets mood and another proves the product/place.
78. **Ambient banner + evidence photo + text panel** — atmospheric image above, concrete evidence photo below, copy on a tinted side panel. One image sets mood, the other proves it.
79. **Ribbon-header image cards** — 3 columns, each with a colored ribbon or chevron title above the image, image in the middle, prose below. The ribbon carries category identity; the photo carries evidence; the body copy stays editable.
79. **Ribbon-header image cards** — 3 columns, colored ribbon or chevron title above each image, prose below.
80. **Side hero image + staggered evidence cards** one full-height or near-full-height image occupies a side column. The opposite side uses 24 smaller evidence cards placed at staggered vertical positions instead of a rigid grid, producing movement and editorial rhythm.
80. **Side hero image + staggered evidence cards** — full-height image in a side column; 24 smaller cards staggered vertically opposite it rather than gridded.
81. **Illustration-as-layout field** — a large decorative vector or cutout illustration behaves like an image region: it sets the page's spatial rhythm, while text blocks sit around or inside its calm areas. Use this when a photo would be too literal but the page still needs image-scale visual mass.
81. **Illustration-as-layout field** — a large vector or cutout illustration acts as the image region and sets spatial rhythm, with text in its calm areas. For when a photo would be too literal but the page still needs image-scale mass.
---
@@ -138,18 +200,34 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
## Non-rectangular Image Shapes
20. **Circular crop**`<clipPath><circle cx cy r/></clipPath>` referenced by `<image clip-path="url(#id)"/>`.
21. **Rounded rectangle crop**`<clipPath><rect rx ry/></clipPath>`; the `rx` value controls roundness.
22. **Ellipse / oval crop**`<clipPath><ellipse cx cy rx ry/></clipPath>`.
23. **Hexagonal / polygonal crop**`<clipPath><polygon points="x1,y1 x2,y2 …"/></clipPath>`; remember to keep all vertices inside the image's display rectangle.
2023. **Basic shape crops** — `<clipPath>` holding one shape, referenced by `<image clip-path="url(#id)"/>`: `<circle>` (**#20**), `<rect rx ry>` (**#21**, `rx` sets roundness), `<ellipse>` (**#22**), `<polygon points>` (**#23**, keep every vertex inside the image's display rect). #24 supersedes all four whenever the contour is curved or organic.
24. **Custom path crop (blob, arrow, leaf, silhouette)** — `<clipPath><path d="…"/></clipPath>`; allows any curved or organic shape. PowerPoint export translates this to `custGeom` and survives roundtrip.
25. **Layered paper-cut stack** — clip each image layer under the image-only contract in [`shared-standards-core.md`](./shared-standards-core.md) §1.2; draw vector layers directly in their final geometry. A small conditional shadow on each layer can create physical separation.
82. **One image shattered across separated shapes (Merge Shapes look)** — a *single* `<image>` clipped by **one `<path>` whose `d` contains several disjoint subpaths** (`M … Z M … Z`), so one photo appears inside several detached containers — staggered rounded slices, a gapped 2×2 grid, a rotated cross. This is the SVG equivalent of PowerPoint's Merge Shapes 结合 → 相交, and export maps it to one picture with `custGeom`.
**Geometry**: write each container as its own subpath in one `d`. A rounded rect is `M x+r,y H x+w-r A r,r 0 0 1 x+w,y+r V y+h-r A r,r 0 0 1 x+w-r,y+h H x+r A r,r 0 0 1 x,y+h-r V y+r A r,r 0 0 1 x+r,y Z`; repeat per container, all in the same `<path>`. Keep the subpaths disjoint so no winding rule is ever needed.
**The one thing that makes or breaks it — registration**: place the `<image>` over the *union bounding box* of every subpath (not one image per shape), sized with `preserveAspectRatio="xMidYMid slice"`. The photo then runs continuously *behind* the containers and the gaps read as cuts through one scene. Give each container a different image and it instantly collapses into an ordinary tile grid (#50 / #51) — the continuity is the entire design, not the shapes.
Distinct from #24 (one connected contour) and #47#56 (every cell its own image). For non-trivial contours take the `d` from `shape_boolean_svg.py union` / `combine` (see [`native-shape-authoring.md`](./native-shape-authoring.md)) instead of deriving it by hand. Clip-shape constraints — one direct shape child, no `fill-rule` / `clip-rule`, `<image>` targets only — are owned by [`shared-standards-core.md`](./shared-standards-core.md) §1.2.
83. **Panel with a real hole punched through it (Subtract window)** — a solid or tinted panel with a shape-cut opening that reveals the image below, PowerPoint's Merge Shapes 剪除.
**Geometry**: one `<path>` containing both contours, running in **opposite directions**. Outer clockwise, inner counter-clockwise — e.g. panel `M 80,80 H 1200 V 640 H 80 Z` followed by hole `M 420,220 V 500 H 760 V 220 H 420 Z` (note the second one descends first, reversing the winding). Under nonzero winding the reversed subpath subtracts, producing a true hole, so the effect never needs `fill-rule` and stays inside the [`shared-standards-core.md`](./shared-standards-core.md) §1.2 boundary. Verified end-to-end: both subpaths survive into a single `<a:path>` in the exported `custGeom`. `shape_boolean_svg.py subtract` emits this contour directly.
**Why not #67**: that pattern fakes the opening by laying a background-colored shape on top. It works only over a flat background and silently breaks the moment the page gains a gradient, a texture, or a second image behind the panel. A real hole also lets the underlying image be moved or swapped without recutting the panel.
84. **Deliberately misregistered fragments (Fragment look)** — the inverse of #82. Cut one image into pieces using several `<image>` elements that share the same source, each with its own clip, then **break the alignment on purpose**: offset a few px, rotate 13°, or nudge one piece's scale. The eye still assembles one photo, but the seams now read as intentional — misprint, torn paper, glitch.
Keep the displacement small and consistent in direction; large or random offsets stop reading as a decision and start reading as a rendering bug. `shape_boolean_svg.py fragment` returns each atomic region as a separately addressable path when the pieces must be individually positioned.
85. **Subject breaking out of its container** — the subject sits half inside a card / grid cell / color panel and half outside its boundary. Two `<image>` elements from the same file: one clipped to the container (optionally tinted, #31), one clipped to only the escaping region, positioned so the two halves stay in perfect register. Produces depth with no shadow at all.
Let the *subject* be what escapes, not a corner of background, and break out only once per page — a page where everything escapes has no frame left to break.
26. **Triptych baked into a single wide image** — one wide `<image width=1160 height=334>` whose internal composition already contains 23 scenes. Generate the triptych as one image (not three separate calls) when scene-to-scene consistency matters — the model preserves character identity, lighting continuity, and color grading far more reliably when panels are produced together.
## Overlay & Masking Treatments
@@ -162,12 +240,47 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
29. **Two-stop scrim — opaque on text side, transparent on focal side** — `<linearGradient>` with one stop at `stop-opacity="0.9"` and another at `stop-opacity="0"`. Use when text sits on one side and the image's subject on the other.
30. **Flat semi-transparent rectangle overlay**`<rect fill="#000" fill-opacity="0.4"/>` over the image. Uniform darkening/lightening; simplest scrim.
3031. **Flat overlay wash** — one `<rect fill-opacity>` over the image: neutral `#000` / `#fff` around 0.4 for uniform darkening or lightening, the simplest scrim there is (**#30**), or a deck color at 0.150.25 to pull a foreign-looking photo toward the palette without regenerating it (**#31**).
31. **Color-tinted overlay**`<rect fill="#brandColor" fill-opacity="0.150.25"/>`. Pushes a foreign-looking image toward the deck's palette without regenerating it.
> **Sample the scrim color from the photo itself.** For any gradient scrim over an image (#27, #29, #31, #32, #90), take the solid end's hex from a dominant color *in that image* rather than defaulting to black or a deck color, and slide the gradient stop until the seam between scrim and photo disappears. A black scrim over a warm photo announces itself as a rectangle; a scrim in the photo's own shadow tone reads as part of the picture. This one substitution is the difference between a page that looks masked and one that looks composed.
98. **Grid scrim with per-cell opacity** — instead of one flat or gradient scrim, cover the image with a grid of adjacent rectangles and give each cell a *slightly different* opacity (say 1040 %, varied irregularly). The photo shows through unevenly, so the overlay reads as texture — panelled glass, a pixel field, a contact sheet — rather than as a sheet of paint. Text sits on the denser cells.
Keep the variation small and non-repeating: a regular light/dark alternation reads as a checkerboard, and a wide spread reads as broken rendering. Butt the cells exactly (no gaps, no strokes) so the grid is felt rather than drawn. Distinct from #50 / #88, where every cell holds its own image; here one image lies beneath one grid of glass.
99. **Selective desaturation — colour only where it matters** — the whole image is muted while one subject stays in full colour, which fixes the focus of a busy photo without cropping it. Two registered copies: a desaturated (and usually darkened) version filling the frame, and the colour original clipped to just the subject region, sitting exactly on top.
**Both copies are baked assets** — there is no runtime colour filter on the native route ([`svg-effects.md`](./svg-effects.md) §6.12), so produce the desaturated file with a one-line Pillow `ImageEnhance.Color(img).enhance(0)` pass rather than reaching for `feColorMatrix`. Clip the colour copy along a real edge in the picture (the subject's own contour, per #96) — a rectangular colour patch over a desaturated field reads as an accident.
32. **Multi-stop scrim with hue shift** — three-or-more-stop `<linearGradient>` where stops are different colors (e.g. dark navy → transparent → warm orange). This re-grades the image's color world without regenerating — particularly useful when an AI image came back with the right composition but wrong color temperature.
90. **Full-canvas scrim with shapes cut out of it (the cover / divider formula)** — the single highest-yield formula in this catalog, and the one real decks reuse most: a full-slide `<path>` whose outer contour is the canvas and whose inner subpath(s) are cut out using the opposite-winding rule from #83, laid over a full-bleed image. The scrim mutes the photo everywhere except through the cuts, so one ordinary image becomes a designed page. Three elements total: image, scrim, title.
**The cut contour** — any of these, all authored as reversed inner subpaths in the same `<path>`:
| Contour | Reads as |
|---|---|
| Wave, arc, ribbon (one soft curve across the page) | Editorial banner / horizon |
| Freehand closed curve (irregular, hand-drawn) | Organic torn-paper window |
| An array of hexagons / trapezoids / circles | Rhythmic screen, a window wall |
| Oversized numeral or letterform | Chapter marker (see caveat) |
An array of cuts is just several reversed subpaths in the same `d` — the same construction as #82, except here the image shows *through* the holes rather than being clipped *into* the shapes.
**Paint the scrim** with either a flat light fill at 0.150.25 opacity (white over a photo is the reliable default) or, for a directional reveal, a gradient that varies `stop-opacity` rather than color (`1 → 0.8 → 0`), so the image emerges progressively instead of through one hard boundary. Add a 12px stroke in the same light color on the cut edge to keep it crisp.
**Edge thickness**: to make the cut read as a physical opening, apply `feDropShadow` with `dx="0" dy="0"` and a small `stdDeviation` to the scrim path. Per [`svg-effects.md`](./svg-effects.md) §6.4 a zero-offset shadow is classified and exported as a **glow**, not a shadow — so use an accent or light color; black will read as diffuse haze rather than an edge. Never apply it to the `<image>` itself (#36).
**Numeral / lettering caveat**: cutting *text* out of the scrim needs the glyph as a `<path>` outline, which is not something to author by hand — least of all for CJK. Set the numeral as ordinary `<text>` over the scrim (nearly as strong, fully editable), or pre-render a knocked-out numeral as an RGBA PNG (#68). Do not approximate glyph outlines.
**Motion pairing**: the scrim stays fixed while the image beneath drifts slowly (a 410s linear path, left, starting with the previous animation) — the cuts then behave like windows onto a moving world. That is an animation-stage decision, not page design; see [`animations.md`](./animations.md). It pairs with this pattern more often than with any other.
97. **Frosted-glass panel over the photo** — a legibility panel that is neither a flat scrim (#30) nor a full blur: a region of the image itself, blurred and lightened, sitting under the text while the rest of the photo stays sharp. It keeps the photo's color and composition visible through the panel, which a solid scrim destroys.
**Build it from a baked asset** — runtime blur does not survive native export ([#34](#), [`svg-effects.md`](./svg-effects.md) §6.12). Produce a blurred copy of the source (a Pillow `GaussianBlur` at a large radius, plus a brightness lift), then place that copy clipped to the panel contour and registered to the same position as the base image, so the blur lines up exactly with what is behind it. Add a thin light stroke and, if the style wants it, a slight lightening overlay.
The panel must stay in register with the base photo; a frosted panel showing a *different* part of the scene is the classic tell. Pair with #95 when the panel should also carry a floating edge.
33. **Spotlight mask — clear region surrounded by darkness** — cover the canvas with `<rect>` filled by a `<radialGradient>` whose inner stop is fully transparent and outer stop is opaque dark. Reads as a flashlight beam on the focal area. Use sparingly — it kills everything outside the spotlight.
34. **Gaussian-blur backdrop** — blur the background in the source image, then layer sharp SVG content above it. Native filter export maps the supported blur graph to a glow/shadow effect; it does not preserve a blurred-image backdrop.
@@ -180,21 +293,19 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
## Image as Texture / Atmosphere
57. **Full-bleed image with extreme low opacity as texture wash** — full-bleed `<image>` + overlay `<rect fill="bg-color" fill-opacity="0.70.85"/>` so the image only barely shows through.
57 · 60 · 61. **Image pushed into the background** — the same move at three intensities: a full-bleed texture wash under the page (**#57**, overlay `<rect fill="bg-color" fill-opacity="0.70.85"/>`), low-contrast ambient atmosphere that is seen but never read (**#60**), or a watermark sitting behind body copy (**#61**). Suppress it with an overlay `<rect>` or a pre-dimmed asset — never a runtime filter.
58. **Image fragment as decorative corner element** — small `<image>` (often with `clipPath`) placed in one corner; not the focus, just visual seasoning.
59. **Image as horizontal divider band** — narrow `<image height=80150>` placed between two text sections instead of a `<line>` divider.
60. **Image as ambient noise** — visible but low contrast; mood-setting only, not informational.
61. **Image as watermark behind body content** — large `<image>` at very low opacity behind body text. Use either a pre-baked low-alpha image or a high-opacity overlay `<rect>` to suppress visibility.
## Special Techniques
62. **Same image, two references — full view + zoom-callout** — reference the same image file twice in two `<image>` elements: one shows the full scene at normal size; the second uses `clipPath` (circle or rectangle) plus a larger display size to "zoom into" a sub-region. Connect them with a bezier `<path>` ending in `marker-end`; ring the zoom with a `<circle stroke>` so it reads as a magnifying lens. No special asset needed — the zoom effect comes from same-source-different-display.
63. **Transparent PNG sticker / cutout** — an RGBA PNG (with alpha channel) placed via standard `<image>` — no `clipPath` required, the transparency lives in the file itself. Useful for subjects that should not appear inside a rectangular frame (people cutouts, product shots, decorative motifs floating over backgrounds). **Spot illustrations from the sheet→slice pipeline land here**: `slice_images.py --alpha` outputs transparent cutouts (see [image-generator.md](./image-generator.md) §4.3), so a sliced element is a ready sticker — never box it in a rectangle. Other sources of transparent PNGs: (a) an AI backend with native transparent output, (b) a chroma-key image stripped separately, (c) a user-supplied asset. A cutout begs for the decorative-placement family — combine with `#4` (bleed off the edge), `#58` (corner fragment), `#66` (fade into background), `#69` (slight rotation), or `#49` (asymmetric collage); the worst thing to do with a transparent spot is center it in a tidy box.
63. **Transparent PNG sticker / cutout** — an RGBA PNG placed via plain `<image>`; the transparency lives in the file, so no `clipPath` is needed. Sources: `slice_images.py --alpha` output (see [image-generator.md](./image-generator.md) §4.3), an AI backend with native transparent output, or a user asset.
Never box a cutout in a rectangle — that throws away the only thing it offers. Combine with #4 (bleed off the edge), #58 (corner fragment), #66 (fade into background), #69 (slight rotation), or #49 (asymmetric collage).
64. **Image with embedded text rendered by the AI** — text becomes part of the artwork: decorative lettering, designed title, hand-lettered keyword. Prompt with explicit text content — name the exact characters literally. Use for text that is part of the artwork and will not change. Anything that must be correct or editable goes in the SVG `<text>` layer (#65).
@@ -208,12 +319,41 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
69. **Image rotated at a slight angle for editorial feel** — `transform="rotate(angle cx cy)"` on the `<image>` or its container `<g>`; 26 degrees typical. Adds dynamism without breaking layout.
70. **Image with thin colored matte frame**`<rect fill="none" stroke="#color" stroke-width="26"/>` over or around the image edge. Single rule, single color.
71. **Image with multiple stacked frames for "photo print" aesthetic** — nested `<rect>` outlines or `<rect>` containers of slightly different sizes giving a "framed photograph" look.
7071. **Frames** — a single `<rect fill="none" stroke="#color" stroke-width="26"/>` at the image edge (**#70**), or several nested outlines at slightly different sizes for a photo-print look (**#71**). When the image was cut to a non-rectangular contour, use #86 instead so the frame follows the cut.
72. **Image-to-image transition / merge** — two `<image>` elements with overlapping regions, one or both with gradient masks (from group C) creating a soft blend between them.
95. **Shape filled with the page background itself** — the most-used trick in real decks and the one that has no obvious SVG name. A shape is painted not with a color but with *the page's own background, sampled at the shape's own position*, so it becomes invisible against the page while still being a real object that can be moved, animated, or given an edge.
**SVG form**: give the shape the same `<image>` as the page background, positioned in root coordinates exactly as the background is, and clip it to the shape contour (§1.2). Because the fill stays registered to the page rather than to the shape, the object reads as a hole in whatever is above it.
Three things it buys you, all of which otherwise require a second asset:
- **A cut that keeps the scene continuous** — the shape "removes" a foreground panel and shows the background through it, with no seam even over a photo or gradient.
- **Invisible objects that still animate** — a background-filled bar can wipe, slide, or morph across the page to reveal or conceal content, while never being visible itself.
- **Edge-only forms** — the shape disappears but its stroke, glow, or shadow remains, giving a floating outline that appears cut into the page.
Distinct from #83 (a panel with a real hole) and #90 (a scrim with cuts): those remove paint, this one *impersonates* the background. Reach for it when the thing above must stay a solid object.
96. **Cutout subject re-laid over its own photo** — the mechanism behind every "subject escapes the frame" page (#85), and worth stating on its own because it is a two-asset technique: keep the original photo as the background layer, and place a background-removed PNG of its subject on top, in perfect register.
Once the subject exists as a free-floating layer, it can overlap anything drawn between the two copies: a title the subject stands in front of, a color panel it steps out of, a shape frame it breaks through, a grid line it crosses. The base photo can be tinted, desaturated, blurred (baked), or scrimmed as hard as the layout needs, because the sharp subject on top is what the eye reads.
Register is everything — the cutout must sit exactly where the subject sits in the base image; a few px of drift reads as a printing error. Keep the cutout's own edge clean rather than adding a stroke, unless the design calls for the sticker look of #63.
89. **Same image twice — sharp cutout over a receded full-bleed copy** — the single best answer to "the photo is too narrow / too short for this canvas, and stretching distorts the subject". Reference the same file twice: the bottom copy fills the whole canvas (or panel) and is pushed back; the top copy is clipped to a shape (#82, #24, a slanted band, a folded contour) at native proportions and stays sharp. The subject reads at full fidelity while the background extends the frame to any aspect ratio — no stretching, no letterbox bars, no second asset.
**Recede the bottom copy with what survives export**: a color-tinted or darkened overlay `<rect>` (#30 / #31) at 0.50.8, or a desaturated / lowered-brightness variant of the file. **Blur does not survive** — per #34 the native route does not preserve a blurred-image backdrop, so if the design depends on blur it must be baked into a second image file (a one-line Pillow `GaussianBlur` pass over the original is enough); never rely on a filter at export time. Keep both copies in register — same center, same crop logic — or the trick reads as two unrelated photos.
86. **Contour echo — the clip path reused as a stroke** — after clipping an image (#20#25, #82, #83), reuse the *same* `d` as a `<path fill="none" stroke="accent"/>`, drawn slightly larger or offset a few px. The outline repeats the cut geometry instead of boxing it in a rectangle, which is what #70 / #71 do. One extra element, no new asset. Offset it in a single consistent direction across the page; an echo on every side reads as a border, not an echo.
91. **Faceted gradients for folded / dimensional form (origami, ribbon, folded band)** — build a folded or faceted object from several adjacent `<path>` facets, then give each facet its own `<linearGradient>` whose direction and lightness differ from its neighbours — one face catching light, the next in shade. The fold is created by the *lightness break between adjacent facets*, not by any shadow effect, so it survives export intact as ordinary shapes.
Keep every facet on one hue and vary only lightness (a white → light-grey → white ramp across three facets already reads as a crease), remove all strokes so the facets meet seamlessly, and keep the light direction consistent across the whole object. Combine with #82 by using the assembled facet outline as the clip contour, which puts a photo inside the folded form. Do not reach for `<filter>` shadows to fake depth here — [`svg-effects.md`](./svg-effects.md) owns effect limits, and the gradient break is both cheaper and more reliable.
87. **One image panned across consecutive pages** — a single wide image referenced by 24 consecutive slides, each showing a different horizontal segment (same `<image>` file and container geometry per page, only `x` shifts). Static on its own, it makes the deck read as one continuous scene; the audience recognizes the place before reading a word.
**To make it actually move, the pages must be morph-compatible**: keep the same image file, the same container size, and the same group `id` on every participating page, then export with `-t morph` ([`animations.md`](./animations.md)). Morph then treats it as one object and slides it — the flip becomes a camera pan. Change the filename or the container dimensions between pages and morph stops matching the object, silently degrading to a cross-fade with none of the effect. Nothing else in the deck needs to know about this; it is a page-authoring decision plus one export flag.
---
## Composition Guidance
@@ -245,7 +385,7 @@ Combine freely. The "AI-default" failure mode is the opposite: defaulting to bar
| Benefits with one dominant proof image | `#80` |
| Light promotional page without photos | `#81` |
**Skip-detection signal** — if every page's `Layout pattern` column resolves to bare #2 / #3 / #5 / #6 with no Modifier ids, the catalog was not consulted. Re-read and reconsider.
**Reach for the boolean-geometry family (#82#99) before adding another photo to the page.** Routing, the registration invariant, and the skip-detection signal are in **High-Yield Patterns** at the top of this file.
**Cross-page through-line (recurring motif).** The patterns above are per-page, but a deck reads as *designed* when one illustration motif family recurs across pages—a cover anchor, section dividers repeating the motif (`#75`), and small `#63` spots threaded through the body. Keep one family (shared rendering / locked deck colors / subject world), vary scale and placement, and never turn recurrence into a quota.
@@ -1,11 +1,12 @@
> See [`shared-standards-core.md`](./shared-standards-core.md) §§1.41.5 for the native-shape metadata and validation contracts.
# Native Preset Shape Authoring Reference
# Native Shape Authoring Reference
Use this reference during Executor SVG construction or project-owned canonical
template maintenance when one standard PowerPoint shape can express one
complete geometric object. The helper does not create the preset shape's own
`p:txBody`; keep visible text outside the atomic fragment.
complete object or multiple closed shapes require a PowerPoint-style Boolean
result. Neither helper writes a page. The preset helper does not create the
shape's own `p:txBody`; keep visible text outside the atomic fragment.
## 1. Selection Gate
@@ -20,6 +21,7 @@ Apply this decision order before drawing a stock geometric object.
|---|---|
| Plain rectangle, symmetric rounded rectangle, circle, or ellipse | Write the ordinary SVG primitive; the exporter already emits an editable native shape. |
| One DrawingML preset exactly expresses the intended object | Run `preset_shape_svg.py render`, then insert its complete stdout fragment into the hand-authored page or canonical template. |
| Two or more closed authored shapes require Union, Combine, Fragment, Intersect, or Subtract | Run `shape_boolean_svg.py render`, then replace the operands with every stdout path; the result remains ordinary editable custom geometry. |
| The visual meaning or contour exceeds one stock shape | Write ordinary `<path>` / `<polygon>` geometry; export keeps it as editable custom geometry. |
| The shape only resembles a preset | Keep ordinary SVG; never infer a preset from contour similarity. |
| Mirror/preserve input already owns native-shape metadata | Keep the existing object and metadata; never reselect its preset. |
@@ -190,3 +192,120 @@ and paint. The exporter performs the same validation, then expands the compact
group only in memory to reuse the lossless native-shape conversion path.
Compatible expanded authored input remains under its separate carrier/preview
freshness contract.
---
## 6. Shape Boolean Materialization
**Trigger**: The authored design explicitly requires a PowerPoint-style Union,
Combine, Fragment, Intersect, or Subtract operation over two or more closed
vector shapes.
```bash
python3 ${SKILL_DIR}/scripts/shape_boolean_svg.py render <svg-file> \
--operation subtract \
--source body \
--source cutout \
--id result
```
| Concern | Contract |
|---|---|
| Sources | Closed `path`, `polygon`, `rect`, `circle`, `ellipse`, or one validated compact authored shape preset. Open ordinary geometry, connectors, ordinary groups, text, images, definitions, and nested SVG viewports fail closed. |
| Primary shape | The first `--source` supplies result paint. For `subtract`, all later operands are removed from that primary geometry. Explicit paint flags override only their named channels. |
| Coordinates | Ancestor and local transforms are baked into SVG-root coordinates. Insert stdout at the root in the primary operand's z-order; never reinsert it under an original transformed ancestor. |
| Result | `union`, `combine`, `intersect`, and `subtract` emit one ordinary `<path>`. `fragment` emits stable sibling paths named `<id>-1`, `<id>-2`, ... in top/left/bottom/right/area order. |
| Winding | Results use explicit nonzero contour direction and never emit `fill-rule`, `clip-rule`, `clip-path`, `mask`, or Merge Shapes metadata. Operands that depend on even-odd fill, clipping, or masking fail closed. |
| Preservation | This helper authors new geometry only. Never use it to merge or split mirror/preserve source structure. |
Operation semantics match PowerPoint's visible Merge Shapes result: `union`
keeps every covered region, `combine` keeps the symmetric difference,
`intersect` keeps only common coverage, `subtract` removes every later source
from the primary, and `fragment` returns each atomic filled region. The PPTX
stores the materialized freeform geometry, not replayable operation history.
**Hard rule — stdout-only replacement**: The helper never writes the source
page. In one normal `apply_patch` edit, remove every selected operand and insert
every returned path at the SVG root in the primary operand's z-order. Fragment
paths remain separate shapes; do not wrap them to claim one structural atom.
---
## 7. Shape-Only Modelling Techniques
Applies to any page built from shapes, **with or without images** — a text-only,
data-only, or icon-only deck reaches these the same way. Each technique below is
plain geometry plus gradient paint, so all of it survives native export.
### 7.1 Alternating light/dark gradient = dimensional form
The single highest-yield shape technique. A cylinder, metallic band, dimensional
numeral, or curved panel is produced by one gradient whose stops **alternate
light and dark** across the shape — light · dark · light for a three-stop ramp,
or light · dark · light · dark · light for a five-stop one. The alternation
imitates a curved surface catching light twice; a plain two-stop ramp always
reads flat no matter how strong the contrast.
Keep every stop on one hue and vary only lightness, hold one light direction for
the whole page, and remove strokes so adjacent facets meet cleanly. For a
cylinder, apply the alternating ramp across the body and cap it with an ellipse
carrying its own shallower ramp. This is the shape-level twin of
[`image-layout-patterns.md`](./image-layout-patterns.md) `#91`, which applies the
same idea across separate facets of a folded form.
### 7.2 Reflection without a reflection effect
Native reflection is `Bake-required` ([`svg-effects.md`](./svg-effects.md) §6.12),
so build it from geometry instead:
1. Duplicate the object and flip it with `transform="translate(0, 2·y_bottom) scale(1, -1)"`.
2. Keep only the top **1025 %** of the flipped copy — that is all a reflection
ever shows.
3. Lay a rectangle over it filled with a gradient running from fully transparent
at the object's base to the page background color at the cut line, so the
copy dissolves into the page.
4. Drop the whole reflection to roughly **6070 %** opacity.
Seat rows of certificates, product shots, logo tiles, and cylinders this way. Do
not add a blur — it will not survive export, and a short gradient fade already
reads correctly at slide scale.
### 7.3 Fragment as a modelling tool, not just a boolean
`fragment` (§6) is the fastest way to build layered diagrams from one silhouette:
lay evenly distributed bars across a triangle and fragment it into pyramid tiers;
cross a circle with two bars for a quadrant wheel; slice an annulus radially for
ring segments. Every piece inherits the parent contour, so the assembly stays
perfectly registered — impossible to achieve by drawing the tiers separately.
Distribute the cutting bars with a constant step before fragmenting; uneven tiers
read as a mistake rather than a hierarchy. Paint the resulting pieces with one
gradient family per §7.1 so the stack reads as a single solid.
### 7.4 Soft edges without the soft-edge effect
Feathered edges are `Bake-required` ([`svg-effects.md`](./svg-effects.md) §6.12),
but the four jobs they normally do are all reachable with gradients:
| Intent | Build instead |
|---|---|
| Contact shadow under an object | Ellipse filled with a `radialGradient` from dark-transparent at the centre to fully transparent at the rim |
| Spotlight / stage pool | Cone or ellipse filled with a gradient fading to transparent at its far end, at low opacity over the scene |
| Object dissolving into the page | Overlay a rectangle whose gradient runs from transparent to the exact page background hex |
| Hiding an object while keeping it live | Full transparency, or a background-registered fill ([`image-layout-patterns.md`](./image-layout-patterns.md) `#95`) |
A radial or linear alpha ramp reads the same as a feathered edge at slide scale
and, unlike a filter, exports intact. Never approximate a soft edge with a stack
of stroked outlines — the banding is visible on projection.
### 7.5 Ground plane and staging
An object floating in empty canvas looks pasted on. Give it a surface: a wide
shallow ellipse or trapezoid beneath it, filled with a gradient that fades to the
background at its edges, optionally with a soft dark ellipse directly under the
object as contact shadow. A trapezoid narrowing away from the viewer reads as a
receding floor; a cylinder or slab reads as a pedestal.
Keep the plane low-contrast — it is staging, not content. This is what makes
certificate rows, product hero shots, and trophy/award pages look composed
rather than floating, and it costs two shapes.
@@ -424,8 +424,10 @@ helper cannot write a project, select layout, or generate a page.
**Authoring paint boundary**: v1 accepts `none` or six-digit solid HEX fill and
stroke, optional fill/stroke opacity, stroke width, line cap, and line join.
Generated pages use `spec_lock.md` for stable semantic color anchors and choose
page-local paint from the retained Design Spec, style, and composition context;
Normal generated pages use `spec_lock.md` for stable semantic color anchors and
choose page-local paint from the retained Design Spec, style, and composition context.
The test-only [`quick-test`](../workflows/profiles/quick-test.md) profile has no
lock: keep every chosen paint value explicit in the SVG.
`create-template` authored templates take their values from the confirmed brief
and template `design_spec.md`.
Use ordinary SVG for gradients, patterns, filters, or other treatments outside
@@ -528,7 +530,7 @@ continue without modification.
## 3. Canvas Format Quick Reference
Use the already locked canvas id and exact viewBox. [`canvas-formats.md`](canvas-formats.md) owns format selection; this core owns only SVG conformance on that canvas.
Use the already locked canvas id and exact viewBox. [`canvas-formats.md`](canvas-formats.md) owns format selection; this core owns only SVG conformance on that canvas. The test-only [`quick-test`](../workflows/profiles/quick-test.md) profile has no lock; its first SVG establishes the canvas and every remaining page must use the identical viewBox.
---
@@ -550,13 +552,15 @@ Use the already locked canvas id and exact viewBox. [`canvas-formats.md`](canvas
Semantic markers are minimal compiler hints. Flat pages declare one root `data-pptx-page-role` and omit Master/Layout/layer/placeholder markers. Structured pages carry their final root identity, layer atoms, slots, and native-object metadata from authoring start and omit `data-pptx-page-role`. Use `data-pptx-role` with a stable `id` only when no specialized marker expresses page-frame behavior. Keep ordinary visible content in SVG attributes/text; [`semantic-svg.md`](semantic-svg.md) owns the vocabulary.
- **Canvas authority**: New authoring writes `viewBox="0 0 W H"` with positive
integer pixels from the lock. Numerically equivalent spellings and positive
integer pixels from the lock, or from the first SVG when the explicit
`quick-test` profile is active. Numerically equivalent spellings and positive
fractional imported dimensions remain compatible; export quantizes once at
`1 SVG px = 9,525 EMU`. Invalid/non-finite values, non-zero origin,
non-positive size, or unsupported PowerPoint dimensions are errors. All pages
and Layout prototypes in one build share the numeric canvas and match
`spec_lock.md canvas.viewBox`; standalone templates match `design_spec.md
canvas_viewbox`. Optional root `width`/`height` do not override `viewBox`.
and Layout prototypes in one normal build share the numeric canvas and match
`spec_lock.md canvas.viewBox`; quick-test pages match the first SVG;
standalone templates match `design_spec.md canvas_viewbox`. Optional root
`width`/`height` do not override `viewBox`.
Root `<svg>` transform is forbidden; nested crop and `<symbol viewBox>` keep
their own contracts.
- **Font portability**: font families used by the deck must resolve to installed
@@ -576,7 +580,7 @@ These forms are needed only when the stated PPT behavior matters:
| One editable PPT text frame with mixed inline formatting or wrapped prose | Keep one logical paragraph in one `<text>`. Use non-positional `<tspan>` children for inline runs. Keep the first wrapped line as direct text and put each later line in a direct positioned `<tspan>` that repeats the parent `x` and uses positive relative `dy`; an all-`<tspan>` form may start with `dy="0"`. Same-size, evenly stacked lines flow in the current paragraph; a font-size change, list marker, or larger accepted gap starts another paragraph in that frame. Sibling `<text>` elements are forbidden as line breaks for one paragraph; they remain valid for semantically independent frames. |
| Stable object grouping or object-level animation anchor | Wrap the intended object in `<g id="...">`. Content grouping is **mandatory** per §4.3 — a top-level `<g id>` is also the animation anchor; it is not an optional convenience. |
| Native PowerPoint background promotion | Outside structured mode, the first eligible visual layer may be a direct full-canvas `<rect>` or one inside a simple single-child group. Its fill must have a registered native mapping (solid, linear/radial gradient, or preset pattern), and it must have no transform, filter, clip, rounding, or visible stroke. Export writes the fill as Slide `p:bg`; image elements remain pictures. Structured routes use the narrower explicit solid-background ownership contract in [`pptx-structure-interface.md`](./pptx-structure-interface.md). |
| Free-design / brand-only PowerPoint structure | Use `pptx_structure.mode: flat`. Keep every represented object Slide-local; export materializes one clean project-owned Master plus one Blank Layout from the current lock, removes stock content placeholders/Layout inventory, and retains only the standard date/footer/slide-number capability hooks. Do not author Master/Layout identities, layers, or placeholder slots. |
| Free-design / brand-only PowerPoint structure | Use `pptx_structure.mode: flat`. Keep every represented object Slide-local; export materializes one clean project-owned Master plus one Blank Layout from the current lock, removes stock content placeholders/Layout inventory, and retains only the standard date/footer/slide-number capability hooks. Do not author Master/Layout identities, layers, or placeholder slots. Quick-test uses the same flat object ownership but converter-default theme scaffolding because no lock exists. |
| Reusable template-based PowerPoint Layout | Select one complete authoring SVG per page in `page_layouts`, declare each unique Master/Layout definition once, and assign pages through `page_pptx_layouts`. Strict preserves the prototype contract; adaptive retains its Master and uses a current or new Layout key already declared and assigned by Strategist. Construction cannot extend or mutate that mapping downstream. Non-mirror skin follows `spec_lock`. |
**Hard rule — supported shape conversion**: Every PPT editability claim in this specification refers to the project converter reading `svg_output/` and emitting native DrawingML. `svg_final/` is a self-contained visual preview that may be inserted into PowerPoint as an SVG picture. PowerPoint's manual Convert-to-Shape operation is unsupported; do not narrow the authoring contract to its undocumented SVG subset.
@@ -640,9 +644,12 @@ separate parent content group; never put them inside the preset group itself.
## 5. Workflow Authority
The serial post-processing and export workflow belongs to
[`generate-pptx.md`](../workflows/generate-pptx.md) Step 7. This file defines SVG authoring boundaries
and intentionally does not mirror commands, flags, or output behavior.
The normal serial post-processing and export workflow belongs to
[`generate-pptx.md`](../workflows/generate-pptx.md) Step 7. The explicit
test-only exception belongs to
[`quick-test.md`](../workflows/profiles/quick-test.md). This file defines SVG
authoring boundaries and intentionally does not mirror commands, flags, or
output behavior.
---
@@ -650,5 +657,5 @@ and intentionally does not mirror commands, flags, or output behavior.
## 8. Scope Boundary
Generate project structure, commands, quality-gate order, and export products
are owned by [`generate-pptx.md`](../workflows/generate-pptx.md). They are
intentionally outside this SVG authoring policy.
are owned by [`generate-pptx.md`](../workflows/generate-pptx.md) and its
selected profile. They are intentionally outside this SVG authoring policy.
@@ -53,7 +53,9 @@ Add §VIII rows for the image resources actually planned from the confirmed sour
**Prepared-user fast path**: For initial imported or user-supplied assets confirmed as `provided`, copy the exact `Filename` basename and derive `Dimensions` / `Ratio` from that row's `Width` / `Height` / `AspectRatio` in the latest `analysis/image_analysis.csv`; drop source-side directories, set `Acquire Via: user` and `Status: Existing`, and decide the remaining §VIII fields normally. Existing §VIII / lock / provenance-manifest records override this inference. Assets declared as `ai`, `web`, `slice`, `formula`, or manual fulfillment retain that provenance and advance through their own status lifecycle after entering `images/`; location never reclassifies them as `user / Existing`.
🚧 **GATE — non-formula rows**: read every entry in [`image-layout-patterns.md`](./image-layout-patterns.md). Copy one primary `#<id> <name>` plus any modifier names verbatim into each row; no empty, paraphrased, or invented ids. Strategist owns that pattern selection; Executor adapts its geometry while retaining the selected primary/modifier semantics, resource role, and explicit constraints. Audit the completed column against page intent: repeated left/right or top/bottom structures are valid when the narrative calls for them, but catalog families and modifiers must remain available without a usage quota.
🚧 **GATE — non-formula rows**: read every entry in [`image-layout-patterns.md`](./image-layout-patterns.md), starting from its `High-Yield Patterns` router. Copy one primary `#<id> <name>` plus any modifier names verbatim into each row; no empty, paraphrased, or invented ids.
**Default — resolve each row against the high-yield router before selecting a plain split or grid (may override when the content genuinely wants one):** the boolean-geometry family costs no extra asset and is the largest single lever on how designed the exported deck looks. A deck whose rows are all bare `#2` / `#3` / `#5` / `#6` with no modifier ids did not consult the catalog; reopen it before finalizing §VIII. Strategist owns that pattern selection; Executor adapts its geometry while retaining the selected primary/modifier semantics, resource role, and explicit constraints. Audit the completed column against page intent: repeated left/right or top/bottom structures are valid when the narrative calls for them, but catalog families and modifiers must remain available without a usage quota.
Choose narrative intent before dimensions: hero/full-bleed, atmosphere/background, side-by-side, or accent/inline. Portrait and multi-image calculations belong to [`image-layout-spec.md`](./image-layout-spec.md). Write `Crop Policy: no-crop` whenever cropping could remove required pixels, labels, evidence, identity, or edge content; screenshots, charts, certificates/contracts, dense diagrams, logos, product markings, and formulas are common triggers rather than an exhaustive list. Otherwise write `Crop Policy: adaptive`: Executor may use complete display or a focal-safe crop, and the value never commands cropping. Formula rows use `Type: Latex Formula`, `Acquire Via: formula`, `Crop Policy: no-crop`, and `Rendered` or `Needs-Manual`.
@@ -16,6 +16,8 @@
# 将受支持的 SVG 元素转换为可编辑的原生 DrawingML 形状
python-pptx>=0.6.21
XlsxWriter>=3.0.0
# PowerPoint-style Merge Shapes materialization / PowerPoint 风格合并形状物化
skia-pathops>=0.9.2
# Recorded narration / 录制计时和旁白
# notes_to_audio.py generates per-slide narration audio on macOS/Linux/Windows.
@@ -46,7 +46,7 @@ python3 scripts/update_repo.py
|------|-----------------|---------------|
| Conversion | `source_to_md.py`, `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py`, `pptx_to_svg.py` | [docs/conversion.md](./docs/conversion.md) |
| Project management | `project_manager.py`, `page_context.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py`, `native_enhance_pptx.py` | [docs/project.md](./docs/project.md) |
| SVG pipeline | `preset_shape_svg.py`, `svg_authoring_view.py`, `compact_svg_coordinates.py`, `mirror_template_materialize.py`, `finalize_svg.py`, `svg_to_pptx.py`, `template_preview_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `extract_svg_pictures.py`, `animation_config.py`, `notes_to_audio.py`, `narration_sync.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md); [native preset authoring](../references/native-shape-authoring.md) |
| SVG pipeline | `preset_shape_svg.py`, `shape_boolean_svg.py`, `svg_authoring_view.py`, `compact_svg_coordinates.py`, `mirror_template_materialize.py`, `finalize_svg.py`, `svg_to_pptx.py`, `template_preview_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `extract_svg_pictures.py`, `animation_config.py`, `notes_to_audio.py`, `narration_sync.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md); [native shape authoring](../references/native-shape-authoring.md) |
| PPTX transitions | `pptx_transitions.py` | [docs/pptx-transitions.md](./docs/pptx-transitions.md) |
| PPTX animations | `pptx_animations.py`, `animation_config.py` | [docs/pptx-animations.md](./docs/pptx-animations.md) |
| Spec maintenance | `update_spec.py`, `chart_recall.py` | [docs/update_spec.md](./docs/update_spec.md); [docs/chart-recall.md](./docs/chart-recall.md) |
@@ -174,7 +174,7 @@ python3 scripts/template_fill_pptx.py apply <project_path>/sources/<source.pptx>
python3 scripts/template_fill_pptx.py validate <project_path>
```
`apply` requires `fill_plan.json` to have top-level `"status": "confirmed"` unless `--force` is passed. It automatically writes `filled_YYYYMMDD_HHMMSS.pptx` unless the output stem already ends with a timestamp. It applies a `fade` page transition by default; `--transition <effect>` (fade/push/wipe/split/strips/cover/random, `--transition-duration` in seconds) changes it, `--transition none` removes it, `--transition keep` preserves the source transitions, and a per-slide `transition` field in the plan overrides whatever the CLI selects.
`apply` requires `fill_plan.json` to have top-level `"status": "confirmed"` unless `--force` is passed. It automatically writes `filled_YYYYMMDD_HHMMSS.pptx` unless the output stem already ends with a timestamp. It applies a `fade` page transition by default; `--transition <effect>` accepts a canonical effect in the shared native gallery registry documented by [`docs/pptx-transitions.md`](docs/pptx-transitions.md), while old names remain accepted only as compatibility inputs, and `--transition-duration <seconds>` changes its duration. `--transition none` removes the visual effect, `--transition keep` preserves the source transitions, and a per-slide `transition` field in the plan overrides whatever the CLI selects. The object form accepts effect-specific native `effect_options`.
Native existing-PPTX enhancement (direct PPTX, no SVG conversion):
@@ -207,6 +207,24 @@ the normative contract and
[`references/native-shape-authoring.md`](../references/native-shape-authoring.md)
for selection and authoring guidance.
PowerPoint-style Merge Shapes materialization (source read-only; result paths
on stdout):
```bash
python3 scripts/shape_boolean_svg.py render slide.svg \
--operation intersect \
--source circle \
--source card \
--id overlap
```
The first source owns result paint and is the primary geometry for `subtract`.
Local and ancestor transforms are baked into SVG-root coordinates. Replace the
operands with every returned path at the root in the primary operand's z-order;
`fragment` returns multiple stable sibling paths. See
[`references/native-shape-authoring.md`](../references/native-shape-authoring.md)
§6 for the closed operand and failure contract.
Create-template/source normalization (optional; never part of automatic export):
```bash
@@ -1103,17 +1103,9 @@ def _wait_only_for_result(
logger.info('waiting for browser confirmation stage=%s...', target_stage)
deadline = None if timeout <= 0 else time.time() + timeout
while True:
current_stage = _result_stage(result_file)
if current_stage == target_stage:
logger.info('confirmation stage=%s received: %s', target_stage, result_file)
return 0
if _result_stage_number(current_stage) > _result_stage_number(target_stage):
logger.error(
'confirmation skipped expected stage=%s and advanced to %s',
target_stage,
current_stage,
)
return 2
result_status = _wait_result_status(result_file, target_stage)
if result_status is not None:
return result_status
skip_error = _stage_skip_error(result_file.parent)
if skip_error:
@@ -1136,6 +1128,25 @@ def _wait_only_for_result(
time.sleep(0.5)
def _wait_result_status(
result_file: Path,
target_stage: str,
) -> Optional[int]:
"""Return a terminal wait status when the persisted result resolves the target."""
current_stage = _result_stage(result_file)
if current_stage == target_stage:
logger.info('confirmation stage=%s received: %s', target_stage, result_file)
return 0
if _result_stage_number(current_stage) > _result_stage_number(target_stage):
logger.error(
'confirmation skipped expected stage=%s and advanced to %s',
target_stage,
current_stage,
)
return 2
return None
def _shutdown_existing(lock_file: Path) -> int:
"""Stop a confirm server left running for this project (idempotent).
@@ -1553,8 +1564,9 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
'--wait-only', action='store_true',
help='Attach to the confirm server for this project and wait for an '
'already-open page to write result.json. If the server died, '
'recover it on the recorded/default port so browser polling can resume.',
'already-open page to write result.json. If the target result is '
'already persisted, return without recovery; otherwise recover a '
'dead server on the recorded/default port so browser polling can resume.',
)
parser.add_argument(
'--wait-stage', default='final', metavar='{stage2,final}',
@@ -1607,8 +1619,12 @@ def main(argv: Optional[list[str]] = None) -> int:
# until the page writes the requested intermediate or final result.json.
if args.wait_only:
lock_file = project_path / LOCK_FILE_NAME
result_file = project_path / CONFIRM_DIR_NAME / RESULT_NAME
if not _live_lock(lock_file):
confirm_dir = project_path / CONFIRM_DIR_NAME
result_status = _wait_result_status(result_file, wait_stage)
if result_status is not None:
return result_status
rec_file = _active_recommendations_path(confirm_dir)
if not rec_file.exists():
logger.error(
@@ -1634,7 +1650,7 @@ def main(argv: Optional[list[str]] = None) -> int:
_server_url(actual_port),
)
return _wait_only_for_result(
project_path / CONFIRM_DIR_NAME / RESULT_NAME,
result_file,
lock_file,
args.wait_timeout,
wait_stage,
@@ -35,7 +35,7 @@ python3 scripts/confirm_ui/server.py <project_path> --shutdown # Step 4 clean
- In `--daemon` mode the launcher starts the child server with browser opening suppressed, waits for `GET /api/health` to prove the server is accepting requests, then opens the printed `http://127.0.0.1:<port>` URL. If health never becomes reachable, the command fails before presenting a dead page.
- **Shares port 5050 with the live preview server** (`svg_editor/server.py`). The two never run at once: confirm is Step 4, live preview is Step 6, and Step 4 always shuts this server down on exit (see `--shutdown`) so the port is free. One port = one forward rule for the whole pipeline. They still keep **separate processes and locks** (`.confirm_ui.lock` vs `.live_preview.lock`).
- `--daemon` starts the Flask process in the background; add `--wait` in the main pipeline so the parent command returns only after the page writes a fresh `result.json`. The `--wait` budget defaults to **590 s** (`--wait-timeout`), kept under the typical 600 s tool ceiling — run the launch with a long tool timeout (≈600000 ms). On timeout the parent returns non-zero but the detached server keeps running, so the caller must re-check `result.json` once before the chat fallback (a slow user may confirm just after the wait returns).
- `--wait-only` attaches to the page already running from the first `--daemon --wait` and blocks until the page writes the requested stage. If the recorded server died, it automatically restarts on the recorded/default port so polling reconnects. Use `--wait-stage stage2` for the complete-solution handoff, then the default `--wait-stage final` for Stage 3. It keys on stage alone (no mtime gate), because a user may submit before the wait command starts.
- `--wait-only` attaches to the page already running from the first `--daemon --wait` and blocks until the page writes the requested stage. If that stage is already persisted, it returns before attempting server recovery, so a fast final confirmation cannot reopen Stage 3 after the page shuts itself down. Otherwise, if the recorded server died, it automatically restarts on the recorded/default port so polling reconnects. Use `--wait-stage stage2` for the complete-solution handoff, then the default `--wait-stage final` for Stage 3. It keys on stage alone (no mtime gate), because a user may submit before the wait command starts.
- `--shutdown` stops a confirm server left running for this project and exits — **idempotent** (a no-op when nothing is running). Tries a graceful `/api/shutdown`, falls back to killing the recorded pid, then clears the lock. Generate Step 4 runs this on every path (page-confirm or chat-fallback) so the page never lingers on the shared port before live preview starts.
- Refuses to start unless the recommendation file expected from `result.json` exists (initially `<project_path>/confirm_ui/recommendations.stage1.json`; `--shutdown` needs no recommendations).
- Per-project lock at `<project_path>/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten.
@@ -1,6 +1,6 @@
# PPTX Animation Core
The shared animation core owns the entrance-effect vocabulary, trigger
The shared animation core owns the object-effect vocabulary, trigger
semantics, OOXML timing writer, semantic read-back, and package validation for
PowerPoint OOXML. Per-element animation remains opt-in: generated PPTX export
defaults to `none`, exactly as before this validation upgrade.
@@ -17,7 +17,7 @@ defaults to `none`, exactly as before this validation upgrade.
| Public authoring contract | `references/animations.md` |
| Customization stage | `workflows/stages/customize-animations.md` |
**Hard rule**: only the generated SVG-to-PPTX route writes object entrance
**Hard rule**: only the generated SVG-to-PPTX route writes object
animations. Direct-PPTX routes preserve source animations and run structural
package validation; they do not resolve or author animation effects.
@@ -30,20 +30,24 @@ One resolved animation-pane row contains these fields:
| Field | Meaning |
|---|---|
| Target | Positive PowerPoint shape id written to `p:spTgt@spid` |
| Effect | One exact registry tuple: filter, `presetID`, and `presetSubtype` |
| Effect | One canonical PowerPoint-authored preset class / id / subtype / behavior-tree signature |
| Trigger | `on-click`, `with-previous`, or `after-previous` |
| Duration | Finite positive schedule duration; filter effects serialize it as behavior duration |
| Delay | Finite non-negative offset used by `after-previous` |
| Trigger shape | Optional different top-level group; maps to PowerPoint `On Click of` |
| Duration | Finite positive schedule duration; scalable native behavior trees preserve their internal timing ratios |
| Delay | Finite non-negative offset used by `after-previous` or as trigger-shape `TriggerDelayTime` |
| Order | Positive integer sidecar order; ties retain stable SVG order |
| Effect options | Effect-specific `direction`, `amount`, `color`, `font_name`, `relative`, or `size` values from PowerPoint `EffectParameters` |
| Timing options | Repeat count/span, auto-reverse, rewind, accelerate/decelerate, bounce-end ratio, and restart policy |
| Completion | Optional dim/hide behavior and packaged `.m4a`/`.mp3`/`.wav` sound |
Modes resolve before XML writing:
| Mode | Resolution |
|---|---|
| `auto` | Deterministic semantic mapping from the SVG group id |
| `mixed` | Deterministic legacy cycle |
| `random` | Stable seeded choice from the legacy pool |
| `none` | No entrance sequence |
| `mixed` | Deterministic cycle over canonical PowerPoint entrance presets |
| `random` | Stable seeded choice from the same canonical preset pool |
| `none` | No object-animation sequence |
The same effective input produces the same `random` choices. When enabled,
`--conversion-trace` records each resolved row and effect, so a generated deck
@@ -51,60 +55,64 @@ can be audited without replaying the resolver.
---
## 3. Compatibility Contract
## 3. Canonical Registry and Compatibility Inputs
The registry preserves these established 22 tuples exactly:
The canonical registry contains 203 PowerPoint-authored presets:
| Key | `p:animEffect@filter` | `presetID` | `presetSubtype` |
|---|---|---:|---:|
| `appear` | none | 1 | 0 |
| `fade` | `fade` | 10 | 0 |
| `fly` | `slide(fromBottom)` | 2 | 4 |
| `cut` | `slide(fromLeft)` | 42 | 8 |
| `zoom` | `image` | 23 | 0 |
| `wipe` | `wipe(left)` | 22 | 1 |
| `split` | `barn(inVertical)` | 16 | 21 |
| `blinds` | `blinds(horizontal)` | 3 | 10 |
| `checkerboard` | `checkerboard(across)` | 5 | 6 |
| `dissolve` | `dissolve` | 9 | 0 |
| `random_bars` | `randombar(horizontal)` | 14 | 10 |
| `peek` | `wipe(down)` | 12 | 4 |
| `wheel` | `wheel(4)` | 21 | 0 |
| `box` | `box(in)` | 4 | 0 |
| `circle` | `circle(in)` | 6 | 0 |
| `diamond` | `diamond(in)` | 8 | 0 |
| `plus` | `plus(in)` | 13 | 0 |
| `strips` | `strips(downRight)` | 18 | 12 |
| `wedge` | `wedge` | 20 | 0 |
| `stretch` | `stretch(across)` | 17 | 0 |
| `expand` | `stretch(across)` | 50 | 0 |
| `swivel` | `wheel(1)` | 19 | 0 |
| Category | Key prefix | Count | Example |
|---|---|---:|---|
| Entrance | `entrance_*` | 53 | `entrance_bounce` |
| Emphasis | `emphasis_*` | 33 | `emphasis_spin` |
| Motion path | `path_*` | 64 | `path_circle` |
| Exit | `exit_*` | 53 | `exit_faded_zoom` |
`cut` is a legacy public key. Compatibility promises the tuple above; it does
not infer a different semantic name from external preset-id tables.
The 29 established short names remain valid only as compatibility inputs.
Normalization resolves them to canonical PowerPoint-authored presets before
selection, XML writing, read-back, tracing, or validation.
Seven directional variants extend the registry without changing any established
tuple:
| Compatibility input | Canonical preset |
|---|---|
| `appear`, `cut` | `entrance_appear` |
| `fade` | `entrance_fade` |
| `fly`, `fly_left`, `fly_right`, `fly_top` | `entrance_fly` |
| `zoom` | `entrance_zoom` |
| `wipe`, `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down` | `entrance_wipe` |
| `split`, `blinds`, `checkerboard`, `dissolve`, `random_bars`, `peek` | matching `entrance_*` preset |
| `wheel`, `box`, `circle`, `diamond`, `plus`, `strips`, `wedge`, `stretch`, `expand`, `swivel` | matching `entrance_*` preset |
| Key | `p:animEffect@filter` | `presetID` | `presetSubtype` |
|---|---|---:|---:|
| `fly_left` | `slide(fromLeft)` | 2 | 8 |
| `fly_right` | `slide(fromRight)` | 2 | 2 |
| `fly_top` | `slide(fromTop)` | 2 | 1 |
| `wipe_left` | `wipe(left)` | 22 | 8 |
| `wipe_right` | `wipe(right)` | 22 | 2 |
| `wipe_up` | `wipe(up)` | 22 | 1 |
| `wipe_down` | `wipe(down)` | 22 | 4 |
`cut` maps to `entrance_appear` because current PowerPoint exposes no separate
Cut object-animation preset. Old Fly/Wipe names desugar to the canonical effect
plus `effect_options.direction`; legacy `wheel` desugars to
`entrance_wheel` plus `amount: 4`. New output never writes those aliases.
The existing `fly` key remains fly-in from bottom. The existing `wipe` key
keeps its historical `wipe(left)` / subtype `1` tuple; use `wipe_left` for
PowerPoint's native left-direction subtype `8`. Directional keys are explicit
effect names rather than a new configuration field, so version-1 sidecars and
the read-back model remain unchanged.
Together with the 29 accepted compatibility names, the public input surface
contains 232 keys. New selections, generated sidecars, conversion traces,
writers, and documentation examples use canonical keys; short names exist only
at compatibility input boundaries.
The shipped `pptx_animation_presets.json` contains the PowerPoint-authored
`p:cTn` row for every native effect. Complex effects use combinations of
`p:set`, `p:anim`, `p:animClr`, `p:animEffect`, `p:animMotion`, `p:animRot`,
and `p:animScale`; reducing them to one filter would silently change the
effect. `pptx_animations.py --list` prints the full categorized public
registry; `pptx_animations.py --describe <effect>` prints that effect's exact
option values and shared timing/completion contract.
Native presets map to the object-capable `MsoAnimEffect` values. Media play,
pause, stop, and play-from-bookmark are excluded because they require a
media/bookmark target rather than an SVG-derived shape. Exit effects use the
same entrance-capable `MsoAnimEffect` identity with PowerPoint's exit flag and
serialize as `presetClass="exit"`.
Paragraph/text-range build controls are likewise outside this writer: generated
targets are top-level SVG groups, not paragraph ranges. For that target model,
the public contract covers all PowerPoint effect parameters, timing modifiers,
completion controls, sound, and object-trigger linkage; Speed and smooth
start/end remain derived rather than duplicated.
**Hard rule — no downgrade**:
- Keep the 22 established tuples byte-for-byte equivalent in meaning.
- Keep all 29 established short names accepted as compatibility inputs.
- Reject an unknown effect, mode, or trigger; never substitute another value.
- Reject booleans and non-finite, out-of-range, or invalidly ordered values.
- Reject a missing slide, missing group, or structural-layer target.
@@ -134,18 +142,26 @@ content.
## 5. OOXML Rules
The writer emits one root-level `p:timing` after `p:transition` and before
`p:extLst`. Its animation tree contains a `tmRoot`, one `mainSeq`, unique
`p:cTn@id` values, and `p:spTgt` references to shapes on the same slide.
The writer emits animation timing after `p:transition` and before `p:extLst`.
Normally this is one root `p:timing`; nonzero `bounce_end` uses PowerPoint's
native `mc:AlternateContent` with a p14 Choice and non-bounce Fallback. Each
branch contains a `tmRoot`, a `mainSeq` when ordinary Start rows exist, one
`interactiveSeq` per trigger-shape row, unique branch-local `p:cTn@id` values,
and same-slide `p:spTgt` references.
Trigger mapping:
| Public trigger | Entrance `p:cTn@nodeType` |
| Public trigger | Object row `p:cTn@nodeType` |
|---|---|
| `on-click` | `clickEffect` |
| `with-previous` | `withEffect` |
| `after-previous` | `afterEffect` |
A group-level `trigger_shape` resolves to a different shape id and writes
PowerPoint's native `interactiveSeq` with `onClick` shape conditions. Its row
remains `clickEffect`; group `delay` becomes `TriggerDelayTime`. Ordinary rows
remain in `mainSeq` and keep the slide Start mode.
The writer does not emit `p:bldP` for grouped content or pictures. Microsoft
defines `p:bldP@spid` for a text-bearing `p:sp`; using it for `p:grpSp` or
`p:pic` creates an invalid build reference. Package validation still accepts a
@@ -157,10 +173,18 @@ the direct routes fingerprint and preserve it instead of blocking those decks.
New generated output never writes it, and generated-package validation remains
strict.
`appear` is the visibility-flip exception: its `p:set` behavior is always 1ms.
The configured positive duration remains the row's scheduling span used when
computing the next `after-previous` offset; read-back verifies the 1ms behavior
and the resulting timeline offset separately.
`entrance_appear` is the visibility-flip exception: its `p:set` behavior is
always 1ms. The configured positive duration remains the row's scheduling span
used when computing the next `after-previous` offset; read-back verifies the
1ms behavior and the resulting timeline offset separately. The compatibility
inputs `appear` and `cut` normalize to this canonical preset.
Other native presets with a
finite duration scale every finite behavior duration and start delay
proportionally, preserving multi-step timing such as bounce and teeter.
PowerPoint-authored instantaneous emphasis presets keep their `indefinite`
behavior duration; their configured duration remains the scheduling span for
the next `after-previous` row.
---
@@ -170,10 +194,13 @@ Generated export reads every slide back before packaging and compares each
requested row with the serialized result:
- row count and row order;
- trigger and shape target;
- resolved effect key, filter, `presetID`, and `presetSubtype`;
- serialized behavior duration and computed timeline offset (`appear` uses the
1ms exception above).
- trigger, optional trigger shape, and shape target;
- resolved effect key, preset class, filter, `presetID`, and `presetSubtype`;
- exact effect options, repeat/reverse/rewind/acceleration/bounce/restart
semantics, completion behavior, sound relationship, and playback span;
- native behavior-tree signature, serialized behavior duration, and computed
timeline offset (`entrance_appear` and instantaneous native presets use the
exceptions above).
After packaging, validation scans every slide part for root timing placement,
duplicate or malformed `p:cTn` ids, missing `p:spTgt` shapes, invalid build
@@ -181,11 +208,9 @@ targets, and unsupported generated effect tuples. A mismatch fails export
before the requested output file replaces an existing deck.
Narration injection parses and merges the slide DOM. It adds audio timing under
the existing `tmRoot` child list, allocates fresh time-node ids, and preserves
the object entrance sequence. It does not replace an existing `p:timing` tree.
The merge accepts only a direct `p:sld/p:timing` source tree; a timing tree
wrapped in `mc:AlternateContent` or another non-root container fails safely
instead of being rewritten or duplicated.
the existing `tmRoot`, allocates fresh ids, and preserves object animation.
For bounce timing it updates both p14 Choice and Fallback; unsupported nested
timing containers still fail safely instead of being duplicated.
Direct-PPTX routes run the structural package validator with generated-effect
enforcement disabled. This permits preservation of source/extension effects and
@@ -195,9 +220,9 @@ object-animation tree before and after their allowed edits; any semantic change
fails. These routes have no object-animation write ownership.
The conversion trace is also the authoritative input for downstream video
motion. `video_motion_plan.py` preserves the resolved effect tuple, direction,
row order, duration, absolute offset, object bounds, and narration-derived slide
advance while adding only renderer-specific enhancement parameters. Video
motion. `video_motion_plan.py` preserves the resolved effect/options, direction,
row order, base and repeat-aware playback duration, absolute offset, object
bounds, and narration-derived slide advance while adding only renderer-specific enhancement parameters. Video
renderers must not bypass this read-back result and infer motion from sidecar
delay values alone.
@@ -211,6 +236,9 @@ differently; the exporter does not make an unconditional Keynote guarantee.
Official references:
- [Microsoft `MsoAnimEffect` enumeration](https://learn.microsoft.com/en-us/office/vba/api/powerpoint.msoanimeffect)
- [Microsoft `Sequence.AddEffect`](https://learn.microsoft.com/en-us/office/vba/api/powerpoint.sequence.addeffect)
- [Microsoft `Effect.Exit`](https://learn.microsoft.com/en-us/office/vba/api/powerpoint.effect.exit)
- [Microsoft animation-filter implementation notes](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/a96dab70-2e72-4319-928d-0eb4b275ce58)
- [Microsoft `p:bldP` implementation restrictions](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/40d17b6d-30c0-4c10-b042-b2597824a820)
- [Open XML SDK time-node values](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.presentation.timenodevalues?view=openxml-3.0.1)
@@ -24,7 +24,7 @@ must not build, replace, or patch a transition with route-local XML or regex.
| Layer | Meaning | OOXML |
|---|---|---|
| Enter | How the current slide appears from the preceding slide | Transition effect child plus duration |
| Enter | How the current slide appears from the preceding slide | Native effect, Effect Options, and duration |
| Advance | How the current slide leaves for the next slide | advClick and advTm |
Enter policy:
@@ -52,17 +52,146 @@ a timing-only p:transition with no visual-effect child.
## 3. Compatibility Contract
The Phase 1 registry preserves the established seven effects:
The native registry covers the complete current PowerPoint transition gallery:
12 Subtle effects, 29 Exciting effects, and 7 Dynamic Content effects. New
selection, sidecars, plans, conversion traces, help, and writers use only these
48 native keys.
| Effect | Required child and attributes |
Eight established low-level names remain valid at input boundaries. They
normalize to one native effect plus native `effect_options`; they are not a
second transition registry:
| Compatibility input | Native request |
|---|---|
| `strips` | `wipe` with `direction: right` |
| `circle` | `shape` with `shape: circle` |
| `diamond` | `shape` with `shape: diamond` |
| `plus` | `shape` with `shape: plus` |
| `newsflash` | `flash` |
| `pull` | `uncover` |
| `wedge` | `clock` with `style: wedge` |
| `wheel` | `clock` with `style: clockwise` |
Standard PresentationML effects use a direct `p:transition` carrier:
| Effect | Required primary child and attributes |
|---|---|
| fade | p:fade |
| push | p:push dir=r |
| wipe | p:wipe dir=r |
| split | p:split orient=horz dir=out |
| strips | p:strips dir=rd |
| split | p:split |
| cut | p:cut |
| random_bars | p:randomBar dir=vert |
| shape | p:circle |
| uncover | p:pull dir=r |
| cover | p:cover dir=r |
| dissolve | p:dissolve |
| checkerboard | p:checker |
| blinds | p:blinds dir=vert |
| clock | p:wheel spokes=1 |
| random | p:random |
| box | p:zoom |
| comb | p:comb |
Office 2010 effects use a `p14` Choice with a `p:fade` Fallback:
| Effect | Required primary child and attributes |
|---|---|
| reveal | p14:reveal dir=r |
| flash | p14:flash |
| ripple | p14:ripple |
| honeycomb | p14:honeycomb |
| glitter | p14:glitter |
| vortex | p14:vortex dir=r |
| shred | p14:shred dir=out |
| switch | p14:switch dir=r |
| flip | p14:flip dir=r |
| gallery | p14:gallery dir=r |
| cube | p14:prism dir=r |
| doors | p14:doors dir=vert |
| zoom | p14:warp dir=in |
| pan | p14:pan dir=r |
| ferris_wheel | p14:ferris dir=r |
| conveyor | p14:conveyor dir=r |
| rotate | p14:prism dir=r isContent=1 |
| window | p14:window |
| orbit | p14:prism dir=r isContent=1 isInverted=1 |
| fly_through | p14:flythrough |
Office 2012 effects use a `p15` Choice with a `p:fade` Fallback:
| Effect | Required primary child and attributes |
|---|---|
| fall_over | p15:prstTrans prst=fallOver invX=1 |
| drape | p15:prstTrans prst=drape invX=1 |
| curtains | p15:prstTrans prst=curtains |
| wind | p15:prstTrans prst=wind |
| prestige | p15:prstTrans prst=prestige |
| fracture | p15:prstTrans prst=fracture |
| crush | p15:prstTrans prst=crush |
| peel_off | p15:prstTrans prst=peelOff invX=1 |
| page_curl | p15:prstTrans prst=pageCurlSingle invX=1 |
| airplane | p15:prstTrans prst=airplane |
| origami | p15:prstTrans prst=origami |
`morph` uses `p159:morph option=byObject` in an Office 2015 Choice with a
`p:fade` Fallback. `none` is the explicit no-visual-effect input and therefore
is not a registry entry.
### 3.1 Native Effect Options
Use `effect_options` only with an explicit native `effect`. Omitted options use
the PowerPoint-authored `default` reported by `--describe-transition`:
| Effect | Supported options |
|---|---|
| `morph` | `morph_by`: `object`, `word`, `character` |
| `fade` | `style`: `smoothly`, `through_black` |
| `push`, `wipe`, `vortex`, `cube`, `pan`, `rotate`, `orbit` | `direction`: `left`, `right`, `up`, `down` |
| `split` | `orientation`: `horizontal`, `vertical`; `direction`: `out`, `in` |
| `reveal` | `direction`: `right`, `left`; `through_black`: boolean |
| `cut` | `through_black`: boolean |
| `random_bars`, `blinds`, `doors` | `orientation`: `vertical`, `horizontal` |
| `checkerboard` | `direction`: `across`, `down` |
| `comb`, `window` | `orientation`: `horizontal`, `vertical` |
| `shape` | `shape`: `circle`, `diamond`, `plus` |
| `uncover`, `cover` | `direction`: `left`, `right`, `up`, `down`, `up_left`, `up_right`, `down_left`, `down_right` |
| `fall_over`, `drape`, `wind`, `peel_off`, `airplane`, `origami` | `direction`: `right`, `left` |
| `page_curl` | `direction`: `right`, `left`; `pages`: `single`, `double` |
| `clock` | `style`: `clockwise`, `counterclockwise`, `wedge` |
| `ripple` | `origin`: `center`, `up_left`, `up_right`, `down_left`, `down_right` |
| `glitter` | `shape`: `diamond`, `hexagon`; `direction`: `right`, `left`, `up`, `down` |
| `shred` | `pattern`: `strips`, `rectangle`; `direction`: `out`, `in` |
| `switch`, `flip`, `gallery`, `ferris_wheel`, `conveyor` | `direction`: `right`, `left` |
| `box`, `zoom` | `direction`: `out`, `in` |
| `fly_through` | `direction`: `in`, `out`; `bounce`: boolean |
| All other native effects | No Effect Options |
Example:
~~~json
{
"transition": {
"effect": "page_curl",
"effect_options": {
"direction": "left",
"pages": "double"
},
"duration": 0.6
}
}
~~~
Inspect the exact contract, including compatibility desugaring:
~~~bash
python3 skills/ppt-master/scripts/pptx_animations.py --describe-transition page_curl
python3 skills/ppt-master/scripts/pptx_animations.py --describe-transition diamond
~~~
Read-back reports the canonical native effect, its complete effective options,
the raw OOXML child, and raw attributes. This makes option loss a validation
failure rather than a silent downgrade.
**Hard rule — no downgrade**:
@@ -70,8 +199,8 @@ The Phase 1 registry preserves the established seven effects:
- Never omit its established direction or split attributes.
- Reject an unknown requested effect; never substitute fade.
- Preserve an unknown source effect when the route selects preserve.
- A future PowerPoint extension counts as successful only when the primary
Choice contains the requested effect. A fallback alone is not success.
- An extension counts as successful only when the primary Choice contains the
requested effect. A fallback alone is not success.
---
@@ -84,7 +213,7 @@ The Phase 1 registry preserves the established seven effects:
| Template Fill v1 | fade, 0.5s | click | keep preserves source; legacy advance_after maps to both |
| Native Enhance v1 | Confirmed plan effect | Confirmed timing module | Disabled transitions preserve unless the v1 plan explicitly selected none |
Template Fill and Native Enhance keep their v1 route defaults in Phase 1.
Template Fill and Native Enhance keep their v1 route defaults.
The public `create_pptx_with_native_svg` Python API also retains its legacy
0.5s default; the CLI explicitly passes 0.4s. Changing a default policy is a
separate migration decision.
@@ -133,6 +262,8 @@ into ppt/presentation.xml.
Reject:
- unknown effect names;
- options without an explicit native effect;
- unknown option fields or values for the selected effect;
- non-finite values, including NaN and Infinity;
- duration less than or equal to zero;
- negative advance or narration padding;
@@ -140,9 +271,11 @@ Reject:
- multiple logical transition carriers;
- unresolved MCE Requires or Ignorable prefixes.
Read-back must report the primary Choice effect separately from the fallback.
It must also report carrier type, duration, click mode, and automatic advance
time. Package validation must run after writing, not only before mutation.
Read-back must report the canonical native effect and complete effective
options, while keeping the primary Choice child separate from the fallback. It
must also report raw effect attributes, carrier type, duration, click mode, and
automatic advance time. Package validation must run after writing, not only
before mutation.
Use inline smoke commands and gitignored projects/_smoke_* artifacts. Do not
add a tests directory or test_*.py files.
@@ -304,18 +304,33 @@ warning and uses `flat`; no SVG regeneration is required. A missing `spec_lock.m
an explicit legacy/unknown mode, or a requested `structured` export without an
explicit current structured contract remains blocking.
Disposable few-page converter/layout tests may use the explicit
[`quick-test`](../../workflows/profiles/quick-test.md) profile:
```bash
python3 scripts/svg_to_pptx.py <project_path> --quick-test
```
This test-only flag reads `svg_output/` directly, infers one consistent canvas,
uses flat converter-default package scaffolding, disables notes and motion, and
does not read or require `spec_lock.md`. It writes the PPTX only: no
`backup/`, conversion trace, or `validation/` report. ZIP integrity and Slide
count are checked in memory and reported through
`[QUICK-TEST] status=passed`. The flag rejects options that would add native
data objects, motion, narration, alternate SVG sources, or diagnostic sidecars.
For generated-project narration, follow the
[`generate-audio`](../../workflows/stages/generate-audio.md) stage. It owns voice
selection, audio generation, and the narrated re-export workflow.
Behavior:
- Default output (default-flow mode, no `-o`):
- Default output (normal flow, no `-o`):
- `exports/<project_name>_<timestamp>.pptx` — native editable pptx (canonical output)
- `validation/<project_name>_<timestamp>.report.json` — package postflight, quality-gate linkage, unresolved resource audit, and published part counts
- `backup/<timestamp>/svg_output/` — copy of Executor SVG source, always written so the pptx can be rebuilt via `finalize_svg → svg_to_pptx` without re-running the LLM
- `exports/` contains only final PPTX deliverables; machine-readable quality and postflight reports belong in `validation/`.
- `finalize_svg.py` always creates `svg_final/` before export. This directory is the self-contained SVG visual preview; it is not packaged as a second PPTX.
- Explicit `-o/--output` changes the native PPTX destination and skips `backup/`; its postflight report still uses the output stem under the project `validation/` directory.
- Normal flow always runs `finalize_svg.py` before export. This directory is the self-contained SVG visual preview; it is not packaged as a second PPTX. Quick-test deliberately skips it.
- In normal flow, explicit `-o/--output` changes the native PPTX destination and skips `backup/`; its postflight report still uses the output stem under the project `validation/` directory. Quick-test writes no report.
- Postflight reruns ZIP integrity and published Slide count. Internal relationships,
structured-package validation, transitions, and animations are enforced before the
builder publishes the PPTX and are reported as `enforced-at-build`, not as repeated
@@ -350,11 +365,11 @@ Behavior:
- `[Content_Types].xml` is generated from the actual media extensions written into the PPTX. Unknown media extensions fail unless Python's `mimetypes` can identify them.
- Native export writes to a temporary file first and publishes the requested PPTX only after conversion succeeds. A failed conversion does not replace the main output file.
- `--conversion-trace` without a path writes `validation/<output_stem>.trace.json`. `--conversion-trace <path>` respects the explicit destination; relative paths are resolved from the project root, so `exports/<name>.trace.json` remains available when intentionally requested.
- After publication, native export writes `validation/<output_stem>.report.json`. The report distinguishes authored Slides from internal Layout definitions, reruns ZIP integrity and published Slide-count checks, records slide/layout/master/notes part counts, labels relationship/structured/transition/animation validation as enforced at build time, links the final SVG quality report only when its SHA-256 source fingerprint matches the exact export inputs, and surfaces stale/unverified gates, unresolved template tokens, generic-only font stacks, and external image references. A matching final quality report with introduced warnings yields `passed-with-warnings` and a `quality_introduced_warnings=<N>` receipt instead of a clean `passed` claim.
- After normal-flow publication, native export writes `validation/<output_stem>.report.json`. The report distinguishes authored Slides from internal Layout definitions, reruns ZIP integrity and published Slide-count checks, records slide/layout/master/notes part counts, labels relationship/structured/transition/animation validation as enforced at build time, links the final SVG quality report only when its SHA-256 source fingerprint matches the exact export inputs, and surfaces stale/unverified gates, unresolved template tokens, generic-only font stacks, and external image references. A matching final quality report with introduced warnings yields `passed-with-warnings` and a `quality_introduced_warnings=<N>` receipt instead of a clean `passed` claim.
- By default, a successful command also prints a compact receipt instead of requiring a report read: `[POSTFLIGHT] status=<...> quality_gate=<...> slides=<N> warning_categories=<N>`, followed by one compact line per warning category and the `[PPTX]` / `[REPORT]` paths. Resource-warning lines carry counts; a non-passing quality gate carries its status. Routine agents use this receipt and do not load either complete validation JSON into model context. Full reports remain cold audit artifacts; failure investigation and explicit audits extract only the required fields. `--quiet` keeps suppressing successful-run output.
- Before publishing structured template output, export reopens the temporary PPTX and validates the Slide → Layout → Master graph and registrations, Layout identity, placeholder identity, reusable bounds, and prompt/level-one sizes. A mismatch aborts publication. Flat release instead validates its single referenced Master/Layout shell and exact date/footer/slide-number hook roster before packaging.
- SVG clip paths are still restricted for authored SVGs, but nested crop wrappers generated by PPTX import are mapped back to native picture crop / geometry when possible.
- Speaker notes are embedded automatically unless `--no-notes` is used
- Normal flow embeds speaker notes automatically unless `--no-notes` is used; quick-test always disables them
- Recorded narration is opt-in:
- `notes_to_audio.py` uses `edge-tts` by default, or a configured cloud TTS provider (`elevenlabs`, `minimax`, `qwen`, `cosyvoice`), and generates one audio file per slide into `audio/`
- Narration text is read strictly from the matching `notes/*.md` file; the script only skips Markdown heading lines (`# ...`) and does not summarize, rewrite, or filter delivery notes
@@ -362,13 +377,13 @@ Behavior:
- `--recorded-narration audio` keeps speaker notes, embeds each matching audio file, and writes slide auto-advance timings from audio duration
- Narrated export defaults to `<project>/narration_animations.json`; pass `--animation-config animations.json` for the canonical presentation animation, or `--no-animations` to remove object animations and page-transition motion while retaining narration and slide timings
- Non-narrated export keeps the existing optional `<project>/animations.json` default
- Narration timing is merged into the existing slide timing DOM; object entrance rows and the resolved page transition are preserved rather than regenerated
- Narration timing is merged into the existing slide timing DOM; object-animation rows and the resolved page transition are preserved rather than regenerated
- `--narration-audio-dir audio` is the lower-level embedding path: it embeds whatever files match and allows partial audio coverage
- Either narration flag names the default-flow export `<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports in the same directory
- This is intended for direct PowerPoint video export with "Use recorded timings and narrations"
- Long-audio import and automatic long-audio splitting are not supported; keep narration assets page-level
- Voice choices can be listed with `python3 scripts/notes_to_audio.py --list-common-voices`, `python3 scripts/notes_to_audio.py --list-voices --locale zh-CN`, or provider-specific `--provider <name> --list-voices`
- Page transitions are controlled by `-t/--transition`; per-element entrance animations are controlled by `-a/--animation`
- Page transitions are controlled by `-t/--transition`; per-element object animations are controlled by `-a/--animation`
- Per-element animation applies to ordinary top-level SVG `<g id="...">` groups in z-order; use one group per logical Slide-local content unit rather than targeting a group count. Master/Layout atoms and slot groups are structural and excluded; exact id tokens remain a fallback only when explicit structural roles are absent
- An explicit `animations.json` group entry may override the marker-free legacy chrome-name heuristic. It cannot override `data-pptx-layer` or an explicit static role/placeholder marker
- Start mode is set by `--animation-trigger`, mirroring PowerPoint's Start dropdown: `after-previous` (default, cascade with `--animation-stagger` spacing on slide entry), `on-click` (presenter-paced), `with-previous` (all together on slide entry)
@@ -381,7 +396,10 @@ Behavior:
(zoom/dissolve/circle/box/diamond/wheel), while unmatched ids cycle through
fade/wipe/fly/zoom.
- `mixed` (legacy) is deterministic: the first animated group on each slide uses `fade`, then later groups cycle through a larger 16-effect pool across the whole deck; `random` uses a stable seed from the effective deck input, and `--conversion-trace` records each resolved effect when enabled
- `--animation-duration` controls per-element entrance length (default `0.4`); `--animation-stagger` adds gap between elements in `after-previous` mode (default `0.5`)
- `--animation-duration` controls the per-element schedule length (default
`0.4`); scalable native effects preserve internal timing ratios, while
instantaneous presets keep their authored duration. `--animation-stagger`
adds gap between elements in `after-previous` mode (default `0.5`)
- Optional object-level overrides live in `<project>/animations.json` or a path passed via `--animation-config`; build and validate them with `animation_config.py scaffold|validate`
- Animation configuration is strict: unknown effects/modes/triggers, invalid finite/range/order values, missing slides/groups, and structural-layer targets fail export without fallback or silent omission
- Generated export reads every slide back and verifies animation row order, trigger, shape target, resolved effect tuple, duration, and offset. Package validation then checks timing placement, `p:cTn` ids, and `p:spTgt` references before publication
@@ -68,20 +68,21 @@ Each object keeps `source_effect` and receives a compatible video family:
| Source effect | Video family |
|---|---|
| `appear` | `hard_reveal` |
| `fade` | `soft_fade` |
| `dissolve` | `grain_dissolve` |
| `fly*`, `cut` | `directional_slide` |
| `wipe*`, `peek` | `soft_mask_reveal` |
| `zoom`, `expand`, `stretch` | `focus_scale` |
| `split` | `split_mask` |
| `box`, `circle`, `diamond`, `plus` | `shape_mask` |
| `blinds`, `checkerboard`, `random_bars`, `strips`, `wedge`, `wheel` | `pattern_reveal` |
| `swivel` | `soft_swivel` |
| `entrance_appear` | `hard_reveal` |
| `entrance_fade` | `soft_fade` |
| `entrance_dissolve` | `grain_dissolve` |
| `entrance_fly`, `entrance_ascend` | `directional_slide` |
| `entrance_wipe`, `entrance_peek` | `soft_mask_reveal` |
| `entrance_zoom`, `entrance_expand`, `entrance_stretch` | `focus_scale` |
| `entrance_split` | `split_mask` |
| `entrance_box`, `entrance_circle`, `entrance_diamond`, `entrance_plus` | `shape_mask` |
| `entrance_blinds`, `entrance_checkerboard`, `entrance_random_bars`, `entrance_strips`, `entrance_wedge`, `entrance_wheel` | `pattern_reveal` |
| `entrance_swivel` | `soft_swivel` |
This mapping is an enhancement contract, not permission to substitute an
unrelated effect. A directional slide remains directional; a wipe remains a
mask reveal.
mask reveal. The retired short effect names remain readable in older
conversion traces, but new traces record canonical PowerPoint keys.
---
@@ -52,6 +52,7 @@ if str(_SCRIPTS_DIR) not in sys.path:
from console_encoding import configure_utf8_stdio # noqa: E402
from pptx_animations import ( # noqa: E402
ANIMATION_TIMING_OPTION_FIELDS,
animation_seconds_to_milliseconds,
normalize_animation_effect,
)
@@ -125,6 +126,7 @@ class SlideAnimationSettings:
duration_ms: int
stagger_ms: int
trigger: str
timing_options: dict[str, Any]
@dataclass(frozen=True)
@@ -535,14 +537,51 @@ def _effective_slide_animation(
)
if not isinstance(trigger, str):
raise ValueError(f"Canonical animation trigger must be a string: {trigger!r}")
timing_options = {
field: (
slide_animation[field]
if field in slide_animation
else default_animation[field]
)
for field in ANIMATION_TIMING_OPTION_FIELDS
if field in slide_animation or field in default_animation
}
return SlideAnimationSettings(
effect=effect,
duration_ms=duration_ms,
stagger_ms=stagger_ms,
trigger=trigger,
timing_options=timing_options,
)
def _animation_playback_duration_ms(
duration_ms: int,
timing_options: dict[str, Any],
*,
label: str,
) -> int:
"""Return schedule duration after repeat and auto-reverse parameters."""
if 'repeat_duration' in timing_options:
return animation_seconds_to_milliseconds(
timing_options['repeat_duration'],
f'{label} repeat_duration',
allow_zero=False,
)
one_play = duration_ms * (
2 if timing_options.get('auto_reverse') is True else 1
)
repeat_count = timing_options.get('repeat_count', 1)
if (
isinstance(repeat_count, bool)
or not isinstance(repeat_count, (int, float))
or not math.isfinite(float(repeat_count))
or float(repeat_count) <= 0
):
raise ValueError(f'{label} repeat_count must be a positive number')
return max(1, round(one_play * float(repeat_count)))
def _group_is_animated(
group_cfg: dict[str, Any],
slide_effect: str | None,
@@ -613,6 +652,17 @@ def _resolve_animation_groups(
"must be an object"
)
groups_cfg[group_id] = group_cfg
interactive_ids = sorted(
group_id
for group_id, group_cfg in groups_cfg.items()
if group_cfg.get("trigger_shape") is not None
and _group_is_animated(group_cfg, settings.effect)
)
if interactive_ids:
raise ValueError(
f'Recorded narration cannot synchronize trigger-shape animations '
f'on slide "{slide_name}": {", ".join(interactive_ids)}'
)
use_svg = _needs_svg_group_resolution(settings, groups_cfg, plan_entries)
candidate_ids: list[str]
@@ -712,6 +762,19 @@ def _resolve_animation_groups(
f'canonical animation duration for "{slide_name}/{group_id}"',
allow_zero=False,
)
timing_options = dict(settings.timing_options)
timing_options.update(
{
field: group_cfg[field]
for field in ANIMATION_TIMING_OPTION_FIELDS
if field in group_cfg
}
)
playback_duration_ms = _animation_playback_duration_ms(
duration_ms,
timing_options,
label=f'canonical animation for "{slide_name}/{group_id}"',
)
original_delay_ms = animation_seconds_to_milliseconds(
group_cfg.get(
"delay",
@@ -725,7 +788,7 @@ def _resolve_animation_groups(
group_id=group_id,
order=order,
source_index=source_index,
duration_ms=duration_ms,
duration_ms=playback_duration_ms,
original_delay_ms=original_delay_ms,
)
)
@@ -50,8 +50,10 @@ from pptx_animations import ( # noqa: E402
from pptx_transitions import ( # noqa: E402
AdvanceUpdate,
EnterUpdate,
TRANSITIONS,
LEGACY_TRANSITION_KEYS,
NATIVE_TRANSITION_KEYS,
apply_slide_motion_xml,
normalize_transition_effect_request,
set_directory_use_timings,
validate_pptx_transition_package,
validate_seconds,
@@ -448,32 +450,63 @@ def _resolve_enter_update(
*,
cli_effect: str | None,
configured_effect: object,
configured_effect_options: object = None,
transitions_enabled: bool,
duration: float,
) -> EnterUpdate:
if cli_effect is None and not transitions_enabled:
if configured_effect == "none":
normalize_transition_effect_request(
configured_effect,
configured_effect_options,
)
return EnterUpdate(policy="none", effect=None, duration=duration)
return EnterUpdate(policy="preserve", duration=duration)
effect = cli_effect if cli_effect is not None else configured_effect
if not isinstance(effect, str):
raise ValueError(f"transition effect must be a string: {effect!r}")
if effect == "none":
return EnterUpdate(policy="none", effect=None, duration=duration)
if effect not in TRANSITIONS:
valid = ", ".join(sorted(TRANSITIONS))
raise ValueError(
f"unknown transition effect {effect!r}; valid effects: {valid}, none"
normalize_transition_effect_request(
effect,
None if cli_effect is not None else configured_effect_options,
)
return EnterUpdate(policy="none", effect=None, duration=duration)
effect, effect_options = normalize_transition_effect_request(
effect,
None if cli_effect is not None else configured_effect_options,
allow_none=False,
)
return EnterUpdate(policy="replace", effect=effect, duration=duration)
return EnterUpdate(
policy="replace",
effect=effect,
duration=duration,
effect_options=effect_options,
)
def _plan_confirmed(plan: dict) -> bool:
return plan.get("status") == "confirmed"
def _native_transition_config(
transition: str,
duration: float,
) -> dict[str, object]:
if transition == "none":
return {"effect": "none", "duration": duration}
effect, effect_options = normalize_transition_effect_request(
transition,
allow_none=False,
)
config: dict[str, object] = {
"effect": effect,
"duration": duration,
}
if effect_options:
config["effect_options"] = effect_options
return config
def _build_enhancement_plan(
project: dict,
*,
@@ -485,6 +518,10 @@ def _build_enhancement_plan(
narration_padding: float,
apply_transition_without_audio: bool,
) -> dict:
transition_config = _native_transition_config(
transition,
transition_duration,
)
return {
"schema": "native_pptx_enhancement_plan.v1",
"status": "draft",
@@ -514,8 +551,7 @@ def _build_enhancement_plan(
"enabled": transition != "none",
"requires_confirmation": True,
"status": "ready",
"effect": transition,
"duration": transition_duration,
**transition_config,
"apply_without_audio": apply_transition_without_audio,
},
},
@@ -601,10 +637,10 @@ def init_project(args: argparse.Namespace) -> int:
"notes_dir": "notes",
"audio_dir": "audio",
"exports_dir": "exports",
"transition": {
"effect": args.transition,
"duration": args.transition_duration,
},
"transition": _native_transition_config(
args.transition,
args.transition_duration,
),
"audio": {
"provider": "",
"voice": "",
@@ -698,12 +734,26 @@ def apply_project(args: argparse.Namespace) -> int:
if not isinstance(timings_cfg, dict):
timings_cfg = {}
transition_options_without_effect = (
(
"effect_options" in transitions_cfg
and "effect" not in transitions_cfg
)
or (
"effect_options" in transition_cfg
and "effect" not in transition_cfg
and "effect" not in transitions_cfg
)
)
if "effect" in transitions_cfg:
configured_effect = transitions_cfg["effect"]
configured_effect_options = transitions_cfg.get("effect_options")
elif "effect" in transition_cfg:
configured_effect = transition_cfg["effect"]
configured_effect_options = transition_cfg.get("effect_options")
else:
configured_effect = "fade"
configured_effect_options = None
if args.transition_duration is not None:
raw_transition_duration = args.transition_duration
@@ -722,6 +772,10 @@ def apply_project(args: argparse.Namespace) -> int:
raw_narration_padding = 0.4
try:
if args.transition is None and transition_options_without_effect:
raise ValueError(
"transition effect_options requires an explicit effect"
)
if args.transition is not None or "transitions" in modules:
transition_duration = validate_seconds(
raw_transition_duration,
@@ -741,6 +795,7 @@ def apply_project(args: argparse.Namespace) -> int:
enter_update = _resolve_enter_update(
cli_effect=args.transition,
configured_effect=configured_effect,
configured_effect_options=configured_effect_options,
transitions_enabled="transitions" in modules,
duration=transition_duration,
)
@@ -916,7 +971,12 @@ def build_parser() -> argparse.ArgumentParser:
init.add_argument("--name", default=None, help="ASCII project name slug")
init.add_argument("--project-dir", default=None, help="explicit project directory")
init.add_argument("--projects-root", default="projects", help="projects root (default: projects)")
init.add_argument("--transition", default="fade", choices=sorted(TRANSITIONS.keys()))
init.add_argument(
"--transition",
default="fade",
choices=[*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS, "none"],
help="PowerPoint-native effect; old names are compatibility inputs",
)
init.add_argument("--transition-duration", type=_positive_seconds_arg, default=0.5)
init.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=0.4)
init.add_argument(
@@ -928,7 +988,12 @@ def build_parser() -> argparse.ArgumentParser:
plan = subparsers.add_parser("plan", help="draft an enhancement module plan")
plan.add_argument("project_path", help="native enhancement project directory")
plan.add_argument("--transition", default="fade", choices=sorted(TRANSITIONS.keys()) + ["none"])
plan.add_argument(
"--transition",
default="fade",
choices=[*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS, "none"],
help="PowerPoint-native effect; old names are compatibility inputs",
)
plan.add_argument("--transition-duration", type=_positive_seconds_arg, default=0.5)
plan.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=0.4)
plan.add_argument(
@@ -942,7 +1007,12 @@ def build_parser() -> argparse.ArgumentParser:
apply.add_argument("project_path", help="native enhancement project directory")
apply.add_argument("-o", "--output", default=None, help="output .pptx path")
apply.add_argument("--overwrite", action="store_true", help="overwrite output if it exists")
apply.add_argument("--transition", default=None, choices=sorted(TRANSITIONS.keys()) + ["none"])
apply.add_argument(
"--transition",
default=None,
choices=[*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS, "none"],
help="PowerPoint-native effect; old names are compatibility inputs",
)
apply.add_argument("--transition-duration", type=_positive_seconds_arg, default=None)
apply.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=None)
apply.add_argument("--force", action="store_true", help="apply without a confirmed enhancement plan")
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,7 @@
"skills/ppt-master/templates/schemas/*.json"
],
"exclude": [],
"max_tokens": 365000
"max_tokens": 365400
},
"file_budgets": {
"AGENTS.md": 2350,
@@ -621,6 +621,14 @@
],
"max_tokens": 21300
},
"stage.generate.video-motion-plan": {
"description": "Conditional resolved animation-to-video handoff contract.",
"scope": "incremental",
"files": [
"skills/ppt-master/scripts/docs/video-motion-plan.md"
],
"max_tokens": 1000
},
"stage.generate.animation-options": {
"description": "Conditional deck-wide transition, auto-advance, and entrance-animation options.",
"scope": "incremental",
@@ -662,12 +670,12 @@
"max_tokens": 800
},
"stage.create-template.authored-preset": {
"description": "Conditional native-preset authoring reference for Create Layout/Create Deck.",
"description": "Conditional native preset and Boolean shape authoring reference for Create Layout/Create Deck.",
"scope": "incremental",
"files": [
"skills/ppt-master/references/native-shape-authoring.md"
],
"max_tokens": 2600
"max_tokens": 3100
},
"route.generate.planning-template": {
"description": "Generate-PPTX planning context after an explicit template workspace is installed.",
@@ -763,12 +771,12 @@
"max_tokens": 900
},
"stage.generate.executor.native-shape": {
"description": "Conditional stock PowerPoint shape selection and fragment contract.",
"description": "Conditional stock PowerPoint shape selection and Boolean materialization contract.",
"scope": "incremental",
"files": [
"skills/ppt-master/references/native-shape-authoring.md"
],
"max_tokens": 2600
"max_tokens": 3100
},
"stage.shared.svg-effects": {
"description": "Conditional advanced SVG paint, effects, transforms, and geometry for Generate Executor or Create Template.",
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
PPT Master - Shape Boolean SVG Fragment Tool
Combine closed SVG shapes and print the resulting canonical SVG path fragment
to stdout. Result geometry is baked into SVG root coordinates: replace the
operands at the SVG root while preserving the primary operand's z-order, and
never reinsert the result into an original transformed ancestor. The source
SVG is read-only; this tool never rewrites the page.
Usage:
python3 scripts/shape_boolean_svg.py render SVG_FILE \
--operation OPERATION --source ID --source ID --id OUTPUT_ID
Examples:
python3 scripts/shape_boolean_svg.py render slide.svg \
--operation intersect --source circle --source card --id overlap
python3 scripts/shape_boolean_svg.py render slide.svg \
--operation subtract --source body --source cutout --id result \
--fill "#2563EB" --stroke none
Dependencies:
skia-pathops and local PPT Master modules
"""
from __future__ import annotations
import argparse
import math
import sys
from pathlib import Path
from typing import Sequence
from console_encoding import configure_utf8_stdio
configure_utf8_stdio()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Print the Boolean result of closed SVG shapes as canonical SVG "
"path fragments in SVG root coordinates. Insert the result at the "
"SVG root in the primary operand's z-order, not under an original "
"transformed ancestor. The source file is never modified."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", required=True)
render_parser = subparsers.add_parser(
"render",
help="Print root-coordinate Boolean-result SVG paths to stdout.",
)
render_parser.add_argument(
"svg_file",
type=Path,
help="Source SVG containing the operand elements.",
)
render_parser.add_argument(
"--operation",
required=True,
choices=("union", "combine", "fragment", "intersect", "subtract"),
help="PowerPoint-compatible merge-shapes operation.",
)
render_parser.add_argument(
"--source",
action="append",
required=True,
dest="source_ids",
metavar="ID",
help=(
"Operand element id in merge order; repeat at least twice. "
"The first operand is primary for subtract and style inheritance."
),
)
render_parser.add_argument(
"--id",
required=True,
dest="output_id",
help="Stable id for the result, or the base id for fragment results.",
)
render_parser.add_argument(
"--fill",
help="Override the primary shape's solid SVG fill, or use none.",
)
render_parser.add_argument(
"--fill-opacity",
type=float,
help="Override fill opacity from 0 to 1.",
)
render_parser.add_argument(
"--stroke",
help="Override the primary shape's solid SVG stroke, or use none.",
)
render_parser.add_argument(
"--stroke-width",
type=float,
help="Override stroke width in SVG page units.",
)
render_parser.add_argument(
"--stroke-opacity",
type=float,
help="Override stroke opacity from 0 to 1.",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
style = _style_from_args(args)
_validate_render_args(args)
from svg_to_pptx.shape_boolean import (
render_boolean_svg_fragments,
)
fragment = render_boolean_svg_fragments(
args.svg_file,
operation=args.operation,
source_ids=args.source_ids,
output_id=args.output_id,
style=style or None,
)
except (OSError, RuntimeError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
print(fragment)
return 0
def _validate_render_args(args: argparse.Namespace) -> None:
if len(args.source_ids) < 2:
raise ValueError("--source must be repeated at least twice")
if len(set(args.source_ids)) != len(args.source_ids):
raise ValueError("--source ids must be unique")
if not args.output_id.strip():
raise ValueError("--id must not be empty")
def _style_from_args(args: argparse.Namespace) -> dict[str, str]:
style: dict[str, str] = {}
if args.fill is not None:
style["fill"] = args.fill
if args.fill_opacity is not None:
_validate_opacity("--fill-opacity", args.fill_opacity)
style["fill-opacity"] = str(args.fill_opacity)
if args.stroke is not None:
style["stroke"] = args.stroke
if args.stroke_width is not None:
if not math.isfinite(args.stroke_width) or args.stroke_width < 0:
raise ValueError("--stroke-width must be greater than or equal to 0")
style["stroke-width"] = str(args.stroke_width)
if args.stroke_opacity is not None:
_validate_opacity("--stroke-opacity", args.stroke_opacity)
style["stroke-opacity"] = str(args.stroke_opacity)
return style
def _validate_opacity(option: str, value: float) -> None:
if not math.isfinite(value) or not 0 <= value <= 1:
raise ValueError(f"{option} must be between 0 and 1")
if __name__ == "__main__":
raise SystemExit(main())
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass
from pathlib import Path
@@ -11,13 +12,22 @@ from xml.etree import ElementTree as ET
from pptx_animations import (
ANIMATIONS,
ANIMATION_AFTER_EFFECTS,
ANIMATION_MODES,
ANIMATION_RESTARTS,
ANIMATION_TIMING_OPTION_FIELDS,
ANIMATION_TRIGGERS,
animation_seconds_to_milliseconds,
normalize_animation_effect,
normalize_animation_effect_options,
normalize_animation_effect_request,
normalize_animation_trigger,
)
from pptx_transitions import TRANSITIONS, validate_seconds
from pptx_transitions import (
normalize_transition_effect,
normalize_transition_effect_request,
validate_seconds,
)
from .drawingml.utils import SVG_NS
from .semantic_markers import is_static_page_frame
@@ -178,7 +188,11 @@ def load_animation_config(project_path: Path, config_path: str | None = None) ->
def _valid_transition_effect(effect: str) -> bool:
return effect == 'none' or effect in TRANSITIONS
try:
normalize_transition_effect(effect)
except ValueError:
return False
return True
def _animation_effect_error(effect: object, label: str) -> str | None:
@@ -195,6 +209,162 @@ def _animation_effect_error(effect: object, label: str) -> str | None:
return None
def _animation_parameter_errors(
value: dict[str, Any],
label: str,
*,
inherited_effect: object,
sound_is_path: bool = True,
) -> list[str]:
"""Validate PowerPoint effect/timing parameters shared by all scopes."""
errors: list[str] = []
effect = value.get('effect', inherited_effect)
effect_options = value.get('effect_options')
if effect_options is not None and 'effect' not in value:
errors.append(
f'animations.json {label} effect_options requires an explicit effect'
)
else:
try:
normalize_animation_effect_request(
effect,
effect_options,
allow_none=True,
allow_modes=True,
)
except ValueError as exc:
errors.append(f'animations.json {label}: {exc}')
repeat_count = value.get('repeat_count')
repeat_duration = value.get('repeat_duration')
if repeat_count is not None:
if (
isinstance(repeat_count, bool)
or not isinstance(repeat_count, (int, float))
or not math.isfinite(float(repeat_count))
or float(repeat_count) <= 0
or float(repeat_count) * 1000 > 4_294_967_295
):
errors.append(
f'animations.json {label} repeat_count must be a positive number: '
f'{repeat_count!r}'
)
if repeat_duration is not None:
try:
animation_seconds_to_milliseconds(
repeat_duration,
f'animations.json {label} repeat_duration',
allow_zero=False,
)
except ValueError as exc:
errors.append(str(exc))
if repeat_count is not None and repeat_duration is not None:
errors.append(
f'animations.json {label} repeat_count and repeat_duration '
'are mutually exclusive'
)
for field in ('auto_reverse', 'rewind'):
if field in value and not isinstance(value[field], bool):
errors.append(
f'animations.json {label} {field} must be a boolean: '
f'{value[field]!r}'
)
ratios: dict[str, float] = {}
for field in ('accelerate', 'decelerate', 'bounce_end'):
if field not in value:
continue
raw_ratio = value[field]
if (
isinstance(raw_ratio, bool)
or not isinstance(raw_ratio, (int, float))
or not math.isfinite(float(raw_ratio))
or not 0 <= float(raw_ratio) <= 1
):
errors.append(
f'animations.json {label} {field} must be between 0 and 1: '
f'{raw_ratio!r}'
)
else:
ratios[field] = float(raw_ratio)
if ratios.get('accelerate', 0) + ratios.get('decelerate', 0) > 1:
errors.append(
f'animations.json {label} accelerate + decelerate must not exceed 1'
)
if ratios.get('bounce_end', 0) and ratios.get('decelerate', 0):
errors.append(
f'animations.json {label} bounce_end and decelerate are '
'mutually exclusive in PowerPoint'
)
if 'restart' in value and value['restart'] not in ANIMATION_RESTARTS:
errors.append(
f'animations.json {label} restart must be one of '
f'{", ".join(ANIMATION_RESTARTS)}: {value["restart"]!r}'
)
if 'after_effect' in value:
after_effect = value['after_effect']
if isinstance(after_effect, str):
after_type = after_effect
after_color = None
elif isinstance(after_effect, dict):
unknown = set(after_effect) - {'type', 'color'}
for field in sorted(unknown):
errors.append(
f'animations.json {label} after_effect has unknown field: {field}'
)
after_type = after_effect.get('type', 'none')
after_color = after_effect.get('color')
else:
after_type = None
after_color = None
errors.append(
f'animations.json {label} after_effect must be a string or object'
)
if after_type is not None and after_type not in ANIMATION_AFTER_EFFECTS:
errors.append(
f'animations.json {label} after_effect.type must be one of '
f'{", ".join(ANIMATION_AFTER_EFFECTS)}: {after_type!r}'
)
elif after_type == 'dim':
if after_color is None:
errors.append(
f'animations.json {label} dim after_effect requires color'
)
else:
try:
normalize_animation_effect_options(
'emphasis_change_fill_color',
{'color': after_color},
)
except ValueError as exc:
errors.append(f'animations.json {label}: {exc}')
elif after_color is not None:
errors.append(
f'animations.json {label} after_effect.color is valid only '
'with type "dim"'
)
if 'sound' in value:
sound = value['sound']
if sound_is_path and (
not isinstance(sound, str) or not sound.strip()
):
errors.append(
f'animations.json {label} sound must be a non-empty path string'
)
elif sound_is_path and Path(sound).suffix.lower() not in {
'.m4a',
'.mp3',
'.wav',
}:
errors.append(
f'animations.json {label} sound must use .m4a, .mp3, or .wav'
)
return errors
def _animation_trigger_error(trigger: object, label: str) -> str | None:
if not isinstance(trigger, str):
return f'animations.json {label} animation trigger must be a string'
@@ -273,20 +443,27 @@ def _transition_scope_errors(
errors = _unknown_field_errors(
transition,
frozenset({'effect', 'duration', 'auto_advance'}),
frozenset({'effect', 'effect_options', 'duration', 'auto_advance'}),
f'{label} transition',
)
if 'effect' in transition:
effect = transition['effect']
if not isinstance(effect, str):
errors.append(
f'animations.json {label} transition effect must be a string'
)
elif not _valid_transition_effect(effect):
errors.append(
f'animations.json {label} has unknown transition effect: {effect}'
)
duration_allows_zero = transition.get('effect', inherited_effect) == 'none'
effect = transition.get('effect', inherited_effect)
effect_options = transition.get('effect_options')
if effect_options is not None and 'effect' not in transition:
errors.append(
f'animations.json {label} transition effect_options requires '
'an explicit effect'
)
else:
try:
normalize_transition_effect_request(effect, effect_options)
except ValueError as exc:
errors.append(f'animations.json {label} transition: {exc}')
try:
duration_allows_zero = (
normalize_transition_effect(effect) is None
)
except ValueError:
duration_allows_zero = False
for field, allow_zero in (
('duration', duration_allows_zero),
('auto_advance', True),
@@ -356,7 +533,16 @@ def _animation_scope_errors(scope: dict[str, Any], label: str) -> list[str]:
errors = _unknown_field_errors(
animation,
frozenset({'effect', 'duration', 'stagger', 'trigger'}),
frozenset({
'effect',
'effect_options',
'duration',
'stagger',
'trigger',
*ANIMATION_TIMING_OPTION_FIELDS,
'after_effect',
'sound',
}),
f'{label} animation',
)
if 'effect' in animation:
@@ -380,6 +566,13 @@ def _animation_scope_errors(scope: dict[str, Any], label: str) -> list[str]:
trigger_error = _animation_trigger_error(animation['trigger'], label)
if trigger_error:
errors.append(trigger_error)
errors.extend(
_animation_parameter_errors(
animation,
f'{label} animation',
inherited_effect='auto',
)
)
return errors
@@ -405,7 +598,17 @@ def _animation_group_errors(
errors.extend(
_unknown_field_errors(
group_cfg,
frozenset({'effect', 'duration', 'delay', 'order'}),
frozenset({
'effect',
'effect_options',
'duration',
'delay',
'order',
'trigger_shape',
*ANIMATION_TIMING_OPTION_FIELDS,
'after_effect',
'sound',
}),
label,
)
)
@@ -434,6 +637,30 @@ def _animation_group_errors(
f'animations.json {label} animation order must be a positive integer: '
f'{order!r}'
)
if 'trigger_shape' in group_cfg:
trigger_shape = group_cfg['trigger_shape']
if not isinstance(trigger_shape, str) or not trigger_shape.strip():
errors.append(
f'animations.json {label} trigger_shape must be a '
f'non-empty group id: {trigger_shape!r}'
)
elif trigger_shape == str(group_id):
errors.append(
f'animations.json {label} trigger_shape must reference '
'a different group'
)
if group_cfg.get('effect') == 'none':
errors.append(
f'animations.json {label} trigger_shape cannot be used '
'with effect "none"'
)
errors.extend(
_animation_parameter_errors(
group_cfg,
label,
inherited_effect='auto',
)
)
return errors
@@ -506,6 +733,28 @@ def validate_animation_config(
'animations.json references non-animatable structural group: '
f'{slide_name}/{group_id}'
)
if not isinstance(group_cfg, dict):
continue
trigger_shape = group_cfg.get('trigger_shape')
if not isinstance(trigger_shape, str) or not trigger_shape.strip():
continue
if trigger_shape in ambiguous_ids:
warnings.append(
'animations.json trigger_shape references ambiguous group: '
f'{slide_name}/{trigger_shape}'
)
continue
trigger_target = known_groups.get(trigger_shape)
if trigger_target is None:
warnings.append(
'animations.json trigger_shape references missing group: '
f'{slide_name}/{trigger_shape}'
)
elif trigger_target.structurally_static:
warnings.append(
'animations.json trigger_shape references non-triggerable '
f'structural group: {slide_name}/{trigger_shape}'
)
return list(dict.fromkeys(warnings))
@@ -68,7 +68,7 @@ class ConvertContext:
# Recursion depth — only the depth==0 (root) context records anim targets.
depth: int = 0
# Top-level <g id="..."> groups, recorded as (shape_id, svg_id) in z-order.
# Used by the PPTX builder to emit per-element entrance timing.
# Used by the PPTX builder to emit per-element object-animation timing.
anim_targets: list = field(default_factory=list)
# Explicit sidecar group ids may override the legacy chrome-name heuristic.
# Explicit structural layer/role/placeholder markers remain non-animatable.
@@ -905,7 +905,7 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
)
# Record top-level semantic groups (e.g. <g id="p02-title">) so the
# PPTX builder can emit per-element entrance timing. Only the outermost
# PPTX builder can emit per-element object timing. Only the outermost
# multi-child wrapper qualifies — flattened single-child groups have no
# <p:grpSp> to anchor a timing target on, and nested groups are
# ignored to keep the animation budget at ~per-section granularity.
@@ -1398,7 +1398,7 @@ def convert_svg_to_slide_shapes(
- rel_entries: List of relationship entries to add.
- anim_targets: List of (shape_id, svg_id) tuples for top-level
semantic groups, in z-order; consumed by the builder's optional
per-element entrance timing emitter.
per-element object-animation timing emitter.
- package_files: Dict of {pptx internal path: bytes} for non-media
OOXML parts such as native chart XML and embedded workbooks.
- content_type_overrides: Dict of {pptx internal path: content type}
@@ -70,6 +70,7 @@ from .styles import (
from .paths import (
PathCommand, parse_svg_path, parse_svg_points, svg_path_to_absolute,
normalize_path_commands, path_commands_to_drawingml,
transform_path_commands,
)
@@ -683,27 +684,6 @@ def _shape_xfrm_from_svg_rect(
return '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy)
def _transform_path_commands(
commands: list[PathCommand],
matrix: tuple[float, float, float, float, float, float],
) -> list[PathCommand]:
"""Apply an affine transform to normalized M/L/C/Z path commands."""
transformed: list[PathCommand] = []
for cmd in commands:
if cmd.cmd in ('M', 'L'):
x, y = transform_point(matrix, cmd.args[0], cmd.args[1])
transformed.append(PathCommand(cmd.cmd, [x, y]))
elif cmd.cmd == 'C':
args: list[float] = []
for i in range(0, 6, 2):
x, y = transform_point(matrix, cmd.args[i], cmd.args[i + 1])
args.extend([x, y])
transformed.append(PathCommand(cmd.cmd, args))
else:
transformed.append(cmd)
return transformed
# ---------------------------------------------------------------------------
# rect
# ---------------------------------------------------------------------------
@@ -1714,7 +1694,7 @@ def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
transform = elem.get('transform')
if _uses_full_transform(ctx, transform):
commands = _transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
commands = transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
commands, 0, 0, 1.0, 1.0,
)
@@ -1797,7 +1777,7 @@ def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
transform = elem.get('transform')
if _uses_full_transform(ctx, transform):
commands = _transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
commands = transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
commands, 0, 0, 1.0, 1.0,
)
@@ -1865,7 +1845,7 @@ def convert_polyline(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | Non
transform = elem.get('transform')
if _uses_full_transform(ctx, transform):
commands = _transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
commands = transform_path_commands(commands, _combined_transform_matrix(ctx, transform))
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
commands, 0, 0, 1.0, 1.0,
)
@@ -12,6 +12,7 @@ from collections.abc import Iterator
from dataclasses import dataclass, field
from xml.etree import ElementTree as ET
from .context import AffineMatrix
from .utils import (
SVG_NS,
parse_inline_style,
@@ -19,6 +20,7 @@ from .utils import (
project_definition_index,
px_to_emu,
resolve_url_id,
transform_point,
)
@@ -750,6 +752,35 @@ def normalize_path_commands(commands: list[PathCommand]) -> list[PathCommand]:
return result
def transform_path_commands(
commands: list[PathCommand],
matrix: AffineMatrix,
) -> list[PathCommand]:
"""Apply an affine transform to normalized M/L/C/Z path commands."""
transformed: list[PathCommand] = []
for command in commands:
if command.cmd in {'M', 'L'}:
x, y = transform_point(
matrix,
command.args[0],
command.args[1],
)
transformed.append(PathCommand(command.cmd, [x, y]))
elif command.cmd == 'C':
args: list[float] = []
for index in range(0, 6, 2):
x, y = transform_point(
matrix,
command.args[index],
command.args[index + 1],
)
args.extend([x, y])
transformed.append(PathCommand(command.cmd, args))
else:
transformed.append(command)
return transformed
def path_commands_to_drawingml(
commands: list[PathCommand],
offset_x: float = 0,
@@ -29,18 +29,20 @@ from pptx import Presentation
from pptx.util import Emu
from pptx_transitions import (
TRANSITIONS,
NATIVE_TRANSITIONS,
create_transition_xml,
normalize_transition_effect,
normalize_transition_effect_request,
set_directory_use_timings,
validate_generated_transition_xml,
validate_pptx_transition_package,
validate_seconds,
)
from pptx_animations import (
ANIMATION_TIMING_OPTION_FIELDS,
animation_seconds_to_milliseconds,
create_sequence_timing_xml,
normalize_animation_effect,
normalize_animation_effect_request,
normalize_animation_trigger,
pick_animation_effect,
validate_generated_animation_xml,
@@ -3907,21 +3909,32 @@ def _slide_config(animation_config: dict[str, Any] | None, svg_stem: str) -> dic
def _slide_transition_settings(
slide_cfg: dict[str, Any],
transition: str | None,
transition_effect_options: dict[str, object] | None,
duration: float,
auto_advance: float | None,
cli_overrides: dict[str, bool],
) -> tuple[str | None, float, float | None]:
) -> tuple[str | None, dict[str, object], float, float | None]:
trans_value = slide_cfg.get('transition', {})
if not isinstance(trans_value, dict):
raise ValueError('animations.json slide transition must be an object')
trans_cfg = trans_value
effect = transition
if not cli_overrides.get('transition') and 'effect' in trans_cfg:
raw_effect = trans_cfg['effect']
if not isinstance(raw_effect, str):
raise ValueError('animations.json transition effect must be a string')
cfg_effect = normalize_transition_effect(raw_effect)
effect = cfg_effect
effect, effect_options = normalize_transition_effect_request(
transition,
transition_effect_options,
)
if not cli_overrides.get('transition'):
if 'effect' in trans_cfg:
raw_effect = trans_cfg['effect']
raw_options = trans_cfg.get('effect_options')
effect, effect_options = normalize_transition_effect_request(
raw_effect,
raw_options,
)
elif 'effect_options' in trans_cfg:
raise ValueError(
'animations.json transition effect_options requires '
'an explicit effect'
)
if not cli_overrides.get('transition_duration'):
if 'duration' in trans_cfg:
duration = validate_seconds(
@@ -3935,48 +3948,69 @@ def _slide_transition_settings(
"transition auto_advance",
allow_zero=True,
)
return effect, duration, auto_advance
return effect, effect_options, duration, auto_advance
def _slide_animation_settings(
slide_cfg: dict[str, Any],
default_animation_cfg: dict[str, Any],
animation: str | None,
duration: float,
stagger: float,
trigger: str,
cli_overrides: dict[str, bool],
) -> tuple[str | None, float, float, str]:
) -> tuple[str | None, float, float, str, dict[str, Any]]:
anim_value = slide_cfg.get('animation', {})
if not isinstance(anim_value, dict):
raise ValueError('animations.json slide animation must be an object')
anim_cfg = anim_value
effect = normalize_animation_effect(
animation,
allow_none=True,
allow_modes=True,
)
if not cli_overrides.get('animation') and 'effect' in anim_cfg:
effect = normalize_animation_effect(
anim_cfg.get('effect'),
resolved_cfg = dict(default_animation_cfg)
resolved_cfg.update(anim_cfg)
if cli_overrides.get('animation'):
effect, effect_options = normalize_animation_effect_request(
animation,
allow_none=True,
allow_modes=True,
)
resolved_cfg['effect'] = effect or 'none'
if effect_options:
resolved_cfg['effect_options'] = effect_options
else:
resolved_cfg.pop('effect_options', None)
else:
raw_effect = resolved_cfg.get('effect', animation)
effect, effect_options = normalize_animation_effect_request(
raw_effect,
resolved_cfg.get('effect_options'),
allow_none=True,
allow_modes=True,
)
resolved_cfg['effect'] = effect or 'none'
if effect_options:
resolved_cfg['effect_options'] = effect_options
else:
resolved_cfg.pop('effect_options', None)
if not cli_overrides.get('animation_duration'):
duration = validate_seconds(
anim_cfg.get('duration', duration),
'animation duration',
allow_zero=False,
)
else:
resolved_cfg['duration'] = duration
if not cli_overrides.get('animation_stagger'):
stagger = validate_seconds(
anim_cfg.get('stagger', stagger),
'animation stagger',
allow_zero=True,
)
else:
resolved_cfg['stagger'] = stagger
if not cli_overrides.get('animation_trigger') and 'trigger' in anim_cfg:
trigger = normalize_animation_trigger(anim_cfg.get('trigger'))
else:
trigger = normalize_animation_trigger(trigger)
resolved_cfg['trigger'] = trigger
animation_seconds_to_milliseconds(
duration,
'animation duration',
@@ -3987,22 +4021,30 @@ def _slide_animation_settings(
'animation stagger',
allow_zero=True,
)
return effect, duration, stagger, trigger
resolved_cfg['effect'] = effect or 'none'
resolved_cfg['duration'] = duration
resolved_cfg['stagger'] = stagger
resolved_cfg['trigger'] = trigger
return effect, duration, stagger, trigger, resolved_cfg
def _build_sequence_targets(
anim_targets: list[tuple[int, str]],
slide_cfg: dict[str, Any],
animation: str | None,
animation_cfg: dict[str, Any],
duration: float,
stagger: float,
mixed_animation_offset: int,
animation_rng: random.Random,
) -> tuple[list[tuple[int, int, str, float]], int]:
) -> tuple[list[dict[str, Any]], int]:
groups_value = slide_cfg.get('groups', {})
if not isinstance(groups_value, dict):
raise ValueError('animations.json slide groups must be an object')
groups_cfg = groups_value
shape_ids_by_group = {
svg_id: sid for sid, svg_id in anim_targets
}
ordered: list[tuple[int, int, str, dict[str, Any]]] = []
for idx, (sid, svg_id) in enumerate(anim_targets):
group_value = groups_cfg.get(svg_id, {})
@@ -4033,11 +4075,12 @@ def _build_sequence_targets(
group_entry = dict(group_cfg)
group_entry['_shape_id'] = sid
group_entry['_effect'] = normalized_effect
group_entry['_effect_raw'] = raw_effect
ordered.append((order, idx, svg_id, group_entry))
ordered.sort(key=lambda item: (item[0], item[1]))
seq_targets: list[tuple[int, int, str, float]] = []
seq_targets: list[dict[str, Any]] = []
resolved_group_modes: list[str | None] = []
for seq_idx, (_order, _original_idx, _svg_id, group_cfg) in enumerate(ordered):
shape_id = int(group_cfg['_shape_id'])
@@ -4050,18 +4093,40 @@ def _build_sequence_targets(
str(raw_effect), seq_idx, mixed_animation_offset, group_id=_svg_id,
rng=animation_rng,
)
effect_options: dict[str, object] = {}
else:
effect = str(raw_effect or pick_animation_effect(
animation, seq_idx, mixed_animation_offset, group_id=_svg_id,
rng=animation_rng,
))
request_effect = (
group_cfg.get('_effect_raw')
if group_cfg.get('_effect_raw') is not None
else effect
)
option_value = (
group_cfg.get('effect_options')
if group_cfg.get('_effect_raw') is not None
else animation_cfg.get('effect_options')
)
effect, effect_options = normalize_animation_effect_request(
request_effect,
option_value,
allow_none=False,
allow_modes=False,
)
item_duration = validate_seconds(
group_cfg.get('duration', duration),
f'animation duration for group "{_svg_id}"',
allow_zero=False,
)
default_delay = (
0
if group_cfg.get('trigger_shape') is not None or seq_idx == 0
else stagger
)
delay_seconds = validate_seconds(
group_cfg.get('delay', 0 if seq_idx == 0 else stagger),
group_cfg.get('delay', default_delay),
f'animation delay for group "{_svg_id}"',
allow_zero=True,
)
@@ -4070,7 +4135,57 @@ def _build_sequence_targets(
f'animation delay for group "{_svg_id}"',
allow_zero=True,
)
seq_targets.append((shape_id, delay_ms, effect, item_duration))
inherited_fields = {
field: animation_cfg[field]
for field in (
*ANIMATION_TIMING_OPTION_FIELDS,
'after_effect',
'sound',
)
if field in animation_cfg
}
inherited_fields.update(
{
field: group_cfg[field]
for field in (
*ANIMATION_TIMING_OPTION_FIELDS,
'after_effect',
'sound',
)
if field in group_cfg
}
)
target_entry: dict[str, Any] = {
'shape_id': shape_id,
'delay_ms': delay_ms,
'effect': effect,
'effect_options': effect_options,
'duration': item_duration,
}
trigger_shape = group_cfg.get('trigger_shape')
if trigger_shape is not None:
if not isinstance(trigger_shape, str) or not trigger_shape.strip():
raise ValueError(
f'animations.json group "{_svg_id}" trigger_shape must '
'be a non-empty group id'
)
trigger_shape_id = shape_ids_by_group.get(trigger_shape)
if trigger_shape_id is None:
raise ValueError(
f'animations.json group "{_svg_id}" trigger_shape '
f'references a missing or non-triggerable group: '
f'{trigger_shape}'
)
if trigger_shape_id == shape_id:
raise ValueError(
f'animations.json group "{_svg_id}" trigger_shape must '
'reference a different group'
)
target_entry['trigger_shape_id'] = trigger_shape_id
target_entry.update(inherited_fields)
if 'sound' in target_entry:
target_entry['_sound_path'] = target_entry.pop('sound')
seq_targets.append(target_entry)
mixed_count = 0
if animation == 'mixed':
@@ -4078,7 +4193,8 @@ def _build_sequence_targets(
elif animation == 'auto':
# 'auto' accumulates a cross-slide offset so the image pool and the
# unmatched-id fallback rotate as the deck advances. Single-effect
# semantic matches (title→fade, chart→wipe etc.) are unaffected
# semantic matches (title→entrance_fade, chart→entrance_wipe, etc.)
# are unaffected
# because they ignore the offset.
mixed_count = len(seq_targets)
else:
@@ -4090,6 +4206,80 @@ def _build_sequence_targets(
return seq_targets, mixed_count
def _next_relationship_id(rel_entries: list[dict[str, str]]) -> str:
"""Return the next slide relationship id, keeping rId1 for the layout."""
used = {1}
for rel in rel_entries:
match = re.fullmatch(r'rId(\d+)', str(rel.get('id', '')))
if match:
used.add(int(match.group(1)))
candidate = 2
while candidate in used:
candidate += 1
return f'rId{candidate}'
def _materialize_animation_sounds(
project_path: Path,
targets: list[dict[str, Any]],
media_files: dict[str, bytes],
rel_entries: list[dict[str, str]],
audio_exts_used: set[str],
) -> list[dict[str, Any]]:
"""Package sidecar sound files and replace paths with OOXML relationships."""
materialized: list[dict[str, Any]] = []
packaged_by_source: dict[Path, tuple[str, str]] = {}
for index, raw_target in enumerate(targets, 1):
target = dict(raw_target)
raw_sound = target.pop('_sound_path', None)
if raw_sound is None:
materialized.append(target)
continue
if not isinstance(raw_sound, str) or not raw_sound.strip():
raise ValueError(
f'animation target {index} sound must be a non-empty path string'
)
sound_path = Path(raw_sound)
if not sound_path.is_absolute():
sound_path = project_path / sound_path
sound_path = sound_path.resolve()
if not sound_path.is_file():
raise ValueError(f'animation sound file not found: {sound_path}')
extension = sound_path.suffix.lower()
if extension not in AUDIO_CONTENT_TYPES:
valid = ', '.join(sorted(AUDIO_CONTENT_TYPES))
raise ValueError(
f'unsupported animation sound format {extension or "(none)"}; '
f'valid formats: {valid}'
)
packaged = packaged_by_source.get(sound_path)
if packaged is None:
payload = sound_path.read_bytes()
digest = hashlib.sha256(payload).hexdigest()[:16]
media_name = f'animation_sound_{digest}{extension}'
relationship_id = _next_relationship_id(rel_entries)
media_files.setdefault(media_name, payload)
rel_entries.append(
{
'id': relationship_id,
'type': AUDIO_REL_TYPE,
'target': f'../media/{media_name}',
}
)
packaged = (relationship_id, media_name)
packaged_by_source[sound_path] = packaged
audio_exts_used.add(extension)
relationship_id, _media_name = packaged
target['sound'] = {
'relationship_id': relationship_id,
'name': sound_path.name,
}
materialized.append(target)
return materialized
def _prerender_legacy_pngs(
svg_files: list[Path],
media_dir: Path,
@@ -4471,6 +4661,8 @@ def create_pptx_with_native_svg(
baseline_layout_specs: list[TemplateSlideSpec] | None = None,
layout_definition_files: list[Path] | None = None,
expected_viewbox: str | None = None,
animation_resource_root: Path | None = None,
transition_effect_options: dict[str, object] | None = None,
) -> bool:
"""Create a PPTX file with native DrawingML shapes.
@@ -4483,17 +4675,25 @@ def create_pptx_with_native_svg(
canvas_format: Canvas format key.
expected_viewbox: Optional project/template-lock canvas contract. Every
public page and internal Layout definition must match it.
animation_resource_root: Base directory for relative animation sound
paths. Defaults to the parent of the SVG source directory.
verbose: Whether to output detailed information.
transition: Transition effect name.
transition_effect_options: PowerPoint Effect Options for the selected
native page transition.
transition_duration: Transition duration in seconds.
auto_advance: Auto-advance interval in seconds.
use_compat_mode: Retained for API compatibility; ignored in native mode.
notes: Notes dict, key is SVG stem, value is notes content.
enable_notes: Whether to enable notes embedding.
use_native_shapes: Must remain true; SVG-image PPTX export is unsupported.
animation: Per-element entrance animation mode (single effect name,
'mixed', 'random', or None to disable). Native shapes mode only.
animation_duration: Per-element entrance duration in seconds.
animation: Per-element object-animation mode (compatibility alias,
PowerPoint-native ``entrance_*``/``emphasis_*``/``path_*``/
``exit_*`` effect, ``'mixed'``, ``'random'``, or None to disable).
Native shapes mode only.
animation_duration: Per-element animation duration in seconds.
Instantaneous native presets retain their PowerPoint-authored
duration.
animation_stagger: Delay between elements in ``after-previous``
trigger mode (seconds). Ignored otherwise.
animation_trigger: PowerPoint Start mode ``'after-previous'`` (default),
@@ -4706,7 +4906,20 @@ def create_pptx_with_native_svg(
else:
print(f" Compatibility mode: Disabled (pure SVG)")
if transition:
trans_name = TRANSITIONS.get(transition, {}).get('name', transition) if TRANSITIONS else transition
canonical_transition, _transition_options = (
normalize_transition_effect_request(
transition,
transition_effect_options,
)
)
trans_name = (
NATIVE_TRANSITIONS.get(canonical_transition, {}).get(
'name',
canonical_transition,
)
if canonical_transition
else transition
)
print(f" Transition effect: {trans_name}")
if enable_notes and notes:
print(f" Speaker notes: {len(notes)} page(s)")
@@ -4800,6 +5013,14 @@ def create_pptx_with_native_svg(
audio_exts_used: set[str] = set()
package_uses_timings = False
mixed_animation_offset = 0
animation_defaults_value = _as_dict(
_as_dict(animation_config).get('defaults')
).get('animation', {})
if not isinstance(animation_defaults_value, dict):
raise ValueError(
'animations.json defaults animation must be an object'
)
default_animation_cfg = animation_defaults_value
animation_seed = json.dumps(
{
'animation': animation,
@@ -4827,7 +5048,7 @@ def create_pptx_with_native_svg(
if is_layout_definition
else f"[Slide {slide_num}/{public_slide_count}]"
)
expected_animation_targets: list[tuple[int, int, str, float]] = []
expected_animation_targets: list[dict[str, Any]] = []
expected_animation_duration = animation_duration
expected_animation_trigger = normalize_animation_trigger(animation_trigger)
@@ -4841,20 +5062,24 @@ def create_pptx_with_native_svg(
)
if is_layout_definition:
slide_transition = None
slide_transition_effect_options = {}
slide_transition_duration = transition_duration
slide_auto_advance = None
slide_animation = None
slide_animation_duration = animation_duration
slide_animation_stagger = animation_stagger
slide_animation_trigger = animation_trigger
slide_animation_cfg = {}
else:
(
slide_transition,
slide_transition_effect_options,
slide_transition_duration,
slide_auto_advance,
) = _slide_transition_settings(
slide_cfg,
transition,
transition_effect_options,
transition_duration,
auto_advance,
animation_cli_overrides,
@@ -4864,8 +5089,10 @@ def create_pptx_with_native_svg(
slide_animation_duration,
slide_animation_stagger,
slide_animation_trigger,
slide_animation_cfg,
) = _slide_animation_settings(
slide_cfg,
default_animation_cfg,
animation,
animation_duration,
animation_stagger,
@@ -4882,7 +5109,7 @@ def create_pptx_with_native_svg(
and animation is None
)
explicit_animation_groups = (
frozenset(
frozenset({
str(group_id)
for group_id, group_cfg in groups_value.items()
if isinstance(group_cfg, dict)
@@ -4891,7 +5118,13 @@ def create_pptx_with_native_svg(
slide_animation is not None
or 'effect' in group_cfg
)
)
} | {
group_cfg['trigger_shape']
for group_cfg in groups_value.values()
if isinstance(group_cfg, dict)
and isinstance(group_cfg.get('trigger_shape'), str)
and group_cfg['trigger_shape'].strip()
})
if not animation_hard_disabled
else frozenset()
)
@@ -4930,6 +5163,7 @@ def create_pptx_with_native_svg(
effect=slide_transition,
duration=slide_transition_duration,
advance_after=slide_auto_advance,
effect_options=slide_transition_effect_options,
)
if transition_fragment:
slide_xml = slide_xml.replace(
@@ -4950,11 +5184,23 @@ def create_pptx_with_native_svg(
anim_targets,
slide_cfg,
slide_animation,
slide_animation_cfg,
slide_animation_duration,
slide_animation_stagger,
mixed_animation_offset,
animation_rng,
)
seq_targets = _materialize_animation_sounds(
(
animation_resource_root
if animation_resource_root is not None
else svg_files[0].parent.parent
),
seq_targets,
media_files_dict,
rel_entries,
audio_exts_used,
)
expected_animation_targets = seq_targets
if mixed_count:
mixed_animation_offset += mixed_count
@@ -4981,7 +5227,12 @@ def create_pptx_with_native_svg(
cached_name = media_cache.get(cache_key)
if cached_name is None:
cached_name = f'image_{media_hash[:16]}.{ext}'
prefix = (
'audio'
if f'.{ext}' in AUDIO_CONTENT_TYPES
else 'image'
)
cached_name = f'{prefix}_{media_hash[:16]}.{ext}'
media_cache[cache_key] = cached_name
with open(media_dir / cached_name, 'wb') as f:
f.write(media_data)
@@ -5042,17 +5293,27 @@ def create_pptx_with_native_svg(
# Track image formats for Content_Types
for media_name in media_name_map.values():
ext = media_name.rsplit('.', 1)[-1].lower()
_content_type_for_extension(ext)
image_exts_used.add(ext)
has_any_image = True
dotted_ext = f'.{ext}'
if dotted_ext in AUDIO_CONTENT_TYPES:
audio_exts_used.add(dotted_ext)
else:
_content_type_for_extension(ext)
image_exts_used.add(ext)
has_any_image = True
# ---- Legacy SVG embedding mode ----
else:
slide_cfg = _slide_config(animation_config, svg_path.stem)
slide_transition, slide_transition_duration, slide_auto_advance = (
(
slide_transition,
slide_transition_effect_options,
slide_transition_duration,
slide_auto_advance,
) = (
_slide_transition_settings(
slide_cfg,
transition,
transition_effect_options,
transition_duration,
auto_advance,
animation_cli_overrides,
@@ -5093,6 +5354,7 @@ def create_pptx_with_native_svg(
png_rid=png_rid, svg_rid=svg_rid,
width_emu=width_emu, height_emu=height_emu,
transition=slide_transition,
transition_effect_options=slide_transition_effect_options,
transition_duration=slide_transition_duration,
auto_advance=slide_auto_advance,
use_compat_mode=(use_compat_mode and slide_has_png),
@@ -5219,6 +5481,7 @@ def create_pptx_with_native_svg(
resolved_motion = validate_generated_transition_xml(
final_slide_xml,
effect=slide_transition,
effect_options=slide_transition_effect_options,
duration=slide_transition_duration,
advance_on_click=resolved_advance_on_click,
advance_after=resolved_advance_after,
@@ -5459,7 +5722,11 @@ def create_pptx_with_native_svg(
for ext in sorted(audio_exts_used):
content_type = AUDIO_CONTENT_TYPES.get(ext)
if content_type:
content_types = _add_default_content_type(content_types, ext, content_type)
content_types = _add_default_content_type(
content_types,
ext.removeprefix('.'),
content_type,
)
if 'Extension="png"' not in content_types:
content_types = _add_default_content_type(content_types, 'png', 'image/png')
with open(content_types_path, 'w', encoding='utf-8') as f:
@@ -27,7 +27,12 @@ from pptx_animations import ( # noqa: E402
normalize_animation_effect,
normalize_animation_trigger,
)
from pptx_transitions import validate_seconds # noqa: E402
from pptx_transitions import ( # noqa: E402
LEGACY_TRANSITION_KEYS,
NATIVE_TRANSITION_KEYS,
normalize_transition_effect_request,
validate_seconds,
)
configure_utf8_stdio()
@@ -55,7 +60,6 @@ from ..drawingml.theme_fonts import (
load_theme_font_spec,
)
from .narration import NARRATION_EXTENSIONS, find_narration_files, probe_audio_duration
from .slide_xml import TRANSITIONS
from .template_structure import (
TemplateStructureError,
load_pptx_structure_lock,
@@ -465,6 +469,30 @@ def _print_postflight_receipt(receipt: _PostflightReceipt) -> None:
print(f' [REPORT] {receipt.report_path}')
def _validate_quick_test_output(
output_path: Path,
*,
expected_slide_count: int,
) -> dict[str, object]:
"""Validate a quick-test PPTX without writing a report sidecar."""
try:
package = _package_part_counts(output_path)
except (OSError, zipfile.BadZipFile) as exc:
raise PptxPostflightValidationError(
f"quick-test PPTX is not a readable ZIP package: {exc}"
) from exc
if package['zip_integrity'] != 'passed':
raise PptxPostflightValidationError(
f"quick-test PPTX ZIP integrity failed at {package['corrupt_member']}"
)
if package['slides'] != expected_slide_count:
raise PptxPostflightValidationError(
"Quick-test Slide count does not match authored SVG count: "
f"{package['slides']} != {expected_slide_count}"
)
return package
def _declared_pptx_structure_mode(project_path: Path) -> str | None:
"""Return the explicitly locked SVG export mode, if the lock declares one."""
lock_path = project_path / 'spec_lock.md'
@@ -620,6 +648,22 @@ def _recorded_narration_on_click_slides(
)
if slide_animation is None and not has_explicit_animation:
continue
has_interactive_animation = any(
isinstance(group_cfg, dict)
and isinstance(group_cfg.get('trigger_shape'), str)
and bool(group_cfg['trigger_shape'].strip())
and (
(
'effect' in group_cfg
and normalize_animation_effect(group_cfg.get('effect')) is not None
)
or ('effect' not in group_cfg and slide_animation is not None)
)
for group_cfg in groups_cfg.values()
)
if has_interactive_animation:
blocked.append(svg_path.stem)
continue
slide_trigger = animation_trigger
if not animation_cli_overrides.get('animation_trigger') and anim_cfg.get('trigger'):
@@ -631,10 +675,11 @@ def _recorded_narration_on_click_slides(
def main(argv: list[str] | None = None) -> int:
"""CLI entry point for the SVG to PPTX conversion tool."""
transition_choices = (
['none'] + (list(TRANSITIONS.keys()) if TRANSITIONS
else ['fade', 'push', 'wipe', 'split', 'strips', 'cover', 'random'])
)
transition_choices = [
'none',
*NATIVE_TRANSITION_KEYS,
*LEGACY_TRANSITION_KEYS,
]
animation_choices = ['none', *ANIMATIONS, 'auto', 'mixed', 'random']
@@ -645,6 +690,7 @@ def main(argv: list[str] | None = None) -> int:
Examples:
%(prog)s examples/ppt169_demo # Default: native pptx -> exports/, svg_output -> backup/<ts>/
%(prog)s examples/ppt169_demo -o out.pptx # Explicit path (no backup/)
%(prog)s projects/_smoke_quick --quick-test # Test-only: svg_output/ -> PPTX, no sidecars
# Disable transition / change transition effect
%(prog)s examples/ppt169_demo -t none
@@ -657,24 +703,32 @@ SVG source directory (-s):
Omit -s to use the default: native export reads svg_output.
Transition effects (-t/--transition):
{', '.join(transition_choices)}
New selections use the 48 PowerPoint-native gallery keys. The 8 old
names remain accepted only as compatibility inputs. Run
scripts/pptx_animations.py --list for the categorized registry and
--describe-transition <effect> for its Effect Options.
Per-element entrance animation (-a/--animation, native shapes mode):
{', '.join(animation_choices)}
Per-element object animation (-a/--animation, native shapes mode):
Use PowerPoint-native entrance_*, emphasis_*, path_*, and exit_* keys for
new animation choices. The 29 old short names remain accepted only as
compatibility inputs. Run scripts/pptx_animations.py --list for the
complete categorized 232-key input registry.
Notes: applied to top-level <g id="..."> SVG groups in z-order. Default is
"none" (no auto element builds; page transitions still apply). Use
"-a auto" to map effects from group id: chartwipe,
card-/step-/pillar-fly, title/takeawayfade; image-like ids
hero/figure-/image/img-/kpi cycle zoom/dissolve/circle/box/diamond/
wheel so multiple images vary across the deck; unmatched ids cycle
fade/wipe/fly/zoom. Start mode set by --animation-trigger, matching
"-a auto" to map effects from group id: chartentrance_wipe,
card-/step-/pillar-entrance_fly,
title/takeawayentrance_fade; image-like ids
hero/figure-/image/img-/kpi cycle canonical entrance presets;
unmatched ids cycle entrance_fade/entrance_wipe/entrance_fly/
entrance_zoom. Start mode set by --animation-trigger, matching
PowerPoint's Start dropdown:
on-click one presenter click per group
with-previous all groups start together on slide entry
after-previous (default) cascade on slide entry;
gap = --animation-stagger seconds
mixed (legacy) cycles a larger 16-effect pool by group order;
random samples from the same legacy pool. Use "-a none" to disable
mixed (compatible mode name) cycles a larger 16-preset canonical
PowerPoint pool by group order; random samples from the same pool.
Use "-a none" to disable
element builds explicitly.
Speaker notes (enabled by default):
@@ -711,6 +765,17 @@ Recorded narration:
choices=list(CANVAS_FORMATS.keys()), default=None,
help='Require SVG canvases to match this registered format')
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
parser.add_argument(
'--quick-test',
action='store_true',
help=(
'Test-only direct export of a small fixed SVG roster from '
'svg_output/. Infer one consistent canvas from the SVGs, use a flat '
'package with converter defaults, and skip spec_lock.md, notes, '
'animations, backup, conversion trace, and validation report '
'artifacts.'
),
)
merge_group = parser.add_mutually_exclusive_group()
merge_group.add_argument('--merge-paragraphs', action='store_true', dest='merge_paragraphs',
@@ -809,15 +874,20 @@ Recorded narration:
parser.add_argument('-a', '--animation', type=str, choices=animation_choices,
default=None,
help='Per-element entrance animation (native shapes mode '
help='Per-element object animation (native shapes mode '
'only). Default "none" (no auto element builds; page '
'transitions still apply). Pick a single effect, "auto" '
'transitions still apply). Pick a native entrance_*/'
'emphasis_*/path_*/exit_* key or "auto" '
'(map effect from group id — image-like ids cycle a '
'richer pool for visual variation, fallback cycles fade/'
'wipe/fly/zoom), "mixed" (legacy 16-effect pool), or '
'"random".')
'richer canonical pool for visual variation, fallback '
'cycles entrance_fade/entrance_wipe/entrance_fly/'
'entrance_zoom), "mixed" (canonical 16-preset pool), or '
'"random". Legacy short names remain accepted only for '
'compatibility.')
parser.add_argument('--animation-duration', type=positive_float, default=None,
help='Per-element entrance duration in seconds (default: 0.4)')
help='Per-element object-animation duration in seconds '
'(default: 0.4; instantaneous native presets keep their '
'PowerPoint-authored duration)')
parser.add_argument('--animation-trigger', type=str,
choices=['on-click', 'with-previous', 'after-previous'],
default=None,
@@ -872,6 +942,49 @@ Recorded narration:
file=sys.stderr,
)
if args.quick_test:
conflicts: list[str] = []
if args.source not in {None, 'output'}:
conflicts.append('--source must be omitted or output')
if args.pptx_structure not in {None, 'flat'}:
conflicts.append('--pptx-structure must be omitted or flat')
if args.conversion_trace is not None:
conflicts.append('--conversion-trace')
if args.native_objects:
conflicts.append('--native-charts-and-tables')
if args.animation_config:
conflicts.append('--animation-config')
if args.recorded_narration:
conflicts.append('--recorded-narration')
if args.narration_audio_dir:
conflicts.append('--narration-audio-dir')
if args.use_narration_timings:
conflicts.append('--use-narration-timings')
if args.auto_advance is not None:
conflicts.append('--auto-advance')
if args.transition is not None or args.transition_duration is not None:
conflicts.append('transition overrides')
if any(
value is not None
for value in (
args.animation,
args.animation_duration,
args.animation_trigger,
args.animation_stagger,
)
):
conflicts.append('animation overrides')
if conflicts:
print(
"Error: --quick-test cannot be combined with: "
+ ", ".join(conflicts),
file=sys.stderr,
)
return 1
args.no_notes = True
args.no_animations = True
args.pptx_structure = 'flat'
project_path = Path(args.project_path)
if not project_path.exists():
print(f"Error: Path does not exist: {project_path}")
@@ -881,13 +994,17 @@ Recorded narration:
native_structure_contract = None
pptx_structure = args.pptx_structure
lock_path = project_path / 'spec_lock.md'
if not lock_path.is_file():
if not args.quick_test and not lock_path.is_file():
print(
"Error: spec_lock.md is required for release SVG export",
file=sys.stderr,
)
return 1
declared_structure_mode = _declared_pptx_structure_mode(project_path)
declared_structure_mode = (
None
if args.quick_test
else _declared_pptx_structure_mode(project_path)
)
if pptx_structure in _LEGACY_PPTX_STRUCTURE_MODES:
_print_structure_contract_error(pptx_structure)
return 1
@@ -934,7 +1051,7 @@ Recorded narration:
theme_font_spec = None
master_text_style_spec = None
theme_color_spec = None
if pptx_structure in {'flat', 'structured'}:
if pptx_structure in {'flat', 'structured'} and not args.quick_test:
try:
theme_font_spec = load_theme_font_spec(project_path)
master_text_style_spec = load_master_text_style_spec(project_path)
@@ -974,8 +1091,12 @@ Recorded narration:
project_name = project_path.name
canvas_format = args.format
expected_viewbox = _declared_canvas_viewbox(project_path)
if expected_viewbox is None:
expected_viewbox = (
None
if args.quick_test
else _declared_canvas_viewbox(project_path)
)
if expected_viewbox is None and not args.quick_test:
print(
"Error: spec_lock.md must contain canvas.viewBox for release export",
file=sys.stderr,
@@ -1106,7 +1227,8 @@ Recorded narration:
narrated_tag = "_narrated" if (args.recorded_narration or args.narration_audio_dir) else ""
native_path = exports_dir / f"{project_name}_{timestamp}{native_tag}{narrated_tag}.pptx"
# Preserve the authored svg_output/ beside every default-flow export.
backup_dir = project_path / "backup" / timestamp
if not args.quick_test:
backup_dir = project_path / "backup" / timestamp
native_path.parent.mkdir(parents=True, exist_ok=True)
@@ -1283,8 +1405,17 @@ Recorded narration:
else transition_defaults.get('effect', 'fade')
)
)
transition = None if transition_effect == 'none' else transition_effect
try:
transition, transition_effect_options = (
normalize_transition_effect_request(
transition_effect,
(
None
if transition_arg is not None or args.no_animations
else transition_defaults.get('effect_options')
),
)
)
transition_duration = validate_seconds(
(
args.transition_duration
@@ -1316,13 +1447,21 @@ Recorded narration:
else (
args.animation
if args.animation is not None
# Per-element entrance is opt-in by default: auto-firing element builds
# read as the "AI deck" tell and were unsolicited. Page transitions stay
# on (see transition default above). Re-enable with -a auto / animations.json.
# Per-element object motion is opt-in by default: unsolicited
# auto-firing builds read as the "AI deck" tell. Page transitions
# stay on; enable objects with -a or animations.json.
else animation_defaults.get('effect', 'none')
)
)
animation = normalize_animation_effect(animation_effect)
normalized_animation = normalize_animation_effect(animation_effect)
# Keep the raw request for the builder so legacy directional aliases
# can desugar into canonical effect_options instead of losing their
# direction during early CLI normalization.
animation = (
None
if normalized_animation is None
else str(animation_effect)
)
animation_duration = validate_seconds(
(
args.animation_duration
@@ -1394,7 +1533,7 @@ Recorded narration:
# are still stamped at export; only the authored fields stay blank.
doc_metadata = None
metadata_path = project_path / 'metadata.json'
if metadata_path.is_file():
if metadata_path.is_file() and not args.quick_test:
try:
loaded = json.loads(metadata_path.read_text(encoding='utf-8'))
except (json.JSONDecodeError, OSError) as exc:
@@ -1420,6 +1559,7 @@ Recorded narration:
structure_name=structure_name,
verbose=verbose,
transition=transition,
transition_effect_options=transition_effect_options,
transition_duration=transition_duration,
auto_advance=auto_advance,
notes=notes,
@@ -1429,6 +1569,7 @@ Recorded narration:
animation_stagger=animation_stagger,
animation_trigger=animation_trigger,
animation_config=animation_config,
animation_resource_root=project_path,
animation_cli_overrides=animation_cli_overrides,
narration_audio=narration_audio,
use_narration_timings=use_narration_timings,
@@ -1516,6 +1657,31 @@ Recorded narration:
print(f" [info] svg_output/ not found, backup skipped")
if success:
if args.quick_test:
try:
package = _validate_quick_test_output(
native_path,
expected_slide_count=len(native_files),
)
except PptxPostflightValidationError as exc:
print(
"Error: quick-test PPTX failed in-memory validation and "
f"must not be used: {exc}",
file=sys.stderr,
)
print(
f" Invalid output remains at: {native_path}",
file=sys.stderr,
)
return 1
if verbose:
print(
" [QUICK-TEST] "
f"status=passed slides={package['slides']} "
"sidecars=none"
)
print(f" [PPTX] {native_path}")
return 0
try:
receipt = _write_postflight_report(
output_path=native_path,
@@ -25,6 +25,9 @@ from pptx_transitions import (
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
RELATIONSHIPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
MARKUP_COMPATIBILITY_NS = (
"http://schemas.openxmlformats.org/markup-compatibility/2006"
)
MEDIA_REL_TYPE = "http://schemas.microsoft.com/office/2007/relationships/media"
AUDIO_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
@@ -395,6 +398,50 @@ def _insert_root_timing(slide: ET.Element, timing: ET.Element) -> None:
slide.insert(insert_at, timing)
def _animation_timing_branches(
slide: ET.Element,
) -> tuple[ET.Element | None, list[ET.Element]]:
"""Return the root timing anchor and every active/fallback timing branch."""
direct = [
child for child in slide
if child.tag == _qn(PML_NS, "timing")
]
alternates: list[tuple[ET.Element, list[ET.Element]]] = []
for child in slide:
if child.tag != _qn(MARKUP_COMPATIBILITY_NS, "AlternateContent"):
continue
timings = [
timing
for branch in list(child)
for timing in list(branch)
if timing.tag == _qn(PML_NS, "timing")
]
if timings:
alternates.append((child, timings))
if direct and alternates:
raise ValueError(
"narration source contains both direct and AlternateContent timing"
)
if len(direct) > 1 or len(alternates) > 1:
raise ValueError("narration source has multiple root animation timings")
if direct:
return direct[0], direct
if alternates:
anchor, timings = alternates[0]
if len(timings) != 2:
raise ValueError(
"narration source animation AlternateContent must contain "
"one Choice and one Fallback timing"
)
return anchor, timings
nested = list(slide.iter(_qn(PML_NS, "timing")))
if nested:
raise ValueError(
"narration source contains unsupported non-root p:timing"
)
return None, []
def inject_narration(
slide_xml: str,
*,
@@ -430,23 +477,21 @@ def inject_narration(
)
if shape_id in shape_ids:
raise ValueError(f"narration shape id already exists on slide: {shape_id}")
timing_ids = _numeric_ids(root.iter(_qn(PML_NS, "cTn")), "timing node")
timing_anchor, timing_branches = _animation_timing_branches(root)
timing_id_sets = [
_numeric_ids(timing.iter(_qn(PML_NS, "cTn")), "timing node")
for timing in timing_branches
]
timing_ids = [
timing_id
for timing_set in timing_id_sets
for timing_id in timing_set
]
next_timing_id = max(timing_ids, default=0) + 1
if next_timing_id > MAX_OOXML_UNSIGNED_INT:
raise ValueError("narration source has no available timing node identifiers")
root_timings = [child for child in root if child.tag == _qn(PML_NS, "timing")]
all_timings = list(root.iter(_qn(PML_NS, "timing")))
if len(all_timings) != len(root_timings):
raise ValueError(
"narration source contains non-root p:timing; only direct p:sld/p:timing "
"can be merged safely"
)
if len(root_timings) > 1:
raise ValueError(
f"narration source has multiple root p:timing elements: {len(root_timings)}"
)
if not root_timings and next_timing_id + 1 > MAX_OOXML_UNSIGNED_INT:
if not timing_branches and next_timing_id + 1 > MAX_OOXML_UNSIGNED_INT:
raise ValueError(
"narration source has no identifiers available for a new timing root"
)
@@ -460,15 +505,20 @@ def inject_narration(
)
shape_tree.append(audio_picture)
if root_timings:
_validate_root_timing_position(root, root_timings[0])
timing_root = _existing_timing_root(root_timings[0])
child_nodes = _direct_child(
timing_root,
_qn(PML_NS, "childTnLst"),
"tmRoot/p:childTnLst",
)
child_nodes.append(_create_audio_timing_element(shape_id, next_timing_id))
if timing_branches:
if timing_anchor is None:
raise AssertionError("timing branches lost their root anchor")
_validate_root_timing_position(root, timing_anchor)
for timing in timing_branches:
timing_root = _existing_timing_root(timing)
child_nodes = _direct_child(
timing_root,
_qn(PML_NS, "childTnLst"),
"tmRoot/p:childTnLst",
)
child_nodes.append(
_create_audio_timing_element(shape_id, next_timing_id)
)
else:
audio_timing = _create_audio_timing_element(shape_id, next_timing_id + 1)
_insert_root_timing(root, _new_timing(audio_timing, next_timing_id))
@@ -2,7 +2,7 @@
from __future__ import annotations
from pptx_transitions import TRANSITIONS, create_transition_xml
from pptx_transitions import create_transition_xml
def create_slide_xml_with_svg(
@@ -15,6 +15,7 @@ def create_slide_xml_with_svg(
transition_duration: float = 0.5,
auto_advance: float | None = None,
use_compat_mode: bool = True,
transition_effect_options: dict[str, object] | None = None,
) -> str:
"""Create slide XML containing an SVG image.
@@ -25,6 +26,8 @@ def create_slide_xml_with_svg(
width_emu: Width in EMU.
height_emu: Height in EMU.
transition: Transition effect name.
transition_effect_options: PowerPoint Effect Options for the selected
native transition.
transition_duration: Transition duration in seconds.
auto_advance: Auto-advance interval in seconds.
use_compat_mode: Whether to use compatibility mode (PNG + SVG dual format).
@@ -35,6 +38,7 @@ def create_slide_xml_with_svg(
effect=transition,
duration=transition_duration,
advance_after=auto_advance,
effect_options=transition_effect_options,
)
if transition_fragment:
transition_xml = '\n' + transition_fragment
@@ -121,7 +121,12 @@ def apply_plan(
source_slide=source_slide,
table_edits=table_edits,
)
slide_effect, slide_duration, slide_advance = _resolve_slide_transition(
(
slide_effect,
slide_effect_options,
slide_duration,
slide_advance,
) = _resolve_slide_transition(
item,
default_effect=transition,
default_duration=transition_duration,
@@ -129,6 +134,7 @@ def apply_plan(
slide_has_auto_advance = _set_slide_transition(
slide_root,
effect=slide_effect,
effect_options=slide_effect_options,
duration=slide_duration,
advance_after=slide_advance,
)
@@ -13,6 +13,10 @@ if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from pptx_transitions import ( # noqa: E402
LEGACY_TRANSITION_KEYS,
NATIVE_TRANSITION_KEYS,
)
configure_utf8_stdio()
@@ -39,7 +43,6 @@ from .transitions import (
DEFAULT_TRANSITION,
DEFAULT_TRANSITION_DURATION,
KEEP_TRANSITION,
TRANSITIONS,
)
from .validator import print_validate_report, validate_project
@@ -116,11 +119,17 @@ def build_parser() -> argparse.ArgumentParser:
)
apply.add_argument(
"--transition",
choices=sorted(TRANSITIONS) + ["none", KEEP_TRANSITION],
choices=[
*NATIVE_TRANSITION_KEYS,
*LEGACY_TRANSITION_KEYS,
"none",
KEEP_TRANSITION,
],
default=DEFAULT_TRANSITION,
help=(
"Page-to-page transition applied to every cloned slide "
"(per-slide 'transition' in the plan overrides this). "
"Use a PowerPoint-native key; old names are compatibility inputs. "
f"Default: {DEFAULT_TRANSITION}. Use 'none' for no motion, "
"or 'keep' to preserve each source slide's existing transition."
),
@@ -12,10 +12,10 @@ from typing import Any
from xml.etree import ElementTree as ET
from pptx_transitions import (
TRANSITIONS,
AdvanceUpdate,
EnterUpdate,
apply_slide_motion,
normalize_transition_effect_request,
validate_seconds,
)
@@ -34,6 +34,7 @@ def _set_slide_transition(
*,
effect: str | None,
duration: float,
effect_options: dict[str, object] | None = None,
advance_after: float | None = None,
) -> bool:
"""Apply a legacy template-fill transition through the shared core.
@@ -61,6 +62,7 @@ def _set_slide_transition(
policy="replace",
effect=effect,
duration=duration,
effect_options=effect_options,
)
advance = AdvanceUpdate(
mode="click" if advance_after is None else "both",
@@ -82,22 +84,44 @@ def _resolve_slide_transition(
*,
default_effect: str | None,
default_duration: float,
) -> tuple[str | None, float, float | None]:
) -> tuple[str | None, dict[str, object], float, float | None]:
"""Pick a slide's transition from its plan entry, falling back to CLI defaults."""
raw = item.get("transition", _UNSET)
if raw is _UNSET:
return default_effect, default_duration, None
if default_effect in (None, "none", KEEP_TRANSITION):
return default_effect, {}, default_duration, None
effect, effect_options = normalize_transition_effect_request(
default_effect,
allow_none=False,
)
return effect, effect_options, default_duration, None
if isinstance(raw, dict):
effect = raw.get("effect", default_effect)
raw_options = raw.get("effect_options")
if raw_options is not None and "effect" not in raw:
raise RuntimeError(
"Transition effect_options requires an explicit effect"
)
duration = raw.get("duration", default_duration)
advance_after = raw.get("advance_after")
else:
effect = None if raw is None else str(raw)
raw_options = None
duration = default_duration
advance_after = None
if effect is not None and effect not in ("none", KEEP_TRANSITION) and effect not in TRANSITIONS:
effect_options: dict[str, object] = {}
if effect is not None and effect not in ("none", KEEP_TRANSITION):
try:
effect, effect_options = normalize_transition_effect_request(
effect,
raw_options,
allow_none=False,
)
except ValueError as exc:
raise RuntimeError(str(exc)) from exc
elif raw_options not in (None, {}):
raise RuntimeError(
f"Unknown transition effect '{effect}'. Valid: {', '.join(sorted(TRANSITIONS))}, none, {KEEP_TRANSITION}"
"Transition effect_options requires an explicit native effect"
)
try:
resolved_duration = validate_seconds(
@@ -107,4 +131,4 @@ def _resolve_slide_transition(
)
except ValueError as exc:
raise RuntimeError(str(exc)) from exc
return effect, resolved_duration, advance_after
return effect, effect_options, resolved_duration, advance_after
@@ -47,6 +47,30 @@ _STYLE_MULTIPLIERS = {
"restrained": 0.72,
"dynamic": 1.28,
}
_VIDEO_EFFECT_ALIASES = {
"entrance_appear": "appear",
"entrance_fade": "fade",
"entrance_fly": "fly",
"entrance_zoom": "zoom",
"entrance_wipe": "wipe_down",
"entrance_split": "split",
"entrance_blinds": "blinds",
"entrance_checkerboard": "checkerboard",
"entrance_dissolve": "dissolve",
"entrance_random_bars": "random_bars",
"entrance_peek": "wipe_up",
"entrance_wheel": "wheel",
"entrance_box": "box",
"entrance_circle": "circle",
"entrance_diamond": "diamond",
"entrance_plus": "plus",
"entrance_strips": "strips",
"entrance_wedge": "wedge",
"entrance_stretch": "stretch",
"entrance_expand": "expand",
"entrance_swivel": "swivel",
"entrance_ascend": "fly_top",
}
_SVG_NS = "http://www.w3.org/2000/svg"
_EMU_PER_PX = 9525
_NUMBER_RE = re.compile(r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?")
@@ -155,7 +179,16 @@ def _shape_events(slide: dict[str, Any]) -> dict[int, dict[str, Any]]:
return selected
def _direction_for_effect(effect: str, filter_name: object) -> str | None:
def _direction_for_effect(
effect: str,
filter_name: object,
effect_options: object,
) -> str | None:
if isinstance(effect_options, dict):
raw_direction = effect_options.get("direction")
if isinstance(raw_direction, str) and raw_direction:
return raw_direction.replace("_", "-")
effect = _VIDEO_EFFECT_ALIASES.get(effect, effect)
explicit = {
"fly": "down",
"fly_left": "left",
@@ -168,19 +201,30 @@ def _direction_for_effect(effect: str, filter_name: object) -> str | None:
"wipe_up": "up",
"wipe_down": "down",
"peek": "down",
"strips": "down-right",
}
if effect in explicit:
return explicit[effect]
if isinstance(filter_name, str):
match = re.search(
r"\((?:from)?(Top|Bottom|Left|Right|Up|Down)\)",
r"\((?:from)?(TopLeft|TopRight|BottomLeft|BottomRight|"
r"UpLeft|UpRight|DownLeft|DownRight|Top|Bottom|Left|Right|Up|Down)\)",
filter_name,
re.IGNORECASE,
)
if match:
value = match.group(1).lower()
return {"top": "up", "bottom": "down"}.get(value, value)
return {
"top": "up",
"bottom": "down",
"topleft": "up-left",
"topright": "up-right",
"bottomleft": "down-left",
"bottomright": "down-right",
"upleft": "up-left",
"upright": "up-right",
"downleft": "down-left",
"downright": "down-right",
}.get(value, value)
return None
@@ -218,6 +262,9 @@ def _travel_vector(direction: str | None, magnitude: float) -> list[float]:
"right": [magnitude, 0.0],
"up": [0.0, -magnitude],
"down": [0.0, magnitude],
"up-left": [-magnitude * 0.72, -magnitude * 0.72],
"up-right": [magnitude * 0.72, -magnitude * 0.72],
"down-left": [-magnitude * 0.72, magnitude * 0.72],
"down-right": [magnitude * 0.72, magnitude * 0.72],
}
return [round(value, 4) for value in vectors.get(direction, [0.0, 0.0])]
@@ -228,6 +275,7 @@ def _video_effect(
direction: str | None,
multiplier: float,
) -> dict[str, Any]:
effect = _VIDEO_EFFECT_ALIASES.get(effect, effect)
common: dict[str, Any] = {
"easing": "ease_out_cubic",
"opacity_from": 0.0,
@@ -433,6 +481,14 @@ def build_video_motion_plan(
raw_row.get("duration_ms"),
f"slide {slide_num} animation duration_ms",
)
playback_duration_ms = raw_row.get(
"playback_duration_ms",
duration_ms,
)
playback_duration_ms = _positive_int(
playback_duration_ms,
f"slide {slide_num} animation playback_duration_ms",
)
event = event_index.get(shape_id)
if event is None:
raise ValueError(
@@ -450,6 +506,7 @@ def build_video_motion_plan(
direction = _direction_for_effect(
effect,
raw_row.get("filter_name"),
raw_row.get("effect_options"),
)
video = _video_effect(effect, direction, multiplier)
video["duration_ms"] = duration_ms
@@ -465,6 +522,11 @@ def build_video_motion_plan(
"trigger": trigger,
"start_ms": start_ms,
"duration_ms": duration_ms,
"playback_duration_ms": playback_duration_ms,
"effect_options": raw_row.get("effect_options", {}),
"repeat_count": raw_row.get("repeat_count"),
"repeat_duration_ms": raw_row.get("repeat_duration_ms"),
"auto_reverse": raw_row.get("auto_reverse"),
"bounds_emu": bounds,
"area_ratio": round(area_ratio, 6),
"video": video,
@@ -481,7 +543,7 @@ def build_video_motion_plan(
)
content_end_ms = max(
(
item["start_ms"] + item["duration_ms"]
item["start_ms"] + item["playback_duration_ms"]
for item in objects
),
default=0,
@@ -6,15 +6,27 @@ description: Generate PPTX route authority for source intake, planning, SVG auth
> Load only after [`routing.md`](./routing.md) selects Generate PPTX. This file owns the route's Step 17 sequence, gates, role switching, and mandatory commands.
**Core Pipeline**: `Initial Materials → [Fact Research] → Create Project → [Template] → Strategist Structured Plan → [Image Acquisition] → Executor Live Preview → Quality Check → Post-processing → Export`
**Default Core Pipeline**: `Initial Materials → [Fact Research] → Create Project → [Template] → Strategist Structured Plan → [Image Acquisition] → Executor Live Preview → Quality Check → Post-processing → Export`
**Generate-specific execution discipline**:
- The current main agent hand-writes every SVG page; never delegate page generation or run a Python, Node, or shell generator over `svg_output/`.
- Initial SVG cadence: P01 → first-page gate → uninterrupted remaining pages → final gate. Grouped batches and mid-run checker calls are forbidden.
- `preset_shape_svg.py` may provide one stdout fragment only after the main agent chooses its semantic role, frame, and paint; it cannot choose layout or write a page.
- `preset_shape_svg.py` and `shape_boolean_svg.py` may provide only their documented stdout fragment(s) after the main agent chooses the object's role, operands, paint, and z-order; neither helper chooses layout or writes a page.
- Gate checklists are internal verification, not user-facing output. On success, continue automatically and emit at most one compact status line when useful; on failure, report only the blocking items and required recovery.
### Quick Test Profile Short Circuit
When the user explicitly requests quick/fast mode for a disposable test with a
small fixed roster of self-contained slides, load and follow
[`quick-test.md`](./profiles/quick-test.md). That profile owns the complete
test-only sequence and skips this route's Steps 17.
**Hard rule — no implicit downgrade**: page count alone never selects quick
test. Normal delivery, source conversion, factual research, template use,
external assets, native data objects, notes, animation, narration, and reusable
output remain on the default pipeline below.
### SVG Page-Design Boundary
| Scope | Contract |
@@ -22,7 +34,7 @@ description: Generate PPTX route authority for source intake, planning, SVG auth
| Any route that authors or regenerates slide visuals through SVG | `svg_output/` is the complete page-design source: every visible text, image, shape, chart/table fallback, and layout element that should appear on the exported slide is present in that page SVG or referenced by it. |
| Templates, `design_spec.md`, and `spec_lock.md` | Authoring/control inputs. They guide SVG creation but MUST NOT supply visible slide content that is absent from the completed SVG during export. |
| Semantic SVG markers | Minimal rendering-neutral compiler hints used only after existing Layout/Layer/Placeholder/Native metadata has been considered. They never replace native SVG geometry, text, styles, grouping, or asset references. |
| `svg_final/` | Mandatory derived, self-contained SVG visual preview. It may be opened directly or inserted into PowerPoint as an SVG picture, but it is not a supported PPTX source and carries no manual Convert-to-Shape compatibility contract. |
| `svg_final/` | Mandatory derived, self-contained SVG visual preview in the default pipeline. It may be opened directly or inserted into PowerPoint as an SVG picture, but it is not a supported PPTX source and carries no manual Convert-to-Shape compatibility contract. Quick-test skips it. |
| SVG-to-PPTX export | The only supported generated-PPTX route reads `svg_output/` and maps its content through the project converter to DrawingML/native objects. It compiles only the selected route's explicit structure contract: `flat` keeps represented content Slide-local, while `structured` may place explicitly scoped content in Master/Layout/Slide parts. It MUST NOT infer structure, upgrade `flat`, or invent new visible page content. |
| Native PPTX routes and presentation-behavior stages | Remain outside SVG page-design closure. `template-fill-pptx`, `native-enhance-pptx`, animations, transitions, speaker notes, narration, and package relationships are not required to round-trip through SVG. |
@@ -146,7 +158,7 @@ Bare names, style descriptions, brand mentions, vague template intent, and silen
---
### Step 4: Strategist Phase (MANDATORY — cannot be skipped)
### Step 4: Strategist Phase (MANDATORY in the default pipeline)
🚧 **GATE**: Step 3 complete; default free-design path taken, or (if triggered) template files copied or confirmed in place in the project.
@@ -345,7 +357,7 @@ Read references/visual-styles/<resolved-id>.md # one preset id, or each `visu
| `spec_lock.md images` or §VIII contains at least one image/formula row, or an active template carries bundled images | `executor-image.md` + `image-layout-patterns.md` + `image-layout-spec.md` + `svg-image-embedding.md` |
| At least one placed image has `Status: Sourced` | `executor-web-image.md` after the image branch |
| The locked style/current page calls for noncanonical or alpha paint, dash/cap/join, tracking/decoration/outline, gradient/filter/glow/shadow, path/transform/clipping, or another constructed effect | `svg-effects.md` before authoring that value or effect |
| A page calls for a literal PowerPoint stock shape | `native-shape-authoring.md` before selecting or emitting that shape |
| A page calls for a literal PowerPoint stock shape, an explicit Merge Shapes operation, or shape-built dimensional form (cylinder/pedestal, layered diagram, reflection, ground plane) | `native-shape-authoring.md` before selecting or materializing that geometry. This trigger is image-independent: a text-only, data-only, or icon-only page reaches it the same way |
| All SVG pages and SVG quality gates are complete | `executor-notes.md` before generating speaker notes |
No branch is loaded by analogy. Evaluate these triggers from `spec_lock.md`, §VII/§VIII, the selected style, and the current page plan.
@@ -374,7 +386,7 @@ python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live --daemon
**Visual Construction Phase**: generate SVG pages sequentially, one at a time, in one continuous pass → `<project_path>/svg_output/`
Each completed SVG MUST be a standalone, complete representation of that slide's visible design. Template SVGs and locked planning artifacts may guide construction, but export must not reach back to them to add visible objects omitted from `svg_output/`. Speaker notes, animation, narration, transitions, and direct native-PPTX workflows remain separately owned artifacts/capabilities. When a page actually needs a literal stock shape, load and apply [`native-shape-authoring.md`](../references/native-shape-authoring.md) before drawing it. Diagram relationships remain Shape-first; do not infer a preset from contour similarity.
Each completed SVG MUST be a standalone, complete representation of that slide's visible design. Template SVGs and locked planning artifacts may guide construction, but export must not reach back to them to add visible objects omitted from `svg_output/`. Speaker notes, animation, narration, transitions, and direct native-PPTX workflows remain separately owned artifacts/capabilities. When a page actually needs a literal stock shape or an explicit Merge Shapes operation, load and apply [`native-shape-authoring.md`](../references/native-shape-authoring.md) before drawing or materializing it. Diagram relationships remain Shape-first; do not infer a preset from contour similarity.
`template_reuse_scope: mirror|layout` pages MUST start from the complete `page_layouts` SVG, keep inherited visible objects, and preserve root Master/Layout identity plus stable atoms/slots. Strict preserves that reusable contract; under `layout`, the once-loaded Design Spec's `Template Application` may still authorize carrier text/tspan reflow inside unchanged slot bounds. Adaptive uses the current or new Layout key/name already declared by Strategist. If construction proves that fixed atoms or slot topology/bounds must change, stop and return upstream for Strategist to repair the owning plan and lock, validate and read back the affected fragments, then resume; Executor never mutates `spec_lock.md`. `mirror` changes only visible text values while preserving text/tspan topology and attributes. `style` follows the flat paragraph below without structure metadata.
@@ -130,7 +130,7 @@ Present the plan to the user before generating notes or audio:
| `notes` | Enabled | Add/replace speaker notes generated from slide content? |
| `audio` | Enabled when user wants narration/video/autoplay | Generate one narration audio file per slide? |
| `timings` | Enabled with audio | Set slide auto-advance from audio duration? |
| `transitions` | Enabled, `fade` 0.5s | Add page transitions? Which effect/duration? |
| `transitions` | Enabled, `fade` 0.5s | Add page transitions? Which canonical native effect, Effect Options, and duration? |
**⛔ BLOCKING**: Stop here and wait for explicit user confirmation. Do not generate notes, generate audio, or patch the PPTX until the user confirms the module plan.
@@ -144,7 +144,13 @@ Present the plan to the user before generating notes or audio:
| Timings enabled with audio | Keep the resolved enter policy | Use audio duration plus narration padding; click disabled |
| Timings disabled | Apply the confirmed enter policy only | Do not run `ffprobe`; do not add/change `advTm` or `useTimings` |
**Hard rule — no silent downgrade**: a requested supported effect must be written with its established direction/variant attributes. Unknown requested effects fail; unknown source effects are preserved when the transition module is disabled.
The confirmed `modules.transitions` object may include `effect_options` beside
an explicit canonical `effect`. Use
`pptx_animations.py --describe-transition <effect>` for its exact fields.
Old names remain accepted only when reading compatibility input; a newly
written plan stores the canonical effect and any implied options.
**Hard rule — no silent downgrade**: a requested native effect must be written with its complete validated Effect Options. Unknown effects or inapplicable options fail; unknown source effects are preserved when the transition module is disabled.
After confirmation, update `<project>/analysis/enhancement_plan.json`:
@@ -0,0 +1,105 @@
---
description: Test-only Generate profile for authoring a few SVG slides and exporting one PPTX without normal planning or sidecar artifacts.
---
# Quick Test Profile
> Generate-PPTX profile, not a top-level route. Use it only for disposable
> converter/layout tests; normal presentation delivery stays on
> [`generate-pptx.md`](../generate-pptx.md) Steps 17.
**Trigger**: the user explicitly requests quick/fast test mode, identifies the
deck as a test, and asks for a small fixed slide roster. A small page count alone
never activates this profile.
---
## 1. Eligibility
| Condition | Required state |
|---|---|
| Intent | Explicit disposable test, not a presentation delivery |
| Page roster | Small, fixed, and named or directly inferable |
| Content | Supplied in chat and sufficient to draw without conversion or research |
| Visual inputs | Plain SVG geometry/text, or already supplied self-contained data; no asset acquisition |
| Output | Only authored SVG pages and one native PPTX |
**Missing eligibility** → use the normal Generate pipeline. Do not ask the user
to weaken a normal delivery request so it can enter this profile.
**Hard rule — explicit scope only**: this profile never activates for a factual
deck, a template-backed deck, source-file conversion, native charts/tables,
external images/icons/fonts, speaker notes, animation, narration, visual
review, or any reusable deliverable.
---
## 2. Minimal Authoring Contract
Read only [`shared-standards-core.md`](../../references/shared-standards-core.md).
Load one of its conditional modules only when the user's exact SVG test needs
that registered feature; otherwise keep the SVG surface to solid paint, basic
geometry, text, and semantic groups.
Create only:
```text
<project_path>/
├── svg_output/
│ └── <ordered-page>.svg
└── exports/
└── <project_name>_<timestamp>.pptx
```
**Hard rule — no normal-pipeline artifacts**: do not run source conversion,
`project_manager.py init`, topic research, template application, Strategist,
Confirm UI, image/icon acquisition, Live Preview, SVG quality checker,
speaker-note generation, `finalize_svg.py`, chart verification, animation,
narration, or any supporting stage. Do not create `design_spec.md`,
`spec_lock.md`, `sources/`, `analysis/`, `images/`, `icons/`, `templates/`,
`confirm_ui/`, `notes/`, `svg_final/`, `validation/`, `backup/`, or metadata
sidecars.
**Canvas**: write `viewBox="0 0 W H"` on every page. The first SVG establishes
the test canvas; every remaining page must match it exactly.
**Structure**: author flat, Slide-local SVG only. Include the complete visible
page in each SVG; set one root `data-pptx-page-role` from `cover`, `toc`,
`section`, `content`, or `ending`, and omit Master/Layout/layer/placeholder
metadata.
**Typography**: name an installed concrete font family in the SVG; do not depend
on a lock or generated font asset.
**Generation pacing**: the current main agent hand-writes the fixed SVG roster
in order. Skip the normal first-page and final checker gates.
---
## 3. Direct Export
Run one export command after every requested SVG exists:
```bash
python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path> --quick-test
```
`--quick-test` reads only `svg_output/`, infers one consistent canvas, uses a
flat PowerPoint package with converter defaults, disables notes and motion,
skips lock/theme sidecars, and writes no backup, conversion trace, or validation
report. An explicit `-o <path>.pptx` may replace the default `exports/`
destination without changing the artifact boundary.
**Validation**: success requires `[QUICK-TEST] status=passed`, the authored SVG
count to equal the published Slide count, and the PPTX to pass in-memory ZIP
integrity. On failure, repair the owning SVG and rerun this command; do not
create planning or validation artifacts.
```markdown
## ✅ Quick Test Complete
- [x] Requested SVG pages exist under `svg_output/`
- [x] One native PPTX exists under `exports/` or the explicit output path
- [x] No normal-pipeline artifacts were created
- [ ] **Next**: Report the PPTX path
```
@@ -30,7 +30,7 @@ route selection. After selection, the route authority owns execution.
| Route | Request shape | Authority | Preconditions | Mutation model | Output contract |
|---|---|---|---|---|---|
| Generate PPTX | Create a new presentation; regenerate an existing deck visually; use source material or a topic; optionally apply an explicit template workspace | [`generate-pptx`](./generate-pptx.md) | Source facts exist or the topic-research stage can gather them | Author new SVG pages and export a new PPTX | New project with `design_spec.md`, `spec_lock.md`, `svg_output/`, `validation/`, and `exports/` |
| Generate PPTX | Create a new presentation; regenerate an existing deck visually; use source material or a topic; optionally apply an explicit template workspace | [`generate-pptx`](./generate-pptx.md) | Source facts exist or the topic-research stage can gather them; explicit `quick-test` supplies self-contained test content | Author new SVG pages and export a new PPTX | Default pipeline: project with `design_spec.md`, `spec_lock.md`, `svg_output/`, `validation/`, and `exports/`; explicit `quick-test`: only `svg_output/` plus one PPTX |
| Create Template | Create a reusable brand/layout/deck template from one or more PPTX/SVG files, images/PDFs, direct or file-based text, documents/websites, brand assets, or a mixed reference bundle | [`create-template`](./create-template.md) | A reusable-template request exists; reference material is optional, and project scope additionally requires an initialized target project | Author a new portable workspace; never modify any reference file in place | Workspace with required `templates/`, optional `images/` / `icons/`, and optional review `exports/` |
| Fill Native PPTX | Use a raw PPTX's native slide shells and replace/fill content | [`template-fill-pptx`](./template-fill-pptx.md) | Source PPTX plus new material/topic | Clone and patch PPTX through OOXML; no SVG pipeline | New filled PPTX in project `exports/` |
| Enhance Native PPTX | Keep a finished PPTX's visible slides stable while adding notes, audio, timings, or transitions | [`native-enhance-pptx`](./native-enhance-pptx.md) | Finished source PPTX exists | Append/update scoped OOXML parts; no slide regeneration | New enhanced PPTX in project `exports/` |
@@ -41,6 +41,7 @@ route selection. After selection, the route authority owns execution.
| Request condition | Generate-route behavior |
|---|---|
| Explicit disposable test intent + explicit quick/fast mode + small fixed roster of self-contained slides | Activate [`quick-test`](./profiles/quick-test.md) inside Generate PPTX; author SVG pages and run the test-only direct exporter without entering normal Steps 17 |
| Topic only, or supplied sources leave planning-critical factual gaps | Run [`topic-research`](./stages/topic-research.md) inside [`generate-pptx`](./generate-pptx.md) Step 1: immediately for topic-only input, or after conversion and reading for source-backed input; research only the identified gaps, then continue Step 2 |
| Existing PPTX may be split, merged, dropped, reordered, or re-outlined | Treat the PPTX as source content through [`generate-pptx`](./generate-pptx.md) Step 1 and its PPTX intake; use the default Generate pipeline |
| Existing PPTX must preserve wording, page count, and page order 1:1 | Activate the [`beautify-pptx`](./profiles/beautify-pptx.md) profile inside the main pipeline |
@@ -57,6 +58,12 @@ route selection. After selection, the route authority owns execution.
**Hard rule — profile, not fifth route**: The 1:1 beautify behavior uses the same Strategist → Executor → SVG export lifecycle as Generate PPTX. It changes content/page invariants; it does not define a separate artifact lifecycle.
**Hard rule — test profile, not a release shortcut**: `quick-test` stays inside
Generate PPTX but owns a test-only SVG → PPTX short circuit. A small page count
alone never activates it, and any reusable, factual, source-backed,
template-backed, asset-dependent, or package-behavior request stays on the
normal Generate pipeline.
---
## 4. Template and Master/Layout Boundary
@@ -7,7 +7,7 @@ description: Optional post-processing stage for per-slide and per-object animati
> Optional Generate-PPTX post-processing stage for per-slide or per-object
> animation control. Run when the user asks to customize slide-specific motion,
> object order, effects, timing, or reveals. Deck-wide transitions,
> auto-advance, and per-element entrance settings use
> auto-advance, and deck-wide per-element object settings use
> [`animations.md`](../../references/animations.md) directly and do not activate
> this stage.
@@ -17,7 +17,7 @@ description: Optional post-processing stage for per-slide and per-object animati
|---|---|
| User asks for per-slide or per-object animation, reveal order, timing, or effect changes | Run this stage |
| User only wants the default deck (page transitions, no element builds) | Do not run; normal `svg_to_pptx.py` export is enough |
| User only wants deck-wide page transitions, auto-advance, or per-element entrance animation | Do not run; apply [`animations.md`](../../references/animations.md) with exporter flags such as `-a auto` |
| User only wants deck-wide page transitions, auto-advance, or one per-element object animation policy | Do not run; apply [`animations.md`](../../references/animations.md) with exporter flags such as `-a auto` or `-a emphasis_spin` |
| `svg_output/*.svg` is missing | Complete the main Executor phase first |
| `animations.json` exists | Resolve regeneration versus modification through the §1 intent gate before changing it |
@@ -134,111 +134,85 @@ Do not read the full scaffold unless it is needed as an editing starting point.
## 3. Plan Slide and Object Motion
**Mandatory**: plan both page-level transitions and in-slide object entrances before editing `animations.json`.
**Mandatory**: plan both page-level transitions and in-slide object animations before editing `animations.json`.
| Layer | Config path | Use |
|---|---|---|
| Page transition | `defaults.transition` or `slides.<slide>.transition` | Control how one slide enters from the previous slide |
| Page animation defaults | `defaults.animation` or `slides.<slide>.animation` | Control the default entrance behavior for animated groups on a slide |
| Page animation defaults | `defaults.animation` or `slides.<slide>.animation` | Control the default object-animation behavior for animated groups on a slide |
| Object overrides | `slides.<slide>.groups.<group_id>` | Control order, effect, delay, or duration for a real SVG group |
**Per-page motion brief**: for each slide, first decide what communication job motion should perform—or that it should perform none—then decide transition effect, transition duration, object reveal sequence, object effects, and timing. Use `design_spec.md` for slide role, `spec_lock.md` for rhythm and visual style, speaker notes for narration order, and SVG group ids for target validity.
**Title reveal decision**: when present, treat the page title as a first-class object in the per-page reveal plan, never an afterthought. Consciously choose one of — static (`effect: none`), immediate entrance, delayed entrance, entrance after the page's hero visual, synchronous with related content, or, when narration is part of the workflow, narration-cued — driven by the user's request, slide role, transition, and narration order. This stage uses the effect (§3.2), order, duration, and timing fields already defined below; narration-cued timing is realized later by the audio stage. It does not preset which choice a title uses. A real title must not drop out of the plan merely because its id resembles a legacy chrome name: use the documented sidecar override (§2 / §4) when animation is intended. Explicit structural or static markers remain authoritative; if they incorrectly mark a title that should animate, repair the SVG semantics before continuing.
**Title reveal decision**: treat each real title as a first-class plan item.
Choose static, immediate, delayed, synchronized, post-hero, or narration-cued
behavior from slide intent. Use the sidecar override for a marker-free legacy
chrome-like id; repair an incorrect explicit structural/static marker before
animating it.
**Hard rule**: a custom animation pass must not only edit group effects. It must also decide whether each slide should inherit the default transition or need a slide-specific `transition` override. Inheritance is a complete decision; do not create slide-specific transitions to satisfy a variation quota.
**Timing guidance**: prefer content-aware durations when the deck has varied slide rhythm or object importance. Uniform timing is acceptable when it matches the user's requested style or the deck's pacing.
**Timing guidance**: use shorter motion for dense/repeated scan content and
longer motion for conceptual pivots, hero diagrams, section boundaries, and
final takeaways. Uniform timing is valid when it fits the requested style.
**Duration planning**:
| Context | Transition duration | Object duration | Delay / stagger |
|---|---:|---:|---:|
| `anchor` slide / section opener / closing synthesis | 0.35-0.60s | 0.45-0.75s | 0.20-0.40s |
| `breathing` concept slide / hero diagram | 0.25-0.45s | 0.40-0.65s | 0.16-0.30s |
| `dense` technical slide / repeated pattern page | 0.18-0.35s | 0.25-0.45s | 0.10-0.24s |
| Minor supporting object | inherit or 0.20-0.35s | 0.20-0.35s | 0.08-0.18s |
| Key insight / final takeaway | 0.30-0.50s | 0.50-0.80s | 0.25-0.45s |
**Duration guidance**: use shorter timing for repeated scan content, longer timing for conceptual pivots, section transitions, hero diagrams, and final takeaways.
**Reference — not a constraint: motion judgment.** Supported effects are a
vocabulary, not assignments. Decide from content and narration before using
layout geometry:
| Decision question | Evidence to consider |
|---|---|
| What communication job exists? | Reveal, sequence, causality, transition, contrast, emphasis, atmosphere, or none |
| What tone should motion preserve? | Communication objective, consumption mode, visual style, page role, and emotional register |
| What should the audience encounter first? | Audience move, speaker-note order, focal claim, and dependency between objects |
| Does direction carry meaning? | Reading flow and spatial position may refine a direction only after the content relationship justifies motion |
| What should remain coherent across the deck? | Reuse can support recurring semantic roles; variation is optional and follows a real change in content or tone |
If motion adds no clarity or intended feeling, inherit the page default or
choose `none`, `appear`, or `fade`. A left/right layout, vertical stack, hero
image, or quote does not by itself require a directional, zoom, dissolve, or
other special effect. `auto`, `mixed`, `random`, directional, and patterned
effects remain available when the user request or the AI's content judgment
supports them; never use them to satisfy an effect-variety quota.
**Reference — not a constraint: motion judgment.** Decide the communication
job, tone, audience order, and whether direction carries meaning before using
geometry. If motion adds no clarity or intended feeling, use `none`,
`entrance_appear`, or `entrance_fade`. Layout direction alone does not require
special motion; variation follows a real content/tone change, never a quota.
### 3.1 Supported Page Transitions
| Effect | Behavior |
|---|---|
| `none` | Remove visual page transition; timed advance may remain |
| `fade` | Neutral default for technical decks |
| `push` | Directional slide entry |
| `wipe` | Directional reveal |
| `split` | Split-open transition |
| `strips` | Diagonal strips transition |
| `cover` | Cover from the side |
| `random` | PowerPoint random transition |
Use one of the 48 canonical native effects from the complete shared registry in
[`animations.md`](../../references/animations.md) §3. It covers all current
PowerPoint Subtle, Exciting, and Dynamic Content gallery effects. The eight old
names are readable only as compatibility inputs; do not write them in new
plans or sidecars. They normalize to a canonical effect plus native
`effect_options` before writing. `none` removes the visual page transition
while allowing timed advance to remain.
**Transition fields**:
| Field | Behavior |
|---|---|
| `effect` | One supported page transition effect; `none` removes only the visual effect |
| `effect_options` | Optional object containing only the selected native effect's PowerPoint Effect Options; requires an explicit `effect` |
| `duration` | Finite transition duration in seconds; must be greater than zero |
| `auto_advance` | Optional finite non-negative seconds before automatic slide advance; click remains enabled, and this field is valid with `effect: none` |
Run
`python3 skills/ppt-master/scripts/pptx_animations.py --describe-transition <effect>`
before authoring Effect Options. Never infer that one effect accepts another
effect's direction, shape, pattern, or boolean fields.
### 3.2 Supported In-Slide Animations
| Effect | Behavior |
Use the 203 canonical PowerPoint-native keys: 53 `entrance_*`, 33
`emphasis_*`, 64 `path_*`, and 53 `exit_*`. Run
`python3 skills/ppt-master/scripts/pptx_animations.py --list` for the exact
categorized names. Each key preserves PowerPoint's complete authored behavior
tree. Media-only commands remain in the audio/video workflows.
| Choice | Behavior |
|---|---|
| `entrance_*` / `emphasis_*` / `path_*` / `exit_*` | Select one explicit canonical PowerPoint object effect |
| `auto` | Map content roles to canonical entrances; image-like ids use a richer canonical pool |
| `mixed` | Cycle 16 canonical entrance presets by group order |
| `random` | Select deterministically from the same canonical entrance pool |
| `none` | Exclude the object or slide from in-slide animation |
| `appear` | Visibility flip without motion |
| `fade` | Neutral entrance |
| `fly` | Fly in from bottom |
| `fly_left` | Fly in from left |
| `fly_right` | Fly in from right |
| `fly_top` | Fly in from top |
| `cut` | Legacy compatibility key; preserve its registered tuple exactly |
| `zoom` | Scale/zoom entrance |
| `wipe` | Legacy wipe tuple; keep for compatibility |
| `wipe_left` | Left wipe entrance |
| `wipe_right` | Right wipe entrance |
| `wipe_up` | Upward wipe entrance |
| `wipe_down` | Downward wipe entrance |
| `split` | Split/barn entrance |
| `blinds` | Horizontal blinds |
| `checkerboard` | Checkerboard reveal |
| `dissolve` | Dissolve reveal |
| `random_bars` | Random bars reveal |
| `peek` | Peek/wipe down |
| `wheel` | Wheel entrance |
| `box` | Box-in reveal |
| `circle` | Circle-in reveal |
| `diamond` | Diamond-in reveal |
| `plus` | Plus-shaped reveal |
| `strips` | Diagonal strips reveal |
| `wedge` | Wedge reveal |
| `stretch` | Stretch entrance |
| `expand` | Expand entrance |
| `swivel` | Swivel entrance |
| `auto` | Map effect from group id (chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade); image-like ids (hero/figure-/image/img-/kpi) cycle zoom/dissolve/circle/box/diamond/wheel for visual variation; unmatched ids cycle fade/wipe/fly/zoom |
| `mixed` | Legacy 16-effect cycle by group order (first group fades, rest cycle the larger pool) |
| `random` | Stable seeded effect per animated group; `--conversion-trace` records resolved effects when diagnostics are enabled |
The 29 old short names remain readable only as compatibility inputs; do not use
them in new plans or sidecars. All Fly direction names normalize to
`entrance_fly`, all Wipe direction names normalize to `entrance_wipe`, and the
other old names normalize to their matching `entrance_*` preset. `cut`
normalizes to `entrance_appear`. Compatibility Fly/Wipe aliases preserve their
direction as `effect_options.direction`; legacy `wheel` preserves its historical
four-spoke amount.
`auto`, `mixed`, and `random` never choose emphasis, motion-path, or exit
effects implicitly. Select an explicit canonical key when the plan calls for
one.
**Start modes**:
@@ -282,15 +256,32 @@ the deck-wide values copied into every complete new slide block.
| Field | Behavior |
|---|---|
| `transition.effect` | Slide-specific page transition effect |
| `transition.effect_options` | Effect-specific native PowerPoint options; requires an explicit slide-specific `transition.effect` |
| `transition.duration` | Slide-specific page transition duration |
| `animation.effect` | Slide-specific default object entrance effect |
| `animation.duration` | Slide-specific default object entrance duration |
| `animation.stagger` | Slide-specific delay between object entrances |
| `animation.effect` | Slide-specific default object animation effect |
| `animation.duration` | Slide-specific default object schedule duration |
| `animation.stagger` | Slide-specific delay between object animation rows |
| `animation.trigger` | Slide-specific start mode |
| `groups.<id>.effect` | Object-specific entrance effect, `auto`, `mixed`, `random`, or `none` |
| `groups.<id>.effect` | Object-specific canonical native effect, `auto`, `mixed`, `random`, or `none`; old names are read-only compatibility inputs |
| `order` | Animation order only; does not change SVG layer order |
| `delay` | Extra seconds before this group starts in `after-previous` mode |
| `duration` | Per-group schedule duration in seconds; `appear` stays a 1ms visibility flip and uses this value only for subsequent `after-previous` spacing |
| `delay` | Extra seconds in `after-previous`, or after clicking `trigger_shape` |
| `duration` | Per-group schedule duration in seconds; scalable native behavior trees keep their internal timing ratios, while `entrance_appear` and instantaneous native presets retain their PowerPoint-authored duration and use this value for subsequent `after-previous` spacing |
| `effect_options` | Effect-specific PowerPoint parameters; requires an explicit canonical `effect` in the same block |
| `trigger_shape` | Different top-level group id for native **On Click of**; group-only and not inherited |
| `repeat_count` / `repeat_duration` | Repeat count or total repeat span; mutually exclusive |
| `auto_reverse`, `rewind` | Reverse each cycle and/or restore the pre-animation state |
| `accelerate`, `decelerate`, `bounce_end` | `0..1` timing ratios; acceleration plus deceleration must not exceed `1`; bounce requires an interpolated effect and cannot combine with deceleration |
| `restart` | `always`, `when-not-active`, or `never` |
| `after_effect` | `none`, `dim` with `color`, `hide`, or `hide-on-next-click` |
| `sound` | Project-relative or absolute `.m4a`, `.mp3`, or `.wav` path |
`effect_options` may contain `direction`, `amount`, `color`, `font_name`,
`relative`, or `size`, but validation permits only fields supported by the
selected effect. Before writing a parameterized effect, run
`python3 skills/ppt-master/scripts/pptx_animations.py --describe
<canonical_effect>` and use the returned values exactly. `duration` owns
PowerPoint Speed; `accelerate`/`decelerate` own smooth start/end, so do not
invent duplicate fields.
**Canonical example — every slide carries explicit transition + animation;
groups appear only when they diverge**:
@@ -300,40 +291,32 @@ groups appear only when they diverge**:
"version": 1,
"defaults": {
"transition": { "effect": "fade", "duration": 0.4 },
"animation": { "effect": "fade", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" }
"animation": { "effect": "entrance_fade", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" }
},
"slides": {
"01_cover": {
"transition": { "effect": "fade", "duration": 0.5 },
"animation": { "effect": "fade", "duration": 0.5, "stagger": 0.4, "trigger": "after-previous" }
},
"02_agenda": {
"transition": { "effect": "fade", "duration": 0.4 },
"animation": { "effect": "fade", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" }
"animation": { "effect": "entrance_fade", "duration": 0.5, "stagger": 0.4, "trigger": "after-previous" }
},
"03_market": {
"transition": { "effect": "wipe", "duration": 0.35 },
"animation": { "effect": "fade", "duration": 0.4, "stagger": 0.25, "trigger": "after-previous" },
"transition": {
"effect": "wipe",
"effect_options": { "direction": "left" },
"duration": 0.35
},
"animation": { "effect": "entrance_fade", "duration": 0.4, "stagger": 0.25, "trigger": "after-previous" },
"groups": {
"chart": { "effect": "wipe", "order": 2, "duration": 0.6 },
"insight": { "effect": "fly", "order": 3, "delay": 0.2 }
}
},
"07_hero_quote": {
"transition": { "effect": "fade", "duration": 0.7 },
"animation": { "effect": "fade", "duration": 0.7, "stagger": 0.3, "trigger": "after-previous" },
"groups": {
"quote": { "duration": 0.9, "delay": 0.3 }
"chart": { "effect": "entrance_wipe", "effect_options": { "direction": "left" }, "order": 2, "duration": 0.6 },
"insight": { "effect": "entrance_fly", "effect_options": { "direction": "up_right" }, "order": 3, "delay": 0.2, "trigger_shape": "chart" }
}
}
}
}
```
Notes:
- `02_agenda` repeats `defaults` verbatim — this is intentional under the new rule so per-page rhythm is auditable in one read.
- `03_market` and `07_hero_quote` only list the groups that diverge; `title`, `footer`, `bg`, `header` etc. are not enumerated.
- Structural chrome groups are never listed. Legacy id-only chrome groups remain omitted unless an explicit reviewed override is required.
`01_cover` shows a complete per-slide block even when values closely match
the defaults. `03_market` lists only divergent groups. Structural chrome stays
omitted unless a marker-free legacy name needs an explicitly reviewed override.
**Forbidden — SVG pollution**: do not add `data-*` animation attributes to SVG files. Animation customization belongs in `animations.json`.
@@ -345,13 +328,7 @@ Run sequentially:
```bash
python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
```
```bash
python3 skills/ppt-master/scripts/finalize_svg.py <project_path>
```
```bash
python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path>
```
@@ -359,15 +336,17 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path>
per-object overrides, and `svg_final/` must reflect any semantic regrouping
performed in §2. `--animation none` still disables all per-element animation
and overrides `animations.json`. Unknown animation
effects/modes/triggers; boolean, NaN, or Infinity numeric values; non-positive
durations; negative delay/stagger; invalid order; missing slides/groups; and
structural-layer targets fail validation. Transition validation remains strict
as well. None of these failures substitutes a fallback effect or silently drops
a requested target.
effects/modes/triggers; unsupported effect options; incompatible, boolean,
non-finite, or out-of-range timing parameters; non-positive durations; negative
delay/stagger; invalid order; missing slides/groups; and structural-layer
targets fail validation. Transition validation remains strict. None of these
failures substitutes a fallback effect or silently drops a requested target.
Generated export performs semantic read-back per slide, comparing row order, trigger, target, resolved effect tuple, duration, and offset. It then validates timing-tree placement, `p:cTn` ids, and `p:spTgt` references across the packaged PPTX. Stable `random` choices appear in the conversion trace when export enables `--conversion-trace`. Narration merges audio into the existing timing tree and must preserve these rows.
Direct-PPTX routes are preserve-only for object animation: they compare the source object-animation fingerprint before and after allowed edits, run structural package validation, and do not write, normalize, or claim ownership of effects. See [`pptx-animations.md`](../../scripts/docs/pptx-animations.md) for the exact compatibility and OOXML contract.
Generated export reads back row order, trigger, target, resolved effect,
duration, offset, timing placement, IDs, and shape references. Narration
preserves these rows. Direct-PPTX routes fingerprint and preserve source object
animation; they never author it. See
[`pptx-animations.md`](../../scripts/docs/pptx-animations.md).
### 5.1 Optional Video Motion Handoff
@@ -382,28 +361,19 @@ python3 skills/ppt-master/scripts/video_motion_plan.py \
--force
```
For narrated output, the source trace must come from the final
`--recorded-narration` export. The video plan inherits object identity, source
effect, semantic direction, order, duration, native bounds, and final timing
anchors. Video-only optimization may refine easing, travel distance, opacity,
scale, mask feather, blur, motion blur, and overshoot; it must not replace the
source effect or reduce the choreography to delay values. See
For narrated output, use the final `--recorded-narration` trace. The video plan
locks identity, effect, direction, order, bounds, and timing; it may refine
renderer parameters but cannot replace the source effect. See
[`video-motion-plan.md`](../../scripts/docs/video-motion-plan.md).
---
## ✅ Customize Animations Complete
- [x] `animations.json` exists only because per-slide or per-object customization was requested
- [x] `design_spec.md`, `spec_lock.md`, and available speaker notes were checked before editing animation overrides
- [x] Every slide's existing `<g>` hierarchy was audited against content and narration before it was accepted or rewritten
- [x] Every animation anchor is one post-regroup semantic reveal unit with a descriptive real SVG id
- [x] Any regrouped SVG passed the final SVG quality gate and `svg_final/` was refreshed
- [x] Every slide in `svg_output/` appears under `slides` with explicit `transition` + `animation` blocks
- [x] Group-level entries were added only for groups that diverge from the slide's `animation` block
- [x] Page transitions and in-slide object animations were planned together
- [x] Transition and object durations were chosen intentionally for the deck's pacing
- [x] `animation_config.py validate` passed
- [x] PPTX re-export completed with custom animation overrides
- [x] Generated animation semantic read-back and package validation passed
- [x] If video enhancement was requested, its motion plan was derived from the final resolved conversion trace
- [x] Semantic context and every slide's visible content were reviewed
- [x] Each target is one post-regroup semantic unit with a real SVG id
- [x] Regrouped SVG passed the final quality gate and refreshed `svg_final/`
- [x] Every slide has explicit motion blocks; only divergent groups are listed
- [x] Page and object motion were planned together with intentional timing
- [x] Sidecar validation, re-export, semantic read-back, and package validation passed
- [x] Any video plan came from the final resolved conversion trace
@@ -196,7 +196,7 @@ The plan structure:
| `layout_rationale` | Human review aid for page selection. Include `layout_pattern`, `why_fit`, and `risk`; it is not a mechanical checker gate. |
| `accepted_warnings` | Optional audit trail for warnings the user or agent explicitly accepts. `check-plan` warnings remain non-blocking; errors must be fixed. |
| `notes` | Optional spoken speaker notes for the filled slide — see **Speaker notes** below; write prose, not a copy of the on-slide text |
| `transition` | Optional per-slide page transition; overrides the `apply --transition` default. Accepts an effect name (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random`), `none` to remove the visual effect, `keep` to preserve the source, or an object such as `{ "effect": "push", "duration": 0.6, "advance_after": 5 }` |
| `transition` | Optional per-slide page transition; overrides the `apply --transition` default. New plans use one canonical native gallery effect from [`animations.md`](../references/animations.md) §3; old names remain read-compatible. Accepts `none` to remove the visual effect, `keep` to preserve the source, or an object such as `{ "effect": "push", "effect_options": { "direction": "left" }, "duration": 0.6, "advance_after": 5 }` |
| `replacements` | Target by `slot_id` whenever possible; `shape_id` and `shape_name` are fallback selectors |
| `table_edits` | Optional native table cell edits; target by `table_id` whenever possible and use zero-based `row` / `col` |
| `chart_edits` | Optional native chart data edits; target by `chart_id`, set `categories`, and provide one or more `series` |
@@ -272,7 +272,7 @@ Run:
python3 skills/ppt-master/scripts/template_fill_pptx.py apply "<project_dir>/sources/<source.pptx>" "<project_dir>/analysis/fill_plan.json" -o "<project_dir>/exports/<output.pptx>"
```
By default `apply` gives every cloned slide a `fade` transition (`0.5s`), preserving the v1 route contract. Override it with `--transition <effect>` (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random`) and `--transition-duration <seconds>`; pass `--transition none` for no visual motion, or `--transition keep` to preserve each source slide's existing transition unchanged. A per-slide `transition` field overrides the CLI. `advance_after` keeps click advance enabled and adds timed advance; it also works with `none` (timing-only transition) and `keep` (source effect preserved, Choice/Fallback timing updated together).
By default `apply` gives every cloned slide a `fade` transition (`0.5s`), preserving the v1 route contract. Override it with `--transition <effect>` using a canonical gallery effect from [`animations.md`](../references/animations.md) §3 and `--transition-duration <seconds>`; old names remain accepted only as compatibility CLI inputs. Pass `--transition none` for no visual motion, or `--transition keep` to preserve each source slide's existing transition unchanged. A per-slide `transition` field overrides the CLI and may include native `effect_options`; these require an explicit effect and are validated effect-by-effect. `advance_after` keeps click advance enabled and adds timed advance; it also works with `none` (timing-only transition) and `keep` (source effect preserved, Choice/Fallback timing updated together).
`apply` appends a timestamp automatically. For example, `-o "<project_dir>/exports/demo.pptx"` writes `demo_YYYYMMDD_HHMMSS.pptx`. If the filename already ends with `_YYYYMMDD_HHMMSS`, it is left unchanged.
@@ -344,7 +344,7 @@ If the extracted text is correct but visual overflow is likely, reduce the text
| Preserve original visual design | Supported by cloning slide parts directly |
| Page-to-page transitions | Supported via `apply --transition` or per-slide `transition` |
| Replace images | Not in v1 |
| Object-level entrance animations | Not in v1; preserved from source only, set as a separate task |
| Object-level animations | Not authored in v1; entrance, emphasis, motion-path, and exit effects are preserved from source only and handled as a separate task |
| Edit chart formatting / axes / legend layout | Not in v1 |
| Edit or generate native SmartArt | Not supported; regenerated visual routes use ordinary editable shapes |
| Automatic visual overflow detection | Not in v1; use text-capacity judgment from the library slots |
@@ -2,8 +2,8 @@
"sourceId": "shadcn",
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
"commit": "7774cd7dcee1e98d0815aa6e829f33a7fc952fdf",
"commit": "bf906bb8aeebc64d374afb54497b822d587ac6d7",
"adapter": "claude-skill",
"sourcePath": "skills/shadcn",
"syncedAt": "2026-07-27T03:11:20Z"
"syncedAt": "2026-07-27T16:00:00Z"
}
@@ -579,7 +579,7 @@ uipro uninstall --global
rm -rf .claude/skills/ui-ux-pro-max # Claude Code
rm -rf .cursor/skills/ui-ux-pro-max # Cursor
rm -rf .windsurf/skills/ui-ux-pro-max # Windsurf
rm -rf .agents/skills/ui-ux-pro-max # Antigravity
rm -rf .agents/skills/ui-ux-pro-max # Antigravity / Codex
```
### Claude Marketplace install fails with "Zip file contains a symbolic link"
@@ -2,8 +2,8 @@
"sourceId": "ui-ux-pro-max",
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
"ref": "main",
"commit": "1307d97a72e6c1cda572cb65471ae5ce82995218",
"commit": "3b5df7547964f0cb3424de74cff55b69039250d3",
"adapter": "claude-skill",
"sourcePath": ".claude/skills/ui-ux-pro-max",
"syncedAt": "2026-07-21T16:00:00Z"
"syncedAt": "2026-07-27T16:00:00Z"
}