From 11ba867f799c3b3950ddf8ea0829ae25d759b8d3 Mon Sep 17 00:00:00 2001 From: KeyInfo Bot Date: Tue, 28 Jul 2026 00:02:47 +0800 Subject: [PATCH] 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. --- config/external-sources.lock.json | 16 +- .../plugins/mcp-playwright/MCP_SOURCE.json | 2 +- .../next-skills/THIRD_PARTY_SOURCE.json | 4 +- plugins/codex/plugins/ppt-master/README.md | 4 +- .../ppt-master/THIRD_PARTY_SOURCE.json | 4 +- .../ppt-master/references/animations.md | 340 +- .../ppt-master/references/executor-base.md | 5 +- .../references/image-layout-patterns.md | 214 +- .../references/native-shape-authoring.md | 125 +- .../references/shared-standards-core.md | 33 +- .../ppt-master/references/strategist-image.md | 4 +- .../skills/ppt-master/requirements.txt | 2 + .../skills/ppt-master/scripts/README.md | 22 +- .../ppt-master/scripts/confirm_ui/server.py | 44 +- .../ppt-master/scripts/docs/confirm_ui.md | 2 +- .../scripts/docs/pptx-animations.md | 174 +- .../scripts/docs/pptx-transitions.md | 155 +- .../ppt-master/scripts/docs/svg-pipeline.md | 34 +- .../scripts/docs/video-motion-plan.md | 23 +- .../ppt-master/scripts/narration_sync.py | 65 +- .../scripts/native_enhance_pptx_core.py | 106 +- .../scripts/pptx_animation_presets.json | 3440 +++++++++++++++++ .../ppt-master/scripts/pptx_animations.py | 2841 ++++++++++++-- .../ppt-master/scripts/pptx_transitions.py | 1417 ++++++- .../scripts/prompt_audit_manifest.json | 18 +- .../ppt-master/scripts/shape_boolean_svg.py | 170 + .../scripts/svg_to_pptx/animation_config.py | 281 +- .../scripts/svg_to_pptx/drawingml/context.py | 2 +- .../svg_to_pptx/drawingml/converter.py | 4 +- .../scripts/svg_to_pptx/drawingml/elements.py | 28 +- .../scripts/svg_to_pptx/drawingml/paths.py | 31 + .../svg_to_pptx/pptx_package/builder.py | 345 +- .../scripts/svg_to_pptx/pptx_package/cli.py | 234 +- .../svg_to_pptx/pptx_package/narration.py | 94 +- .../svg_to_pptx/pptx_package/slide_xml.py | 6 +- .../scripts/svg_to_pptx/shape_boolean.py | 1249 ++++++ .../scripts/template_fill_pptx/applier.py | 8 +- .../scripts/template_fill_pptx/cli.py | 13 +- .../scripts/template_fill_pptx/transitions.py | 36 +- .../ppt-master/scripts/video_motion_plan.py | 72 +- .../ppt-master/workflows/generate-pptx.md | 24 +- .../workflows/native-enhance-pptx.md | 10 +- .../workflows/profiles/quick-test.md | 105 + .../skills/ppt-master/workflows/routing.md | 9 +- .../workflows/stages/customize-animations.md | 248 +- .../workflows/template-fill-pptx.md | 6 +- .../plugins/shadcn/THIRD_PARTY_SOURCE.json | 4 +- plugins/codex/plugins/ui-ux-pro-max/README.md | 2 +- .../ui-ux-pro-max/THIRD_PARTY_SOURCE.json | 4 +- 49 files changed, 10978 insertions(+), 1101 deletions(-) create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_animation_presets.json create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/shape_boolean_svg.py create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/shape_boolean.py create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/profiles/quick-test.md diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index c3ae970a..0cb6a15e 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -33,8 +33,8 @@ "repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git", "ref": "main", "adapter": "claude-skill", - "commit": "1307d97a72e6c1cda572cb65471ae5ce82995218", - "syncedAt": "2026-07-21T16:00:00Z" + "commit": "3b5df7547964f0cb3424de74cff55b69039250d3", + "syncedAt": "2026-07-27T16:00:00Z" }, { "id": "caveman", @@ -60,8 +60,8 @@ "repo": "https://github.com/shadcn-ui/ui.git", "ref": "main", "adapter": "claude-skill", - "commit": "7774cd7dcee1e98d0815aa6e829f33a7fc952fdf", - "syncedAt": "2026-07-27T03:11:20Z" + "commit": "bf906bb8aeebc64d374afb54497b822d587ac6d7", + "syncedAt": "2026-07-27T16:00:00Z" }, { "id": "frontend-slides", @@ -96,8 +96,8 @@ "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", "adapter": "claude-skill", - "commit": "de0af38a07706eee03b97f6186d8e0ffba595892", - "syncedAt": "2026-07-27T03:11:20Z" + "commit": "5260a14f8467e52d7fc945c5efead3e2090f0f86", + "syncedAt": "2026-07-27T16:00:00Z" }, { "id": "next-skills", @@ -105,8 +105,8 @@ "repo": "https://github.com/vercel/next.js.git", "ref": "canary", "adapter": "skill-collection", - "commit": "3f2cf7e1a024ab0b5a0ee16e02f0a258173947ae", - "syncedAt": "2026-07-27T03:11:20Z" + "commit": "1f65c7646eb57660e4eb38899b8197346d0d93c1", + "syncedAt": "2026-07-27T16:00:00Z" } ] } diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index c147673b..098f87f8 100644 --- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json +++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json @@ -3,5 +3,5 @@ "name": "playwright浏览器自动化操作", "version": "20260605", "keySource": "none", - "syncedAt": "2026-07-27T03:14:35Z" + "syncedAt": "2026-07-27T16:02:46Z" } diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index 6b13d8a9..e546e5d9 100644 --- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json @@ -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" } diff --git a/plugins/codex/plugins/ppt-master/README.md b/plugins/codex/plugins/ppt-master/README.md index 84b25b70..2dd4cc3c 100644 --- a/plugins/codex/plugins/ppt-master/README.md +++ b/plugins/codex/plugins/ppt-master/README.md @@ -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/_.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//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/__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/_.pptx`. The normal delivery flow runs `finalize_svg.py`, produces self-contained previews in `svg_final/`, and snapshots `svg_output/` to `backup//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/__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). diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json index 5ef2044f..e633d879 100644 --- a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json @@ -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" } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/animations.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/animations.md index 6dc258e7..3b4b7401 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/animations.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/animations.md @@ -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`, 4–10 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 `, 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 -`` 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 `` 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:` | + | `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 + ` 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 --auto-advance 5 python3 skills/ppt-master/scripts/svg_to_pptx.py -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 ` 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 - -# Enable per-element animation deck-wide (auto effect + after-previous cascade) -python3 skills/ppt-master/scripts/svg_to_pptx.py -a auto - -# Enable with a single effect (cascades via the after-previous trigger) -python3 skills/ppt-master/scripts/svg_to_pptx.py --animation fade - -# Enable and switch to on-click for live presentations (presenter controls pacing) -python3 skills/ppt-master/scripts/svg_to_pptx.py -a auto --animation-trigger on-click - -# Custom pacing -python3 skills/ppt-master/scripts/svg_to_pptx.py --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 -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 `/narration_animations.json`; other export defaults to `/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 **4–10 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 0–9 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 `` -Per-element animations are anchored on **top-level `` content groups** in the SVG (e.g. ``, ``). 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 `` content groups** in the SVG (e.g. ``, ``). 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: ``, ``, ``, ``, ``, ``, ``, ``, ``. Examples that still animate: ``, ``, ``, ``. Do not strip the `` 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 `` wrappers, only raw `` / `` / `` 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 `` 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 `` 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 /validation/.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). diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md index 3ece3e21..b195683c 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-base.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) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-patterns.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-patterns.md index b3c94de0..39cc1452 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-patterns.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-patterns.md @@ -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 `` 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 1–3 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 60–120° 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 `` leader lines + small `` endpoints + offset text labels in surrounding space. -11. **Diagonal split with directional gradient (not hard polygon cut)** — full-bleed `` (do NOT hard-clip) + overlay `` whose `` axis runs along the desired diagonal + a `` 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 `` + overlay `` whose gradient axis runs along the diagonal, plus a `` 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** — `` + heavy semi-transparent `` over it + huge `` (80–120px) 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** — `` with edge fades; text blocks above and below the band. -15. **Multi-image montage with bold text spanning across** — multiple `` tiled with 2–4px gaps + large `` (60–100px) in a darkened band spanning the full montage. The band uses `` to keep text legible across all underlying images. +15. **Multi-image montage with bold text spanning across** — `` tiled with 2–4px gaps + large `` (60–100px) in a `` 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 — 3–6 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 — 3–6 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 `` of equal size in 50/50 split with thin divider `` 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 `` 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 `` 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 `` 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. + +52–53. **Filmstrip / stack** — a sequence of `` 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** — `` 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 ``. +55–56. **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 ``. 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 `` 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 2–3px 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 1–3 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** — 3–5 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** — 3–5 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 45–60% 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 45–60%, 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 2–4 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; 2–4 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** — `` referenced by ``. - -21. **Rounded rectangle crop** — ``; the `rx` value controls roundness. - -22. **Ellipse / oval crop** — ``. - -23. **Hexagonal / polygonal crop** — ``; remember to keep all vertices inside the image's display rectangle. +20–23. **Basic shape crops** — `` holding one shape, referenced by ``: `` (**#20**), `` (**#21**, `rx` sets roundness), `` (**#22**), `` (**#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)** — ``; 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* `` clipped by **one `` 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 ``. Keep the subpaths disjoint so no winding rule is ever needed. + + **The one thing that makes or breaks it — registration**: place the `` 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`, `` 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 `` 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 `` 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 `` elements that share the same source, each with its own clip, then **break the alignment on purpose**: offset a few px, rotate 1–3°, 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 `` 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 `` whose internal composition already contains 2–3 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** — `` 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** — `` over the image. Uniform darkening/lightening; simplest scrim. +30–31. **Flat overlay wash** — one `` 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.15–0.25 to pull a foreign-looking photo toward the palette without regenerating it (**#31**). -31. **Color-tinted overlay** — ``. 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 10–40 %, 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 `` 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 `` 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 ``: + + | 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.15–0.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 1–2px 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 `` itself (#36). + + **Numeral / lettering caveat**: cutting *text* out of the scrim needs the glyph as a `` outline, which is not something to author by hand — least of all for CJK. Set the numeral as ordinary `` 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 4–10s 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 `` filled by a `` 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 `` + overlay `` 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 ``), low-contrast ambient atmosphere that is seen but never read (**#60**), or a watermark sitting behind body copy (**#61**). Suppress it with an overlay `` or a pre-dimmed asset — never a runtime filter. 58. **Image fragment as decorative corner element** — small `` (often with `clipPath`) placed in one corner; not the focus, just visual seasoning. 59. **Image as horizontal divider band** — narrow `` placed between two text sections instead of a `` divider. -60. **Image as ambient noise** — visible but low contrast; mood-setting only, not informational. - -61. **Image as watermark behind body content** — large `` at very low opacity behind body text. Use either a pre-baked low-alpha image or a high-opacity overlay `` to suppress visibility. - ## Special Techniques 62. **Same image, two references — full view + zoom-callout** — reference the same image file twice in two `` 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 `` ending in `marker-end`; ring the zoom with a `` 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 `` — 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 ``; 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 `` 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 `` or its container ``; 2–6 degrees typical. Adds dynamism without breaking layout. -70. **Image with thin colored matte frame** — `` over or around the image edge. Single rule, single color. - -71. **Image with multiple stacked frames for "photo print" aesthetic** — nested `` outlines or `` containers of slightly different sizes giving a "framed photograph" look. +70–71. **Frames** — a single `` 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 `` 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 `` 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 `` (#30 / #31) at 0.5–0.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 ``, 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 `` facets, then give each facet its own `` 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 `` 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 2–4 consecutive slides, each showing a different horizontal segment (same `` 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. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-shape-authoring.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-shape-authoring.md index 62670f13..8fa02e69 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-shape-authoring.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/native-shape-authoring.md @@ -1,11 +1,12 @@ > See [`shared-standards-core.md`](./shared-standards-core.md) §§1.4–1.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 `` / `` 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 \ + --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 ``. `fragment` emits stable sibling paths named `-1`, `-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 **10–25 %** 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 **60–70 %** 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. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/shared-standards-core.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/shared-standards-core.md index f2026642..15d483cf 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/shared-standards-core.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/shared-standards-core.md @@ -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 `` transform is forbidden; nested crop and `` 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 ``. Use non-positional `` children for inline runs. Keep the first wrapped line as direct text and put each later line in a direct positioned `` that repeats the parent `x` and uses positive relative `dy`; an all-`` 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 `` 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 ``. Content grouping is **mandatory** per §4.3 — a top-level `` 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 `` 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. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-image.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-image.md index 8e50d88e..1284e33e 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-image.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-image.md @@ -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 `# ` 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 `# ` 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`. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/requirements.txt b/plugins/codex/plugins/ppt-master/skills/ppt-master/requirements.txt index 273f5123..3f46a2a6 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/requirements.txt +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/requirements.txt @@ -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. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md index d5923fec..45dea1b4 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md @@ -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 /sources/ python3 scripts/template_fill_pptx.py validate ``` -`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 ` (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 ` 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 ` 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 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/server.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/server.py index 5e083dde..ca550ad7 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/server.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/server.py @@ -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, diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/confirm_ui.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/confirm_ui.md index 5b9cb128..3766e581 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/confirm_ui.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/confirm_ui.md @@ -35,7 +35,7 @@ python3 scripts/confirm_ui/server.py --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:` 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 `/confirm_ui/recommendations.stage1.json`; `--shutdown` needs no recommendations). - Per-project lock at `/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-animations.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-animations.md index 2565c650..a72149c6 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-animations.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-animations.md @@ -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 ` 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) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-transitions.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-transitions.md index a1e9602e..349ee8dc 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-transitions.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-transitions.md @@ -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. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md index b4446af7..21e9cb62 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md @@ -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 --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/_.pptx` — native editable pptx (canonical output) - `validation/_.report.json` — package postflight, quality-gate linkage, unresolved resource audit, and published part counts - `backup//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/.trace.json`. `--conversion-trace ` respects the explicit destination; relative paths are resolved from the project root, so `exports/.trace.json` remains available when intentionally requested. -- After publication, native export writes `validation/.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=` receipt instead of a clean `passed` claim. +- After normal-flow publication, native export writes `validation/.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=` 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= warning_categories=`, 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 `/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 `/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 `__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 --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 `` 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 `/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 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/video-motion-plan.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/video-motion-plan.md index 00286820..822638d3 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/video-motion-plan.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/video-motion-plan.md @@ -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. --- diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py index db3ad751..175e214d 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py @@ -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, ) ) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_enhance_pptx_core.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_enhance_pptx_core.py index 041c2f51..19c88706 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_enhance_pptx_core.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_enhance_pptx_core.py @@ -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") diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_animation_presets.json b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_animation_presets.json new file mode 100644 index 00000000..03d9d289 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_animation_presets.json @@ -0,0 +1,3440 @@ +{ + "version": 2, + "source": { + "application": "Microsoft PowerPoint 16", + "enum": "MsoAnimEffect", + "object_effect_count": 203, + "media_only_effect_ids": [ + 83, + 84, + 85, + 150 + ] + }, + "effects": [ + { + "key": "entrance_appear", + "name": "Appear", + "category": "entrance", + "mso_effect_id": 1, + "preset_id": 1, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1, + "duration_scalable": false, + "row_xml": "style.visibility", + "effect_options": {} + }, + { + "key": "entrance_fly", + "name": "Fly", + "category": "entrance", + "mso_effect_id": 2, + "preset_id": 2, + "preset_subtype": 4, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xppt_y", + "effect_options": { + "direction": { + "type": "enum", + "default": "down", + "values": { + "up": "style.visibilityppt_xppt_y", + "right": "style.visibilityppt_xppt_y", + "down": "style.visibilityppt_xppt_y", + "left": "style.visibilityppt_xppt_y", + "up_left": "style.visibilityppt_xppt_y", + "up_right": "style.visibilityppt_xppt_y", + "down_right": "style.visibilityppt_xppt_y", + "down_left": "style.visibilityppt_xppt_y" + } + } + } + }, + { + "key": "entrance_blinds", + "name": "Blinds", + "category": "entrance", + "mso_effect_id": 3, + "preset_id": 3, + "preset_subtype": 10, + "filter": "blinds(horizontal)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "horizontal", + "values": { + "horizontal": "style.visibility", + "vertical": "style.visibility" + } + } + } + }, + { + "key": "entrance_box", + "name": "Box", + "category": "entrance", + "mso_effect_id": 4, + "preset_id": 4, + "preset_subtype": 16, + "filter": "box(in)", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "in", + "values": { + "in": "style.visibility", + "out": "style.visibility" + } + } + } + }, + { + "key": "entrance_checkerboard", + "name": "Checkerboard", + "category": "entrance", + "mso_effect_id": 5, + "preset_id": 5, + "preset_subtype": 10, + "filter": "checkerboard(across)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "horizontal", + "values": { + "horizontal": "style.visibility", + "vertical": "style.visibility" + } + } + } + }, + { + "key": "entrance_circle", + "name": "Circle", + "category": "entrance", + "mso_effect_id": 6, + "preset_id": 6, + "preset_subtype": 16, + "filter": "circle(in)", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "in", + "values": { + "in": "style.visibility", + "out": "style.visibility" + } + } + } + }, + { + "key": "entrance_crawl", + "name": "Crawl", + "category": "entrance", + "mso_effect_id": 7, + "preset_id": 7, + "preset_subtype": 4, + "filter": null, + "default_duration_ms": 5000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xppt_y", + "effect_options": { + "direction": { + "type": "enum", + "default": "down", + "values": { + "up": "style.visibilityppt_xppt_y", + "right": "style.visibilityppt_xppt_y", + "down": "style.visibilityppt_xppt_y", + "left": "style.visibilityppt_xppt_y", + "up_left": "style.visibilityppt_xppt_y", + "up_right": "style.visibilityppt_xppt_y", + "down_right": "style.visibilityppt_xppt_y", + "down_left": "style.visibilityppt_xppt_y" + } + } + } + }, + { + "key": "entrance_diamond", + "name": "Diamond", + "category": "entrance", + "mso_effect_id": 8, + "preset_id": 8, + "preset_subtype": 16, + "filter": "diamond(in)", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "in", + "values": { + "in": "style.visibility", + "out": "style.visibility" + } + } + } + }, + { + "key": "entrance_dissolve", + "name": "Dissolve", + "category": "entrance", + "mso_effect_id": 9, + "preset_id": 9, + "preset_subtype": 0, + "filter": "dissolve", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": {} + }, + { + "key": "entrance_fade", + "name": "Fade", + "category": "entrance", + "mso_effect_id": 10, + "preset_id": 10, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": {} + }, + { + "key": "entrance_flash_once", + "name": "Flash Once", + "category": "entrance", + "mso_effect_id": 11, + "preset_id": 11, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": {} + }, + { + "key": "entrance_peek", + "name": "Peek", + "category": "entrance", + "mso_effect_id": 12, + "preset_id": 12, + "preset_subtype": 4, + "filter": "wipe(up)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibilityppt_y", + "effect_options": { + "direction": { + "type": "enum", + "default": "down", + "values": { + "up": "style.visibilityppt_y", + "right": "style.visibilityppt_x", + "down": "style.visibilityppt_y", + "left": "style.visibilityppt_x" + } + } + } + }, + { + "key": "entrance_plus", + "name": "Plus", + "category": "entrance", + "mso_effect_id": 13, + "preset_id": 13, + "preset_subtype": 16, + "filter": "plus(in)", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "in", + "values": { + "in": "style.visibility", + "out": "style.visibility" + } + } + } + }, + { + "key": "entrance_random_bars", + "name": "Random Bars", + "category": "entrance", + "mso_effect_id": 14, + "preset_id": 14, + "preset_subtype": 10, + "filter": "randombar(horizontal)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "horizontal", + "values": { + "horizontal": "style.visibility", + "vertical": "style.visibility" + } + } + } + }, + { + "key": "entrance_spiral", + "name": "Spiral", + "category": "entrance", + "mso_effect_id": 15, + "preset_id": 15, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_wppt_hppt_xppt_y", + "effect_options": {} + }, + { + "key": "entrance_split", + "name": "Split", + "category": "entrance", + "mso_effect_id": 16, + "preset_id": 16, + "preset_subtype": 21, + "filter": "barn(inVertical)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "vertical_in", + "values": { + "horizontal_in": "style.visibility", + "horizontal_out": "style.visibility", + "vertical_in": "style.visibility", + "vertical_out": "style.visibility" + } + } + } + }, + { + "key": "entrance_stretch", + "name": "Stretch", + "category": "entrance", + "mso_effect_id": 17, + "preset_id": 17, + "preset_subtype": 10, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibilityppt_wppt_h", + "effect_options": { + "direction": { + "type": "enum", + "default": "horizontal", + "values": { + "up": "style.visibilityppt_xppt_yppt_wppt_h", + "right": "style.visibilityppt_xppt_yppt_wppt_h", + "down": "style.visibilityppt_xppt_yppt_wppt_h", + "left": "style.visibilityppt_xppt_yppt_wppt_h", + "horizontal": "style.visibilityppt_wppt_h" + } + } + } + }, + { + "key": "entrance_strips", + "name": "Strips", + "category": "entrance", + "mso_effect_id": 18, + "preset_id": 18, + "preset_subtype": 12, + "filter": "strips(downLeft)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "down_left", + "values": { + "up_left": "style.visibility", + "up_right": "style.visibility", + "down_right": "style.visibility", + "down_left": "style.visibility" + } + } + } + }, + { + "key": "entrance_swivel", + "name": "Swivel", + "category": "entrance", + "mso_effect_id": 19, + "preset_id": 19, + "preset_subtype": 10, + "filter": null, + "default_duration_ms": 5000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_wppt_h", + "effect_options": { + "direction": { + "type": "enum", + "default": "horizontal", + "values": { + "horizontal": "style.visibilityppt_wppt_h", + "vertical": "style.visibilityppt_wppt_h" + } + } + } + }, + { + "key": "entrance_wedge", + "name": "Wedge", + "category": "entrance", + "mso_effect_id": 20, + "preset_id": 20, + "preset_subtype": 0, + "filter": "wedge", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": {} + }, + { + "key": "entrance_wheel", + "name": "Wheel", + "category": "entrance", + "mso_effect_id": 21, + "preset_id": 21, + "preset_subtype": 1, + "filter": "wheel(1)", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "amount": { + "type": "enum", + "default": 1, + "values": { + "1": "style.visibility", + "2": "style.visibility", + "3": "style.visibility", + "4": "style.visibility", + "8": "style.visibility" + } + } + } + }, + { + "key": "entrance_wipe", + "name": "Wipe", + "category": "entrance", + "mso_effect_id": 22, + "preset_id": 22, + "preset_subtype": 4, + "filter": "wipe(down)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "down", + "values": { + "up": "style.visibility", + "right": "style.visibility", + "down": "style.visibility", + "left": "style.visibility" + } + } + } + }, + { + "key": "entrance_zoom", + "name": "Zoom", + "category": "entrance", + "mso_effect_id": 23, + "preset_id": 23, + "preset_subtype": 16, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibilityppt_wppt_h", + "effect_options": { + "direction": { + "type": "enum", + "default": "in", + "values": { + "in": "style.visibilityppt_wppt_h", + "out": "style.visibilityppt_wppt_h", + "in_slightly": "style.visibilityppt_wppt_h", + "in_center": "style.visibilityppt_wppt_hppt_xppt_y", + "out_slightly": "style.visibilityppt_wppt_h", + "out_bottom": "style.visibilityppt_wppt_hppt_xppt_y" + } + } + } + }, + { + "key": "entrance_random_effects", + "name": "Random Effects", + "category": "entrance", + "mso_effect_id": 24, + "preset_id": 24, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1, + "duration_scalable": false, + "row_xml": "style.visibility", + "effect_options": {} + }, + { + "key": "entrance_boomerang", + "name": "Boomerang", + "category": "entrance", + "mso_effect_id": 25, + "preset_id": 25, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilitystyle.rotationppt_wppt_wppt_hppt_xppt_yppt_y", + "effect_options": {} + }, + { + "key": "entrance_bounce", + "name": "Bounce", + "category": "entrance", + "mso_effect_id": 26, + "preset_id": 26, + "preset_subtype": 0, + "filter": "wipe(down)", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xppt_yppt_yppt_yppt_y", + "effect_options": {} + }, + { + "key": "entrance_color_reveal", + "name": "Color Reveal", + "category": "entrance", + "mso_effect_id": 27, + "preset_id": 27, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 80, + "duration_scalable": true, + "row_xml": "style.visibilitystyle.colorfillcolorfill.type", + "effect_options": {} + }, + { + "key": "entrance_credits", + "name": "Credits", + "category": "entrance", + "mso_effect_id": 28, + "preset_id": 28, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 15000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xppt_y", + "effect_options": {} + }, + { + "key": "entrance_ease_in", + "name": "Ease In", + "category": "entrance", + "mso_effect_id": 29, + "preset_id": 29, + "preset_subtype": 0, + "filter": "wipe(right)", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xppt_y", + "effect_options": {} + }, + { + "key": "entrance_float", + "name": "Float", + "category": "entrance", + "mso_effect_id": 30, + "preset_id": 30, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilitystyle.rotationppt_xppt_yppt_xppt_y", + "effect_options": {} + }, + { + "key": "entrance_grow_and_turn", + "name": "Grow And Turn", + "category": "entrance", + "mso_effect_id": 31, + "preset_id": 31, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_wppt_hstyle.rotation", + "effect_options": {} + }, + { + "key": "entrance_light_speed", + "name": "Light Speed", + "category": "entrance", + "mso_effect_id": 32, + "preset_id": 34, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 800, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xxshearppt_x", + "effect_options": {} + }, + { + "key": "entrance_pinwheel", + "name": "Pinwheel", + "category": "entrance", + "mso_effect_id": 33, + "preset_id": 35, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibilitystyle.rotationppt_hppt_w", + "effect_options": {} + }, + { + "key": "entrance_rise_up", + "name": "Rise Up", + "category": "entrance", + "mso_effect_id": 34, + "preset_id": 37, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xppt_yppt_y", + "effect_options": {} + }, + { + "key": "entrance_swish", + "name": "Swish", + "category": "entrance", + "mso_effect_id": 35, + "preset_id": 38, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilitystyle.rotationstyle.rotationppt_yppt_yppt_y", + "effect_options": {} + }, + { + "key": "entrance_thin_line", + "name": "Thin Line", + "category": "entrance", + "mso_effect_id": 36, + "preset_id": 39, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibilityppt_hppt_wppt_xppt_y", + "effect_options": {} + }, + { + "key": "entrance_unfold", + "name": "Unfold", + "category": "entrance", + "mso_effect_id": 37, + "preset_id": 40, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xppt_y", + "effect_options": {} + }, + { + "key": "entrance_whip", + "name": "Whip", + "category": "entrance", + "mso_effect_id": 38, + "preset_id": 41, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xppt_yppt_hppt_w", + "effect_options": {} + }, + { + "key": "entrance_ascend", + "name": "Ascend", + "category": "entrance", + "mso_effect_id": 39, + "preset_id": 42, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xppt_y", + "effect_options": {} + }, + { + "key": "entrance_center_revolve", + "name": "Center Revolve", + "category": "entrance", + "mso_effect_id": 40, + "preset_id": 43, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xppt_yppt_xppt_y", + "effect_options": {} + }, + { + "key": "entrance_faded_swivel", + "name": "Faded Swivel", + "category": "entrance", + "mso_effect_id": 41, + "preset_id": 45, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_wppt_h", + "effect_options": {} + }, + { + "key": "entrance_descend", + "name": "Descend", + "category": "entrance", + "mso_effect_id": 42, + "preset_id": 47, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xppt_y", + "effect_options": {} + }, + { + "key": "entrance_sling", + "name": "Sling", + "category": "entrance", + "mso_effect_id": 43, + "preset_id": 48, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilitystyle.rotationppt_xppt_y", + "effect_options": {} + }, + { + "key": "entrance_spinner", + "name": "Spinner", + "category": "entrance", + "mso_effect_id": 44, + "preset_id": 49, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibilityppt_wppt_hstyle.rotation", + "effect_options": {} + }, + { + "key": "entrance_stretchy", + "name": "Stretchy", + "category": "entrance", + "mso_effect_id": 45, + "preset_id": 50, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_wppt_h", + "effect_options": {} + }, + { + "key": "entrance_zip", + "name": "Zip", + "category": "entrance", + "mso_effect_id": 46, + "preset_id": 51, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xppt_xppt_yppt_y", + "effect_options": {} + }, + { + "key": "entrance_arc_up", + "name": "Arc Up", + "category": "entrance", + "mso_effect_id": 47, + "preset_id": 52, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_xppt_y", + "effect_options": {} + }, + { + "key": "entrance_faded_zoom", + "name": "Faded Zoom", + "category": "entrance", + "mso_effect_id": 48, + "preset_id": 53, + "preset_subtype": 16, + "filter": "fade", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibilityppt_wppt_h", + "effect_options": { + "direction": { + "type": "enum", + "default": "in", + "values": { + "in": "style.visibilityppt_wppt_h", + "in_center": "style.visibilityppt_wppt_hppt_xppt_y" + } + } + } + }, + { + "key": "entrance_glide", + "name": "Glide", + "category": "entrance", + "mso_effect_id": 49, + "preset_id": 54, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibilityppt_wppt_hppt_xppt_y", + "effect_options": {} + }, + { + "key": "entrance_expand", + "name": "Expand", + "category": "entrance", + "mso_effect_id": 50, + "preset_id": 55, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_wppt_h", + "effect_options": {} + }, + { + "key": "entrance_flip", + "name": "Flip", + "category": "entrance", + "mso_effect_id": 51, + "preset_id": 56, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilityppt_wppt_xppt_yr", + "effect_options": {} + }, + { + "key": "entrance_fold", + "name": "Fold", + "category": "entrance", + "mso_effect_id": 53, + "preset_id": 58, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibilityppt_wppt_hppt_xppt_y", + "effect_options": {} + }, + { + "key": "entrance_3d_arrive", + "name": "3D Arrive", + "category": "entrance", + "mso_effect_id": 151, + "preset_id": 60, + "preset_subtype": 128, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibility3d.view.rotation.y3d.object.scale.x3d.object.scale.y3d.object.scale.z", + "effect_options": {} + }, + { + "key": "emphasis_shimmer", + "name": "Shimmer", + "category": "emphasis", + "mso_effect_id": 52, + "preset_id": 36, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 250, + "duration_scalable": true, + "row_xml": "ppt_xppt_yr", + "effect_options": {} + }, + { + "key": "emphasis_change_fill_color", + "name": "Change Fill Color", + "category": "emphasis", + "mso_effect_id": 54, + "preset_id": 1, + "preset_subtype": 2, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "fillcolorfill.typefill.on", + "effect_options": { + "color": { + "type": "color", + "default": "theme:accent2" + } + } + }, + { + "key": "emphasis_change_font", + "name": "Change Font", + "category": "emphasis", + "mso_effect_id": 55, + "preset_id": 2, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": null, + "duration_scalable": false, + "row_xml": "style.fontFamily", + "effect_options": { + "font_name": { + "type": "string", + "default": "??" + } + } + }, + { + "key": "emphasis_change_font_color", + "name": "Change Font Color", + "category": "emphasis", + "mso_effect_id": 56, + "preset_id": 3, + "preset_subtype": 2, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.color", + "effect_options": { + "color": { + "type": "color", + "default": "theme:accent2" + } + } + }, + { + "key": "emphasis_change_font_size", + "name": "Change Font Size", + "category": "emphasis", + "mso_effect_id": 57, + "preset_id": 4, + "preset_subtype": 2, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.fontSize", + "effect_options": {} + }, + { + "key": "emphasis_change_font_style", + "name": "Change Font Style", + "category": "emphasis", + "mso_effect_id": 58, + "preset_id": 5, + "preset_subtype": 1, + "filter": null, + "default_duration_ms": null, + "duration_scalable": false, + "row_xml": "style.fontStylestyle.fontWeightstyle.textDecorationUnderline", + "effect_options": {} + }, + { + "key": "emphasis_grow_shrink", + "name": "Grow Shrink", + "category": "emphasis", + "mso_effect_id": 59, + "preset_id": 6, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "", + "effect_options": { + "size": { + "type": "number", + "default": 100.0, + "minimum": 0.001, + "maximum": 2147483.0 + } + } + }, + { + "key": "emphasis_change_line_color", + "name": "Change Line Color", + "category": "emphasis", + "mso_effect_id": 60, + "preset_id": 7, + "preset_subtype": 2, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "stroke.colorstroke.on", + "effect_options": { + "color": { + "type": "color", + "default": "theme:accent2" + } + } + }, + { + "key": "emphasis_spin", + "name": "Spin", + "category": "emphasis", + "mso_effect_id": 61, + "preset_id": 8, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "r", + "effect_options": { + "amount": { + "type": "number", + "default": 360.0, + "minimum": -35791.0, + "maximum": 35791.0 + } + } + }, + { + "key": "emphasis_transparency", + "name": "Transparency", + "category": "emphasis", + "mso_effect_id": 62, + "preset_id": 9, + "preset_subtype": 0, + "filter": "image", + "default_duration_ms": null, + "duration_scalable": false, + "row_xml": "style.opacity", + "effect_options": { + "amount": { + "type": "number", + "default": 0.5, + "minimum": 0.0, + "maximum": 1.0 + } + } + }, + { + "key": "emphasis_bold_flash", + "name": "Bold Flash", + "category": "emphasis", + "mso_effect_id": 63, + "preset_id": 10, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.fontWeight", + "effect_options": {} + }, + { + "key": "emphasis_blast", + "name": "Blast", + "category": "emphasis", + "mso_effect_id": 64, + "preset_id": 14, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.colorfillColorfill.typefill.on", + "effect_options": { + "color": { + "type": "color", + "default": "theme:accent2" + } + } + }, + { + "key": "emphasis_bold_reveal", + "name": "Bold Reveal", + "category": "emphasis", + "mso_effect_id": 65, + "preset_id": 15, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": null, + "duration_scalable": false, + "row_xml": "style.fontWeight", + "effect_options": {} + }, + { + "key": "emphasis_brush_on_color", + "name": "Brush On Color", + "category": "emphasis", + "mso_effect_id": 66, + "preset_id": 16, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.colorfillcolorfill.type", + "effect_options": { + "color": { + "type": "color", + "default": "theme:accent2" + } + } + }, + { + "key": "emphasis_brush_on_underline", + "name": "Brush On Underline", + "category": "emphasis", + "mso_effect_id": 67, + "preset_id": 18, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.textDecorationUnderline", + "effect_options": {} + }, + { + "key": "emphasis_color_blend", + "name": "Color Blend", + "category": "emphasis", + "mso_effect_id": 68, + "preset_id": 19, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.colorfillcolorfill.typefill.on", + "effect_options": { + "color": { + "type": "color", + "default": "theme:accent2" + } + } + }, + { + "key": "emphasis_color_wave", + "name": "Color Wave", + "category": "emphasis", + "mso_effect_id": 69, + "preset_id": 20, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.colorfillcolorfill.type", + "effect_options": { + "color": { + "type": "color", + "default": "theme:accent2" + } + } + }, + { + "key": "emphasis_complementary_color", + "name": "Complementary Color", + "category": "emphasis", + "mso_effect_id": 70, + "preset_id": 21, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.colorfillcolorstroke.colorfill.type", + "effect_options": {} + }, + { + "key": "emphasis_complementary_color2", + "name": "Complementary Color2", + "category": "emphasis", + "mso_effect_id": 71, + "preset_id": 22, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.colorfillcolorstroke.colorfill.type", + "effect_options": {} + }, + { + "key": "emphasis_contrasting_color", + "name": "Contrasting Color", + "category": "emphasis", + "mso_effect_id": 72, + "preset_id": 23, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.colorfillcolorstroke.colorfill.type", + "effect_options": {} + }, + { + "key": "emphasis_darken", + "name": "Darken", + "category": "emphasis", + "mso_effect_id": 73, + "preset_id": 24, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.colorfillcolorstroke.colorfill.type", + "effect_options": {} + }, + { + "key": "emphasis_desaturate", + "name": "Desaturate", + "category": "emphasis", + "mso_effect_id": 74, + "preset_id": 25, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.colorfillcolorstroke.colorfill.type", + "effect_options": {} + }, + { + "key": "emphasis_flash_bulb", + "name": "Flash Bulb", + "category": "emphasis", + "mso_effect_id": 75, + "preset_id": 26, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "", + "effect_options": {} + }, + { + "key": "emphasis_flicker", + "name": "Flicker", + "category": "emphasis", + "mso_effect_id": 76, + "preset_id": 27, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 250, + "duration_scalable": true, + "row_xml": "style.colorfillcolorfill.typefill.on", + "effect_options": { + "color": { + "type": "color", + "default": "theme:bg1" + } + } + }, + { + "key": "emphasis_grow_with_color", + "name": "Grow With Color", + "category": "emphasis", + "mso_effect_id": 77, + "preset_id": 28, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.colorfillcolorfill.typestyle.fontSize", + "effect_options": { + "color": { + "type": "color", + "default": "theme:accent2" + } + } + }, + { + "key": "emphasis_lighten", + "name": "Lighten", + "category": "emphasis", + "mso_effect_id": 78, + "preset_id": 30, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.colorfillcolorstroke.colorfill.type", + "effect_options": {} + }, + { + "key": "emphasis_style_emphasis", + "name": "Style Emphasis", + "category": "emphasis", + "mso_effect_id": 79, + "preset_id": 31, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.colorstyle.fontStylestyle.fontWeightstyle.textDecorationUnderline", + "effect_options": { + "color": { + "type": "color", + "default": "theme:accent2" + } + } + }, + { + "key": "emphasis_teeter", + "name": "Teeter", + "category": "emphasis", + "mso_effect_id": 80, + "preset_id": 32, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "rrrrr", + "effect_options": {} + }, + { + "key": "emphasis_vertical_grow", + "name": "Vertical Grow", + "category": "emphasis", + "mso_effect_id": 81, + "preset_id": 33, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 3000, + "duration_scalable": true, + "row_xml": "style.colorfillcolorfill.typefill.on", + "effect_options": { + "color": { + "type": "color", + "default": "theme:accent2" + } + } + }, + { + "key": "emphasis_wave", + "name": "Wave", + "category": "emphasis", + "mso_effect_id": 82, + "preset_id": 34, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "ppt_xppt_yrrrr", + "effect_options": {} + }, + { + "key": "emphasis_3d_turntable", + "name": "3D Turntable", + "category": "emphasis", + "mso_effect_id": 152, + "preset_id": 37, + "preset_subtype": 128, + "filter": null, + "default_duration_ms": 20000, + "duration_scalable": true, + "row_xml": "3d.view.rotation.y", + "effect_options": {} + }, + { + "key": "emphasis_3d_swing", + "name": "3D Swing", + "category": "emphasis", + "mso_effect_id": 153, + "preset_id": 38, + "preset_subtype": 128, + "filter": null, + "default_duration_ms": 5000, + "duration_scalable": true, + "row_xml": "3d.view.rotation.y", + "effect_options": {} + }, + { + "key": "emphasis_3d_jump_and_turn", + "name": "3D Jump And Turn", + "category": "emphasis", + "mso_effect_id": 154, + "preset_id": 39, + "preset_subtype": 2, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "3d.object.rotation.y3d.object.translation.y3d.object.scale.x3d.object.scale.y3d.object.scale.z", + "effect_options": {} + }, + { + "key": "path_circle", + "name": "Circle", + "category": "path", + "mso_effect_id": 86, + "preset_id": 1, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_right_triangle", + "name": "Right Triangle", + "category": "path", + "mso_effect_id": 87, + "preset_id": 2, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_diamond", + "name": "Diamond", + "category": "path", + "mso_effect_id": 88, + "preset_id": 3, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_hexagon", + "name": "Hexagon", + "category": "path", + "mso_effect_id": 89, + "preset_id": 4, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_5_point_star", + "name": "5 Point Star", + "category": "path", + "mso_effect_id": 90, + "preset_id": 5, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_crescent_moon", + "name": "Crescent Moon", + "category": "path", + "mso_effect_id": 91, + "preset_id": 6, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_square", + "name": "Square", + "category": "path", + "mso_effect_id": 92, + "preset_id": 7, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_trapezoid", + "name": "Trapezoid", + "category": "path", + "mso_effect_id": 93, + "preset_id": 8, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_heart", + "name": "Heart", + "category": "path", + "mso_effect_id": 94, + "preset_id": 9, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_octagon", + "name": "Octagon", + "category": "path", + "mso_effect_id": 95, + "preset_id": 10, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_6_point_star", + "name": "6 Point Star", + "category": "path", + "mso_effect_id": 96, + "preset_id": 11, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_football", + "name": "Football", + "category": "path", + "mso_effect_id": 97, + "preset_id": 12, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_equal_triangle", + "name": "Equal Triangle", + "category": "path", + "mso_effect_id": 98, + "preset_id": 13, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_parallelogram", + "name": "Parallelogram", + "category": "path", + "mso_effect_id": 99, + "preset_id": 14, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_pentagon", + "name": "Pentagon", + "category": "path", + "mso_effect_id": 100, + "preset_id": 15, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_4_point_star", + "name": "4 Point Star", + "category": "path", + "mso_effect_id": 101, + "preset_id": 16, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_8_point_star", + "name": "8 Point Star", + "category": "path", + "mso_effect_id": 102, + "preset_id": 17, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_teardrop", + "name": "Teardrop", + "category": "path", + "mso_effect_id": 103, + "preset_id": 18, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_pointy_star", + "name": "Pointy Star", + "category": "path", + "mso_effect_id": 104, + "preset_id": 19, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_curved_square", + "name": "Curved Square", + "category": "path", + "mso_effect_id": 105, + "preset_id": 20, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_curved_x", + "name": "Curved X", + "category": "path", + "mso_effect_id": 106, + "preset_id": 21, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_vertical_figure8", + "name": "Vertical Figure8", + "category": "path", + "mso_effect_id": 107, + "preset_id": 22, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_curvy_star", + "name": "Curvy Star", + "category": "path", + "mso_effect_id": 108, + "preset_id": 23, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_loopde_loop", + "name": "Loopde Loop", + "category": "path", + "mso_effect_id": 109, + "preset_id": 24, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_buzzsaw", + "name": "Buzzsaw", + "category": "path", + "mso_effect_id": 110, + "preset_id": 25, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_horizontal_figure8", + "name": "Horizontal Figure8", + "category": "path", + "mso_effect_id": 111, + "preset_id": 26, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_peanut", + "name": "Peanut", + "category": "path", + "mso_effect_id": 112, + "preset_id": 27, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_figure8_four", + "name": "Figure8 Four", + "category": "path", + "mso_effect_id": 113, + "preset_id": 28, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_neutron", + "name": "Neutron", + "category": "path", + "mso_effect_id": 114, + "preset_id": 29, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_swoosh", + "name": "Swoosh", + "category": "path", + "mso_effect_id": 115, + "preset_id": 30, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_bean", + "name": "Bean", + "category": "path", + "mso_effect_id": 116, + "preset_id": 31, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_plus", + "name": "Plus", + "category": "path", + "mso_effect_id": 117, + "preset_id": 32, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_inverted_triangle", + "name": "Inverted Triangle", + "category": "path", + "mso_effect_id": 118, + "preset_id": 33, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_inverted_square", + "name": "Inverted Square", + "category": "path", + "mso_effect_id": 119, + "preset_id": 34, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_left", + "name": "Left", + "category": "path", + "mso_effect_id": 120, + "preset_id": 35, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_turn_right", + "name": "Turn Right", + "category": "path", + "mso_effect_id": 121, + "preset_id": 36, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_arc_down", + "name": "Arc Down", + "category": "path", + "mso_effect_id": 122, + "preset_id": 37, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_zigzag", + "name": "Zigzag", + "category": "path", + "mso_effect_id": 123, + "preset_id": 38, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_s_curve2", + "name": "S Curve2", + "category": "path", + "mso_effect_id": 124, + "preset_id": 39, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_sine_wave", + "name": "Sine Wave", + "category": "path", + "mso_effect_id": 125, + "preset_id": 40, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_bounce_left", + "name": "Bounce Left", + "category": "path", + "mso_effect_id": 126, + "preset_id": 41, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_down", + "name": "Down", + "category": "path", + "mso_effect_id": 127, + "preset_id": 42, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_turn_up", + "name": "Turn Up", + "category": "path", + "mso_effect_id": 128, + "preset_id": 43, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_arc_up", + "name": "Arc Up", + "category": "path", + "mso_effect_id": 129, + "preset_id": 44, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_heartbeat", + "name": "Heartbeat", + "category": "path", + "mso_effect_id": 130, + "preset_id": 45, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_spiral_right", + "name": "Spiral Right", + "category": "path", + "mso_effect_id": 131, + "preset_id": 46, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_wave", + "name": "Wave", + "category": "path", + "mso_effect_id": 132, + "preset_id": 47, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_curvy_left", + "name": "Curvy Left", + "category": "path", + "mso_effect_id": 133, + "preset_id": 48, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_diagonal_down_right", + "name": "Diagonal Down Right", + "category": "path", + "mso_effect_id": 134, + "preset_id": 49, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_turn_down", + "name": "Turn Down", + "category": "path", + "mso_effect_id": 135, + "preset_id": 50, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_arc_left", + "name": "Arc Left", + "category": "path", + "mso_effect_id": 136, + "preset_id": 51, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_funnel", + "name": "Funnel", + "category": "path", + "mso_effect_id": 137, + "preset_id": 52, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_spring", + "name": "Spring", + "category": "path", + "mso_effect_id": 138, + "preset_id": 53, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_bounce_right", + "name": "Bounce Right", + "category": "path", + "mso_effect_id": 139, + "preset_id": 54, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_spiral_left", + "name": "Spiral Left", + "category": "path", + "mso_effect_id": 140, + "preset_id": 55, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_diagonal_up_right", + "name": "Diagonal Up Right", + "category": "path", + "mso_effect_id": 141, + "preset_id": 56, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_turn_up_right", + "name": "Turn Up Right", + "category": "path", + "mso_effect_id": 142, + "preset_id": 57, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_arc_right", + "name": "Arc Right", + "category": "path", + "mso_effect_id": 143, + "preset_id": 58, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_s_curve1", + "name": "S Curve1", + "category": "path", + "mso_effect_id": 144, + "preset_id": 59, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_decaying_wave", + "name": "Decaying Wave", + "category": "path", + "mso_effect_id": 145, + "preset_id": 60, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_curvy_right", + "name": "Curvy Right", + "category": "path", + "mso_effect_id": 146, + "preset_id": 61, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_stairs_down", + "name": "Stairs Down", + "category": "path", + "mso_effect_id": 147, + "preset_id": 62, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_up", + "name": "Up", + "category": "path", + "mso_effect_id": 148, + "preset_id": 64, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "path_right", + "name": "Right", + "category": "path", + "mso_effect_id": 149, + "preset_id": 63, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_y", + "effect_options": { + "relative": { + "type": "boolean", + "default": true + } + } + }, + { + "key": "exit_appear", + "name": "Appear", + "category": "exit", + "mso_effect_id": 1, + "preset_id": 1, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1, + "duration_scalable": false, + "row_xml": "style.visibility", + "effect_options": {} + }, + { + "key": "exit_fly", + "name": "Fly", + "category": "exit", + "mso_effect_id": 2, + "preset_id": 2, + "preset_subtype": 4, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "ppt_xppt_ystyle.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "down", + "values": { + "up": "ppt_xppt_ystyle.visibility", + "right": "ppt_xppt_ystyle.visibility", + "down": "ppt_xppt_ystyle.visibility", + "left": "ppt_xppt_ystyle.visibility", + "up_left": "ppt_xppt_ystyle.visibility", + "up_right": "ppt_xppt_ystyle.visibility", + "down_right": "ppt_xppt_ystyle.visibility", + "down_left": "ppt_xppt_ystyle.visibility" + } + } + } + }, + { + "key": "exit_blinds", + "name": "Blinds", + "category": "exit", + "mso_effect_id": 3, + "preset_id": 3, + "preset_subtype": 10, + "filter": "blinds(horizontal)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "horizontal", + "values": { + "horizontal": "style.visibility", + "vertical": "style.visibility" + } + } + } + }, + { + "key": "exit_box", + "name": "Box", + "category": "exit", + "mso_effect_id": 4, + "preset_id": 4, + "preset_subtype": 16, + "filter": "box(in)", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "in", + "values": { + "in": "style.visibility", + "out": "style.visibility" + } + } + } + }, + { + "key": "exit_checkerboard", + "name": "Checkerboard", + "category": "exit", + "mso_effect_id": 5, + "preset_id": 5, + "preset_subtype": 10, + "filter": "checkerboard(across)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "horizontal", + "values": { + "horizontal": "style.visibility", + "vertical": "style.visibility" + } + } + } + }, + { + "key": "exit_circle", + "name": "Circle", + "category": "exit", + "mso_effect_id": 6, + "preset_id": 6, + "preset_subtype": 16, + "filter": "circle(in)", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "in", + "values": { + "in": "style.visibility", + "out": "style.visibility" + } + } + } + }, + { + "key": "exit_crawl", + "name": "Crawl", + "category": "exit", + "mso_effect_id": 7, + "preset_id": 7, + "preset_subtype": 4, + "filter": null, + "default_duration_ms": 5000, + "duration_scalable": true, + "row_xml": "ppt_xppt_ystyle.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "down", + "values": { + "up": "ppt_xppt_ystyle.visibility", + "right": "ppt_xppt_ystyle.visibility", + "down": "ppt_xppt_ystyle.visibility", + "left": "ppt_xppt_ystyle.visibility", + "up_left": "ppt_xppt_ystyle.visibility", + "up_right": "ppt_xppt_ystyle.visibility", + "down_right": "ppt_xppt_ystyle.visibility", + "down_left": "ppt_xppt_ystyle.visibility" + } + } + } + }, + { + "key": "exit_diamond", + "name": "Diamond", + "category": "exit", + "mso_effect_id": 8, + "preset_id": 8, + "preset_subtype": 16, + "filter": "diamond(in)", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "in", + "values": { + "in": "style.visibility", + "out": "style.visibility" + } + } + } + }, + { + "key": "exit_dissolve", + "name": "Dissolve", + "category": "exit", + "mso_effect_id": 9, + "preset_id": 9, + "preset_subtype": 0, + "filter": "dissolve", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": {} + }, + { + "key": "exit_fade", + "name": "Fade", + "category": "exit", + "mso_effect_id": 10, + "preset_id": 10, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": {} + }, + { + "key": "exit_flash_once", + "name": "Flash Once", + "category": "exit", + "mso_effect_id": 11, + "preset_id": 11, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.visibilitystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_peek", + "name": "Peek", + "category": "exit", + "mso_effect_id": 12, + "preset_id": 12, + "preset_subtype": 4, + "filter": "wipe(down)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "ppt_ystyle.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "down", + "values": { + "up": "ppt_ystyle.visibility", + "right": "ppt_xstyle.visibility", + "down": "ppt_ystyle.visibility", + "left": "ppt_xstyle.visibility" + } + } + } + }, + { + "key": "exit_plus", + "name": "Plus", + "category": "exit", + "mso_effect_id": 13, + "preset_id": 13, + "preset_subtype": 16, + "filter": "plus(in)", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "in", + "values": { + "in": "style.visibility", + "out": "style.visibility" + } + } + } + }, + { + "key": "exit_random_bars", + "name": "Random Bars", + "category": "exit", + "mso_effect_id": 14, + "preset_id": 14, + "preset_subtype": 10, + "filter": "randombar(horizontal)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "horizontal", + "values": { + "horizontal": "style.visibility", + "vertical": "style.visibility" + } + } + } + }, + { + "key": "exit_spiral", + "name": "Spiral", + "category": "exit", + "mso_effect_id": 15, + "preset_id": 15, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_wppt_hppt_xppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_split", + "name": "Split", + "category": "exit", + "mso_effect_id": 16, + "preset_id": 16, + "preset_subtype": 21, + "filter": "barn(inVertical)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "vertical_in", + "values": { + "horizontal_in": "style.visibility", + "horizontal_out": "style.visibility", + "vertical_in": "style.visibility", + "vertical_out": "style.visibility" + } + } + } + }, + { + "key": "exit_stretch", + "name": "Stretch", + "category": "exit", + "mso_effect_id": 17, + "preset_id": 17, + "preset_subtype": 10, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "ppt_wppt_hstyle.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "horizontal", + "values": { + "up": "ppt_xppt_yppt_wppt_hstyle.visibility", + "right": "ppt_xppt_yppt_wppt_hstyle.visibility", + "down": "ppt_xppt_yppt_wppt_hstyle.visibility", + "left": "ppt_xppt_yppt_wppt_hstyle.visibility", + "horizontal": "ppt_wppt_hstyle.visibility" + } + } + } + }, + { + "key": "exit_strips", + "name": "Strips", + "category": "exit", + "mso_effect_id": 18, + "preset_id": 18, + "preset_subtype": 12, + "filter": "strips(downLeft)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "down_left", + "values": { + "up_left": "style.visibility", + "up_right": "style.visibility", + "down_right": "style.visibility", + "down_left": "style.visibility" + } + } + } + }, + { + "key": "exit_swivel", + "name": "Swivel", + "category": "exit", + "mso_effect_id": 19, + "preset_id": 19, + "preset_subtype": 10, + "filter": null, + "default_duration_ms": 5000, + "duration_scalable": true, + "row_xml": "ppt_hppt_wstyle.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "horizontal", + "values": { + "horizontal": "ppt_hppt_wstyle.visibility", + "vertical": "ppt_wppt_hstyle.visibility" + } + } + } + }, + { + "key": "exit_wedge", + "name": "Wedge", + "category": "exit", + "mso_effect_id": 20, + "preset_id": 20, + "preset_subtype": 0, + "filter": "wedge", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": {} + }, + { + "key": "exit_wheel", + "name": "Wheel", + "category": "exit", + "mso_effect_id": 21, + "preset_id": 21, + "preset_subtype": 1, + "filter": "wheel(1)", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "amount": { + "type": "enum", + "default": 1, + "values": { + "1": "style.visibility", + "2": "style.visibility", + "3": "style.visibility", + "4": "style.visibility", + "8": "style.visibility" + } + } + } + }, + { + "key": "exit_wipe", + "name": "Wipe", + "category": "exit", + "mso_effect_id": 22, + "preset_id": 22, + "preset_subtype": 4, + "filter": "wipe(down)", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "style.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "down", + "values": { + "up": "style.visibility", + "right": "style.visibility", + "down": "style.visibility", + "left": "style.visibility" + } + } + } + }, + { + "key": "exit_zoom", + "name": "Zoom", + "category": "exit", + "mso_effect_id": 23, + "preset_id": 23, + "preset_subtype": 16, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "ppt_wppt_hstyle.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "in", + "values": { + "in": "ppt_wppt_hstyle.visibility", + "out": "ppt_wppt_hstyle.visibility", + "in_slightly": "ppt_wppt_hstyle.visibility", + "in_bottom": "ppt_wppt_hppt_ystyle.visibility", + "out_slightly": "ppt_wppt_hstyle.visibility", + "out_center": "ppt_wppt_hppt_xppt_ystyle.visibility" + } + } + } + }, + { + "key": "exit_random_effects", + "name": "Random Effects", + "category": "exit", + "mso_effect_id": 24, + "preset_id": 24, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1, + "duration_scalable": false, + "row_xml": "style.visibility", + "effect_options": {} + }, + { + "key": "exit_boomerang", + "name": "Boomerang", + "category": "exit", + "mso_effect_id": 25, + "preset_id": 25, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_yppt_yppt_xppt_hppt_wppt_wstyle.rotationstyle.visibility", + "effect_options": {} + }, + { + "key": "exit_bounce", + "name": "Bounce", + "category": "exit", + "mso_effect_id": 26, + "preset_id": 26, + "preset_subtype": 0, + "filter": "wipe(down)", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_xppt_yppt_yppt_yppt_yppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_color_reveal", + "name": "Color Reveal", + "category": "exit", + "mso_effect_id": 27, + "preset_id": 27, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 80, + "duration_scalable": true, + "row_xml": "style.colorfillcolorfill.typestyle.visibility", + "effect_options": {} + }, + { + "key": "exit_credits", + "name": "Credits", + "category": "exit", + "mso_effect_id": 28, + "preset_id": 28, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 15000, + "duration_scalable": true, + "row_xml": "ppt_xppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_ease_in", + "name": "Ease In", + "category": "exit", + "mso_effect_id": 29, + "preset_id": 29, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_xppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_float", + "name": "Float", + "category": "exit", + "mso_effect_id": 30, + "preset_id": 30, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.rotationppt_xppt_yppt_xppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_grow_and_turn", + "name": "Grow And Turn", + "category": "exit", + "mso_effect_id": 31, + "preset_id": 31, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_wppt_hstyle.rotationstyle.visibility", + "effect_options": {} + }, + { + "key": "exit_light_speed", + "name": "Light Speed", + "category": "exit", + "mso_effect_id": 32, + "preset_id": 34, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_xxshearxshearstyle.visibility", + "effect_options": {} + }, + { + "key": "exit_pinwheel", + "name": "Pinwheel", + "category": "exit", + "mso_effect_id": 33, + "preset_id": 35, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "style.rotationppt_hppt_wstyle.visibility", + "effect_options": {} + }, + { + "key": "exit_rise_up", + "name": "Rise Up", + "category": "exit", + "mso_effect_id": 34, + "preset_id": 37, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_xppt_yppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_swish", + "name": "Swish", + "category": "exit", + "mso_effect_id": 35, + "preset_id": 38, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.rotationppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_thin_line", + "name": "Thin Line", + "category": "exit", + "mso_effect_id": 36, + "preset_id": 39, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "ppt_hppt_wppt_xppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_unfold", + "name": "Unfold", + "category": "exit", + "mso_effect_id": 37, + "preset_id": 40, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_xppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_whip", + "name": "Whip", + "category": "exit", + "mso_effect_id": 38, + "preset_id": 41, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "ppt_xppt_yppt_hppt_wstyle.visibility", + "effect_options": {} + }, + { + "key": "exit_ascend", + "name": "Ascend", + "category": "exit", + "mso_effect_id": 39, + "preset_id": 42, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_xppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_center_revolve", + "name": "Center Revolve", + "category": "exit", + "mso_effect_id": 40, + "preset_id": 43, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_xppt_xppt_yppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_faded_swivel", + "name": "Faded Swivel", + "category": "exit", + "mso_effect_id": 41, + "preset_id": 45, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_wppt_hstyle.visibility", + "effect_options": {} + }, + { + "key": "exit_descend", + "name": "Descend", + "category": "exit", + "mso_effect_id": 42, + "preset_id": 47, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_xppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_sling", + "name": "Sling", + "category": "exit", + "mso_effect_id": 43, + "preset_id": 48, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "style.rotationppt_xppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_spinner", + "name": "Spinner", + "category": "exit", + "mso_effect_id": 44, + "preset_id": 49, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "ppt_wppt_hstyle.rotationstyle.visibility", + "effect_options": {} + }, + { + "key": "exit_stretchy", + "name": "Stretchy", + "category": "exit", + "mso_effect_id": 45, + "preset_id": 50, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_wppt_hstyle.visibility", + "effect_options": {} + }, + { + "key": "exit_zip", + "name": "Zip", + "category": "exit", + "mso_effect_id": 46, + "preset_id": 51, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 2000, + "duration_scalable": true, + "row_xml": "ppt_xppt_xppt_yppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_arc_up", + "name": "Arc Up", + "category": "exit", + "mso_effect_id": 47, + "preset_id": 52, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_xppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_faded_zoom", + "name": "Faded Zoom", + "category": "exit", + "mso_effect_id": 48, + "preset_id": 53, + "preset_subtype": 32, + "filter": "fade", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "ppt_wppt_hstyle.visibility", + "effect_options": { + "direction": { + "type": "enum", + "default": "out", + "values": { + "out": "ppt_wppt_hstyle.visibility", + "out_center": "ppt_wppt_hppt_xppt_ystyle.visibility" + } + } + } + }, + { + "key": "exit_glide", + "name": "Glide", + "category": "exit", + "mso_effect_id": 49, + "preset_id": 54, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "ppt_wppt_hppt_xppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_expand", + "name": "Expand", + "category": "exit", + "mso_effect_id": 50, + "preset_id": 55, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_wppt_hstyle.visibility", + "effect_options": {} + }, + { + "key": "exit_flip", + "name": "Flip", + "category": "exit", + "mso_effect_id": 51, + "preset_id": 56, + "preset_subtype": 0, + "filter": null, + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "ppt_wppt_xppt_yrstyle.visibility", + "effect_options": {} + }, + { + "key": "exit_fold", + "name": "Fold", + "category": "exit", + "mso_effect_id": 53, + "preset_id": 58, + "preset_subtype": 0, + "filter": "fade", + "default_duration_ms": 500, + "duration_scalable": true, + "row_xml": "ppt_wppt_hppt_xppt_ystyle.visibility", + "effect_options": {} + }, + { + "key": "exit_3d_arrive", + "name": "3D Arrive", + "category": "exit", + "mso_effect_id": 151, + "preset_id": 60, + "preset_subtype": 128, + "filter": "fade", + "default_duration_ms": 1000, + "duration_scalable": true, + "row_xml": "3d.view.rotation.y3d.object.scale.x3d.object.scale.y3d.object.scale.zstyle.visibility", + "effect_options": {} + } + ] +} diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_animations.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_animations.py index c78bdc0f..525565b9 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_animations.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_animations.py @@ -2,36 +2,47 @@ """ PPT Master - PPTX Animation Module -Provides one strict entrance-animation registry plus OOXML read/write helpers. +Provides one strict object-animation registry plus OOXML read/write helpers. Supported transition effects: - - fade: Fade in/out - - push: Push - - wipe: Wipe - - split: Split - - strips: Strips (diagonal wipe) - - cover: Cover - - random: Random + - Complete current PowerPoint gallery: Subtle, Exciting, Dynamic Content + - Compatibility aliases: strips, circle, diamond, newsflash, plus, pull, + wedge, wheel + - none: no visual transition (handled by the shared transition core) -Supported entrance animations (per-element): +PowerPoint-native object animations: + - 53 entrance effects (``entrance_*``) + - 33 emphasis effects (``emphasis_*``) + - 64 motion paths (``path_*``) + - 53 exit effects (``exit_*``) + +Legacy compatibility inputs (accepted but never selected for new output): 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 +The four media commands in MsoAnimEffect are intentionally excluded because +they require audio/video shapes or bookmarks rather than generated SVG groups. + Animation modes used by the builder: - single effect name (one of the above) — apply to every element - 'auto' — pick effect from the group's SVG id. Image-like ids (hero / figure- / image / img- / kpi) cycle through a - visual pool (zoom / dissolve / circle / box / diamond / - wheel) so multiple images vary across the deck. Other + visual pool (``entrance_zoom`` / ``entrance_dissolve`` / + ``entrance_circle`` / ``entrance_box`` / + ``entrance_diamond`` / ``entrance_wheel``) so multiple + images vary across the deck. Other semantic matches map to a single stable effect - (chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade). + (chart→``entrance_wipe``, + card-/step-/pillar-→``entrance_fly``, + title/takeaway→``entrance_fade``). Unmatched ids cycle through a small modern pool - (fade / wipe / fly / zoom). - - 'mixed' — legacy mode: first element fades, the rest cycle through a - larger curated visible pool (kept for backward compatibility). - - 'random' — pick a random effect from the legacy pool per element + (``entrance_fade`` / ``entrance_wipe`` / + ``entrance_fly`` / ``entrance_zoom``). + - 'mixed' — compatible mode name: first element fades, the rest cycle + through a larger canonical PowerPoint entrance pool. + - 'random' — pick a seeded canonical PowerPoint entrance per element Generated animation rows are validated against their requested effect, target, duration, order, and Start mode before a PPTX is published. Package validation @@ -44,26 +55,36 @@ Dependencies: None (standard-library XML generation and validation) Usage: python3 scripts/pptx_animations.py --demo python3 scripts/pptx_animations.py --list + python3 scripts/pptx_animations.py --describe entrance_fly """ import argparse +import copy import hashlib +import json import math +import posixpath import random import re import zipfile -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import Any, Mapping, Sequence from xml.etree import ElementTree as ET from console_encoding import configure_utf8_stdio from pptx_transitions import ( + LEGACY_TRANSITION_KEYS, MAX_OOXML_MILLISECONDS, MAX_OOXML_UNSIGNED_INT, + NATIVE_TRANSITION_KEYS, + NATIVE_TRANSITIONS, PML_NS, - TRANSITIONS, + TRANSITION_ALIAS_OPTIONS, + TRANSITION_ALIASES, + TRANSITION_CATEGORIES, create_transition_xml, + describe_transition_effect, validate_seconds, ) @@ -71,44 +92,252 @@ configure_utf8_stdio() # ============================================================================ -# Entrance animation definitions +# Object animation definitions # ============================================================================ -# -# 'filter' values must be valid PowerPoint strings -# (see ECMA-376 §19.5.10 ST_TLAnimateEffectTransition / filter dictionary). -# Effects with filter=None render as plain "Appear" (visibility flip only). -# -ANIMATIONS: dict[str, dict[str, Any]] = { - 'appear': {'name': 'Appear', 'filter': None, 'presetID': 1, 'presetSubtype': 0}, - 'fade': {'name': 'Fade', 'filter': 'fade', 'presetID': 10, 'presetSubtype': 0}, - 'fly': {'name': 'Fly In', 'filter': 'slide(fromBottom)', 'presetID': 2, 'presetSubtype': 4}, - 'fly_left': {'name': 'Fly In from Left', 'filter': 'slide(fromLeft)', 'presetID': 2, 'presetSubtype': 8}, - 'fly_right': {'name': 'Fly In from Right', 'filter': 'slide(fromRight)', 'presetID': 2, 'presetSubtype': 2}, - 'fly_top': {'name': 'Fly In from Top', 'filter': 'slide(fromTop)', 'presetID': 2, 'presetSubtype': 1}, - 'cut': {'name': 'Cut In', 'filter': 'slide(fromLeft)', 'presetID': 42, 'presetSubtype': 8}, - 'zoom': {'name': 'Zoom', 'filter': 'image', 'presetID': 23, 'presetSubtype': 0}, - 'wipe': {'name': 'Wipe', 'filter': 'wipe(left)', 'presetID': 22, 'presetSubtype': 1}, - 'wipe_left': {'name': 'Wipe Left', 'filter': 'wipe(left)', 'presetID': 22, 'presetSubtype': 8}, - 'wipe_right': {'name': 'Wipe Right', 'filter': 'wipe(right)', 'presetID': 22, 'presetSubtype': 2}, - 'wipe_up': {'name': 'Wipe Up', 'filter': 'wipe(up)', 'presetID': 22, 'presetSubtype': 1}, - 'wipe_down': {'name': 'Wipe Down', 'filter': 'wipe(down)', 'presetID': 22, 'presetSubtype': 4}, - 'split': {'name': 'Split', 'filter': 'barn(inVertical)', 'presetID': 16, 'presetSubtype': 21}, - 'blinds': {'name': 'Blinds', 'filter': 'blinds(horizontal)', 'presetID': 3, 'presetSubtype': 10}, - 'checkerboard': {'name': 'Checkerboard', 'filter': 'checkerboard(across)', 'presetID': 5, 'presetSubtype': 6}, - 'dissolve': {'name': 'Dissolve', 'filter': 'dissolve', 'presetID': 9, 'presetSubtype': 0}, - 'random_bars': {'name': 'Random Bars', 'filter': 'randombar(horizontal)', 'presetID': 14, 'presetSubtype': 10}, - 'peek': {'name': 'Peek', 'filter': 'wipe(down)', 'presetID': 12, 'presetSubtype': 4}, - 'wheel': {'name': 'Wheel', 'filter': 'wheel(4)', 'presetID': 21, 'presetSubtype': 0}, - 'box': {'name': 'Box', 'filter': 'box(in)', 'presetID': 4, 'presetSubtype': 0}, - 'circle': {'name': 'Circle', 'filter': 'circle(in)', 'presetID': 6, 'presetSubtype': 0}, - 'diamond': {'name': 'Diamond', 'filter': 'diamond(in)', 'presetID': 8, 'presetSubtype': 0}, - 'plus': {'name': 'Plus', 'filter': 'plus(in)', 'presetID': 13, 'presetSubtype': 0}, - 'strips': {'name': 'Strips', 'filter': 'strips(downRight)', 'presetID': 18, 'presetSubtype': 12}, - 'wedge': {'name': 'Wedge', 'filter': 'wedge', 'presetID': 20, 'presetSubtype': 0}, - 'stretch': {'name': 'Stretch', 'filter': 'stretch(across)', 'presetID': 17, 'presetSubtype': 0}, - 'expand': {'name': 'Expand', 'filter': 'stretch(across)', 'presetID': 50, 'presetSubtype': 0}, - 'swivel': {'name': 'Swivel', 'filter': 'wheel(1)', 'presetID': 19, 'presetSubtype': 0}, +# Compatibility names normalize to canonical PowerPoint-authored presets. +# ``cut`` has no current object-animation preset, so the compatibility name +# resolves to the standard instantaneous entrance, ``entrance_appear``. +ANIMATION_ALIASES: dict[str, str] = { + 'appear': 'entrance_appear', + 'fade': 'entrance_fade', + 'fly': 'entrance_fly', + 'fly_left': 'entrance_fly', + 'fly_right': 'entrance_fly', + 'fly_top': 'entrance_fly', + 'cut': 'entrance_appear', + 'zoom': 'entrance_zoom', + 'wipe': 'entrance_wipe', + 'wipe_left': 'entrance_wipe', + 'wipe_right': 'entrance_wipe', + 'wipe_up': 'entrance_wipe', + 'wipe_down': 'entrance_wipe', + 'split': 'entrance_split', + 'blinds': 'entrance_blinds', + 'checkerboard': 'entrance_checkerboard', + 'dissolve': 'entrance_dissolve', + 'random_bars': 'entrance_random_bars', + 'peek': 'entrance_peek', + 'wheel': 'entrance_wheel', + 'box': 'entrance_box', + 'circle': 'entrance_circle', + 'diamond': 'entrance_diamond', + 'plus': 'entrance_plus', + 'strips': 'entrance_strips', + 'wedge': 'entrance_wedge', + 'stretch': 'entrance_stretch', + 'expand': 'entrance_expand', + 'swivel': 'entrance_swivel', +} + +LEGACY_ANIMATION_KEYS = tuple(ANIMATION_ALIASES) +ANIMATION_CATEGORIES = ('entrance', 'emphasis', 'path', 'exit') +_PRESET_CLASS_BY_CATEGORY = { + 'entrance': 'entr', + 'emphasis': 'emph', + 'path': 'path', + 'exit': 'exit', +} +_DML_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main' +_REL_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' +_PACKAGE_REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships' +_AUDIO_REL_TYPE = ( + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio' +) +_P14_NS = 'http://schemas.microsoft.com/office/powerpoint/2010/main' +_MC_NS = 'http://schemas.openxmlformats.org/markup-compatibility/2006' +ET.register_namespace('p', PML_NS) +ET.register_namespace('a', _DML_NS) +ET.register_namespace('r', _REL_NS) +ET.register_namespace('p14', _P14_NS) + +ANIMATION_EFFECT_OPTION_FIELDS = ( + 'direction', + 'amount', + 'color', + 'font_name', + 'relative', + 'size', +) +ANIMATION_TIMING_OPTION_FIELDS = ( + 'repeat_count', + 'repeat_duration', + 'auto_reverse', + 'rewind', + 'accelerate', + 'decelerate', + 'bounce_end', + 'restart', +) +ANIMATION_RESTARTS = ('always', 'when-not-active', 'never') +ANIMATION_AFTER_EFFECTS = ('none', 'dim', 'hide', 'hide-on-next-click') + +# Legacy directional names retain their historical semantics by desugaring +# into one canonical effect plus the matching PowerPoint EffectParameters +# value. New plans never select these aliases. +ANIMATION_ALIAS_OPTIONS: dict[str, dict[str, object]] = { + 'fly_left': {'direction': 'left'}, + 'fly_right': {'direction': 'right'}, + 'fly_top': {'direction': 'up'}, + 'wipe_left': {'direction': 'left'}, + 'wipe_right': {'direction': 'right'}, + 'wipe_up': {'direction': 'up'}, + 'wipe_down': {'direction': 'down'}, + 'wheel': {'amount': 4}, +} + + +def _load_native_animations() -> dict[str, dict[str, Any]]: + """Load the PowerPoint-authored preset rows shipped with this module.""" + manifest_path = Path(__file__).with_name('pptx_animation_presets.json') + try: + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError( + f'unable to load native animation presets from {manifest_path}: {exc}' + ) from exc + if manifest.get('version') != 2: + raise RuntimeError( + f'unsupported native animation preset version: {manifest.get("version")!r}' + ) + raw_effects = manifest.get('effects') + if not isinstance(raw_effects, list): + raise RuntimeError('native animation preset manifest field "effects" must be a list') + + native: dict[str, dict[str, Any]] = {} + category_counts = {category: 0 for category in ANIMATION_CATEGORIES} + for raw in raw_effects: + if not isinstance(raw, dict): + raise RuntimeError('native animation preset entries must be objects') + key = raw.get('key') + category = raw.get('category') + if not isinstance(key, str) or not key: + raise RuntimeError(f'native animation preset has invalid key: {key!r}') + if category not in ANIMATION_CATEGORIES: + raise RuntimeError( + f'native animation preset {key!r} has invalid category: {category!r}' + ) + if key in native or key in ANIMATION_ALIASES: + raise RuntimeError(f'duplicate animation preset key: {key}') + row_xml = raw.get('row_xml') + if not isinstance(row_xml, str): + raise RuntimeError(f'native animation preset {key!r} is missing row_xml') + try: + row = ET.fromstring(row_xml) + except ET.ParseError as exc: + raise RuntimeError( + f'native animation preset {key!r} contains invalid row_xml: {exc}' + ) from exc + if row.tag != f'{{{PML_NS}}}cTn': + raise RuntimeError(f'native animation preset {key!r} is not a p:cTn row') + + spec = { + 'name': str(raw.get('name') or key), + 'filter': raw.get('filter'), + 'presetID': int(raw.get('preset_id')), + 'presetSubtype': int(raw.get('preset_subtype')), + 'presetClass': _PRESET_CLASS_BY_CATEGORY[category], + 'category': category, + 'msoEffectId': int(raw.get('mso_effect_id')), + 'defaultDurationMs': raw.get('default_duration_ms'), + 'durationScalable': bool(raw.get('duration_scalable')), + 'rowXml': row_xml, + 'effectOptions': raw.get('effect_options', {}), + } + if row.get('presetClass') != spec['presetClass']: + raise RuntimeError(f'native animation preset {key!r} changed presetClass') + if int(row.get('presetID', '-1')) != spec['presetID']: + raise RuntimeError(f'native animation preset {key!r} changed presetID') + if int(row.get('presetSubtype', '-1')) != spec['presetSubtype']: + raise RuntimeError(f'native animation preset {key!r} changed presetSubtype') + effect_options = spec['effectOptions'] + if not isinstance(effect_options, dict): + raise RuntimeError( + f'native animation preset {key!r} effect_options must be an object' + ) + unknown_options = set(effect_options) - set(ANIMATION_EFFECT_OPTION_FIELDS) + if unknown_options: + raise RuntimeError( + f'native animation preset {key!r} has unknown effect option(s): ' + + ', '.join(sorted(unknown_options)) + ) + for option_name, option_spec in effect_options.items(): + if not isinstance(option_spec, dict): + raise RuntimeError( + f'native animation preset {key!r} option {option_name!r} ' + 'must be an object' + ) + option_type = option_spec.get('type') + if option_type == 'enum': + values = option_spec.get('values') + if not isinstance(values, dict) or not values: + raise RuntimeError( + f'native animation preset {key!r} enum option ' + f'{option_name!r} must define values' + ) + default = str(option_spec.get('default')) + if default not in values: + raise RuntimeError( + f'native animation preset {key!r} enum option ' + f'{option_name!r} has an unknown default' + ) + for option_value, variant_xml in values.items(): + if not isinstance(option_value, str) or not isinstance( + variant_xml, + str, + ): + raise RuntimeError( + f'native animation preset {key!r} enum option ' + f'{option_name!r} contains an invalid variant' + ) + try: + variant = ET.fromstring(variant_xml) + except ET.ParseError as exc: + raise RuntimeError( + f'native animation preset {key!r} enum option ' + f'{option_name!r}/{option_value!r} contains invalid XML: ' + f'{exc}' + ) from exc + if variant.tag != f'{{{PML_NS}}}cTn': + raise RuntimeError( + f'native animation preset {key!r} enum option ' + f'{option_name!r}/{option_value!r} is not a p:cTn row' + ) + if variant.get('presetClass') != spec['presetClass']: + raise RuntimeError( + f'native animation preset {key!r} enum option ' + f'{option_name!r}/{option_value!r} changed presetClass' + ) + if int(variant.get('presetID', '-1')) != spec['presetID']: + raise RuntimeError( + f'native animation preset {key!r} enum option ' + f'{option_name!r}/{option_value!r} changed presetID' + ) + elif option_type not in {'number', 'string', 'boolean', 'color'}: + raise RuntimeError( + f'native animation preset {key!r} option {option_name!r} ' + f'has unknown type: {option_type!r}' + ) + native[key] = spec + category_counts[category] += 1 + + expected_counts = {'entrance': 53, 'emphasis': 33, 'path': 64, 'exit': 53} + if category_counts != expected_counts: + raise RuntimeError( + 'native animation preset category counts changed: ' + f'{category_counts!r}; expected {expected_counts!r}' + ) + return native + + +NATIVE_ANIMATIONS = _load_native_animations() +NATIVE_ANIMATION_KEYS = tuple(NATIVE_ANIMATIONS) +ANIMATIONS = { + **NATIVE_ANIMATIONS, + **{ + alias: NATIVE_ANIMATIONS[canonical] + for alias, canonical in ANIMATION_ALIASES.items() + }, } ANIMATION_MODES = ('auto', 'mixed', 'random') @@ -126,31 +355,72 @@ _NODE_TYPE_TRIGGERS = { @dataclass(frozen=True) class AnimationTarget: - """Resolved entrance-animation request for one PowerPoint shape.""" + """Resolved object-animation request for one PowerPoint shape.""" shape_id: int delay_ms: int effect: str duration_ms: int + effect_options: Mapping[str, object] + trigger_shape_id: int | None = None + repeat_count: float | None = None + repeat_duration_ms: int | None = None + auto_reverse: bool | None = None + rewind: bool | None = None + accelerate: float | None = None + decelerate: float | None = None + bounce_end: float | None = None + restart: str | None = None + after_effect: str = 'none' + after_effect_color: str | None = None + sound_relationship_id: str | None = None + sound_name: str | None = None + + @property + def playback_duration_ms(self) -> int: + """Return the wall-clock duration used by after-previous scheduling.""" + one_play = self.duration_ms * (2 if self.auto_reverse else 1) + if self.repeat_duration_ms is not None: + return self.repeat_duration_ms + if self.repeat_count is not None: + return max(1, round(one_play * self.repeat_count)) + return one_play @dataclass(frozen=True) class AnimationRowSummary: - """Read-back summary for one entrance row in the animation pane.""" + """Read-back summary for one object-animation row in the animation pane.""" shape_id: int effect: str | None + supported_effects: tuple[str, ...] + preset_class: str trigger: str - duration_ms: int + duration_ms: int | None offset_ms: int preset_id: int preset_subtype: int filter_name: str | None + effect_options: Mapping[str, object] + trigger_shape_id: int | None + repeat_count: float | None + repeat_duration_ms: int | None + auto_reverse: bool + rewind: bool + accelerate: float + decelerate: float + bounce_end: float + restart: str + after_effect: str + after_effect_color: str | None + sound_relationship_id: str | None + sound_name: str | None + playback_duration_ms: int | None @dataclass(frozen=True) class AnimationSequenceSummary: - """Read-back summary for the logical entrance sequence on one slide.""" + """Read-back summary for the logical object sequence on one slide.""" timing_count: int trigger: str | None @@ -179,7 +449,9 @@ def normalize_animation_effect( raise ValueError('animation effect is required') if not isinstance(effect, str): raise ValueError(f'animation effect must be a string: {effect!r}') - if effect in ANIMATIONS: + if effect in ANIMATION_ALIASES: + return ANIMATION_ALIASES[effect] + if effect in NATIVE_ANIMATIONS: return effect if allow_modes and effect in ANIMATION_MODES: return effect @@ -193,6 +465,151 @@ def normalize_animation_effect( ) +def _finite_number(value: object, field: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f'{field} must be a finite number: {value!r}') + number = float(value) + if not math.isfinite(number): + raise ValueError(f'{field} must be a finite number: {value!r}') + return number + + +def _normalize_animation_color(value: object, field: str) -> str: + if not isinstance(value, str): + raise ValueError( + f'{field} must be #RRGGBB or theme:: {value!r}' + ) + if re.fullmatch(r'#[0-9A-Fa-f]{6}', value): + return value.upper() + if re.fullmatch( + r'theme:(?:dk1|lt1|dk2|lt2|tx1|tx2|bg1|bg2|accent[1-6]|' + r'hlink|folHlink)', + value, + ): + return value + raise ValueError( + f'{field} must be #RRGGBB or theme:: {value!r}' + ) + + +def normalize_animation_effect_options( + effect: str, + options: object = None, +) -> dict[str, object]: + """Validate effect-specific PowerPoint EffectParameters values.""" + if effect not in NATIVE_ANIMATIONS: + if options in (None, {}): + return {} + raise ValueError( + 'animation effect_options require one explicit canonical effect; ' + f'found {effect!r}' + ) + if options is None: + options = {} + if not isinstance(options, Mapping): + raise ValueError(f'animation effect_options must be an object: {options!r}') + + option_specs = NATIVE_ANIMATIONS[effect]['effectOptions'] + unknown = set(options) - set(option_specs) + if unknown: + unsupported = ', '.join(sorted(unknown)) + supported = ', '.join(option_specs) or '(none)' + raise ValueError( + f'animation effect {effect!r} does not support effect option(s): ' + f'{unsupported}; supported options: {supported}' + ) + + normalized: dict[str, object] = {} + for name, value in options.items(): + spec = option_specs[name] + option_type = spec['type'] + field = f'animation effect_options.{name}' + if option_type == 'enum': + key = str(value) + if isinstance(value, bool) or key not in spec['values']: + valid = ', '.join(spec['values']) + raise ValueError( + f'{field} for {effect!r} must be one of {valid}: {value!r}' + ) + normalized[name] = ( + int(key) + if name == 'amount' and re.fullmatch(r'\d+', key) + else key + ) + elif option_type == 'number': + number = _finite_number(value, field) + minimum = spec.get('minimum') + maximum = spec.get('maximum') + if minimum is not None and number < float(minimum): + raise ValueError( + f'{field} for {effect!r} must be at least {minimum}: {value!r}' + ) + if maximum is not None and number > float(maximum): + raise ValueError( + f'{field} for {effect!r} must be at most {maximum}: {value!r}' + ) + normalized[name] = number + elif option_type == 'string': + if not isinstance(value, str) or not value.strip(): + raise ValueError(f'{field} must be a non-empty string: {value!r}') + if len(value) > 255: + raise ValueError(f'{field} exceeds 255 characters') + normalized[name] = value + elif option_type == 'boolean': + if not isinstance(value, bool): + raise ValueError(f'{field} must be a boolean: {value!r}') + normalized[name] = value + elif option_type == 'color': + normalized[name] = _normalize_animation_color(value, field) + else: + raise AssertionError(f'unhandled animation effect option type: {option_type}') + return normalized + + +def normalize_animation_effect_request( + effect: object, + options: object = None, + *, + allow_none: bool = True, + allow_modes: bool = True, +) -> tuple[str | None, dict[str, object]]: + """Normalize one effect plus options, including legacy semantic aliases.""" + raw_effect = effect + canonical = normalize_animation_effect( + effect, + allow_none=allow_none, + allow_modes=allow_modes, + ) + alias_options = ( + ANIMATION_ALIAS_OPTIONS.get(raw_effect, {}) + if isinstance(raw_effect, str) + else {} + ) + explicit_options: Mapping[str, object] + if options is None: + explicit_options = {} + elif isinstance(options, Mapping): + explicit_options = options + else: + raise ValueError(f'animation effect_options must be an object: {options!r}') + for name, alias_value in alias_options.items(): + if name in explicit_options and explicit_options[name] != alias_value: + raise ValueError( + f'legacy animation effect {raw_effect!r} implies ' + f'effect_options.{name}={alias_value!r}, which conflicts with ' + f'{explicit_options[name]!r}' + ) + merged = {**alias_options, **explicit_options} + if canonical is None or canonical in ANIMATION_MODES: + if merged: + raise ValueError( + 'animation effect_options require one explicit canonical effect; ' + f'found {canonical or "none"!r}' + ) + return canonical, {} + return canonical, normalize_animation_effect_options(canonical, merged) + + def normalize_animation_trigger(trigger: object) -> str: """Return a supported PowerPoint Start mode or raise a precise error.""" if not isinstance(trigger, str): @@ -257,14 +674,249 @@ def _non_negative_milliseconds(value: object, field: str) -> int: return milliseconds -def _normalize_target(target: Sequence[object], default_duration_ms: int) -> AnimationTarget: +def _optional_bool(value: object, field: str) -> bool: + if not isinstance(value, bool): + raise ValueError(f'{field} must be a boolean: {value!r}') + return value + + +def _optional_ratio(value: object, field: str) -> float: + ratio = _finite_number(value, field) + if ratio < 0 or ratio > 1: + raise ValueError(f'{field} must be between 0 and 1: {value!r}') + return ratio + + +def _normalize_repeat_count(value: object) -> float: + count = _finite_number(value, 'animation repeat_count') + if count <= 0 or count * 1000 > MAX_OOXML_UNSIGNED_INT: + raise ValueError( + 'animation repeat_count must be positive and fit the OOXML range: ' + f'{value!r}' + ) + return count + + +def _normalize_after_effect(value: object) -> tuple[str, str | None]: + if value is None: + return 'none', None + if isinstance(value, str): + effect_type = value + color = None + elif isinstance(value, Mapping): + unknown = set(value) - {'type', 'color'} + if unknown: + raise ValueError( + 'animation after_effect has unknown field(s): ' + + ', '.join(sorted(unknown)) + ) + effect_type = value.get('type', 'none') + color = value.get('color') + else: + raise ValueError( + f'animation after_effect must be a string or object: {value!r}' + ) + if effect_type not in ANIMATION_AFTER_EFFECTS: + raise ValueError( + f'animation after_effect.type must be one of ' + f'{", ".join(ANIMATION_AFTER_EFFECTS)}: {effect_type!r}' + ) + if effect_type == 'dim': + if color is None: + raise ValueError('animation dim after_effect requires color') + return effect_type, _normalize_animation_color( + color, + 'animation after_effect.color', + ) + if color is not None: + raise ValueError( + f'animation after_effect.color is valid only with type "dim": {color!r}' + ) + return effect_type, None + + +def _normalize_sound(value: object) -> tuple[str | None, str | None]: + if value is None: + return None, None + if not isinstance(value, Mapping): + raise ValueError( + 'low-level animation sound must be an object with ' + 'relationship_id and name' + ) + unknown = set(value) - {'relationship_id', 'name'} + if unknown: + raise ValueError( + 'low-level animation sound has unknown field(s): ' + + ', '.join(sorted(unknown)) + ) + relationship_id = value.get('relationship_id') + name = value.get('name') + if not isinstance(relationship_id, str) or not re.fullmatch( + r'rId[1-9]\d*', + relationship_id, + ): + raise ValueError( + 'low-level animation sound relationship_id must match rIdN: ' + f'{relationship_id!r}' + ) + if not isinstance(name, str) or not name.strip(): + raise ValueError( + f'low-level animation sound name must be non-empty: {name!r}' + ) + return relationship_id, name + + +def _normalize_target_mapping( + target: Mapping[str, object], + default_duration_ms: int, +) -> AnimationTarget: + allowed = { + 'shape_id', + 'delay_ms', + 'effect', + 'duration', + 'effect_options', + 'trigger_shape_id', + *ANIMATION_TIMING_OPTION_FIELDS, + 'after_effect', + 'sound', + } + unknown = set(target) - allowed + if unknown: + raise ValueError( + 'animation target has unknown field(s): ' + ', '.join(sorted(unknown)) + ) + shape_id = _positive_shape_id(target.get('shape_id')) + delay_ms = _non_negative_milliseconds( + target.get('delay_ms', 0), + 'animation target delay_ms', + ) + effect, effect_options = normalize_animation_effect_request( + target.get('effect'), + target.get('effect_options'), + allow_none=False, + allow_modes=False, + ) + duration_ms = default_duration_ms + if target.get('duration') is not None: + duration_ms = _seconds_to_ms( + target.get('duration'), + 'animation target duration', + allow_zero=False, + ) + trigger_shape_id = ( + _positive_shape_id( + target['trigger_shape_id'], + 'animation target trigger_shape_id', + ) + if 'trigger_shape_id' in target + else None + ) + if trigger_shape_id == shape_id: + raise ValueError( + 'animation trigger_shape_id must target a different shape' + ) + repeat_count = ( + _normalize_repeat_count(target['repeat_count']) + if 'repeat_count' in target + else None + ) + repeat_duration_ms = ( + _seconds_to_ms( + target['repeat_duration'], + 'animation repeat_duration', + allow_zero=False, + ) + if 'repeat_duration' in target + else None + ) + if repeat_count is not None and repeat_duration_ms is not None: + raise ValueError( + 'animation repeat_count and repeat_duration are mutually exclusive' + ) + auto_reverse = ( + _optional_bool(target['auto_reverse'], 'animation auto_reverse') + if 'auto_reverse' in target + else None + ) + rewind = ( + _optional_bool(target['rewind'], 'animation rewind') + if 'rewind' in target + else None + ) + accelerate = ( + _optional_ratio(target['accelerate'], 'animation accelerate') + if 'accelerate' in target + else None + ) + decelerate = ( + _optional_ratio(target['decelerate'], 'animation decelerate') + if 'decelerate' in target + else None + ) + if ( + accelerate is not None + and decelerate is not None + and accelerate + decelerate > 1 + ): + raise ValueError( + 'animation accelerate + decelerate must not exceed 1' + ) + bounce_end = ( + _optional_ratio(target['bounce_end'], 'animation bounce_end') + if 'bounce_end' in target + else None + ) + if bounce_end and decelerate: + raise ValueError( + 'animation bounce_end and decelerate are mutually exclusive ' + 'in PowerPoint' + ) + restart = target.get('restart') + if restart is not None and restart not in ANIMATION_RESTARTS: + raise ValueError( + f'animation restart must be one of ' + f'{", ".join(ANIMATION_RESTARTS)}: {restart!r}' + ) + after_effect, after_effect_color = _normalize_after_effect( + target.get('after_effect') + ) + sound_relationship_id, sound_name = _normalize_sound(target.get('sound')) + return AnimationTarget( + shape_id=shape_id, + delay_ms=delay_ms, + effect=effect, + duration_ms=duration_ms, + effect_options=effect_options, + trigger_shape_id=trigger_shape_id, + repeat_count=repeat_count, + repeat_duration_ms=repeat_duration_ms, + auto_reverse=auto_reverse, + rewind=rewind, + accelerate=accelerate, + decelerate=decelerate, + bounce_end=bounce_end, + restart=restart, + after_effect=after_effect, + after_effect_color=after_effect_color, + sound_relationship_id=sound_relationship_id, + sound_name=sound_name, + ) + + +def _normalize_target( + target: Sequence[object] | Mapping[str, object], + default_duration_ms: int, +) -> AnimationTarget: + if isinstance(target, Mapping): + return _normalize_target_mapping(target, default_duration_ms) if isinstance(target, (str, bytes)) or not isinstance(target, Sequence): raise ValueError(f'animation target must be a 3- or 4-item sequence: {target!r}') if len(target) not in (3, 4): raise ValueError(f'animation target must contain 3 or 4 items: {target!r}') shape_id = _positive_shape_id(target[0]) delay_ms = _non_negative_milliseconds(target[1], 'animation target delay_ms') - effect = normalize_animation_effect( + effect, effect_options = normalize_animation_effect_request( target[2], allow_none=False, allow_modes=False, @@ -281,29 +933,45 @@ def _normalize_target(target: Sequence[object], default_duration_ms: int) -> Ani delay_ms=delay_ms, effect=effect, duration_ms=duration_ms, + effect_options=effect_options, ) -# Pool used by 'mixed' / 'random' modes. Excludes 'appear' because it has no -# visible motion; mixed handles the first title-like element as fade separately. +# Pool used by 'mixed' / 'random' modes. Every entry is a canonical +# PowerPoint-authored preset; compatibility aliases never enter selection. _MIXED_POOL = [ - 'blinds', 'checkerboard', 'dissolve', 'fly', 'cut', - 'random_bars', 'box', 'split', 'strips', 'wedge', 'wheel', - 'wipe', 'expand', 'fade', 'swivel', 'zoom', + 'entrance_blinds', 'entrance_checkerboard', 'entrance_dissolve', + 'entrance_fly', 'entrance_ascend', 'entrance_random_bars', + 'entrance_box', 'entrance_split', 'entrance_strips', 'entrance_wedge', + 'entrance_wheel', 'entrance_wipe', 'entrance_expand', 'entrance_fade', + 'entrance_swivel', 'entrance_zoom', ] # Small modern pool used by 'auto' mode when the group id matches no semantic # pattern. Restricted to four widely supported, restrained effects so the # fallback cycle never produces PowerPoint-era visuals. -_AUTO_POOL = ['fade', 'wipe', 'fly', 'zoom'] +_AUTO_POOL = [ + 'entrance_fade', + 'entrance_wipe', + 'entrance_fly', + 'entrance_zoom', +] # Image-only diversity pool. Image-like groups (`hero`, `figure-`, `image`, # `img-`, `kpi`) deliberately cycle through a richer set of visual effects # rather than mapping to a single effect: images are visual focal points, so # variation is desirable on them even when surrounding information-dense # elements (titles, charts, lists) stay reserved. Pool members are chosen for -# image-friendly motion — no PowerPoint-era patterns (blinds / checkerboard / -# random_bars / wedge) that would dominate raster content. -_IMAGE_POOL = ['zoom', 'dissolve', 'circle', 'box', 'diamond', 'wheel'] +# image-friendly motion — no PowerPoint-era patterns (``entrance_blinds`` / +# ``entrance_checkerboard`` / ``entrance_random_bars`` / ``entrance_wedge``) +# that would dominate raster content. +_IMAGE_POOL = [ + 'entrance_zoom', + 'entrance_dissolve', + 'entrance_circle', + 'entrance_box', + 'entrance_diamond', + 'entrance_wheel', +] _IMAGE_KEYWORDS: tuple[str, ...] = ('hero', 'figure-', 'image', 'img-', 'kpi') # Ordered (substring, effect) patterns consumed by 'auto' mode for non-image @@ -313,12 +981,18 @@ _IMAGE_KEYWORDS: tuple[str, ...] = ('hero', 'figure-', 'image', 'img-', 'kpi') # lowercase. Image-like ids are handled separately via ``_IMAGE_POOL`` because # they cycle rather than map to a single effect. _SEMANTIC_PATTERNS: list[tuple[tuple[str, ...], str]] = [ - (('title', 'chapter-', 'section-', 'cover-', 'tagline', 'subtitle'), 'fade'), - (('chart', 'table', 'legend', 'timeline', 'track'), 'wipe'), + ( + ('title', 'chapter-', 'section-', 'cover-', 'tagline', 'subtitle'), + 'entrance_fade', + ), + ( + ('chart', 'table', 'legend', 'timeline', 'track'), + 'entrance_wipe', + ), (('card-', 'pillar-', 'item-', 'step-', 'stage-', 'tier-', - 'principle-', 'q-', 'schema-'), 'fly'), + 'principle-', 'q-', 'schema-'), 'entrance_fly'), (('takeaway', 'callout', 'quote', 'source', 'conclusion', 'note', - 'try-at-home'), 'fade'), + 'try-at-home'), 'entrance_fade'), ] @@ -342,16 +1016,16 @@ def _semantic_effect(group_id: str | None, idx: int = 0, offset: int = 0) -> str def create_timing_xml( - animation: str = 'fade', + animation: str = 'entrance_fade', duration: float = 1.0, delay: float = 0, shape_id: int = 2 ) -> str: """ - Generate an entrance animation timing XML fragment + Generate an object-animation timing XML fragment Args: - animation: Animation effect name (fade/fly/zoom/appear) + animation: Canonical PowerPoint effect name duration: Animation duration (seconds) delay: Animation delay (seconds) shape_id: Target shape ID (SVG image is typically 2) @@ -365,134 +1039,613 @@ def create_timing_xml( allow_modes=False, ) shape_id = _positive_shape_id(shape_id) - anim_info = ANIMATIONS[animation] - dur_ms = _seconds_to_ms( - duration, - 'animation duration', - allow_zero=False, - ) delay_ms = _seconds_to_ms( delay, 'animation delay', allow_zero=True, ) - - # Generate different effect XML depending on animation type - if anim_info['filter'] is None: - # appear animation: only sets visibility - effect_xml = f''' - - - - - - style.visibility - - - ''' - else: - # Other animations: set visibility + animation effect - filter_name = anim_info['filter'] - pr_attr = '' - if 'prLst' in anim_info: - pr_attr = f' prLst="{anim_info["prLst"]}"' - - effect_xml = f''' - - - - - - style.visibility - - - - - - - - - ''' - - return f''' - - - - - - - - - - - - - - - - -{effect_xml} - - - - - - - - - - - - - - ''' + return create_sequence_timing_xml( + [(shape_id, delay_ms, animation, duration)], + duration=duration, + trigger='after-previous', + ) -def _build_effect_xml( +def _ctn_numeric_delay(ctn: ET.Element) -> int: + """Return the largest direct numeric start delay on one time node.""" + conditions = ctn.find(_qn(PML_NS, 'stCondLst')) + if conditions is None: + return 0 + values = [ + int(condition.get('delay', '0')) + for condition in conditions.findall(_qn(PML_NS, 'cond')) + if condition.get('delay', '').isdigit() + ] + return max(values, default=0) + + +def _row_effective_duration_ms(row: ET.Element) -> int | None: + """Return the finite end time PowerPoint exposes for one preset row.""" + ends: list[int] = [] + for ctn in row.iter(_qn(PML_NS, 'cTn')): + if ctn is row: + continue + raw_duration = ctn.get('dur') + if raw_duration is None or not raw_duration.isdigit(): + continue + ends.append(_ctn_numeric_delay(ctn) + int(raw_duration)) + return max(ends) if ends else None + + +def _scale_animation_row_duration( + row: ET.Element, + *, + base_duration_ms: int, + requested_duration_ms: int, +) -> None: + """Scale all finite behavior durations/delays as one PowerPoint row.""" + ratio = requested_duration_ms / base_duration_ms + for ctn in row.iter(_qn(PML_NS, 'cTn')): + if ctn is row: + continue + raw_duration = ctn.get('dur') + if raw_duration is not None and raw_duration.isdigit(): + numeric_duration = int(raw_duration) + ctn.set( + 'dur', + ( + '1' + if numeric_duration == 1 + else str(max(1, round(numeric_duration * ratio))) + ), + ) + conditions = ctn.find(_qn(PML_NS, 'stCondLst')) + if conditions is None: + continue + for condition in conditions.findall(_qn(PML_NS, 'cond')): + raw_delay = condition.get('delay') + if raw_delay is not None and raw_delay.isdigit(): + condition.set('delay', str(round(int(raw_delay) * ratio))) + + actual_duration = _row_effective_duration_ms(row) + if actual_duration is None or actual_duration == requested_duration_ms: + return + end_nodes = [ + ctn + for ctn in row.iter(_qn(PML_NS, 'cTn')) + if ctn is not row + and (ctn.get('dur') or '').isdigit() + and _ctn_numeric_delay(ctn) + int(ctn.get('dur', '0')) == actual_duration + ] + for ctn in end_nodes: + delay = _ctn_numeric_delay(ctn) + if delay >= requested_duration_ms: + conditions = ctn.find(_qn(PML_NS, 'stCondLst')) + numeric = ( + [ + condition + for condition in conditions.findall(_qn(PML_NS, 'cond')) + if condition.get('delay', '').isdigit() + ] + if conditions is not None + else [] + ) + if numeric: + numeric[-1].set('delay', str(max(0, requested_duration_ms - 1))) + delay = _ctn_numeric_delay(ctn) + ctn.set('dur', str(max(1, requested_duration_ms - delay))) + + +def _row_semantic_signature(row: ET.Element) -> tuple[object, ...]: + """Return an id/target/duration-independent preset behavior signature.""" + def canonical(element: ET.Element) -> tuple[object, ...]: + attributes: list[tuple[str, str]] = [] + local_name = _local_name(element.tag) + for name, value in sorted(element.attrib.items()): + if name in {'id', 'nodeType', 'grpId'}: + continue + if local_name == 'spTgt' and name == 'spid': + value = '#shape' + elif local_name == 'cTn' and name == 'dur' and value.isdigit(): + value = '#duration' + elif local_name == 'cond' and name == 'delay' and value.isdigit(): + value = '#delay' + attributes.append((name, value)) + return ( + element.tag, + tuple(attributes), + (element.text or '').strip(), + tuple(canonical(child) for child in list(element)), + ) + + return canonical(row) + + +def _row_timing_profile( + row: ET.Element, +) -> tuple[tuple[str, float, float], ...]: + """Return behavior timing ratios, excluding 1ms visibility bookkeeping.""" + total = _row_effective_duration_ms(row) + if total is None or total <= 1: + return () + parent_map = { + child: parent + for parent in row.iter() + for child in list(parent) + } + behavior_names = { + 'anim', + 'animClr', + 'animEffect', + 'animMotion', + 'animRot', + 'animScale', + 'set', + } + profile: list[tuple[str, float, float]] = [] + for ctn in row.iter(_qn(PML_NS, 'cTn')): + if ctn is row: + continue + raw_duration = ctn.get('dur') + if raw_duration is None or not raw_duration.isdigit(): + continue + duration = int(raw_duration) + owner = parent_map.get(ctn) + while owner is not None and _local_name(owner.tag) not in behavior_names: + owner = parent_map.get(owner) + behavior = _local_name(owner.tag) if owner is not None else 'unknown' + if ( + duration == 1 + and behavior == 'set' + and owner is not None + and any( + (attribute.text or '') == 'style.visibility' + for attribute in owner.iter(_qn(PML_NS, 'attrName')) + ) + ): + continue + profile.append( + ( + behavior, + _ctn_numeric_delay(ctn) / total, + duration / total, + ) + ) + return tuple(profile) + + +def _animation_spec_matches_row(row: ET.Element, spec: Mapping[str, Any]) -> bool: + """Require the PowerPoint-authored structure and internal timing ratios.""" + template = ET.fromstring(spec['rowXml']) + if _row_semantic_signature(row) != _row_semantic_signature(template): + return False + actual_duration = _row_effective_duration_ms(row) + if actual_duration is not None and actual_duration < 100: + # Millisecond quantization necessarily collapses multi-step ratios at + # sub-100ms speeds; structure and total duration remain authoritative. + return True + actual_profile = _row_timing_profile(row) + template_profile = _row_timing_profile(template) + if len(actual_profile) != len(template_profile): + return False + for actual, expected in zip(actual_profile, template_profile): + if actual[0] != expected[0]: + return False + if abs(actual[1] - expected[1]) > 0.01: + return False + if abs(actual[2] - expected[2]) > 0.01: + return False + return True + + +def _format_decimal(value: float) -> str: + """Format one finite decimal without exponent notation or negative zero.""" + rendered = f'{value:.9f}'.rstrip('0').rstrip('.') + return '0' if rendered in {'', '-0'} else rendered + + +def _color_element(value: str) -> ET.Element: + if value.startswith('#'): + return ET.Element(_qn(_DML_NS, 'srgbClr'), {'val': value[1:]}) + return ET.Element(_qn(_DML_NS, 'schemeClr'), {'val': value.split(':', 1)[1]}) + + +def _replace_animation_colors(row: ET.Element, value: str) -> None: + parent_map = { + child: parent + for parent in row.iter() + for child in list(parent) + } + color_tags = { + _qn(_DML_NS, 'srgbClr'), + _qn(_DML_NS, 'schemeClr'), + _qn(_DML_NS, 'hslClr'), + _qn(_DML_NS, 'scrgbClr'), + _qn(_DML_NS, 'sysClr'), + _qn(_DML_NS, 'prstClr'), + } + colors = [element for element in row.iter() if element.tag in color_tags] + if not colors: + raise RuntimeError('color-capable animation preset lost its color node') + for color in colors: + parent = parent_map[color] + index = list(parent).index(color) + parent.remove(color) + parent.insert(index, _color_element(value)) + + +def _set_effect_option_value( + row: ET.Element, animation: str, - shape_id: int, - duration_ms: int, - set_id: int, - eff_id: int, -) -> str: - """Inner effect block for one target. + name: str, + value: object, +) -> None: + if name == 'amount' and animation == 'emphasis_spin': + rotations = list(row.iter(_qn(PML_NS, 'animRot'))) + if len(rotations) != 1: + raise RuntimeError('emphasis_spin preset lost its p:animRot node') + rotations[0].set('by', str(round(float(value) * 60000))) + return + if name == 'amount' and animation == 'emphasis_transparency': + matched = False + for node in row.iter(_qn(PML_NS, 'set')): + attributes = { + (attribute.text or '').strip() + for attribute in node.iter(_qn(PML_NS, 'attrName')) + } + if 'style.opacity' not in attributes: + continue + values = list(node.iter(_qn(PML_NS, 'strVal'))) + if len(values) != 1: + raise RuntimeError( + 'emphasis_transparency preset lost its opacity value' + ) + values[0].set('val', _format_decimal(1 - float(value))) + matched = True + if not matched: + raise RuntimeError( + 'emphasis_transparency preset lost its opacity behavior' + ) + return + if name == 'color': + _replace_animation_colors(row, str(value)) + return + if name == 'font_name': + matched = False + for node in row.iter(_qn(PML_NS, 'set')): + attributes = { + (attribute.text or '').strip() + for attribute in node.iter(_qn(PML_NS, 'attrName')) + } + if 'style.fontFamily' not in attributes: + continue + values = list(node.iter(_qn(PML_NS, 'strVal'))) + if len(values) != 1: + raise RuntimeError( + 'emphasis_change_font preset lost its font value' + ) + values[0].set('val', str(value)) + matched = True + if not matched: + raise RuntimeError( + 'emphasis_change_font preset lost its font behavior' + ) + return + if name == 'relative': + motions = list(row.iter(_qn(PML_NS, 'animMotion'))) + if len(motions) != 1: + raise RuntimeError('motion-path preset lost its p:animMotion node') + motions[0].set('pathEditMode', 'relative' if value else 'fixed') + return + if name == 'size': + scales = list(row.iter(_qn(PML_NS, 'animScale'))) + if len(scales) != 1: + raise RuntimeError( + 'emphasis_grow_shrink preset lost its p:animScale node' + ) + amount = str(round(float(value) * 1000)) + targets = scales[0].findall(_qn(PML_NS, 'to')) + if len(targets) > 1: + raise RuntimeError( + 'emphasis_grow_shrink preset lost its scale value' + ) + target = ( + targets[0] + if targets + else ET.SubElement(scales[0], _qn(PML_NS, 'to')) + ) + target.set('x', amount) + target.set('y', amount) + return + raise AssertionError(f'unhandled continuous animation option: {name}') - Entrance effects are emitted as one animation pane row per target. Plain - Appear uses a visibility set; motion/filter effects use animEffect directly - to avoid duplicate rows for the same shape in PowerPoint. - """ - animation = normalize_animation_effect( - animation, - allow_none=False, - allow_modes=False, + +def _animation_row_for_options( + animation: str, + effect_options: Mapping[str, object], +) -> ET.Element: + """Return the authored variant row with continuous options applied.""" + spec = NATIVE_ANIMATIONS[animation] + option_specs = spec['effectOptions'] + row_xml = spec['rowXml'] + for name, value in effect_options.items(): + option_spec = option_specs[name] + if option_spec['type'] != 'enum': + continue + key = str(value) + row_xml = option_spec['values'][key] + row = ET.fromstring(row_xml) + for name, value in effect_options.items(): + if option_specs[name]['type'] == 'enum': + continue + _set_effect_option_value(row, animation, name, value) + return row + + +def _apply_timing_options(row: ET.Element, target: AnimationTarget) -> None: + if target.repeat_count is not None: + row.set('repeatCount', str(round(target.repeat_count * 1000))) + row.attrib.pop('repeatDur', None) + if target.repeat_duration_ms is not None: + row.set('repeatDur', str(target.repeat_duration_ms)) + row.attrib.pop('repeatCount', None) + if target.auto_reverse is not None: + if target.auto_reverse: + row.set('autoRev', '1') + else: + row.attrib.pop('autoRev', None) + if target.rewind is not None: + row.set('fill', 'remove' if target.rewind else 'hold') + if target.accelerate is not None: + if target.accelerate: + row.set('accel', str(round(target.accelerate * 100000))) + else: + row.attrib.pop('accel', None) + if target.decelerate is not None: + if target.decelerate: + row.set('decel', str(round(target.decelerate * 100000))) + else: + row.attrib.pop('decel', None) + if target.bounce_end is not None: + bounce_nodes = [ + node + for node in row.iter() + if _local_name(node.tag) in { + 'anim', + 'animClr', + 'animEffect', + 'animMotion', + 'animRot', + 'animScale', + } + ] + if target.bounce_end and not bounce_nodes: + raise ValueError( + f'animation effect {target.effect!r} has no behavior that ' + 'supports bounce_end' + ) + if target.bounce_end: + row.set( + _qn(_P14_NS, 'presetBounceEnd'), + str(round(target.bounce_end * 100000)), + ) + else: + row.attrib.pop(_qn(_P14_NS, 'presetBounceEnd'), None) + for node in bounce_nodes: + if target.bounce_end: + node.set( + _qn(_P14_NS, 'bounceEnd'), + str(round(target.bounce_end * 100000)), + ) + else: + node.attrib.pop(_qn(_P14_NS, 'bounceEnd'), None) + if target.restart is not None: + row.set( + 'restart', + { + 'always': 'always', + 'when-not-active': 'whenNotActive', + 'never': 'never', + }[target.restart], + ) + + +def _append_after_effect( + row: ET.Element, + target: AnimationTarget, + row_id: int, +) -> None: + if target.after_effect == 'none': + return + sub_timing = row.find(_qn(PML_NS, 'subTnLst')) + if sub_timing is None: + sub_timing = ET.SubElement(row, _qn(PML_NS, 'subTnLst')) + if target.after_effect == 'dim': + animation = ET.SubElement( + sub_timing, + _qn(PML_NS, 'animClr'), + {'clrSpc': 'rgb', 'dir': 'cw'}, + ) + behavior = ET.SubElement( + animation, + _qn(PML_NS, 'cBhvr'), + {'override': 'childStyle'}, + ) + ET.SubElement( + behavior, + _qn(PML_NS, 'cTn'), + { + 'dur': '1', + 'fill': 'hold', + 'display': '0', + 'masterRel': 'nextClick', + 'afterEffect': '1', + }, + ) + target_element = ET.SubElement(behavior, _qn(PML_NS, 'tgtEl')) + ET.SubElement( + target_element, + _qn(PML_NS, 'spTgt'), + {'spid': str(target.shape_id)}, + ) + names = ET.SubElement(behavior, _qn(PML_NS, 'attrNameLst')) + ET.SubElement(names, _qn(PML_NS, 'attrName')).text = 'ppt_c' + destination = ET.SubElement(animation, _qn(PML_NS, 'to')) + destination.append(_color_element(str(target.after_effect_color))) + return + + setting = ET.SubElement(sub_timing, _qn(PML_NS, 'set')) + behavior = ET.SubElement( + setting, + _qn(PML_NS, 'cBhvr'), + {'override': 'childStyle'}, ) - shape_id = _positive_shape_id(shape_id) - duration_ms = _non_negative_milliseconds( - duration_ms, - 'animation duration_ms', + ctn_attributes = { + 'dur': '1', + 'fill': 'hold', + 'display': '0', + 'masterRel': ( + 'sameClick' + if target.after_effect == 'hide' + else 'nextClick' + ), + 'afterEffect': '1', + } + ctn = ET.SubElement(behavior, _qn(PML_NS, 'cTn'), ctn_attributes) + if target.after_effect == 'hide': + conditions = ET.SubElement(ctn, _qn(PML_NS, 'stCondLst')) + condition = ET.SubElement( + conditions, + _qn(PML_NS, 'cond'), + {'evt': 'end', 'delay': '0'}, + ) + ET.SubElement(condition, _qn(PML_NS, 'tn'), {'val': str(row_id)}) + target_element = ET.SubElement(behavior, _qn(PML_NS, 'tgtEl')) + ET.SubElement( + target_element, + _qn(PML_NS, 'spTgt'), + {'spid': str(target.shape_id)}, + ) + names = ET.SubElement(behavior, _qn(PML_NS, 'attrNameLst')) + ET.SubElement(names, _qn(PML_NS, 'attrName')).text = 'style.visibility' + destination = ET.SubElement(setting, _qn(PML_NS, 'to')) + ET.SubElement(destination, _qn(PML_NS, 'strVal'), {'val': 'hidden'}) + + +def _append_animation_sound(row: ET.Element, target: AnimationTarget) -> None: + if target.sound_relationship_id is None: + return + sub_timing = row.find(_qn(PML_NS, 'subTnLst')) + if sub_timing is None: + sub_timing = ET.SubElement(row, _qn(PML_NS, 'subTnLst')) + audio = ET.SubElement(sub_timing, _qn(PML_NS, 'audio')) + media = ET.SubElement(audio, _qn(PML_NS, 'cMediaNode')) + ctn = ET.SubElement( + media, + _qn(PML_NS, 'cTn'), + {'display': '0', 'masterRel': 'sameClick'}, + ) + conditions = ET.SubElement(ctn, _qn(PML_NS, 'stCondLst')) + condition = ET.SubElement( + conditions, + _qn(PML_NS, 'cond'), + {'evt': 'begin', 'delay': '0'}, + ) + ET.SubElement(condition, _qn(PML_NS, 'tn'), {'val': str(row.get('id'))}) + end_conditions = ET.SubElement(ctn, _qn(PML_NS, 'endCondLst')) + end_condition = ET.SubElement( + end_conditions, + _qn(PML_NS, 'cond'), + {'evt': 'onStopAudio', 'delay': '0'}, + ) + target_element = ET.SubElement(end_condition, _qn(PML_NS, 'tgtEl')) + ET.SubElement(target_element, _qn(PML_NS, 'sldTgt')) + target_element = ET.SubElement(media, _qn(PML_NS, 'tgtEl')) + ET.SubElement( + target_element, + _qn(PML_NS, 'sndTgt'), + { + _qn(_REL_NS, 'embed'): str(target.sound_relationship_id), + 'name': str(target.sound_name), + }, + ) + + +def _instantiate_animation_row( + target: AnimationTarget, + node_type: str, + row_id: int, + first_behavior_id: int, +) -> tuple[str, int]: + """Instantiate one PowerPoint-authored preset row for a generated shape.""" + animation = target.effect + shape_id = target.shape_id + duration_ms = target.duration_ms + spec = NATIVE_ANIMATIONS[animation] + row = _animation_row_for_options(animation, target.effect_options) + row.set('id', str(row_id)) + row.set('nodeType', node_type) + conditions = row.find(_qn(PML_NS, 'stCondLst')) + if conditions is None: + raise RuntimeError(f'animation preset {animation!r} lost p:stCondLst') + direct_conditions = conditions.findall(_qn(PML_NS, 'cond')) + if len(direct_conditions) != 1: + raise RuntimeError( + f'animation preset {animation!r} must have one start condition' + ) + direct_conditions[0].attrib.clear() + direct_conditions[0].set( + 'delay', + str( + target.delay_ms + if ( + node_type == 'afterEffect' + or ( + node_type == 'clickEffect' + and target.trigger_shape_id is not None + ) + ) + else 0 + ), + ) + + if spec['durationScalable']: + base_duration_ms = int(spec['defaultDurationMs']) + _scale_animation_row_duration( + row, + base_duration_ms=base_duration_ms, + requested_duration_ms=duration_ms, + ) + + _apply_timing_options(row, target) + _append_after_effect(row, target, row_id) + _append_animation_sound(row, target) + + next_id = first_behavior_id + for ctn in row.iter(_qn(PML_NS, 'cTn')): + if ctn is row: + continue + ctn.set('id', str(next_id)) + next_id += 1 + for target in row.iter(_qn(PML_NS, 'spTgt')): + target.set('spid', str(shape_id)) + return ET.tostring(row, encoding='unicode'), next_id + + +def _build_animation_row_xml( + target: AnimationTarget, + trigger: str, + row_id: int, + first_behavior_id: int, +) -> tuple[str, int]: + """Build one canonical PowerPoint-authored animation-pane row.""" + node_type = _TRIGGER_NODE_TYPES[trigger] + return _instantiate_animation_row( + target, + node_type, + row_id, + first_behavior_id, ) - if duration_ms == 0: - raise ValueError('animation duration_ms must be greater than zero') - anim_info = ANIMATIONS[animation] - set_block = f''' - - - - - - style.visibility - - -''' - if anim_info['filter'] is None: - return set_block - return set_block + f''' - - - - - -''' def create_sequence_timing_xml( @@ -500,16 +1653,17 @@ def create_sequence_timing_xml( duration: float = 0.3, trigger: str = 'after-previous', ) -> str: - """Generate a multi-target entrance sequence. + """Generate a multi-target object-animation sequence. Args: targets: list of (shape_id, delay_ms, animation_name) or (shape_id, delay_ms, animation_name, duration_seconds) tuples, in the order they should play. ``delay_ms`` is the gap before - this element starts, measured from when the previous element - triggers (only used in ``after-previous`` mode; ignored in - the other two). - duration: per-element entrance duration in seconds. + this element starts in ``after-previous`` mode. For a mapping + target with ``trigger_shape_id``, it is the delay after that + shape is clicked; otherwise the other Start modes ignore it. + duration: per-element animation duration in seconds. Instantaneous + native presets retain their PowerPoint-authored duration. trigger: PowerPoint-standard Start mode for each element. ``'after-previous'`` — first element fires on slide entry, rest chain after the previous one with ``delay_ms`` spacing @@ -540,6 +1694,16 @@ def create_sequence_timing_xml( if len(shape_ids) != len(set(shape_ids)): raise ValueError('animation targets must not contain duplicate shape ids') next_id = 3 + main_targets = [ + target + for target in normalized_targets + if target.trigger_shape_id is None + ] + interactive_targets = [ + target + for target in normalized_targets + if target.trigger_shape_id is not None + ] if trigger == 'on-click': # Each element is an independent click-driven par directly under @@ -547,20 +1711,16 @@ def create_sequence_timing_xml( # the click via delay="indefinite", innermost cTn owns the # clickEffect + animation children. Each click advances the seq. steps = [] - for target in normalized_targets: - shape_id = target.shape_id - animation = target.effect - item_dur_ms = target.duration_ms - anim_info = ANIMATIONS[animation] - preset_id = anim_info.get('presetID', 1) - preset_subtype = anim_info.get('presetSubtype', 0) + for target in main_targets: wrapper_id = next_id inner_id = next_id + 1 leaf_id = next_id + 2 - set_id = next_id + 3 - eff_id = next_id + 4 - next_id += 5 - effect_xml = _build_effect_xml(animation, shape_id, item_dur_ms, set_id, eff_id) + row_xml, next_id = _build_animation_row_xml( + target, + trigger, + leaf_id, + next_id + 3, + ) steps.append(f''' @@ -570,13 +1730,7 @@ def create_sequence_timing_xml( - - - - {effect_xml} - - + {row_xml} @@ -589,11 +1743,9 @@ def create_sequence_timing_xml( # with-previous / after-previous: wrap the entire cascade in ONE # par so the sequence has a real trigger anchor under mainSeq. # - # Native PowerPoint after-previous export uses two timing layers for - # each animation row: an outer wrapper owns the timeline offset, while - # the inner effect cTn stays nodeType="afterEffect" with delay="0". - # This keeps the animation pane editable as standard "After Previous" - # rows instead of exposing synthetic per-effect cumulative delays. + # Native PowerPoint after-previous export uses two timing layers: + # each row owns its TriggerDelayTime, while its wrapper owns the + # previous row's absolute end. Their sum is the absolute start offset. outer_id = next_id next_id += 1 inner_steps = [] @@ -602,64 +1754,43 @@ def create_sequence_timing_xml( with_wrapper_id = next_id next_id += 1 elapsed_ms = 0 - prev_duration_ms = 0 - for i, target in enumerate(normalized_targets): - shape_id = target.shape_id - delay_ms = target.delay_ms - animation = target.effect - item_dur_ms = target.duration_ms - anim_info = ANIMATIONS[animation] - preset_id = anim_info.get('presetID', 1) - preset_subtype = anim_info.get('presetSubtype', 0) - + for target_index, target in enumerate(main_targets, 1): if trigger == 'with-previous': leaf_id = next_id - set_id = next_id + 1 - eff_id = next_id + 2 - next_id += 3 - effect_xml = _build_effect_xml(animation, shape_id, item_dur_ms, set_id, eff_id) + row_xml, next_id = _build_animation_row_xml( + target, + trigger, + leaf_id, + next_id + 1, + ) inner_steps.append(f''' - - - - {effect_xml} - - + {row_xml} ''') else: - if i == 0: - elapsed_ms = int(delay_ms) - else: - elapsed_ms += prev_duration_ms + int(delay_ms) if elapsed_ms > MAX_OOXML_MILLISECONDS: raise ValueError( 'animation sequence offset exceeds the OOXML ' - f'millisecond limit at target {i + 1}: {elapsed_ms}' + f'millisecond limit at target {target_index}: {elapsed_ms}' ) wrapper_id = next_id leaf_id = next_id + 1 - set_id = next_id + 2 - eff_id = next_id + 3 - next_id += 4 - effect_xml = _build_effect_xml(animation, shape_id, item_dur_ms, set_id, eff_id) + row_xml, next_id = _build_animation_row_xml( + target, + trigger, + leaf_id, + next_id + 2, + ) inner_steps.append(f''' - - - - {effect_xml} - - + {row_xml} ''') - prev_duration_ms = item_dur_ms + elapsed_ms += target.delay_ms + target.playback_duration_ms inner_xml = '\n '.join(inner_steps) if trigger == 'with-previous': @@ -692,12 +1823,8 @@ def create_sequence_timing_xml( ''' - return f''' - - - - - + if main_targets: + main_sequence_xml = f''' {all_steps} @@ -705,12 +1832,88 @@ def create_sequence_timing_xml( - + ''' + else: + main_sequence_xml = '' + next_id = 2 + + interactive_sequences: list[str] = [] + for target in interactive_targets: + sequence_id = next_id + wrapper_id = next_id + 1 + inner_id = next_id + 2 + row_id = next_id + 3 + row_xml, next_id = _build_animation_row_xml( + target, + 'on-click', + row_id, + next_id + 4, + ) + trigger_shape_id = target.trigger_shape_id + interactive_sequences.append(f''' + + + + + + + + + + + + {row_xml} + + + + + + + + + ''') + sequence_xml = '\n '.join( + [ + value + for value in ( + main_sequence_xml, + *interactive_sequences, + ) + if value + ] + ) + + timing_xml = f''' + + + + + {sequence_xml} ''' + if not any(target.bounce_end for target in normalized_targets): + return timing_xml + + fallback_xml = re.sub( + r'\s+p14:(?:presetBounceEnd|bounceEnd)="\d+"', + '', + timing_xml, + ) + fallback_xml = fallback_xml.replace( + f' xmlns:p14="{_P14_NS}"', + '', + ) + return f''' + + {timing_xml} + + + {fallback_xml} + + ''' def pick_animation_effect( @@ -726,16 +1929,18 @@ def pick_animation_effect( - A specific animation name returns itself (no variation). - 'auto': map ``group_id`` to an effect. Image-like ids (hero / figure- / image / img- / kpi) cycle through ``_IMAGE_POOL`` - (zoom / dissolve / circle / box / diamond / wheel) by ``idx + offset`` + (``entrance_zoom`` / ``entrance_dissolve`` / ``entrance_circle`` / + ``entrance_box`` / ``entrance_diamond`` / ``entrance_wheel``) by + ``idx + offset`` so multiple images vary across the deck. Other semantic matches in - ``_SEMANTIC_PATTERNS`` return a single stable effect (chart→wipe, - card-/step-/pillar-→fly, title/takeaway→fade). When the id matches no - pattern, cycle through ``_AUTO_POOL`` (fade / wipe / fly / zoom). - - 'mixed' (legacy): first element fixed to 'fade', rest cycle through - ``_MIXED_POOL`` plus ``offset`` (so titles stay calm while content varies - across slides). Kept for backward compatibility with existing CLI flags - and animations.json sidecars. - - 'random' (legacy): uniform random choice from ``_MIXED_POOL``. + ``_SEMANTIC_PATTERNS`` return a single stable effect + (chart→``entrance_wipe``, card-/step-/pillar-→``entrance_fly``, + title/takeaway→``entrance_fade``). When the id matches no pattern, cycle + through ``_AUTO_POOL``. + - 'mixed' (compatible mode name): first element fixed to + ``entrance_fade``; the rest cycle through ``_MIXED_POOL`` plus + ``offset`` so titles stay calm while content varies across slides. + - 'random': uniform seeded choice from the same canonical preset pool. Unknown modes fail explicitly; no effect is silently substituted. """ mode = normalize_animation_effect( @@ -749,7 +1954,7 @@ def pick_animation_effect( raise ValueError( f'animation offset must be a non-negative integer: {offset!r}' ) - if mode in ANIMATIONS: + if mode in NATIVE_ANIMATIONS: return mode if mode == 'auto': semantic = _semantic_effect(group_id, idx, offset) @@ -758,7 +1963,7 @@ def pick_animation_effect( return _AUTO_POOL[(idx + offset) % len(_AUTO_POOL)] if mode == 'mixed': if idx == 0: - return 'fade' + return 'entrance_fade' return _MIXED_POOL[(idx - 1 + offset) % len(_MIXED_POOL)] if mode == 'random': chooser = rng if rng is not None else random @@ -856,22 +2061,32 @@ def _row_shape_id(row: ET.Element, errors: list[str]) -> int | None: unique = sorted(set(shape_ids)) if len(unique) != 1: errors.append( - 'one entrance row must resolve to exactly one shape id; ' + 'one object-animation row must resolve to exactly one shape id; ' f'found {unique or "none"}' ) return None return unique[0] -def _row_filter(row: ET.Element, errors: list[str]) -> str | None: +def _row_filter( + row: ET.Element, + preset_class: str, + errors: list[str], +) -> str | None: effects = list(row.iter(_qn(PML_NS, 'animEffect'))) if len(effects) > 1: - errors.append(f'entrance row contains {len(effects)} p:animEffect nodes') + errors.append( + f'object-animation row contains {len(effects)} p:animEffect nodes' + ) if not effects: return None effect = effects[0] - if effect.get('transition') != 'in': - errors.append('entrance p:animEffect must set transition="in"') + expected_transition = {'entr': 'in', 'exit': 'out'}.get(preset_class) + if expected_transition is not None and effect.get('transition') != expected_transition: + errors.append( + f'{preset_class} p:animEffect must set ' + f'transition="{expected_transition}"' + ) return effect.get('filter') @@ -879,79 +2094,435 @@ def _resolve_row_effect( row: ET.Element, filter_name: str | None, errors: list[str], -) -> tuple[str | None, int | None, int | None]: +) -> tuple[tuple[str, ...], str | None, int | None, int | None]: + preset_class = row.get('presetClass') + if preset_class not in set(_PRESET_CLASS_BY_CATEGORY.values()): + errors.append( + f'unsupported p:cTn@presetClass {preset_class!r}; expected ' + + ', '.join(sorted(set(_PRESET_CLASS_BY_CATEGORY.values()))) + ) + return (), preset_class, None, None preset_id = _int_attribute( row, 'presetID', - 'entrance p:cTn@presetID', + 'object-animation p:cTn@presetID', errors, maximum=MAX_OOXML_UNSIGNED_INT, ) preset_subtype = _int_attribute( row, 'presetSubtype', - 'entrance p:cTn@presetSubtype', + 'object-animation p:cTn@presetSubtype', errors, maximum=MAX_OOXML_UNSIGNED_INT, ) if preset_id is None or preset_subtype is None: - return None, preset_id, preset_subtype + return (), preset_class, preset_id, preset_subtype matches = [ key - for key, info in ANIMATIONS.items() + for key, info in NATIVE_ANIMATIONS.items() + if info['presetClass'] == preset_class if int(info['presetID']) == preset_id - and int(info['presetSubtype']) == preset_subtype - and info['filter'] == filter_name ] - if len(matches) == 1: - return matches[0], preset_id, preset_subtype - return None, preset_id, preset_subtype + return tuple(matches), preset_class, preset_id, preset_subtype def _behavior_duration_ms( row: ET.Element, + errors: list[str], +) -> int | None: + duration = _row_effective_duration_ms(row) + if duration is not None and duration > MAX_OOXML_MILLISECONDS: + errors.append( + 'object-animation behavior duration exceeds the OOXML ' + f'millisecond limit: {duration}' + ) + return duration + + +def _read_animation_color( + row: ET.Element, + errors: list[str], + label: str, +) -> str | None: + colors = [ + element + for element in row.iter() + if element.tag in { + _qn(_DML_NS, 'srgbClr'), + _qn(_DML_NS, 'schemeClr'), + } + ] + if not colors: + errors.append(f'{label} is missing its color value') + return None + rendered = [] + for color in colors: + if color.tag == _qn(_DML_NS, 'srgbClr'): + value = color.get('val') + rendered.append(f'#{value.upper()}' if value else '') + else: + value = color.get('val') + rendered.append(f'theme:{value}' if value else '') + unique = tuple(dict.fromkeys(value for value in rendered if value)) + if len(unique) != 1: + errors.append(f'{label} contains inconsistent color values: {unique!r}') + return None + return unique[0] if unique else None + + +def _read_effect_options( + row: ET.Element, + effect: str | None, filter_name: str | None, errors: list[str], -) -> int: - if filter_name is None: - behaviors = list(row.iter(_qn(PML_NS, 'set'))) - else: - behaviors = list(row.iter(_qn(PML_NS, 'animEffect'))) - if len(behaviors) != 1: +) -> dict[str, object]: + if effect is None: + return {} + option_specs = NATIVE_ANIMATIONS[effect]['effectOptions'] + values: dict[str, object] = {} + for name, spec in option_specs.items(): + option_type = spec['type'] + if option_type == 'enum': + matches = [] + for value, variant_xml in spec['values'].items(): + variant = ET.fromstring(variant_xml) + variant_filter = _row_filter(variant, variant.get('presetClass', ''), []) + if variant.get('presetSubtype') != row.get('presetSubtype'): + continue + if variant_filter != filter_name: + continue + matches.append(value) + if len(matches) != 1: + errors.append( + f'animation effect {effect!r} option {name!r} could not be ' + f'read from presetSubtype={row.get("presetSubtype")!r}, ' + f'filter={filter_name!r}' + ) + continue + value: object = matches[0] + if name == 'amount' and re.fullmatch(r'\d+', str(value)): + value = int(str(value)) + values[name] = value + elif name == 'amount' and effect == 'emphasis_spin': + rotations = list(row.iter(_qn(PML_NS, 'animRot'))) + raw = rotations[0].get('by') if len(rotations) == 1 else None + if raw is None or not re.fullmatch(r'-?\d+', raw): + errors.append('emphasis_spin row has an invalid rotation amount') + else: + values[name] = int(raw) / 60000 + elif name == 'amount' and effect == 'emphasis_transparency': + opacity_values = [] + for node in row.iter(_qn(PML_NS, 'set')): + attributes = { + (attribute.text or '').strip() + for attribute in node.iter(_qn(PML_NS, 'attrName')) + } + if 'style.opacity' in attributes: + opacity_values.extend( + value.get('val') + for value in node.iter(_qn(PML_NS, 'strVal')) + ) + if len(opacity_values) != 1: + errors.append('emphasis_transparency row has an invalid opacity') + else: + try: + values[name] = 1 - float(str(opacity_values[0])) + except ValueError: + errors.append( + 'emphasis_transparency row has a non-numeric opacity' + ) + elif name == 'color': + color = _read_animation_color( + row, + errors, + f'animation effect {effect!r} color option', + ) + if color is not None: + values[name] = color + elif name == 'font_name': + fonts = [] + for node in row.iter(_qn(PML_NS, 'set')): + attributes = { + (attribute.text or '').strip() + for attribute in node.iter(_qn(PML_NS, 'attrName')) + } + if 'style.fontFamily' in attributes: + fonts.extend( + value.get('val') + for value in node.iter(_qn(PML_NS, 'strVal')) + ) + if len(fonts) != 1 or not fonts[0]: + errors.append('emphasis_change_font row has an invalid font name') + else: + values[name] = fonts[0] + elif name == 'relative': + motions = list(row.iter(_qn(PML_NS, 'animMotion'))) + if len(motions) != 1: + errors.append(f'motion-path effect {effect!r} has no single path') + else: + values[name] = motions[0].get('pathEditMode') != 'fixed' + elif name == 'size': + scales = list(row.iter(_qn(PML_NS, 'animScale'))) + targets = ( + scales[0].findall(_qn(PML_NS, 'to')) + if len(scales) == 1 + else [] + ) + raw = ( + targets[0].get('x') + if len(targets) == 1 + else '100000' if not targets and len(scales) == 1 else None + ) + if raw is None or not re.fullmatch(r'\d+', raw): + errors.append('emphasis_grow_shrink row has an invalid size') + else: + values[name] = int(raw) / 1000 + else: + errors.append( + f'animation effect {effect!r} option {name!r} has no reader' + ) + return values + + +def _timing_summary( + row: ET.Element, + duration_ms: int | None, + errors: list[str], +) -> tuple[ + float | None, + int | None, + bool, + bool, + float, + float, + float, + str, + int | None, +]: + raw_repeat_count = row.get('repeatCount') + repeat_count = None + if raw_repeat_count is not None: + if not re.fullmatch(r'\d+', raw_repeat_count): + errors.append( + f'object-animation repeatCount must be numeric; found ' + f'{raw_repeat_count!r}' + ) + else: + repeat_count = int(raw_repeat_count) / 1000 + raw_repeat_duration = row.get('repeatDur') + repeat_duration_ms = None + if raw_repeat_duration is not None: + if not re.fullmatch(r'\d+', raw_repeat_duration): + errors.append( + f'object-animation repeatDur must be numeric; found ' + f'{raw_repeat_duration!r}' + ) + else: + repeat_duration_ms = int(raw_repeat_duration) + if repeat_count is not None and repeat_duration_ms is not None: + errors.append('object-animation row sets both repeatCount and repeatDur') + + def ratio(attribute: str) -> float: + raw = row.get(attribute) + if raw is None: + return 0.0 + if not re.fullmatch(r'\d+', raw): + errors.append( + f'object-animation {attribute} must be numeric; found {raw!r}' + ) + return 0.0 + number = int(raw) + if number > 100000: + errors.append( + f'object-animation {attribute} exceeds 100000; found {number}' + ) + return number / 100000 + + accelerate = ratio('accel') + decelerate = ratio('decel') + if accelerate + decelerate > 1: + errors.append('object-animation accel + decel exceeds 100000') + raw_bounce_values = { + node.get(_qn(_P14_NS, 'bounceEnd')) + for node in row.iter() + if node.get(_qn(_P14_NS, 'bounceEnd')) is not None + } + preset_bounce = row.get(_qn(_P14_NS, 'presetBounceEnd')) + if preset_bounce is not None: + raw_bounce_values.add(preset_bounce) + bounce_end = 0.0 + if len(raw_bounce_values) > 1: errors.append( - 'entrance row must contain exactly one primary behavior; ' - f'found {len(behaviors)}' + 'object-animation behaviors disagree on p14:bounceEnd' ) - return 0 - common_behavior = behaviors[0].find(_qn(PML_NS, 'cBhvr')) - duration_node = ( - common_behavior.find(_qn(PML_NS, 'cTn')) - if common_behavior is not None - else None + elif raw_bounce_values: + raw_bounce = next(iter(raw_bounce_values)) + if raw_bounce is None or not re.fullmatch(r'\d+', raw_bounce): + errors.append( + f'object-animation p14:bounceEnd must be numeric; ' + f'found {raw_bounce!r}' + ) + else: + bounce_value = int(raw_bounce) + if bounce_value > 100000: + errors.append( + 'object-animation p14:bounceEnd exceeds 100000; ' + f'found {bounce_value}' + ) + bounce_end = bounce_value / 100000 + auto_reverse = row.get('autoRev') == '1' + rewind = row.get('fill') == 'remove' + restart = { + None: 'never', + 'always': 'always', + 'whenNotActive': 'when-not-active', + 'never': 'never', + }.get(row.get('restart')) + if restart is None: + errors.append( + f'object-animation restart has unknown value: {row.get("restart")!r}' + ) + restart = 'never' + + playback_duration_ms = None + if duration_ms is not None: + one_play = duration_ms * (2 if auto_reverse else 1) + if repeat_duration_ms is not None: + playback_duration_ms = repeat_duration_ms + elif repeat_count is not None: + playback_duration_ms = max(1, round(one_play * repeat_count)) + else: + playback_duration_ms = one_play + return ( + repeat_count, + repeat_duration_ms, + auto_reverse, + rewind, + accelerate, + decelerate, + bounce_end, + restart, + playback_duration_ms, ) - if duration_node is None: - errors.append('entrance behavior is missing p:cBhvr/p:cTn') - return 0 - duration = _int_attribute( - duration_node, - 'dur', - 'entrance behavior duration', - errors, - minimum=1, - maximum=MAX_OOXML_MILLISECONDS, - ) - return duration or 0 + + +def _after_effect_summary( + row: ET.Element, + errors: list[str], +) -> tuple[str, str | None]: + sub_timing = row.find(_qn(PML_NS, 'subTnLst')) + if sub_timing is None: + return 'none', None + after_nodes = [ + node + for node in list(sub_timing) + if any( + ctn.get('afterEffect') == '1' + for ctn in node.iter(_qn(PML_NS, 'cTn')) + ) + ] + if not after_nodes: + return 'none', None + if len(after_nodes) != 1: + errors.append( + f'object-animation row contains {len(after_nodes)} after effects' + ) + return 'none', None + node = after_nodes[0] + if node.tag == _qn(PML_NS, 'animClr'): + return ( + 'dim', + _read_animation_color(node, errors, 'animation dim after effect'), + ) + if node.tag == _qn(PML_NS, 'set'): + ctns = list(node.iter(_qn(PML_NS, 'cTn'))) + master_relation = ctns[0].get('masterRel') if ctns else None + if master_relation == 'sameClick': + return 'hide', None + if master_relation == 'nextClick': + return 'hide-on-next-click', None + errors.append('object-animation row contains an unknown after effect') + return 'none', None + + +def _sound_summary( + row: ET.Element, + errors: list[str], +) -> tuple[str | None, str | None]: + sounds = list(row.iter(_qn(PML_NS, 'sndTgt'))) + if not sounds: + return None, None + if len(sounds) != 1: + errors.append(f'object-animation row contains {len(sounds)} sounds') + return None, None + relationship_id = sounds[0].get(_qn(_REL_NS, 'embed')) + name = sounds[0].get('name') + if relationship_id is None or name is None: + errors.append('object-animation sound is missing relationship id or name') + return relationship_id, name + + +def _row_trigger_shape_id( + row: ET.Element, + parent_map: Mapping[ET.Element, ET.Element], + errors: list[str], +) -> int | None: + current = parent_map.get(row) + while current is not None: + if ( + current.tag == _qn(PML_NS, 'cTn') + and current.get('nodeType') == 'interactiveSeq' + ): + shape_targets = [ + target + for condition in _direct_conditions(current) + if condition.get('evt') == 'onClick' + for target in condition.iter(_qn(PML_NS, 'spTgt')) + ] + if len(shape_targets) != 1: + errors.append( + 'interactive animation sequence must have one trigger shape' + ) + return None + return _int_attribute( + shape_targets[0], + 'spid', + 'interactive animation trigger shape id', + errors, + minimum=1, + maximum=MAX_OOXML_UNSIGNED_INT, + ) + current = parent_map.get(current) + return None def _row_offset_ms( row: ET.Element, trigger: str, + trigger_shape_id: int | None, parent_map: Mapping[ET.Element, ET.Element], errors: list[str], ) -> int: leaf_conditions = _direct_conditions(row) - if len(leaf_conditions) != 1 or leaf_conditions[0].get('delay') != '0': - errors.append('entrance row must have one leaf start condition with delay="0"') + leaf_delay = None + if ( + len(leaf_conditions) == 1 + and re.fullmatch(r'\d+', leaf_conditions[0].get('delay') or '') + ): + leaf_delay = int(leaf_conditions[0].get('delay', '0')) + else: + errors.append( + 'object-animation row must have one numeric leaf start condition' + ) + if ( + trigger != 'after-previous' + and trigger_shape_id is None + and leaf_delay not in {None, 0} + ): + errors.append( + f'{trigger} object-animation row must have leaf delay="0"' + ) current = parent_map.get(row) saw_indefinite = False @@ -988,16 +2559,32 @@ def _row_offset_ms( ) current = parent_map.get(current) - if trigger == 'on-click' and not saw_indefinite: - errors.append('on-click entrance row is missing an indefinite click wrapper') + if ( + trigger == 'on-click' + and trigger_shape_id is None + and not saw_indefinite + ): + errors.append( + 'on-click object-animation row is missing an indefinite click wrapper' + ) if trigger in {'with-previous', 'after-previous'} and not ( saw_indefinite and saw_main_begin ): errors.append(f'{trigger} sequence is missing the slide-entry onBegin anchor') if trigger == 'after-previous' and numeric_offset is None: - errors.append('after-previous entrance row is missing its numeric offset wrapper') + errors.append( + 'after-previous object-animation row is missing its numeric offset wrapper' + ) if trigger == 'after-previous': - return numeric_offset or 0 + absolute_offset = (numeric_offset or 0) + (leaf_delay or 0) + if absolute_offset > MAX_OOXML_MILLISECONDS: + errors.append( + 'animation row absolute offset exceeds the OOXML ' + f'millisecond limit: {absolute_offset}' + ) + return absolute_offset + if trigger_shape_id is not None: + return leaf_delay or 0 return 0 @@ -1012,51 +2599,139 @@ def _animation_rows( } rows: list[AnimationRowSummary] = [] for row in slide_root.iter(_qn(PML_NS, 'cTn')): - if row.get('presetClass') != 'entr': + preset_class = row.get('presetClass') + if preset_class not in set(_PRESET_CLASS_BY_CATEGORY.values()): continue node_type = row.get('nodeType') trigger = _NODE_TYPE_TRIGGERS.get(node_type or '') if trigger is None: errors.append( - f'unsupported entrance nodeType {node_type!r}; expected ' + f'unsupported object-animation nodeType {node_type!r}; expected ' f'{", ".join(_NODE_TYPE_TRIGGERS)}' ) continue shape_id = _row_shape_id(row, errors) - filter_name = _row_filter(row, errors) - effect, preset_id, preset_subtype = _resolve_row_effect( + filter_name = _row_filter(row, preset_class, errors) + supported_effects, resolved_class, preset_id, preset_subtype = ( + _resolve_row_effect( row, filter_name, errors, ) - duration_ms = _behavior_duration_ms(row, filter_name, errors) - offset_ms = _row_offset_ms(row, trigger, parent_map, errors) - if shape_id is None or preset_id is None or preset_subtype is None: + ) + duration_ms = _behavior_duration_ms(row, errors) + trigger_shape_id = _row_trigger_shape_id(row, parent_map, errors) + offset_ms = _row_offset_ms( + row, + trigger, + trigger_shape_id, + parent_map, + errors, + ) + resolved_effect = supported_effects[0] if supported_effects else None + effect_options = _read_effect_options( + row, + resolved_effect, + filter_name, + errors, + ) + ( + repeat_count, + repeat_duration_ms, + auto_reverse, + rewind, + accelerate, + decelerate, + bounce_end, + restart, + playback_duration_ms, + ) = _timing_summary(row, duration_ms, errors) + after_effect, after_effect_color = _after_effect_summary(row, errors) + sound_relationship_id, sound_name = _sound_summary(row, errors) + if ( + shape_id is None + or resolved_class is None + or preset_id is None + or preset_subtype is None + ): continue rows.append( AnimationRowSummary( shape_id=shape_id, - effect=effect, + effect=resolved_effect, + supported_effects=supported_effects, + preset_class=resolved_class, trigger=trigger, duration_ms=duration_ms, offset_ms=offset_ms, preset_id=preset_id, preset_subtype=preset_subtype, filter_name=filter_name, + effect_options=effect_options, + trigger_shape_id=trigger_shape_id, + repeat_count=repeat_count, + repeat_duration_ms=repeat_duration_ms, + auto_reverse=auto_reverse, + rewind=rewind, + accelerate=accelerate, + decelerate=decelerate, + bounce_end=bounce_end, + restart=restart, + after_effect=after_effect, + after_effect_color=after_effect_color, + sound_relationship_id=sound_relationship_id, + sound_name=sound_name, + playback_duration_ms=playback_duration_ms, ) ) return rows +def _select_supported_timing_branch(slide_root: ET.Element) -> ET.Element: + """Project p14 AlternateContent timing onto one effective slide tree.""" + projected = copy.deepcopy(slide_root) + alternate_tag = _qn(_MC_NS, 'AlternateContent') + choice_tag = _qn(_MC_NS, 'Choice') + fallback_tag = _qn(_MC_NS, 'Fallback') + for index, child in list(enumerate(list(projected))): + if child.tag != alternate_tag: + continue + branches = [ + branch + for branch in list(child) + if branch.tag in {choice_tag, fallback_tag} + ] + selected_timing = None + for branch in branches: + if ( + branch.tag == choice_tag + and 'p14' in (branch.get('Requires') or '').split() + ): + selected_timing = branch.find(_qn(PML_NS, 'timing')) + if selected_timing is not None: + break + if selected_timing is None: + for branch in branches: + selected_timing = branch.find(_qn(PML_NS, 'timing')) + if selected_timing is not None: + break + if selected_timing is None: + continue + projected.remove(child) + projected.insert(index, copy.deepcopy(selected_timing)) + return projected + + def validate_slide_animation_structure( slide_root: ET.Element, *, require_supported_effects: bool = False, ) -> list[str]: - """Return root timing, target, and generated-entrance structure errors.""" + """Return root timing, target, and generated-object structure errors.""" errors: list[str] = [] if slide_root.tag != _qn(PML_NS, 'sld'): return ['animation validation requires a PresentationML p:sld root'] + slide_root = _select_supported_timing_branch(slide_root) direct_timings = [ child for child in list(slide_root) @@ -1105,8 +2780,24 @@ def validate_slide_animation_structure( if timing_name_order.index('bldLst') < timing_name_order.index('tnLst'): errors.append('p:tnLst must precede p:bldLst') + parent_map = { + child: parent + for parent in timing.iter() + for child in list(parent) + } ctn_ids: list[int] = [] for ctn in timing.iter(_qn(PML_NS, 'cTn')): + if ctn.get('id') is None: + ancestor = parent_map.get(ctn) + while ancestor is not None and ancestor is not timing: + if ancestor.tag == _qn(PML_NS, 'subTnLst'): + break + ancestor = parent_map.get(ancestor) + if ( + ancestor is not None + and ancestor.tag == _qn(PML_NS, 'subTnLst') + ): + continue value = _int_attribute( ctn, 'id', @@ -1178,40 +2869,72 @@ def validate_slide_animation_structure( if len(build_keys) != len(set(build_keys)): errors.append('p:bldP (spid, grpId) pairs must be unique') - entrance_nodes = [ + animation_nodes = [ node for node in timing.iter(_qn(PML_NS, 'cTn')) - if node.get('presetClass') == 'entr' + if node.get('presetClass') in set(_PRESET_CLASS_BY_CATEGORY.values()) ] - if entrance_nodes and require_supported_effects: + if require_supported_effects: + rows = _animation_rows(slide_root, errors) + if not rows and animation_nodes: + errors.append('generated object-animation rows could not be read back') + else: + rows = [] + if rows: + regular_rows = [ + row for row in rows if row.trigger_shape_id is None + ] + interactive_rows = [ + row for row in rows if row.trigger_shape_id is not None + ] main_sequences = [ node for node in timing.iter(_qn(PML_NS, 'cTn')) if node.get('nodeType') == 'mainSeq' ] - if len(main_sequences) != 1: + expected_main_sequences = 1 if regular_rows else 0 + if len(main_sequences) != expected_main_sequences: errors.append( - 'slides with entrance rows must contain exactly one mainSeq time node' + 'generated object-animation rows require ' + f'{expected_main_sequences} mainSeq time node(s); found ' + f'{len(main_sequences)}' ) - if require_supported_effects: - rows = _animation_rows(slide_root, errors) - if not rows and entrance_nodes: - errors.append('generated entrance rows could not be read back') - else: - rows = [] - if rows: - triggers = {row.trigger for row in rows} - if len(triggers) != 1: + interactive_sequences = [ + node for node in timing.iter(_qn(PML_NS, 'cTn')) + if node.get('nodeType') == 'interactiveSeq' + ] + if len(interactive_sequences) != len(interactive_rows): errors.append( - 'one generated entrance sequence must use one Start mode; found ' - + ', '.join(sorted(triggers)) + 'each generated trigger-shape animation must have one ' + 'interactiveSeq time node' + ) + regular_triggers = {row.trigger for row in regular_rows} + if len(regular_triggers) > 1: + errors.append( + 'one generated main object-animation sequence must use one ' + 'Start mode; found ' + + ', '.join(sorted(regular_triggers)) ) row_shape_ids = [row.shape_id for row in rows] if len(row_shape_ids) != len(set(row_shape_ids)): - errors.append('generated entrance sequence repeats a shape target') + errors.append('generated object-animation sequence repeats a shape target') for row in rows: - if row.effect is None: + if ( + row.trigger_shape_id is not None + and row.trigger != 'on-click' + ): errors.append( - 'unsupported entrance effect tuple for shape ' - f'{row.shape_id}: presetID={row.preset_id}, ' + 'trigger-shape animation must use the on-click Start mode ' + f'for shape {row.shape_id}' + ) + if row.trigger_shape_id == row.shape_id: + errors.append( + 'animation target and trigger shape must differ for shape ' + f'{row.shape_id}' + ) + if not row.supported_effects: + errors.append( + 'unsupported object-animation effect tuple for shape ' + f'{row.shape_id}: presetClass={row.preset_class}, ' + f'presetID={row.preset_id}, ' f'presetSubtype={row.preset_subtype}, ' f'filter={row.filter_name!r}' ) @@ -1223,12 +2946,13 @@ def read_slide_animation_sequence( *, require_supported_effects: bool = False, ) -> AnimationSequenceSummary: - """Read and validate the logical entrance sequence from one slide XML.""" + """Read and validate the logical object-animation sequence from slide XML.""" data = slide_xml.encode('utf-8') if isinstance(slide_xml, str) else slide_xml try: root = ET.fromstring(data) except ET.ParseError as exc: raise ValueError(f'invalid slide XML: {exc}') from exc + root = _select_supported_timing_branch(root) errors = validate_slide_animation_structure( root, require_supported_effects=require_supported_effects, @@ -1250,7 +2974,14 @@ def read_slide_animation_sequence( value = target.get('spid') if value and value.isdigit(): audio_targets.append(int(value)) - trigger = rows[0].trigger if rows else None + regular_triggers = { + row.trigger for row in rows if row.trigger_shape_id is None + } + trigger = ( + next(iter(regular_triggers)) + if len(regular_triggers) == 1 + else ('on-click' if rows and not regular_triggers else None) + ) return AnimationSequenceSummary( timing_count=len(direct_timings), trigger=trigger, @@ -1261,7 +2992,7 @@ def read_slide_animation_sequence( def validate_generated_animation_xml( slide_xml: str | bytes, - targets: Sequence[Sequence[object]], + targets: Sequence[Sequence[object] | Mapping[str, object]], *, duration: float = 0.3, trigger: str = 'after-previous', @@ -1273,10 +3004,22 @@ def validate_generated_animation_xml( 'animation duration', allow_zero=False, ) - expected = tuple( + normalized_expected = tuple( _normalize_target(target, default_duration_ms) for target in targets ) + # PowerPoint stores ordinary rows in mainSeq and shape-triggered rows in + # separate interactiveSeq containers. Their relative cross-sequence order + # has no playback meaning, so read-back follows the native container order. + expected = tuple( + target + for target in normalized_expected + if target.trigger_shape_id is None + ) + tuple( + target + for target in normalized_expected + if target.trigger_shape_id is not None + ) summary = read_slide_animation_sequence( slide_xml, require_supported_effects=True, @@ -1287,17 +3030,27 @@ def validate_generated_animation_xml( f'animation read-back row count is {len(summary.rows)}; ' f'expected {len(expected)}' ) - if expected and summary.trigger != trigger: + expected_main_targets = tuple( + target for target in expected if target.trigger_shape_id is None + ) + expected_sequence_trigger = ( + trigger if expected_main_targets else ('on-click' if expected else None) + ) + if expected and summary.trigger != expected_sequence_trigger: errors.append( - f'animation read-back trigger is {summary.trigger!r}; expected {trigger!r}' + f'animation read-back trigger is {summary.trigger!r}; ' + f'expected {expected_sequence_trigger!r}' ) expected_offsets: list[int] = [] elapsed_ms = 0 previous_duration_ms = 0 + main_index = 0 for index, target in enumerate(expected): - if trigger == 'after-previous': - if index == 0: + if target.trigger_shape_id is not None: + expected_offsets.append(target.delay_ms) + elif trigger == 'after-previous': + if main_index == 0: elapsed_ms = target.delay_ms else: elapsed_ms += previous_duration_ms + target.delay_ms @@ -1307,29 +3060,102 @@ def validate_generated_animation_xml( f'millisecond limit at row {index + 1}: {elapsed_ms}' ) expected_offsets.append(elapsed_ms) - previous_duration_ms = target.duration_ms + previous_duration_ms = target.playback_duration_ms + main_index += 1 else: expected_offsets.append(0) for index, (actual, target) in enumerate(zip(summary.rows, expected), 1): - spec = ANIMATIONS[target.effect] + spec = NATIVE_ANIMATIONS[target.effect] + expected_row = _animation_row_for_options( + target.effect, + target.effect_options, + ) + if spec['durationScalable']: + _scale_animation_row_duration( + expected_row, + base_duration_ms=int(spec['defaultDurationMs']), + requested_duration_ms=target.duration_ms, + ) + _apply_timing_options(expected_row, target) + option_errors: list[str] = [] + expected_filter = _row_filter( + expected_row, + str(spec['presetClass']), + option_errors, + ) + expected_options = _read_effect_options( + expected_row, + target.effect, + expected_filter, + option_errors, + ) + ( + expected_repeat_count, + expected_repeat_duration_ms, + expected_auto_reverse, + expected_rewind, + expected_accelerate, + expected_decelerate, + expected_bounce_end, + expected_restart, + expected_playback_duration_ms, + ) = _timing_summary( + expected_row, + ( + target.duration_ms + if spec['durationScalable'] + else spec['defaultDurationMs'] + ), + option_errors, + ) + if option_errors: + errors.append( + f'animation row {index} expected-option model failed: ' + + '; '.join(option_errors) + ) if actual.shape_id != target.shape_id: errors.append( f'animation row {index} targets shape {actual.shape_id}; ' f'expected {target.shape_id}' ) - if actual.effect != target.effect: + expected_row_trigger = ( + 'on-click' if target.trigger_shape_id is not None else trigger + ) + if actual.trigger != expected_row_trigger: errors.append( - f'animation row {index} resolved effect {actual.effect!r}; ' - f'expected {target.effect!r}' + f'animation row {index} trigger is {actual.trigger!r}; ' + f'expected {expected_row_trigger!r}' ) + if actual.trigger_shape_id != target.trigger_shape_id: + errors.append( + f'animation row {index} trigger shape is ' + f'{actual.trigger_shape_id!r}; expected ' + f'{target.trigger_shape_id!r}' + ) + if target.effect not in actual.supported_effects: + errors.append( + f'animation row {index} resolved effects ' + f'{actual.supported_effects!r}; expected {target.effect!r}' + ) + if actual.preset_class != spec['presetClass']: + errors.append(f'animation row {index} presetClass changed') if actual.preset_id != int(spec['presetID']): errors.append(f'animation row {index} presetID changed') - if actual.preset_subtype != int(spec['presetSubtype']): + if actual.preset_subtype != int(expected_row.get('presetSubtype', '-1')): errors.append(f'animation row {index} presetSubtype changed') - if actual.filter_name != spec['filter']: + if actual.filter_name != expected_filter: errors.append(f'animation row {index} filter changed') - expected_duration = 1 if target.effect == 'appear' else target.duration_ms + if dict(actual.effect_options) != expected_options: + errors.append( + f'animation row {index} effect_options are ' + f'{dict(actual.effect_options)!r}; expected {expected_options!r}' + ) + expected_duration = ( + target.duration_ms + if spec['durationScalable'] + else spec['defaultDurationMs'] + ) if actual.duration_ms != expected_duration: errors.append( f'animation row {index} duration is {actual.duration_ms}ms; ' @@ -1340,9 +3166,60 @@ def validate_generated_animation_xml( f'animation row {index} offset is {actual.offset_ms}ms; ' f'expected {expected_offsets[index - 1]}ms' ) + timing_pairs = ( + ('repeat_count', actual.repeat_count, expected_repeat_count), + ( + 'repeat_duration_ms', + actual.repeat_duration_ms, + expected_repeat_duration_ms, + ), + ('auto_reverse', actual.auto_reverse, expected_auto_reverse), + ('rewind', actual.rewind, expected_rewind), + ('accelerate', actual.accelerate, expected_accelerate), + ('decelerate', actual.decelerate, expected_decelerate), + ('bounce_end', actual.bounce_end, expected_bounce_end), + ('restart', actual.restart, expected_restart), + ( + 'playback_duration_ms', + actual.playback_duration_ms, + expected_playback_duration_ms, + ), + ) + for field, actual_value, expected_value in timing_pairs: + if actual_value != expected_value: + errors.append( + f'animation row {index} {field} is {actual_value!r}; ' + f'expected {expected_value!r}' + ) + if actual.after_effect != target.after_effect: + errors.append( + f'animation row {index} after_effect is ' + f'{actual.after_effect!r}; expected {target.after_effect!r}' + ) + if actual.after_effect_color != target.after_effect_color: + errors.append( + f'animation row {index} after_effect_color is ' + f'{actual.after_effect_color!r}; ' + f'expected {target.after_effect_color!r}' + ) + if actual.sound_relationship_id != target.sound_relationship_id: + errors.append( + f'animation row {index} sound relationship is ' + f'{actual.sound_relationship_id!r}; ' + f'expected {target.sound_relationship_id!r}' + ) + if actual.sound_name != target.sound_name: + errors.append( + f'animation row {index} sound name is ' + f'{actual.sound_name!r}; expected {target.sound_name!r}' + ) if errors: raise ValueError('; '.join(errors)) - return summary + resolved_rows = tuple( + replace(actual, effect=target.effect) + for actual, target in zip(summary.rows, expected) + ) + return replace(summary, rows=resolved_rows) def validate_pptx_animation_package( @@ -1361,8 +3238,9 @@ def validate_pptx_animation_package( if re.fullmatch(r'ppt/slides/slide\d+\.xml', name) ) for name in names: + slide_data = package.read(name) try: - root = ET.fromstring(package.read(name)) + root = ET.fromstring(slide_data) except ET.ParseError as exc: errors.append(f'{name}: invalid XML: {exc}') continue @@ -1371,6 +3249,66 @@ def validate_pptx_animation_package( require_supported_effects=require_supported_effects, ): errors.append(f'{name}: {error}') + try: + summary = read_slide_animation_sequence(slide_data) + except ValueError: + continue + sound_ids = { + row.sound_relationship_id + for row in summary.rows + if row.sound_relationship_id is not None + } + if not sound_ids: + continue + slide_leaf = posixpath.basename(name) + rels_name = ( + f'ppt/slides/_rels/{slide_leaf}.rels' + ) + if rels_name not in package.namelist(): + errors.append( + f'{name}: animation sound relationships are missing' + ) + continue + try: + relationships = ET.fromstring(package.read(rels_name)) + except ET.ParseError as exc: + errors.append(f'{rels_name}: invalid XML: {exc}') + continue + by_id = { + rel.get('Id'): rel + for rel in relationships.findall( + _qn(_PACKAGE_REL_NS, 'Relationship') + ) + } + for relationship_id in sorted(sound_ids): + relationship = by_id.get(relationship_id) + if relationship is None: + errors.append( + f'{name}: animation sound relationship ' + f'{relationship_id} is missing' + ) + continue + if relationship.get('Type') != _AUDIO_REL_TYPE: + errors.append( + f'{name}: animation sound relationship ' + f'{relationship_id} is not an audio relationship' + ) + continue + target = relationship.get('Target') + if not target: + errors.append( + f'{name}: animation sound relationship ' + f'{relationship_id} has no target' + ) + continue + target_part = posixpath.normpath( + posixpath.join(posixpath.dirname(name), target) + ) + if target_part not in package.namelist(): + errors.append( + f'{name}: animation sound target is missing: ' + f'{target_part}' + ) except (OSError, zipfile.BadZipFile) as exc: raise ValueError(f'unable to read PPTX package {path}: {exc}') from exc if errors: @@ -1389,6 +3327,7 @@ def object_animation_fingerprint(slide_xml: str | bytes) -> str | None: root = ET.fromstring(data) except ET.ParseError as exc: raise ValueError(f'invalid slide XML: {exc}') from exc + root = _select_supported_timing_branch(root) timings = [ child for child in list(root) if child.tag == _qn(PML_NS, 'timing') @@ -1449,41 +3388,171 @@ def entrance_animation_fingerprint(slide_xml: str | bytes) -> str | None: def get_available_transitions() -> list: - """Get a list of all available transition effects""" - return list(TRANSITIONS.keys()) + """Get native transition keys followed by compatibility inputs.""" + return [*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS] def get_available_animations() -> list: - """Get a list of all available entrance animations""" + """Get canonical object-animation keys followed by compatibility inputs.""" return list(ANIMATIONS.keys()) def get_transition_help() -> str: - """Get help text for transition effects""" + """Get categorized native transitions plus legacy compatibility inputs.""" lines = ["Available transition effects:"] - for key, info in TRANSITIONS.items(): - lines.append(f" {key}: {info['name']}") + for category in TRANSITION_CATEGORIES: + lines.append(f" PowerPoint-native {category} effects:") + for key in NATIVE_TRANSITION_KEYS: + info = NATIVE_TRANSITIONS[key] + if info["category"] == category: + lines.append(f" {key}: {info['name']}") + lines.append(" Legacy compatibility inputs (never selected for new output):") + for key in LEGACY_TRANSITION_KEYS: + canonical = TRANSITION_ALIASES[key] + implied = TRANSITION_ALIAS_OPTIONS.get(key) + option_suffix = f", implies {implied}" if implied else "" + lines.append( + f" {key}: compatibility alias for {canonical} " + f"({NATIVE_TRANSITIONS[canonical]['name']}{option_suffix})" + ) return '\n'.join(lines) def get_animation_help() -> str: - """Get help text for entrance animations""" - lines = ["Available entrance animations:"] - for key, info in ANIMATIONS.items(): - lines.append(f" {key}: {info['name']}") + """Get categorized help text for every object-animation effect.""" + lines = ['Available object animations:'] + for category in ANIMATION_CATEGORIES: + lines.append(f' PowerPoint-native {category} effects:') + for key in NATIVE_ANIMATION_KEYS: + info = NATIVE_ANIMATIONS[key] + if info['category'] == category: + lines.append(f" {key}: {info['name']}") + lines.append(' Legacy compatibility inputs (never selected for new output):') + for key in LEGACY_ANIMATION_KEYS: + canonical = ANIMATION_ALIASES[key] + implied = ANIMATION_ALIAS_OPTIONS.get(key) + option_suffix = ( + f', implies {implied}' + if implied + else '' + ) + lines.append( + f" {key}: compatibility alias for {canonical} " + f"({NATIVE_ANIMATIONS[canonical]['name']}{option_suffix})" + ) return '\n'.join(lines) +def describe_animation_effect(effect: object) -> dict[str, Any]: + """Return the author-facing option contract for one animation effect.""" + canonical, implied_options = normalize_animation_effect_request( + effect, + allow_none=False, + allow_modes=False, + ) + option_contract: dict[str, Any] = {} + for name, raw_spec in NATIVE_ANIMATIONS[canonical]['effectOptions'].items(): + spec = { + key: value + for key, value in raw_spec.items() + if key != 'values' + } + if raw_spec.get('type') == 'enum': + spec['values'] = list(raw_spec['values']) + option_contract[name] = spec + return { + 'input': effect, + 'effect': canonical, + 'compatibility_alias': ( + effect if isinstance(effect, str) and effect in ANIMATION_ALIASES else None + ), + 'implied_effect_options': implied_options, + 'effect_options': option_contract, + 'timing': { + 'duration': 'positive seconds', + 'delay': 'non-negative seconds; group scope only', + 'stagger': 'non-negative seconds; animation scope only', + 'trigger': list(ANIMATION_TRIGGERS), + 'trigger_shape': ( + 'other top-level SVG group id; group scope only; maps to ' + 'PowerPoint "On Click of"' + ), + 'repeat_count': 'positive number; mutually exclusive with repeat_duration', + 'repeat_duration': 'positive seconds; mutually exclusive with repeat_count', + 'auto_reverse': 'boolean', + 'rewind': 'boolean', + 'accelerate': 'number from 0 to 1', + 'decelerate': 'number from 0 to 1', + 'bounce_end': ( + 'number from 0 to 1; requires an interpolated behavior and ' + 'is mutually exclusive with decelerate' + ), + 'restart': list(ANIMATION_RESTARTS), + }, + 'after_effect': list(ANIMATION_AFTER_EFFECTS), + 'sound': 'project-relative or absolute .m4a, .mp3, or .wav path', + 'derived_not_configured': { + 'speed': 'derived from duration', + 'smooth_start': 'derived from accelerate', + 'smooth_end': 'derived from decelerate', + }, + } + + def main() -> None: """Run the CLI entry point.""" parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument("--demo", action="store_true", help="print sample XML for a fade transition and animation") - parser.add_argument("--list", action="store_true", help="list available transitions and entrance animations") + parser.add_argument( + "--demo", + action="store_true", + help="print sample XML for a fade transition and entrance_fade animation", + ) + parser.add_argument( + '--list', + action='store_true', + help='list available transitions and object animations', + ) + parser.add_argument( + '--describe', + metavar='EFFECT', + help='print the complete parameter contract for one object animation', + ) + parser.add_argument( + '--describe-transition', + metavar='EFFECT', + help='print the PowerPoint Effect Options for one page transition', + ) args = parser.parse_args() + if args.describe: + try: + print( + json.dumps( + describe_animation_effect(args.describe), + ensure_ascii=False, + indent=2, + ) + ) + except ValueError as exc: + parser.error(str(exc)) + return + + if args.describe_transition: + try: + print( + json.dumps( + describe_transition_effect(args.describe_transition), + ensure_ascii=False, + indent=2, + ) + ) + except ValueError as exc: + parser.error(str(exc)) + return + if args.list: print(get_transition_help()) print() @@ -1494,8 +3563,8 @@ def main() -> None: print("=== Transition Effect XML Example (fade, 500ms) ===") print(create_transition_xml('fade', 0.5)) print() - print("=== Entrance Animation XML Example (fade) ===") - print(create_timing_xml('fade', 1.0)) + print("=== Entrance Animation XML Example (entrance_fade) ===") + print(create_timing_xml('entrance_fade', 1.0)) return parser.print_help() diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_transitions.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_transitions.py index faf67e51..9ceebaf3 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_transitions.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_transitions.py @@ -2,8 +2,9 @@ """ PPT Master - PPTX Transition Core -Provide one strict transition registry plus shared OOXML read/write helpers for -generated slides, template-filled PPTX files, and native PPTX enhancement. +Provide one strict PowerPoint-native transition registry, a compatibility input +map, and shared OOXML read/write helpers for generated slides, template-filled +PPTX files, and native PPTX enhancement. See references/animations.md for the public workflow and scripts/docs/pptx-transitions.md for the OOXML contract. @@ -24,9 +25,9 @@ import math import posixpath import re import zipfile -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path -from typing import Any, MutableMapping +from typing import Any, Mapping, MutableMapping from xml.etree import ElementTree as ET from xml.sax.saxutils import quoteattr @@ -38,6 +39,8 @@ except ImportError: PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main" +P15_NS = "http://schemas.microsoft.com/office/powerpoint/2012/main" +P159_NS = "http://schemas.microsoft.com/office/powerpoint/2015/09/main" MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006" PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types" @@ -60,7 +63,8 @@ MAX_OOXML_MILLISECONDS = 4_294_967_295 MAX_OOXML_UNSIGNED_INT = MAX_OOXML_MILLISECONDS -TRANSITIONS: dict[str, dict[str, Any]] = { +_TRANSITION_SPECS: dict[str, dict[str, Any]] = { + # PowerPoint-native public ordering is assembled by gallery category below. "fade": { "name": "Fade", "element": "fade", @@ -81,11 +85,6 @@ TRANSITIONS: dict[str, dict[str, Any]] = { "element": "split", "attrs": {"orient": "horz", "dir": "out"}, }, - "strips": { - "name": "Strips", - "element": "strips", - "attrs": {"dir": "rd"}, - }, "cover": { "name": "Cover", "element": "cover", @@ -96,10 +95,794 @@ TRANSITIONS: dict[str, dict[str, Any]] = { "element": "random", "attrs": {}, }, + "blinds": { + "name": "Blinds", + "element": "blinds", + "attrs": {"dir": "vert"}, + }, + "checkerboard": { + "name": "Checkerboard", + "element": "checker", + "attrs": {"dir": "horz"}, + }, + "comb": { + "name": "Comb", + "element": "comb", + "attrs": {"dir": "horz"}, + }, + "cut": { + "name": "Cut", + "element": "cut", + "attrs": {"thruBlk": "0"}, + }, + "dissolve": { + "name": "Dissolve", + "element": "dissolve", + "attrs": {}, + }, + "random_bars": { + "name": "Random Bars", + "element": "randomBar", + "attrs": {"dir": "vert"}, + }, + "zoom": { + "name": "Zoom", + "prefix": "p14", + "element": "warp", + "attrs": {"dir": "in"}, + "fallback": "fade", + }, + # Current PowerPoint transition gallery: Subtle. + "morph": { + "name": "Morph", + "prefix": "p159", + "element": "morph", + "attrs": {"option": "byObject"}, + "fallback": "fade", + }, + "reveal": { + "name": "Reveal", + "prefix": "p14", + "element": "reveal", + "attrs": {"dir": "r"}, + "fallback": "fade", + }, + "shape": { + "name": "Shape", + "element": "circle", + "attrs": {}, + }, + "uncover": { + "name": "Uncover", + "element": "pull", + "attrs": {"dir": "r"}, + }, + "flash": { + "name": "Flash", + "prefix": "p14", + "element": "flash", + "attrs": {}, + "fallback": "fade", + }, + # Current PowerPoint transition gallery: Exciting. + "fall_over": { + "name": "Fall Over", + "prefix": "p15", + "element": "prstTrans", + "attrs": {"prst": "fallOver", "invX": "1"}, + "fallback": "fade", + }, + "drape": { + "name": "Drape", + "prefix": "p15", + "element": "prstTrans", + "attrs": {"prst": "drape", "invX": "1"}, + "fallback": "fade", + }, + "curtains": { + "name": "Curtains", + "prefix": "p15", + "element": "prstTrans", + "attrs": {"prst": "curtains"}, + "fallback": "fade", + }, + "wind": { + "name": "Wind", + "prefix": "p15", + "element": "prstTrans", + "attrs": {"prst": "wind"}, + "fallback": "fade", + }, + "prestige": { + "name": "Prestige", + "prefix": "p15", + "element": "prstTrans", + "attrs": {"prst": "prestige"}, + "fallback": "fade", + }, + "fracture": { + "name": "Fracture", + "prefix": "p15", + "element": "prstTrans", + "attrs": {"prst": "fracture"}, + "fallback": "fade", + }, + "crush": { + "name": "Crush", + "prefix": "p15", + "element": "prstTrans", + "attrs": {"prst": "crush"}, + "fallback": "fade", + }, + "peel_off": { + "name": "Peel Off", + "prefix": "p15", + "element": "prstTrans", + "attrs": {"prst": "peelOff", "invX": "1"}, + "fallback": "fade", + }, + "page_curl": { + "name": "Page Curl", + "prefix": "p15", + "element": "prstTrans", + "attrs": {"prst": "pageCurlSingle", "invX": "1"}, + "fallback": "fade", + }, + "airplane": { + "name": "Airplane", + "prefix": "p15", + "element": "prstTrans", + "attrs": {"prst": "airplane"}, + "fallback": "fade", + }, + "origami": { + "name": "Origami", + "prefix": "p15", + "element": "prstTrans", + "attrs": {"prst": "origami"}, + "fallback": "fade", + }, + "clock": { + "name": "Clock", + "element": "wheel", + "attrs": {"spokes": "1"}, + }, + "ripple": { + "name": "Ripple", + "prefix": "p14", + "element": "ripple", + "attrs": {}, + "fallback": "fade", + }, + "honeycomb": { + "name": "Honeycomb", + "prefix": "p14", + "element": "honeycomb", + "attrs": {}, + "fallback": "fade", + }, + "glitter": { + "name": "Glitter", + "prefix": "p14", + "element": "glitter", + "attrs": {}, + "fallback": "fade", + }, + "vortex": { + "name": "Vortex", + "prefix": "p14", + "element": "vortex", + "attrs": {"dir": "r"}, + "fallback": "fade", + }, + "shred": { + "name": "Shred", + "prefix": "p14", + "element": "shred", + "attrs": {"dir": "out"}, + "fallback": "fade", + }, + "switch": { + "name": "Switch", + "prefix": "p14", + "element": "switch", + "attrs": {"dir": "r"}, + "fallback": "fade", + }, + "flip": { + "name": "Flip", + "prefix": "p14", + "element": "flip", + "attrs": {"dir": "r"}, + "fallback": "fade", + }, + "gallery": { + "name": "Gallery", + "prefix": "p14", + "element": "gallery", + "attrs": {"dir": "r"}, + "fallback": "fade", + }, + "cube": { + "name": "Cube", + "prefix": "p14", + "element": "prism", + "attrs": {"dir": "r"}, + "fallback": "fade", + }, + "doors": { + "name": "Doors", + "prefix": "p14", + "element": "doors", + "attrs": {"dir": "vert"}, + "fallback": "fade", + }, + "box": { + "name": "Box", + "element": "zoom", + "attrs": {}, + }, + # Current PowerPoint transition gallery: Dynamic Content. + "pan": { + "name": "Pan", + "prefix": "p14", + "element": "pan", + "attrs": {"dir": "r"}, + "fallback": "fade", + }, + "ferris_wheel": { + "name": "Ferris Wheel", + "prefix": "p14", + "element": "ferris", + "attrs": {"dir": "r"}, + "fallback": "fade", + }, + "conveyor": { + "name": "Conveyor", + "prefix": "p14", + "element": "conveyor", + "attrs": {"dir": "r"}, + "fallback": "fade", + }, + "rotate": { + "name": "Rotate", + "prefix": "p14", + "element": "prism", + "attrs": {"dir": "r", "isContent": "1"}, + "fallback": "fade", + }, + "window": { + "name": "Window", + "prefix": "p14", + "element": "window", + "attrs": {}, + "fallback": "fade", + }, + "orbit": { + "name": "Orbit", + "prefix": "p14", + "element": "prism", + "attrs": {"dir": "r", "isContent": "1", "isInverted": "1"}, + "fallback": "fade", + }, + "fly_through": { + "name": "Fly Through", + "prefix": "p14", + "element": "flythrough", + "attrs": {}, + "fallback": "fade", + }, } +TRANSITION_CATEGORIES = ("subtle", "exciting", "dynamic_content") +_TRANSITION_KEYS_BY_CATEGORY = { + "subtle": ( + "morph", + "fade", + "push", + "wipe", + "split", + "reveal", + "cut", + "random_bars", + "shape", + "uncover", + "cover", + "flash", + ), + "exciting": ( + "fall_over", + "drape", + "curtains", + "wind", + "prestige", + "fracture", + "crush", + "peel_off", + "page_curl", + "airplane", + "origami", + "dissolve", + "checkerboard", + "blinds", + "clock", + "ripple", + "honeycomb", + "glitter", + "vortex", + "shred", + "switch", + "flip", + "gallery", + "cube", + "doors", + "box", + "comb", + "zoom", + "random", + ), + "dynamic_content": ( + "pan", + "ferris_wheel", + "conveyor", + "rotate", + "window", + "orbit", + "fly_through", + ), +} -for _prefix, _uri in (("p", PML_NS), ("p14", P14_NS), ("mc", MC_NS)): +TRANSITION_EFFECT_OPTION_FIELDS = ( + "direction", + "orientation", + "style", + "shape", + "pattern", + "origin", + "pages", + "morph_by", + "through_black", + "bounce", +) + + +def _enum_option( + default: str, + values: Mapping[str, Mapping[str, Any]], +) -> dict[str, Any]: + return { + "type": "enum", + "default": default, + "values": dict(values), + } + + +def _attribute_enum( + default: str, + attribute: str, + values: Mapping[str, str | None], +) -> dict[str, Any]: + overrides: dict[str, dict[str, Any]] = {} + for name, value in values.items(): + if value is None: + overrides[name] = {"remove_attrs": (attribute,)} + else: + overrides[name] = {"attrs": {attribute: value}} + return _enum_option(default, overrides) + + +def _boolean_option( + default: bool, + attribute: str, +) -> dict[str, Any]: + return { + "type": "boolean", + "default": default, + "values": { + False: {"remove_attrs": (attribute,)}, + True: {"attrs": {attribute: "1"}}, + }, + } + + +_CARDINAL_DIRECTIONS = { + "left": None, + "right": "r", + "up": "u", + "down": "d", +} +_CORNER_DIRECTIONS = { + **_CARDINAL_DIRECTIONS, + "up_left": "lu", + "up_right": "ru", + "down_left": "ld", + "down_right": "rd", +} +_TRANSITION_EFFECT_OPTIONS: dict[str, dict[str, dict[str, Any]]] = { + "morph": { + "morph_by": _attribute_enum( + "object", + "option", + { + "object": "byObject", + "word": "byWord", + "character": "byChar", + }, + ), + }, + "fade": { + "style": _attribute_enum( + "smoothly", + "thruBlk", + {"smoothly": None, "through_black": "1"}, + ), + }, + "push": { + "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS), + }, + "wipe": { + "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS), + }, + "split": { + "orientation": _attribute_enum( + "horizontal", + "orient", + {"horizontal": None, "vertical": "vert"}, + ), + "direction": _attribute_enum( + "out", + "dir", + {"in": "in", "out": None}, + ), + }, + "reveal": { + "direction": _attribute_enum( + "right", + "dir", + {"left": None, "right": "r"}, + ), + "through_black": _boolean_option(False, "thruBlk"), + }, + "cut": { + "through_black": { + "type": "boolean", + "default": False, + "values": { + False: {"remove_attrs": ("thruBlk",)}, + True: {"attrs": {"thruBlk": "1"}}, + }, + }, + }, + "random_bars": { + "orientation": _attribute_enum( + "vertical", + "dir", + {"horizontal": None, "vertical": "vert"}, + ), + }, + "shape": { + "shape": _enum_option( + "circle", + { + "circle": {"element": "circle"}, + "diamond": {"element": "diamond"}, + "plus": {"element": "plus"}, + }, + ), + }, + "uncover": { + "direction": _attribute_enum("right", "dir", _CORNER_DIRECTIONS), + }, + "cover": { + "direction": _attribute_enum("right", "dir", _CORNER_DIRECTIONS), + }, + "fall_over": { + "direction": _attribute_enum( + "right", + "invX", + {"left": None, "right": "1"}, + ), + }, + "drape": { + "direction": _attribute_enum( + "right", + "invX", + {"left": None, "right": "1"}, + ), + }, + "wind": { + "direction": _attribute_enum( + "right", + "invX", + {"left": "1", "right": None}, + ), + }, + "peel_off": { + "direction": _attribute_enum( + "right", + "invX", + {"left": None, "right": "1"}, + ), + }, + "page_curl": { + "direction": _attribute_enum( + "right", + "invX", + {"left": None, "right": "1"}, + ), + "pages": _attribute_enum( + "single", + "prst", + {"single": "pageCurlSingle", "double": "pageCurlDouble"}, + ), + }, + "airplane": { + "direction": _attribute_enum( + "right", + "invX", + {"left": "1", "right": None}, + ), + }, + "origami": { + "direction": _attribute_enum( + "right", + "invX", + {"left": "1", "right": None}, + ), + }, + "checkerboard": { + "direction": _attribute_enum( + "across", + "dir", + {"across": None, "down": "vert"}, + ), + }, + "blinds": { + "orientation": _attribute_enum( + "vertical", + "dir", + {"horizontal": None, "vertical": "vert"}, + ), + }, + "clock": { + "style": _enum_option( + "clockwise", + { + "clockwise": { + "element": "wheel", + "attrs": {"spokes": "1"}, + }, + "counterclockwise": { + "prefix": "p14", + "element": "wheelReverse", + "attrs": {"spokes": "1"}, + "fallback": "fade", + }, + "wedge": { + "element": "wedge", + "remove_attrs": ("spokes",), + }, + }, + ), + }, + "ripple": { + "origin": _attribute_enum( + "center", + "dir", + { + "center": None, + "up_left": "lu", + "up_right": "ru", + "down_left": "ld", + "down_right": "rd", + }, + ), + }, + "glitter": { + "shape": _attribute_enum( + "diamond", + "pattern", + {"diamond": None, "hexagon": "hexagon"}, + ), + "direction": _attribute_enum( + "right", + "dir", + { + "left": "r", + "right": None, + "up": "d", + "down": "u", + }, + ), + }, + "vortex": { + "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS), + }, + "shred": { + "pattern": _attribute_enum( + "strips", + "pattern", + {"strips": None, "rectangle": "rectangle"}, + ), + "direction": _attribute_enum( + "out", + "dir", + {"in": None, "out": "out"}, + ), + }, + "switch": { + "direction": _attribute_enum( + "right", + "dir", + {"left": "l", "right": "r"}, + ), + }, + "flip": { + "direction": _attribute_enum( + "right", + "dir", + {"left": "l", "right": "r"}, + ), + }, + "gallery": { + "direction": _attribute_enum( + "right", + "dir", + {"left": "l", "right": "r"}, + ), + }, + "cube": { + "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS), + }, + "doors": { + "orientation": _attribute_enum( + "vertical", + "dir", + {"horizontal": None, "vertical": "vert"}, + ), + }, + "box": { + "direction": _attribute_enum( + "out", + "dir", + {"in": "in", "out": None}, + ), + }, + "comb": { + "orientation": _attribute_enum( + "horizontal", + "dir", + {"horizontal": None, "vertical": "vert"}, + ), + }, + "zoom": { + "direction": _attribute_enum( + "in", + "dir", + {"in": "in", "out": None}, + ), + }, + "pan": { + "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS), + }, + "ferris_wheel": { + "direction": _attribute_enum( + "right", + "dir", + {"left": "l", "right": "r"}, + ), + }, + "conveyor": { + "direction": _attribute_enum( + "right", + "dir", + {"left": "l", "right": "r"}, + ), + }, + "rotate": { + "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS), + }, + "window": { + "orientation": _attribute_enum( + "horizontal", + "dir", + {"horizontal": None, "vertical": "vert"}, + ), + }, + "orbit": { + "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS), + }, + "fly_through": { + "direction": _attribute_enum( + "in", + "dir", + {"in": None, "out": "out"}, + ), + "bounce": _boolean_option(False, "hasBounce"), + }, +} + +NATIVE_TRANSITIONS: dict[str, dict[str, Any]] = {} +for _category in TRANSITION_CATEGORIES: + for _key in _TRANSITION_KEYS_BY_CATEGORY[_category]: + if _key in NATIVE_TRANSITIONS: + raise RuntimeError(f"duplicate native transition key: {_key}") + _spec = dict(_TRANSITION_SPECS[_key]) + _spec["category"] = _category + _spec["effectOptions"] = _TRANSITION_EFFECT_OPTIONS.get(_key, {}) + NATIVE_TRANSITIONS[_key] = _spec +if set(NATIVE_TRANSITIONS) != set(_TRANSITION_SPECS): + raise RuntimeError("native transition gallery categories are incomplete") +if len(NATIVE_TRANSITIONS) != 48: + raise RuntimeError( + f"native transition gallery count changed: {len(NATIVE_TRANSITIONS)}" + ) +for _key, _spec in NATIVE_TRANSITIONS.items(): + _options = _spec["effectOptions"] + _unknown_options = set(_options) - set(TRANSITION_EFFECT_OPTION_FIELDS) + if _unknown_options: + raise RuntimeError( + f"native transition {_key!r} has unknown effect option(s): " + + ", ".join(sorted(_unknown_options)) + ) + for _option_name, _option_spec in _options.items(): + if _option_spec.get("type") not in {"enum", "boolean"}: + raise RuntimeError( + f"native transition {_key!r} option {_option_name!r} " + "must be enum or boolean" + ) + _values = _option_spec.get("values") + if not isinstance(_values, dict) or not _values: + raise RuntimeError( + f"native transition {_key!r} option {_option_name!r} " + "must define values" + ) + if _option_spec.get("default") not in _values: + raise RuntimeError( + f"native transition {_key!r} option {_option_name!r} " + "has an unknown default" + ) + +NATIVE_TRANSITION_KEYS = tuple(NATIVE_TRANSITIONS) +# Retained as a module-level compatibility name for existing imports. New code +# should use ``NATIVE_TRANSITIONS`` to distinguish the native registry from +# accepted legacy input names. +CANONICAL_TRANSITIONS = NATIVE_TRANSITIONS + +TRANSITION_ALIASES: dict[str, str] = { + "strips": "wipe", + "circle": "shape", + "diamond": "shape", + "newsflash": "flash", + "plus": "shape", + "pull": "uncover", + "wedge": "clock", + "wheel": "clock", +} +LEGACY_TRANSITION_KEYS = tuple(TRANSITION_ALIASES) +TRANSITION_ALIAS_OPTIONS: dict[str, dict[str, object]] = { + "strips": {"direction": "right"}, + "circle": {"shape": "circle"}, + "diamond": {"shape": "diamond"}, + "plus": {"shape": "plus"}, + "wedge": {"style": "wedge"}, + "wheel": {"style": "clockwise"}, +} +TRANSITIONS: dict[str, dict[str, Any]] = dict(NATIVE_TRANSITIONS) +for _alias, _canonical_key in TRANSITION_ALIASES.items(): + TRANSITIONS[_alias] = NATIVE_TRANSITIONS[_canonical_key] + +TRANSITION_NAMESPACES = { + "p": PML_NS, + "p14": P14_NS, + "p15": P15_NS, + "p159": P159_NS, +} + +for _prefix, _uri in ( + *TRANSITION_NAMESPACES.items(), + ("mc", MC_NS), +): try: ET.register_namespace(_prefix, _uri) except (AttributeError, ValueError): @@ -113,6 +896,7 @@ class EnterUpdate: policy: str = "replace" effect: str | None = DEFAULT_TRANSITION duration: float = DEFAULT_TRANSITION_DURATION + effect_options: Mapping[str, object] | None = None @dataclass(frozen=True) @@ -137,6 +921,9 @@ class TransitionSummary: speed: str | None = None advance_on_click: bool | None = None advance_after_ms: int | None = None + effect_attributes: Mapping[str, str] = field(default_factory=dict) + canonical_effect: str | None = None + effect_options: Mapping[str, object] = field(default_factory=dict) def _qn(namespace: str, tag: str) -> str: @@ -187,19 +974,145 @@ def validate_seconds( def normalize_transition_effect(effect: object, *, allow_none: bool = True) -> str | None: - """Return a known transition effect without silently changing it.""" + """Return one canonical PowerPoint transition or explicit no-effect.""" if effect is None or effect == "none": if allow_none: return None raise ValueError("transition effect is required") if not isinstance(effect, str): raise ValueError(f"transition effect must be a string: {effect!r}") - if effect not in TRANSITIONS: - valid = ", ".join(sorted(TRANSITIONS)) + if effect in TRANSITION_ALIASES: + return TRANSITION_ALIASES[effect] + if effect in NATIVE_TRANSITIONS: + return effect + valid = ", ".join((*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS)) + raise ValueError( + f"unknown transition effect {effect!r}; valid effects: {valid}, none" + ) + + +def normalize_transition_effect_options( + effect: str, + options: object = None, +) -> dict[str, object]: + """Validate PowerPoint Effect Options for one native transition.""" + if effect not in NATIVE_TRANSITIONS: + if options in (None, {}): + return {} raise ValueError( - f"unknown transition effect {effect!r}; valid effects: {valid}, none" + "transition effect_options require one explicit native effect; " + f"found {effect!r}" ) - return effect + if options is None: + options = {} + if not isinstance(options, Mapping): + raise ValueError( + f"transition effect_options must be an object: {options!r}" + ) + + option_specs = NATIVE_TRANSITIONS[effect]["effectOptions"] + unknown = set(options) - set(option_specs) + if unknown: + unsupported = ", ".join(sorted(unknown)) + supported = ", ".join(option_specs) or "(none)" + raise ValueError( + f"transition effect {effect!r} does not support effect option(s): " + f"{unsupported}; supported options: {supported}" + ) + + normalized: dict[str, object] = {} + for name, value in options.items(): + spec = option_specs[name] + field = f"transition effect_options.{name}" + if spec["type"] == "enum": + if not isinstance(value, str) or value not in spec["values"]: + valid = ", ".join(spec["values"]) + raise ValueError( + f"{field} for {effect!r} must be one of {valid}: {value!r}" + ) + normalized[name] = value + elif spec["type"] == "boolean": + if not isinstance(value, bool): + raise ValueError(f"{field} must be a boolean: {value!r}") + normalized[name] = value + else: + raise AssertionError( + f"unhandled transition option type: {spec['type']!r}" + ) + return normalized + + +def normalize_transition_effect_request( + effect: object, + options: object = None, + *, + allow_none: bool = True, +) -> tuple[str | None, dict[str, object]]: + """Normalize one native effect plus options and legacy semantic aliases.""" + raw_effect = effect + canonical = normalize_transition_effect(effect, allow_none=allow_none) + alias_options = ( + TRANSITION_ALIAS_OPTIONS.get(raw_effect, {}) + if isinstance(raw_effect, str) + else {} + ) + explicit_options: Mapping[str, object] + if options is None: + explicit_options = {} + elif isinstance(options, Mapping): + explicit_options = options + else: + raise ValueError( + f"transition effect_options must be an object: {options!r}" + ) + for name, alias_value in alias_options.items(): + if name in explicit_options and explicit_options[name] != alias_value: + raise ValueError( + f"legacy transition effect {raw_effect!r} implies " + f"effect_options.{name}={alias_value!r}, which conflicts with " + f"{explicit_options[name]!r}" + ) + merged = {**alias_options, **explicit_options} + if canonical is None: + if merged: + raise ValueError( + "transition effect_options require one explicit native effect; " + "found 'none'" + ) + return None, {} + return canonical, normalize_transition_effect_options(canonical, merged) + + +def describe_transition_effect(effect: object) -> dict[str, Any]: + """Return the author-facing parameter contract for one transition.""" + canonical, implied_options = normalize_transition_effect_request( + effect, + allow_none=False, + ) + option_contract: dict[str, Any] = {} + for name, raw_spec in NATIVE_TRANSITIONS[canonical]["effectOptions"].items(): + option_contract[name] = { + "type": raw_spec["type"], + "default": raw_spec["default"], + "values": list(raw_spec["values"]), + } + return { + "input": effect, + "effect": canonical, + "name": NATIVE_TRANSITIONS[canonical]["name"], + "category": NATIVE_TRANSITIONS[canonical]["category"], + "compatibility_alias": ( + effect + if isinstance(effect, str) and effect in TRANSITION_ALIASES + else None + ), + "implied_effect_options": implied_options, + "effect_options": option_contract, + "timing": { + "duration": "positive seconds", + "auto_advance": "non-negative seconds", + }, + } def _seconds_to_ms(value: object, field: str, *, allow_zero: bool) -> int: @@ -216,14 +1129,77 @@ def _seconds_to_ms(value: object, field: str, *, allow_zero: bool) -> int: return milliseconds if allow_zero else max(1, milliseconds) -def _effect_xml(effect: str) -> tuple[str, str]: - info = TRANSITIONS[effect] +def _effect_spec( + effect: str, + effect_options: object = None, +) -> tuple[str, str, str, dict[str, Any], str | None]: + info = NATIVE_TRANSITIONS[effect] + prefix = str(info.get("prefix", "p")) + namespace = TRANSITION_NAMESPACES[prefix] + element = str(info["element"]) + attrs = dict(info.get("attrs", {})) + fallback = info.get("fallback") + options = { + name: option_spec["default"] + for name, option_spec in info["effectOptions"].items() + } + options.update( + normalize_transition_effect_options(effect, effect_options) + ) + option_specs = info["effectOptions"] + for name, value in options.items(): + override = option_specs[name]["values"][value] + if "prefix" in override: + prefix = str(override["prefix"]) + namespace = TRANSITION_NAMESPACES[prefix] + if "element" in override: + element = str(override["element"]) + for attribute in override.get("remove_attrs", ()): + attrs.pop(str(attribute), None) + attrs.update( + { + str(attribute): str(attribute_value) + for attribute, attribute_value in override.get("attrs", {}).items() + } + ) + if "fallback" in override: + fallback = override["fallback"] + return prefix, namespace, element, attrs, str(fallback) if fallback else None + + +def _effect_xml( + effect: str, + effect_options: object = None, +) -> tuple[str, str, str]: + prefix, _namespace, element, effect_attrs, _fallback = _effect_spec( + effect, + effect_options, + ) attrs = " ".join( f'{key}="{value}"' - for key, value in info.get("attrs", {}).items() + for key, value in effect_attrs.items() ) suffix = f" {attrs}" if attrs else "" - return str(info["element"]), suffix + return prefix, element, suffix + + +def _transition_attributes( + *, + duration_ms: int | None, + advance_after_ms: int | None, + advance_on_click: bool | None, + declare_p14: bool, +) -> str: + attrs: list[str] = [] + if duration_ms is not None: + attrs.append(f'p14:dur="{duration_ms}"') + if declare_p14: + attrs.append(f'xmlns:p14="{P14_NS}"') + if advance_on_click is not None: + attrs.append(f'advClick="{1 if advance_on_click else 0}"') + if advance_after_ms is not None: + attrs.append(f'advTm="{advance_after_ms}"') + return " " + " ".join(attrs) if attrs else "" def create_transition_xml( @@ -231,45 +1207,86 @@ def create_transition_xml( duration: float = 0.5, advance_after: float | None = None, advance_on_click: bool | None = None, + effect_options: Mapping[str, object] | None = None, ) -> str: - """Build a legacy-compatible direct p:transition XML fragment.""" - normalized_effect = normalize_transition_effect(effect) - attrs: list[str] = [] + """Build a direct or MCE-backed p:transition XML fragment.""" + normalized_effect, normalized_options = normalize_transition_effect_request( + effect, + effect_options, + ) + duration_ms = None if normalized_effect is not None: duration_ms = _seconds_to_ms( duration, "transition duration", allow_zero=False, ) - attrs.extend(( - f'p14:dur="{duration_ms}"', - f'xmlns:p14="{P14_NS}"', - )) if advance_on_click is not None: if not isinstance(advance_on_click, bool): raise ValueError( "transition advance_on_click must be a boolean or None" ) - attrs.append(f'advClick="{1 if advance_on_click else 0}"') + advance_ms = None if advance_after is not None: advance_ms = _seconds_to_ms( advance_after, "transition advance_after", allow_zero=True, ) - attrs.append(f'advTm="{advance_ms}"') - if normalized_effect is None and not attrs: + if ( + normalized_effect is None + and advance_ms is None + and advance_on_click is None + ): return "" - attr_text = " " + " ".join(attrs) if attrs else "" + attr_text = _transition_attributes( + duration_ms=duration_ms, + advance_after_ms=advance_ms, + advance_on_click=advance_on_click, + declare_p14=normalized_effect is not None, + ) if normalized_effect is None: return f" " - element_name, effect_attrs = _effect_xml(normalized_effect) + prefix, element_name, effect_attrs = _effect_xml( + normalized_effect, + normalized_options, + ) + if prefix != "p": + _effect_prefix, _namespace, _element, _attrs, fallback = _effect_spec( + normalized_effect, + normalized_options, + ) + fallback_effect = fallback or "fade" + fallback_prefix, fallback_name, fallback_attrs = _effect_xml( + fallback_effect + ) + fallback_attr_text = _transition_attributes( + duration_ms=None, + advance_after_ms=advance_ms, + advance_on_click=advance_on_click, + declare_p14=False, + ) + return ( + f' \n' + f' \n' + f" \n" + f" <{prefix}:{element_name}{effect_attrs}/>\n" + " \n" + " \n" + " \n" + f" \n" + f" <{fallback_prefix}:{fallback_name}{fallback_attrs}/>\n" + " \n" + " \n" + " " + ) return ( f" \n" - f" \n" + f" <{prefix}:{element_name}{effect_attrs}/>\n" " " ) @@ -296,8 +1313,12 @@ def _build_transition_element( duration: float, advance_after: float | None, advance_on_click: bool | None, + effect_options: Mapping[str, object] | None = None, ) -> Any | None: - normalized_effect = normalize_transition_effect(effect) + normalized_effect, normalized_options = normalize_transition_effect_request( + effect, + effect_options, + ) if ( normalized_effect is None and advance_after is None @@ -305,47 +1326,98 @@ def _build_transition_element( ): return None - nsmap = {"p": PML_NS} - if normalized_effect is not None: - nsmap["p14"] = P14_NS - transition = _new_element( - context, - _qn(PML_NS, "transition"), - nsmap=nsmap, - ) - - if normalized_effect is not None: - duration_ms = _seconds_to_ms( - duration, - "transition duration", - allow_zero=False, - ) - transition.set(_qn(P14_NS, "dur"), str(duration_ms)) if advance_on_click is not None: if not isinstance(advance_on_click, bool): raise ValueError( "transition advance_on_click must be a boolean or None" ) - transition.set("advClick", "1" if advance_on_click else "0") - if advance_after is not None: - transition.set( - "advTm", - str( - _seconds_to_ms( - advance_after, - "transition advance_after", - allow_zero=True, - ) - ), + duration_ms = ( + _seconds_to_ms( + duration, + "transition duration", + allow_zero=False, + ) + if normalized_effect is not None + else None + ) + advance_ms = ( + _seconds_to_ms( + advance_after, + "transition advance_after", + allow_zero=True, + ) + if advance_after is not None + else None + ) + + def build_transition( + effect_name: str | None, + *, + include_duration: bool, + options: Mapping[str, object] | None = None, + ) -> Any: + nsmap = {"p": PML_NS} + if include_duration: + nsmap["p14"] = P14_NS + transition = _new_element( + context, + _qn(PML_NS, "transition"), + nsmap=nsmap, + ) + if include_duration and duration_ms is not None: + transition.set(_qn(P14_NS, "dur"), str(duration_ms)) + if advance_on_click is not None: + transition.set("advClick", "1" if advance_on_click else "0") + if advance_ms is not None: + transition.set("advTm", str(advance_ms)) + if effect_name is not None: + _prefix, namespace, element_name, effect_attrs, _fallback = ( + _effect_spec(effect_name, options) + ) + child = _new_element(context, _qn(namespace, element_name)) + for key, value in effect_attrs.items(): + child.set(key, str(value)) + transition.append(child) + return transition + + if normalized_effect is None: + return build_transition(None, include_duration=False) + + prefix, _namespace, _element, _attrs, fallback = _effect_spec( + normalized_effect, + normalized_options, + ) + if prefix == "p": + return build_transition( + normalized_effect, + include_duration=True, + options=normalized_options, ) - if normalized_effect is not None: - info = TRANSITIONS[normalized_effect] - child = _new_element(context, _qn(PML_NS, str(info["element"]))) - for key, value in info.get("attrs", {}).items(): - child.set(key, str(value)) - transition.append(child) - return transition + carrier = _new_element( + context, + _qn(MC_NS, "AlternateContent"), + nsmap={"mc": MC_NS}, + ) + choice = _new_element( + context, + _qn(MC_NS, "Choice"), + nsmap={prefix: TRANSITION_NAMESPACES[prefix]}, + ) + choice.set("Requires", prefix) + choice.append( + build_transition( + normalized_effect, + include_duration=True, + options=normalized_options, + ) + ) + fallback_node = _new_element(context, _qn(MC_NS, "Fallback")) + fallback_node.append( + build_transition(fallback or "fade", include_duration=False) + ) + carrier.extend((choice, fallback_node)) + return carrier def _transition_elements(carrier: Any) -> list[Any]: @@ -392,14 +1464,67 @@ def _primary_and_fallback(carrier: Any) -> tuple[Any | None, Any | None]: return primary, fallback -def _effect_identity(transition: Any | None) -> tuple[str | None, str | None]: +def _effect_identity( + transition: Any | None, +) -> tuple[str | None, str | None, dict[str, str]]: if transition is None: - return None, None + return None, None, {} for child in list(transition): if child.tag == _qn(PML_NS, "sndAc"): continue - return _local_name(child.tag), _namespace_name(child.tag) - return None, None + return ( + _local_name(child.tag), + _namespace_name(child.tag), + {str(name): str(value) for name, value in child.attrib.items()}, + ) + return None, None, {} + + +def _effective_transition_options( + effect: str, + options: Mapping[str, object] | None = None, +) -> dict[str, object]: + effective = { + name: spec["default"] + for name, spec in NATIVE_TRANSITIONS[effect]["effectOptions"].items() + } + effective.update(normalize_transition_effect_options(effect, options)) + return effective + + +def _transition_option_combinations(effect: str) -> list[dict[str, object]]: + combinations: list[dict[str, object]] = [{}] + for name, spec in NATIVE_TRANSITIONS[effect]["effectOptions"].items(): + combinations = [ + {**combination, name: value} + for combination in combinations + for value in spec["values"] + ] + return combinations + + +def _identify_native_transition( + element: str | None, + namespace: str | None, + attributes: Mapping[str, str], +) -> tuple[str | None, dict[str, object]]: + if element is None or namespace is None: + return None, {} + for effect in NATIVE_TRANSITION_KEYS: + for options in _transition_option_combinations(effect): + _prefix, expected_namespace, expected_element, expected_attrs, _fallback = ( + _effect_spec(effect, options) + ) + if ( + namespace == expected_namespace + and element == expected_element + and dict(attributes) == { + str(name): str(value) + for name, value in expected_attrs.items() + } + ): + return effect, options + return None, {} def _int_attribute(element: Any | None, *names: str) -> int | None: @@ -431,8 +1556,15 @@ def read_slide_transition(slide_root: Any) -> TransitionSummary: carrier = carriers[0] primary, fallback = _primary_and_fallback(carrier) - effect, effect_namespace = _effect_identity(primary) - fallback_effect, fallback_namespace = _effect_identity(fallback) + effect, effect_namespace, effect_attributes = _effect_identity(primary) + fallback_effect, fallback_namespace, _fallback_attributes = _effect_identity( + fallback + ) + canonical_effect, effect_options = _identify_native_transition( + effect, + effect_namespace, + effect_attributes, + ) carrier_name = ( "alternate-content" if carrier.tag == _qn(MC_NS, "AlternateContent") @@ -449,6 +1581,9 @@ def read_slide_transition(slide_root: Any) -> TransitionSummary: logical_count=len(carriers), effect=effect, effect_namespace=effect_namespace, + effect_attributes=effect_attributes, + canonical_effect=canonical_effect, + effect_options=effect_options, fallback_effect=fallback_effect, fallback_effect_namespace=fallback_namespace, duration_ms=duration_ms, @@ -564,6 +1699,13 @@ def _apply_slide_motion_unchecked( f"unknown transition enter policy {enter.policy!r}; " f"valid policies: {', '.join(sorted(valid_policies))}" ) + if ( + enter.policy != "replace" + and enter.effect_options not in (None, {}) + ): + raise ValueError( + "transition effect_options require enter policy 'replace'" + ) carriers = transition_carriers(slide_root) if len(carriers) > 1: @@ -601,15 +1743,20 @@ def _apply_slide_motion_unchecked( duration=enter.duration, advance_after=advance_after, advance_on_click=advance_on_click, + effect_options=None, ) if transition is not None: _insert_transition_carrier(slide_root, transition) return advance_after is not None - effect = ( - normalize_transition_effect(enter.effect, allow_none=False) + effect, effect_options = ( + normalize_transition_effect_request( + enter.effect, + enter.effect_options, + allow_none=False, + ) if enter.policy == "replace" - else None + else (None, {}) ) duration = validate_seconds( enter.duration, @@ -626,6 +1773,7 @@ def _apply_slide_motion_unchecked( duration=duration, advance_after=advance_after, advance_on_click=advance_on_click, + effect_options=effect_options, ) if transition is not None: _insert_transition_carrier(slide_root, transition) @@ -638,6 +1786,9 @@ def _visual_identity(summary: TransitionSummary) -> tuple[Any, ...]: summary.logical_count, summary.effect, summary.effect_namespace, + tuple(sorted(summary.effect_attributes.items())), + summary.canonical_effect, + tuple(sorted(summary.effect_options.items())), summary.fallback_effect, summary.fallback_effect_namespace, summary.duration_ms, @@ -645,6 +1796,35 @@ def _visual_identity(summary: TransitionSummary) -> tuple[Any, ...]: ) +def _expected_visual_identity( + effect: str, + effect_options: Mapping[str, object] | None = None, +) -> tuple[str, str, str, str | None, str | None, dict[str, Any]]: + prefix, namespace, element, attrs, fallback = _effect_spec( + effect, + effect_options, + ) + carrier = "direct" if prefix == "p" else "alternate-content" + fallback_element = None + fallback_namespace = None + if carrier == "alternate-content": + ( + _fallback_prefix, + fallback_namespace, + fallback_element, + _fallback_attrs, + _nested_fallback, + ) = _effect_spec(fallback or "fade") + return ( + carrier, + element, + namespace, + fallback_element, + fallback_namespace, + attrs, + ) + + def _validate_applied_motion( slide_root: Any, *, @@ -661,18 +1841,37 @@ def _validate_applied_motion( elif not before.logical_count and after.effect is not None: errors.append("preserve policy added a visual transition") elif enter.policy == "replace": - effect = normalize_transition_effect(enter.effect, allow_none=False) + effect, effect_options = normalize_transition_effect_request( + enter.effect, + enter.effect_options, + allow_none=False, + ) + expected_effect_options = _effective_transition_options( + effect, + effect_options, + ) expected_duration = _seconds_to_ms( enter.duration, "transition duration", allow_zero=False, ) + ( + expected_carrier, + expected_effect, + expected_namespace, + expected_fallback, + expected_fallback_namespace, + expected_attrs, + ) = _expected_visual_identity(effect, effect_options) if ( - after.carrier != "direct" + after.carrier != expected_carrier or after.logical_count != 1 - or after.effect != TRANSITIONS[effect]["element"] - or after.effect_namespace != PML_NS - or after.fallback_effect is not None + or after.effect != expected_effect + or after.effect_namespace != expected_namespace + or after.canonical_effect != effect + or dict(after.effect_options) != expected_effect_options + or after.fallback_effect != expected_fallback + or after.fallback_effect_namespace != expected_fallback_namespace or after.duration_ms != expected_duration ): errors.append( @@ -688,7 +1887,7 @@ def _validate_applied_motion( ] if effect_children: actual_attrs = effect_children[0].attrib - for name, value in TRANSITIONS[effect].get("attrs", {}).items(): + for name, value in expected_attrs.items(): if actual_attrs.get(name) != str(value): errors.append( f"replace policy wrote invalid {effect} {name} attribute" @@ -885,6 +2084,12 @@ def serialize_source_xml(root: ET.Element, source_xml: str | bytes) -> bytes: ) bindings = register_source_namespaces(source) prefixes = _required_mce_prefixes(root) + for prefix in prefixes: + namespace = TRANSITION_NAMESPACES.get(prefix) + if namespace is None: + continue + bindings[prefix] = namespace + ET.register_namespace(prefix, namespace) serialized = ET.tostring(root, encoding="utf-8", xml_declaration=True) serialized = _inject_root_namespace_declarations( serialized, @@ -962,13 +2167,17 @@ def validate_generated_transition_xml( duration: object, advance_on_click: bool | None, advance_after: object | None, + effect_options: Mapping[str, object] | None = None, ) -> TransitionSummary: - """Validate a generated direct transition against its resolved settings.""" + """Validate a generated transition against its resolved settings.""" data = slide_xml.encode("utf-8") if isinstance(slide_xml, str) else slide_xml root = LET.fromstring(data) if LET is not None else parse_source_xml(data) errors = validate_slide_transition_structure(root) + validate_mce_prefixes(data) summary = read_slide_transition(root) - normalized_effect = normalize_transition_effect(effect) + normalized_effect, normalized_options = normalize_transition_effect_request( + effect, + effect_options, + ) expected_click = True if advance_on_click is None else advance_on_click if not isinstance(expected_click, bool): errors.append("transition advance_on_click must be a boolean or None") @@ -1000,16 +2209,39 @@ def validate_generated_transition_xml( if normalized_effect is not None else None ) - expected_effect = ( - str(TRANSITIONS[normalized_effect]["element"]) - if normalized_effect is not None - else None - ) + expected_carrier = "direct" + expected_effect = None + expected_namespace = None + expected_fallback = None + expected_fallback_namespace = None + expected_attrs: dict[str, Any] = {} + if normalized_effect is not None: + ( + expected_carrier, + expected_effect, + expected_namespace, + expected_fallback, + expected_fallback_namespace, + expected_attrs, + ) = _expected_visual_identity( + normalized_effect, + normalized_options, + ) + expected_effect_options = _effective_transition_options( + normalized_effect, + normalized_options, + ) + else: + expected_effect_options = {} if ( - summary.carrier != "direct" + summary.carrier != expected_carrier or summary.logical_count != 1 or summary.effect != expected_effect - or summary.fallback_effect is not None + or summary.effect_namespace != expected_namespace + or summary.canonical_effect != normalized_effect + or dict(summary.effect_options) != expected_effect_options + or summary.fallback_effect != expected_fallback + or summary.fallback_effect_namespace != expected_fallback_namespace or summary.duration_ms != expected_duration_ms or summary.advance_on_click != expected_click or summary.advance_after_ms != expected_after_ms @@ -1024,7 +2256,6 @@ def validate_generated_transition_xml( for child in (list(primary) if primary is not None else []) if child.tag != _qn(PML_NS, "sndAc") ] - expected_attrs = TRANSITIONS[normalized_effect].get("attrs", {}) if not effect_children: errors.append("generated transition has no visual effect child") else: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit_manifest.json b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit_manifest.json index 94030fda..bba32afd 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit_manifest.json +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit_manifest.json @@ -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.", diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/shape_boolean_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/shape_boolean_svg.py new file mode 100644 index 00000000..94e43664 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/shape_boolean_svg.py @@ -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()) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/animation_config.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/animation_config.py index 20af3e82..e829ad88 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/animation_config.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/animation_config.py @@ -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)) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/context.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/context.py index 386ce543..c0a91790 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/context.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/context.py @@ -68,7 +68,7 @@ class ConvertContext: # Recursion depth — only the depth==0 (root) context records anim targets. depth: int = 0 # Top-level 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. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/converter.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/converter.py index 63720229..90aea074 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/converter.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/converter.py @@ -905,7 +905,7 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: ) # Record top-level semantic groups (e.g. ) 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 # 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} diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/elements.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/elements.py index 59aa4a97..5be5109e 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/elements.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/elements.py @@ -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, ) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/paths.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/paths.py index aba92947..a12a6594 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/paths.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/drawingml/paths.py @@ -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, diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/builder.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/builder.py index a548dc4c..66b7f296 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/builder.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/builder.py @@ -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: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py index fdd6dd34..b296d732 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py @@ -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// %(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 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 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: chart→wipe, - card-/step-/pillar-→fly, title/takeaway→fade; 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: chart→entrance_wipe, + card-/step-/pillar-→entrance_fly, + title/takeaway→entrance_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, diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/narration.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/narration.py index 25a6b625..29c010b9 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/narration.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/narration.py @@ -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)) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/slide_xml.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/slide_xml.py index 0c541f1c..f9ec9e63 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/slide_xml.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/slide_xml.py @@ -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 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/shape_boolean.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/shape_boolean.py new file mode 100644 index 00000000..1b772070 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/shape_boolean.py @@ -0,0 +1,1249 @@ +#!/usr/bin/env python3 +""" +PPT Master - Shape Boolean Core + +Resolve closed SVG operands into root-coordinate paths and apply +PowerPoint-compatible merge-shapes operations without mutating the source SVG. +Callers insert returned paths at the SVG root in the primary operand's z-order; +reinserting them below an original transformed ancestor would transform twice. + +Usage: + Import render_boolean_svg_fragments from svg_to_pptx.shape_boolean. + +Examples: + fragment = render_boolean_svg_fragments( + "projects/demo/svg_output/slide-01.svg", + operation="intersect", + source_ids=("shape-a", "shape-b"), + output_id="shape-overlap", + ) + +Dependencies: + skia-pathops and local PPT Master modules +""" + +from __future__ import annotations + +import math +import re +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any +from xml.etree import ElementTree as ET + +from pptx_to_svg.emu_units import Xfrm +from pptx_to_svg.preset_authoring import ( + authored_preset_encoding, + validate_authored_preset_group, +) +from pptx_to_svg.preset_registry_to_svg import render_preset_geometry +from pptx_to_svg.preset_svg_markup import attrs_to_xml + +from .drawingml.context import AffineMatrix, IDENTITY_MATRIX +from .drawingml.paths import ( + PathCommand, + normalize_path_commands, + parse_svg_path, + parse_svg_points, + svg_path_to_absolute, + transform_path_commands, +) +from .drawingml.utils import ( + matrix_multiply, + parse_inline_style, + parse_opacity, + parse_project_geometry_length, + parse_transform_matrix, +) + + +BOOLEAN_OPERATIONS = frozenset({ + "combine", + "fragment", + "intersect", + "subtract", + "union", +}) + +_BOOLEAN_TAGS = frozenset({"circle", "ellipse", "path", "polygon", "rect"}) +_DEFINITION_TAGS = frozenset({ + "clipPath", + "defs", + "marker", + "mask", + "pattern", + "symbol", +}) +_ID_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.:-]*") +_PRESENTATION_ATTRS = ( + "fill", + "fill-opacity", + "opacity", + "stroke", + "stroke-dasharray", + "stroke-width", + "stroke-opacity", + "stroke-linecap", + "stroke-linejoin", +) +_ROUNDTRIP_TRANSPORT_ATTRS = frozenset({ + "data-pptx-custgeom", + "data-pptx-custgeom-ref", + "data-pptx-geometry-kind", + "data-pptx-native", + "data-pptx-native-ref", + "data-pptx-native-source", + "data-pptx-native-status", + "data-pptx-part", + "data-pptx-shape-id", + "data-pptx-shape-scope", + "data-pptx-source-ref", +}) +_RESULT_PRECISION = 6 +_BEZIER_QUARTER_K = 0.5522847498307936 + + +def render_boolean_svg_fragments( + svg_file: str | Path, + *, + operation: str, + source_ids: Sequence[str], + output_id: str, + style: Mapping[str, str] | None = None, +) -> str: + """Return canonical SVG path fragments for one merge-shapes operation. + + The first source is the primary shape: subtract removes all later sources + from it, and every result inherits its effective presentation attributes. + All transforms are baked into root SVG coordinates, so callers must insert + the result at the root in the primary operand's z-order. Fragment returns + top-to-bottom, left-to-right sibling paths named ``-1`` onward; + every other operation returns one path named exactly ``output_id``. + """ + if not isinstance(operation, str): + raise ValueError("Shape Boolean operation must be a string") + normalized_operation = operation.strip().lower() + if normalized_operation not in BOOLEAN_OPERATIONS: + allowed = ", ".join(sorted(BOOLEAN_OPERATIONS)) + raise ValueError( + f"Unsupported shape Boolean operation {operation!r}; use {allowed}" + ) + source_id_list = _validate_source_ids(source_ids) + _validate_output_id(output_id) + style_overrides = _validate_style_overrides(style) + + source_path = Path(svg_file) + try: + root = ET.parse(source_path).getroot() + except ET.ParseError as exc: + raise ValueError(f"Cannot parse SVG {source_path}: {exc}") from exc + if _local_name(root.tag) != "svg": + raise ValueError(f"Shape Boolean source must have an SVG root: {source_path}") + if any(_local_name(element.tag) == "style" for element in root.iter()): + raise ValueError( + "Shape Boolean does not resolve embedded CSS; use explicit SVG " + "presentation attributes on the operands" + ) + + parents = { + child: parent + for parent in root.iter() + for child in list(parent) + } + elements_by_id, duplicate_ids = _index_element_ids(root) + selected: list[ET.Element] = [] + for source_id in source_id_list: + if source_id in duplicate_ids: + raise ValueError( + f"Shape Boolean source id {source_id!r} is not unique" + ) + element = elements_by_id.get(source_id) + if element is None: + raise ValueError(f"Shape Boolean source id not found: {source_id!r}") + selected.append(element) + + pathops = _load_pathops() + operands = [ + _element_to_pathops(element, parents, pathops) + for element in selected + ] + inherited_style = _effective_style(selected[0], parents) + inherited_style.update(style_overrides) + + if normalized_operation == "fragment": + results = _fragment_paths(operands, pathops) + fragments: list[str] = [] + for index, result in enumerate(results, start=1): + result_id = f"{output_id}-{index}" + _validate_result_id_collision( + result_id, + elements_by_id, + source_id_list, + ) + fragments.append( + _serialize_result_path( + result, + result_id, + inherited_style, + pathops, + ) + ) + return "\n".join(fragments) + + result = _merge_paths(normalized_operation, operands, pathops) + if _path_is_empty(result): + raise ValueError( + f"Shape Boolean {normalized_operation} produced no filled area" + ) + _validate_result_id_collision(output_id, elements_by_id, source_id_list) + return _serialize_result_path( + result, + output_id, + inherited_style, + pathops, + ) + + +def _validate_source_ids(source_ids: Sequence[str]) -> list[str]: + if isinstance(source_ids, (str, bytes)): + raise ValueError("Shape Boolean source_ids must be a sequence of ids") + values = list(source_ids) + if len(values) < 2: + raise ValueError("Shape Boolean requires at least two source ids") + if any(not isinstance(value, str) or not value for value in values): + raise ValueError("Shape Boolean source ids must be non-empty strings") + if len(set(values)) != len(values): + raise ValueError("Shape Boolean source ids must be unique") + return values + + +def _validate_output_id(output_id: str) -> None: + if not isinstance(output_id, str) or _ID_RE.fullmatch(output_id) is None: + raise ValueError(f"Invalid SVG output id: {output_id!r}") + + +def _validate_style_overrides( + style: Mapping[str, str] | None, +) -> dict[str, str]: + if style is None: + return {} + unknown = sorted(set(style) - set(_PRESENTATION_ATTRS)) + if unknown: + raise ValueError( + "Unsupported shape Boolean style override(s): " + + ", ".join(unknown) + ) + normalized: dict[str, str] = {} + for name in _PRESENTATION_ATTRS: + if name not in style: + continue + value = style[name] + if not isinstance(value, str) or not value.strip(): + raise ValueError( + f"Shape Boolean style override {name!r} must be a non-empty string" + ) + normalized[name] = value.strip() + return normalized + + +def _index_element_ids( + root: ET.Element, +) -> tuple[dict[str, ET.Element], set[str]]: + elements: dict[str, ET.Element] = {} + duplicates: set[str] = set() + for element in root.iter(): + element_id = element.get("id") + if not element_id: + continue + if element_id in elements: + duplicates.add(element_id) + else: + elements[element_id] = element + return elements, duplicates + + +def _validate_result_id_collision( + result_id: str, + elements_by_id: Mapping[str, ET.Element], + source_ids: Sequence[str], +) -> None: + if result_id in elements_by_id and result_id not in source_ids: + raise ValueError( + f"Shape Boolean result id already exists outside the sources: " + f"{result_id!r}" + ) + + +def _load_pathops() -> Any: + try: + import pathops + except ImportError as exc: + raise RuntimeError( + "Shape Boolean operations require skia-pathops. Install the " + "project requirements, or run: pip install skia-pathops" + ) from exc + required = ( + "FillType", + "Path", + "PathOp", + "PathOpsError", + "PathVerb", + "op", + "simplify", + ) + missing = [name for name in required if not hasattr(pathops, name)] + if missing: + raise RuntimeError( + "Installed skia-pathops is incompatible; missing: " + + ", ".join(missing) + ) + return pathops + + +def _element_to_pathops( + element: ET.Element, + parents: Mapping[ET.Element, ET.Element], + pathops: Any, +) -> Any: + chain = _element_chain(element, parents) + _validate_source_location(element, chain) + matrix = _combined_transform(chain) + tag = _local_name(element.tag) + + if tag == "g": + commands = _compact_preset_commands(element, matrix) + elif tag in _BOOLEAN_TAGS: + commands = _shape_commands(element) + commands = transform_path_commands(commands, matrix) + else: + supported = ", ".join(sorted(_BOOLEAN_TAGS)) + raise ValueError( + f"Shape Boolean source {_element_label(element)} must be a closed " + f"{supported}, or a compact authored preset group" + ) + + result = _simplify_path(_commands_to_pathops(commands, pathops), pathops) + if _path_is_empty(result): + raise ValueError( + f"Shape Boolean source {_element_label(element)} has no filled area" + ) + return result + + +def _element_chain( + element: ET.Element, + parents: Mapping[ET.Element, ET.Element], +) -> list[ET.Element]: + chain = [element] + current = element + while current in parents: + current = parents[current] + chain.append(current) + chain.reverse() + return chain + + +def _validate_source_location( + element: ET.Element, + chain: Sequence[ET.Element], +) -> None: + definition_ancestor = next( + ( + ancestor + for ancestor in chain[:-1] + if _local_name(ancestor.tag) in _DEFINITION_TAGS + ), + None, + ) + if definition_ancestor is not None: + raise ValueError( + f"Shape Boolean source {_element_label(element)} is inside " + f"<{_local_name(definition_ancestor.tag)}>" + ) + nested_svg_count = sum( + 1 for ancestor in chain if _local_name(ancestor.tag) == "svg" + ) + if nested_svg_count > 1: + raise ValueError( + f"Shape Boolean source {_element_label(element)} is inside a nested " + "; materialize its viewport transform first" + ) + for ancestor in chain[:-1]: + if ( + _local_name(ancestor.tag) == "g" + and authored_preset_encoding(ancestor) is not None + ): + raise ValueError( + f"Shape Boolean source {_element_label(element)} is inside an " + "authored preset; select the preset group's id" + ) + _validate_geometry_presentation(element, chain) + + +def _validate_geometry_presentation( + element: ET.Element, + chain: Sequence[ET.Element], +) -> None: + fill_rule = "nonzero" + for current in chain: + if current.get("class") is not None: + raise ValueError( + f"Shape Boolean source {_element_label(element)} uses class; " + "materialize its computed presentation first" + ) + transport_attrs = sorted( + name + for name in _ROUNDTRIP_TRANSPORT_ATTRS + if current.get(name) is not None + ) + if transport_attrs: + raise ValueError( + f"Shape Boolean source {_element_label(element)} carries " + "PPTX import/round-trip metadata " + f"({', '.join(transport_attrs)}); author or materialize an " + "ordinary SVG shape first" + ) + inline = parse_inline_style(current.get("style")) + inline_transform = inline.get("transform") + if ( + inline_transform is not None + and inline_transform.strip().lower() not in {"", "none"} + ): + raise ValueError( + f"Shape Boolean source {_element_label(element)} uses a CSS " + "transform; write it as an SVG transform attribute first" + ) + display = inline.get("display", current.get("display")) + if display is not None and display.strip().lower() == "none": + raise ValueError( + f"Shape Boolean source {_element_label(element)} is not " + "displayed" + ) + visibility = inline.get("visibility", current.get("visibility")) + if visibility is not None and visibility.strip().lower() in { + "collapse", + "hidden", + }: + raise ValueError( + f"Shape Boolean source {_element_label(element)} is not visible" + ) + for name in ("clip-path", "mask"): + raw = inline.get(name, current.get(name)) + if raw is not None and raw.strip().lower() not in {"", "none"}: + raise ValueError( + f"Shape Boolean source {_element_label(element)} uses " + f"{name}; materialize the visible contour first" + ) + raw_filter = inline.get("filter", current.get("filter")) + if raw_filter is not None and raw_filter.strip().lower() not in { + "", + "none", + }: + raise ValueError( + f"Shape Boolean source {_element_label(element)} uses filter; " + "reapply the effect after materializing the result" + ) + raw_fill_rule = inline.get("fill-rule", current.get("fill-rule")) + if ( + raw_fill_rule is not None + and raw_fill_rule.strip().lower() != "inherit" + ): + fill_rule = raw_fill_rule.strip().lower() + if fill_rule != "nonzero": + raise ValueError( + f"Shape Boolean source {_element_label(element)} uses " + f"fill-rule={fill_rule!r}; use explicit nonzero contour direction" + ) + + +def _combined_transform(chain: Sequence[ET.Element]) -> AffineMatrix: + matrix = IDENTITY_MATRIX + for element in chain[1:]: + transform = element.get("transform") + if transform: + matrix = matrix_multiply( + matrix, + parse_transform_matrix(transform), + ) + return matrix + + +def _shape_commands(element: ET.Element) -> list[PathCommand]: + tag = _local_name(element.tag) + if tag == "path": + raw_path = element.get("d") + if raw_path is None: + raise ValueError(f"{_element_label(element)} requires d") + commands = normalize_path_commands( + svg_path_to_absolute(parse_svg_path(raw_path)) + ) + _require_closed_commands(commands, _element_label(element)) + return commands + + if tag == "polygon": + points = parse_svg_points( + element.get("points", ""), + min_points=3, + ) + commands = [PathCommand("M", [*points[0]])] + commands.extend( + PathCommand("L", [x, y]) + for x, y in points[1:] + ) + commands.append(PathCommand("Z", [])) + return commands + + if tag == "rect": + return _rect_commands(element) + + if tag == "circle": + cx = _geometry_value(element, "cx", default=0.0) + cy = _geometry_value(element, "cy", default=0.0) + radius = _geometry_value(element, "r", required=True) + if radius <= 0: + raise ValueError(f"{_element_label(element)} r must be greater than 0") + return _ellipse_commands(cx, cy, radius, radius) + + if tag == "ellipse": + cx = _geometry_value(element, "cx", default=0.0) + cy = _geometry_value(element, "cy", default=0.0) + radius_x = _geometry_value(element, "rx", required=True) + radius_y = _geometry_value(element, "ry", required=True) + if radius_x <= 0 or radius_y <= 0: + raise ValueError( + f"{_element_label(element)} rx and ry must be greater than 0" + ) + return _ellipse_commands(cx, cy, radius_x, radius_y) + + raise AssertionError(f"Unhandled Boolean source tag: {tag}") + + +def _geometry_value( + element: ET.Element, + attribute: str, + *, + default: float | None = None, + required: bool = False, +) -> float: + raw = element.get(attribute) + if raw is None: + if required: + raise ValueError(f"{_element_label(element)} requires {attribute}") + if default is None: + raise AssertionError("Geometry attribute requires a default") + return default + try: + return parse_project_geometry_length(raw, attribute) + except ValueError as exc: + raise ValueError( + f"{_element_label(element)} {attribute}: {exc}" + ) from exc + + +def _rect_commands(element: ET.Element) -> list[PathCommand]: + x = _geometry_value(element, "x", default=0.0) + y = _geometry_value(element, "y", default=0.0) + width = _geometry_value(element, "width", required=True) + height = _geometry_value(element, "height", required=True) + if width <= 0 or height <= 0: + raise ValueError( + f"{_element_label(element)} width and height must be greater than 0" + ) + + raw_rx = element.get("rx") + raw_ry = element.get("ry") + radius_x = ( + _geometry_value(element, "rx", required=True) + if raw_rx is not None else 0.0 + ) + radius_y = ( + _geometry_value(element, "ry", required=True) + if raw_ry is not None else 0.0 + ) + if raw_rx is not None and raw_ry is None: + radius_y = radius_x + elif raw_ry is not None and raw_rx is None: + radius_x = radius_y + if radius_x < 0 or radius_y < 0: + raise ValueError( + f"{_element_label(element)} rx and ry must not be negative" + ) + + radius_x = min(radius_x, width / 2.0) + radius_y = min(radius_y, height / 2.0) + if radius_x <= 0 or radius_y <= 0: + return [ + PathCommand("M", [x, y]), + PathCommand("L", [x + width, y]), + PathCommand("L", [x + width, y + height]), + PathCommand("L", [x, y + height]), + PathCommand("Z", []), + ] + + kx = radius_x * _BEZIER_QUARTER_K + ky = radius_y * _BEZIER_QUARTER_K + right = x + width + bottom = y + height + return [ + PathCommand("M", [x + radius_x, y]), + PathCommand("L", [right - radius_x, y]), + PathCommand( + "C", + [ + right - radius_x + kx, + y, + right, + y + radius_y - ky, + right, + y + radius_y, + ], + ), + PathCommand("L", [right, bottom - radius_y]), + PathCommand( + "C", + [ + right, + bottom - radius_y + ky, + right - radius_x + kx, + bottom, + right - radius_x, + bottom, + ], + ), + PathCommand("L", [x + radius_x, bottom]), + PathCommand( + "C", + [ + x + radius_x - kx, + bottom, + x, + bottom - radius_y + ky, + x, + bottom - radius_y, + ], + ), + PathCommand("L", [x, y + radius_y]), + PathCommand( + "C", + [ + x, + y + radius_y - ky, + x + radius_x - kx, + y, + x + radius_x, + y, + ], + ), + PathCommand("Z", []), + ] + + +def _ellipse_commands( + center_x: float, + center_y: float, + radius_x: float, + radius_y: float, +) -> list[PathCommand]: + kx = radius_x * _BEZIER_QUARTER_K + ky = radius_y * _BEZIER_QUARTER_K + return [ + PathCommand("M", [center_x + radius_x, center_y]), + PathCommand( + "C", + [ + center_x + radius_x, + center_y + ky, + center_x + kx, + center_y + radius_y, + center_x, + center_y + radius_y, + ], + ), + PathCommand( + "C", + [ + center_x - kx, + center_y + radius_y, + center_x - radius_x, + center_y + ky, + center_x - radius_x, + center_y, + ], + ), + PathCommand( + "C", + [ + center_x - radius_x, + center_y - ky, + center_x - kx, + center_y - radius_y, + center_x, + center_y - radius_y, + ], + ), + PathCommand( + "C", + [ + center_x + kx, + center_y - radius_y, + center_x + radius_x, + center_y - ky, + center_x + radius_x, + center_y, + ], + ), + PathCommand("Z", []), + ] + + +def _compact_preset_commands( + group: ET.Element, + matrix: AffineMatrix, +) -> list[PathCommand]: + encoding = authored_preset_encoding(group) + if encoding != "compact": + raise ValueError( + f"Shape Boolean source {_element_label(group)} must be a compact " + "authored preset group" + ) + errors = validate_authored_preset_group(group) + if errors: + raise ValueError( + f"Invalid compact authored preset {_element_label(group)}: " + + "; ".join(errors) + ) + if group.get("data-pptx-object") != "shape": + raise ValueError( + f"Shape Boolean source {_element_label(group)} must be a shape, " + "not a connector" + ) + + frame = tuple( + float(value) + for value in (group.get("data-pptx-frame") or "").split() + ) + if len(frame) != 4: + raise ValueError( + f"Compact authored preset {_element_label(group)} has an invalid " + "data-pptx-frame" + ) + adjustments = { + name[len("data-pptx-av-"):]: value + for name, value in group.attrib.items() + if name.startswith("data-pptx-av-") + } + rendered = render_preset_geometry( + group.get("data-pptx-prst") or "", + Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]), + adjustments, + ) + children = list(group) + if len(children) != len(rendered.paths): + raise ValueError( + f"Compact authored preset {_element_label(group)} layer count " + "differs from its registry geometry" + ) + + commands: list[PathCommand] = [] + for child, layer in zip(children, rendered.paths): + if layer.fill == "none": + continue + raw_path = child.get("d") + if raw_path is None: + raise ValueError( + f"Compact authored preset {_element_label(group)} contains a " + "path without d" + ) + child_commands = normalize_path_commands( + svg_path_to_absolute(parse_svg_path(raw_path)) + ) + child_commands = _close_filled_commands( + child_commands, + f"{_element_label(group)} preset layer", + ) + commands.extend(transform_path_commands(child_commands, matrix)) + if not commands: + raise ValueError( + f"Compact authored preset {_element_label(group)} has no filled " + "silhouette" + ) + return commands + + +def _close_filled_commands( + commands: Sequence[PathCommand], + label: str, +) -> list[PathCommand]: + closed: list[PathCommand] = [] + open_subpath = False + drew_segment = False + for command in commands: + if command.cmd == "M": + if open_subpath: + if not drew_segment: + raise ValueError(f"{label} has an empty subpath") + closed.append(PathCommand("Z", [])) + open_subpath = True + drew_segment = False + elif command.cmd in {"L", "C"}: + if not open_subpath: + raise ValueError(f"{label} draws before its first move") + drew_segment = True + elif command.cmd == "Z": + if not open_subpath: + raise ValueError(f"{label} closes without an open subpath") + if not drew_segment: + raise ValueError(f"{label} has an empty subpath") + open_subpath = False + closed.append(command) + if open_subpath: + if not drew_segment: + raise ValueError(f"{label} has an empty subpath") + closed.append(PathCommand("Z", [])) + if not closed: + raise ValueError(f"{label} has no drawable contour") + return closed + + +def _require_closed_commands( + commands: Sequence[PathCommand], + label: str, +) -> None: + open_subpath = False + drew_segment = False + closed_contour = False + for command in commands: + if command.cmd == "M": + if open_subpath: + raise ValueError(f"{label} contains an open subpath") + open_subpath = True + drew_segment = False + elif command.cmd in {"L", "C"}: + if not open_subpath: + raise ValueError(f"{label} draws before its first move") + drew_segment = True + elif command.cmd == "Z": + if not open_subpath: + raise ValueError(f"{label} closes without an open subpath") + if not drew_segment: + raise ValueError(f"{label} has an empty subpath") + open_subpath = False + closed_contour = True + if open_subpath: + raise ValueError(f"{label} contains an open subpath") + if not closed_contour: + raise ValueError(f"{label} has no drawable closed contour") + + +def _commands_to_pathops( + commands: Sequence[PathCommand], + pathops: Any, +) -> Any: + result = pathops.Path(fillType=pathops.FillType.WINDING) + for command in commands: + args = command.args + if command.cmd == "M": + result.moveTo(args[0], args[1]) + elif command.cmd == "L": + result.lineTo(args[0], args[1]) + elif command.cmd == "C": + result.cubicTo(*args) + elif command.cmd == "Z": + result.close() + else: + raise AssertionError( + f"Unexpected normalized path command: {command.cmd}" + ) + return result + + +def _merge_paths( + operation: str, + operands: Sequence[Any], + pathops: Any, +) -> Any: + operator_by_name = { + "combine": pathops.PathOp.XOR, + "intersect": pathops.PathOp.INTERSECTION, + "subtract": pathops.PathOp.DIFFERENCE, + "union": pathops.PathOp.UNION, + } + operator = operator_by_name[operation] + result = operands[0] + for operand in operands[1:]: + result = _path_op(result, operand, operator, pathops) + if _path_is_empty(result) and operation in {"intersect", "subtract"}: + break + return result + + +def _path_op( + first: Any, + second: Any, + operator: Any, + pathops: Any, +) -> Any: + try: + return pathops.op( + first, + second, + operator, + fix_winding=True, + keep_starting_points=False, + clockwise=False, + ) + except pathops.PathOpsError as exc: + raise ValueError(f"Skia path Boolean operation failed: {exc}") from exc + + +def _simplify_path(path: Any, pathops: Any) -> Any: + """Resolve winding and self-intersections before filled-area checks.""" + try: + return pathops.simplify( + path, + fix_winding=True, + keep_starting_points=False, + clockwise=False, + ) + except pathops.PathOpsError as exc: + raise ValueError(f"Skia path simplification failed: {exc}") from exc + + +def _fragment_paths( + operands: Sequence[Any], + pathops: Any, +) -> list[Any]: + pieces: list[Any] = [] + covered: Any | None = None + for operand in operands: + split_pieces: list[Any] = [] + for piece in pieces: + difference = _path_op( + piece, + operand, + pathops.PathOp.DIFFERENCE, + pathops, + ) + intersection = _path_op( + piece, + operand, + pathops.PathOp.INTERSECTION, + pathops, + ) + if not _path_is_empty(difference): + split_pieces.append(difference) + if not _path_is_empty(intersection): + split_pieces.append(intersection) + + unique = ( + operand + if covered is None + else _path_op( + operand, + covered, + pathops.PathOp.DIFFERENCE, + pathops, + ) + ) + if not _path_is_empty(unique): + split_pieces.append(unique) + pieces = split_pieces + covered = ( + operand + if covered is None + else _path_op( + covered, + operand, + pathops.PathOp.UNION, + pathops, + ) + ) + + components = [ + component + for piece in pieces + for component in _connected_components(piece, pathops) + if not _path_is_empty(component) + ] + if not components: + raise ValueError("Shape Boolean fragment produced no filled area") + components.sort(key=_path_sort_key) + return components + + +def _connected_components(path: Any, pathops: Any) -> list[Any]: + contours = [ + contour + for contour in path.contours + if not _path_is_empty(contour) + ] + if len(contours) <= 1: + return contours + + parents: list[int | None] = [None] * len(contours) + for child_index, child in enumerate(contours): + child_bounds = child.bounds + child_points = child.firstPoints + if child_bounds is None or not child_points: + continue + candidates: list[tuple[float, int]] = [] + for parent_index, candidate in enumerate(contours): + if child_index == parent_index: + continue + candidate_bounds = candidate.bounds + if candidate_bounds is None: + continue + if not _bounds_contain(candidate_bounds, child_bounds): + continue + if candidate.area <= child.area: + continue + if candidate.contains(child_points[0]): + candidates.append((candidate.area, parent_index)) + if candidates: + parents[child_index] = min(candidates)[1] + + depths = [ + _contour_depth(index, parents) + for index in range(len(contours)) + ] + components: list[Any] = [] + for index, contour in enumerate(contours): + if depths[index] % 2: + continue + component = pathops.Path(fillType=pathops.FillType.WINDING) + component.addPath(contour) + for child_index, parent_index in enumerate(parents): + if parent_index == index and depths[child_index] % 2: + component.addPath(contours[child_index]) + components.append(component) + return components + + +def _bounds_contain( + outer: tuple[float, float, float, float], + inner: tuple[float, float, float, float], +) -> bool: + scale = max( + *(abs(value) for value in outer), + *(abs(value) for value in inner), + 1.0, + ) + tolerance = scale * 1e-7 + return ( + outer[0] <= inner[0] + tolerance + and outer[1] <= inner[1] + tolerance + and outer[2] >= inner[2] - tolerance + and outer[3] >= inner[3] - tolerance + ) + + +def _contour_depth( + index: int, + parents: Sequence[int | None], +) -> int: + depth = 0 + seen = {index} + parent = parents[index] + while parent is not None: + if parent in seen: + raise ValueError("Shape Boolean fragment produced cyclic contours") + seen.add(parent) + depth += 1 + parent = parents[parent] + return depth + + +def _path_is_empty(path: Any) -> bool: + bounds = path.bounds + if bounds is None or len(path) == 0: + return True + width = bounds[2] - bounds[0] + height = bounds[3] - bounds[1] + scale = max(abs(width), abs(height), 1.0) + return ( + width <= 0 + or height <= 0 + or not math.isfinite(path.area) + or path.area <= scale * scale * 1e-12 + ) + + +def _path_sort_key( + path: Any, +) -> tuple[ + float, + float, + float, + float, + float, + tuple[tuple[str, tuple[float, ...]], ...], +]: + bounds = path.bounds + signature = tuple( + ( + str(verb), + tuple( + round(coordinate, _RESULT_PRECISION) + for point in points + for coordinate in point + ), + ) + for verb, points in path + ) + if bounds is None: + return ( + math.inf, + math.inf, + math.inf, + math.inf, + math.inf, + signature, + ) + return ( + round(bounds[1], _RESULT_PRECISION), + round(bounds[0], _RESULT_PRECISION), + round(bounds[3], _RESULT_PRECISION), + round(bounds[2], _RESULT_PRECISION), + round(path.area, _RESULT_PRECISION), + signature, + ) + + +def _effective_style( + element: ET.Element, + parents: Mapping[ET.Element, ET.Element], +) -> dict[str, str]: + values: dict[str, str] = {} + opacity = 1.0 + parent_opacity = 1.0 + chain = _element_chain(element, parents) + for index, current in enumerate(chain): + inline = parse_inline_style(current.get("style")) + raw_opacity = inline.get("opacity", current.get("opacity")) + if raw_opacity is None: + local_opacity = 1.0 + elif raw_opacity.strip() == "inherit": + local_opacity = parent_opacity + else: + local_opacity = parse_opacity(raw_opacity) + if index > 0: + opacity *= local_opacity + parent_opacity = local_opacity + for name in _PRESENTATION_ATTRS: + if name == "opacity": + continue + raw = inline.get(name, current.get(name)) + if raw is not None and raw.strip() != "inherit": + values[name] = raw.strip() + if opacity < 1.0: + values["opacity"] = _format_number(opacity) + return { + name: values[name] + for name in _PRESENTATION_ATTRS + if name in values + } + + +def _serialize_result_path( + path: Any, + result_id: str, + style: Mapping[str, str], + pathops: Any, +) -> str: + path_data = _pathops_to_svg_path(path, pathops) + attributes = { + "id": result_id, + "d": path_data, + **style, + } + return f"" + + +def _pathops_to_svg_path(path: Any, pathops: Any) -> str: + path.convertConicsToQuads(0.05) + parts: list[str] = [] + current: tuple[float, float] | None = None + subpath_start: tuple[float, float] | None = None + open_subpath = False + + for verb, points in path: + if verb == pathops.PathVerb.MOVE: + point = points[0] + parts.append( + f"M {_format_number(point[0])} {_format_number(point[1])}" + ) + current = point + subpath_start = point + open_subpath = True + elif verb == pathops.PathVerb.LINE: + point = points[0] + parts.append( + f"L {_format_number(point[0])} {_format_number(point[1])}" + ) + current = point + elif verb == pathops.PathVerb.QUAD: + if current is None: + raise ValueError("Shape Boolean result quadratic has no start point") + control, end = points + control_1 = ( + current[0] + (control[0] - current[0]) * 2.0 / 3.0, + current[1] + (control[1] - current[1]) * 2.0 / 3.0, + ) + control_2 = ( + end[0] + (control[0] - end[0]) * 2.0 / 3.0, + end[1] + (control[1] - end[1]) * 2.0 / 3.0, + ) + parts.append( + "C " + f"{_format_point(control_1)} " + f"{_format_point(control_2)} " + f"{_format_point(end)}" + ) + current = end + elif verb == pathops.PathVerb.CUBIC: + control_1, control_2, end = points + parts.append( + "C " + f"{_format_point(control_1)} " + f"{_format_point(control_2)} " + f"{_format_point(end)}" + ) + current = end + elif verb == pathops.PathVerb.CLOSE: + parts.append("Z") + current = subpath_start + open_subpath = False + else: + raise ValueError(f"Unsupported skia-pathops result verb: {verb}") + + if open_subpath: + raise ValueError("Shape Boolean engine returned an open contour") + if not parts: + raise ValueError("Shape Boolean engine returned no path commands") + return " ".join(parts) + + +def _format_point(point: tuple[float, float]) -> str: + return f"{_format_number(point[0])} {_format_number(point[1])}" + + +def _format_number(value: float) -> str: + if not math.isfinite(value): + raise ValueError("Shape Boolean result contains a non-finite coordinate") + rounded = round(value, _RESULT_PRECISION) + if abs(rounded) < 10 ** (-_RESULT_PRECISION): + return "0" + if rounded == int(rounded): + return str(int(rounded)) + return f"{rounded:.{_RESULT_PRECISION}f}".rstrip("0").rstrip(".") + + +def _local_name(tag: object) -> str: + raw = str(tag) + return raw.rsplit("}", 1)[-1] if "}" in raw else raw + + +def _element_label(element: ET.Element) -> str: + tag = _local_name(element.tag) + element_id = element.get("id") + return f"<{tag} id={element_id!r}>" if element_id else f"<{tag}>" diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/applier.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/applier.py index e6a4f1b9..7285f506 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/applier.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/applier.py @@ -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, ) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/cli.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/cli.py index 885d1e93..d2125d34 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/cli.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/cli.py @@ -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." ), diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/transitions.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/transitions.py index 868bd463..3ba86623 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/transitions.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/template_fill_pptx/transitions.py @@ -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 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_motion_plan.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_motion_plan.py index a5b6b905..c78215b4 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_motion_plan.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_motion_plan.py @@ -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, diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/generate-pptx.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/generate-pptx.md index dd41e674..b51bb856 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/generate-pptx.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/generate-pptx.md @@ -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 1–7 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 1–7. + +**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/.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 --live --daemon **Visual Construction Phase**: generate SVG pages sequentially, one at a time, in one continuous pass → `/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. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/native-enhance-pptx.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/native-enhance-pptx.md index b1f776ab..8071cfc4 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/native-enhance-pptx.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/native-enhance-pptx.md @@ -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 ` 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 `/analysis/enhancement_plan.json`: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/profiles/quick-test.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/profiles/quick-test.md new file mode 100644 index 00000000..a14df386 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/profiles/quick-test.md @@ -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 1–7. + +**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 +/ +├── svg_output/ +│ └── .svg +└── exports/ + └── _.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 --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 .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 +``` diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/routing.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/routing.md index 40371439..eeff3437 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/routing.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/routing.md @@ -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 1–7 | | 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 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/stages/customize-animations.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/stages/customize-animations.md index 8b05e1f7..b07828da 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/stages/customize-animations.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/stages/customize-animations.md @@ -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..transition` | Control how one slide enters from the previous slide | -| Page animation defaults | `defaults.animation` or `slides..animation` | Control the default entrance behavior for animated groups on a slide | +| Page animation defaults | `defaults.animation` or `slides..animation` | Control the default object-animation behavior for animated groups on a slide | | Object overrides | `slides..groups.` | 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 ` +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..effect` | Object-specific entrance effect, `auto`, `mixed`, `random`, or `none` | +| `groups..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 +` 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 -``` - -```bash python3 skills/ppt-master/scripts/finalize_svg.py -``` - -```bash python3 skills/ppt-master/scripts/svg_to_pptx.py ``` @@ -359,15 +336,17 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py 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 `` 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 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/template-fill-pptx.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/template-fill-pptx.md index 2f50801c..a33c22ec 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/template-fill-pptx.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/workflows/template-fill-pptx.md @@ -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 "/sources/" "/analysis/fill_plan.json" -o "/exports/" ``` -By default `apply` gives every cloned slide a `fade` transition (`0.5s`), preserving the v1 route contract. Override it with `--transition ` (`fade` / `push` / `wipe` / `split` / `strips` / `cover` / `random`) and `--transition-duration `; 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 ` using a canonical gallery effect from [`animations.md`](../references/animations.md) §3 and `--transition-duration `; 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 "/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 | diff --git a/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json index 69bd526c..75aea06e 100644 --- a/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json @@ -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" } diff --git a/plugins/codex/plugins/ui-ux-pro-max/README.md b/plugins/codex/plugins/ui-ux-pro-max/README.md index 81e14885..27f53f5a 100755 --- a/plugins/codex/plugins/ui-ux-pro-max/README.md +++ b/plugins/codex/plugins/ui-ux-pro-max/README.md @@ -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" diff --git a/plugins/codex/plugins/ui-ux-pro-max/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ui-ux-pro-max/THIRD_PARTY_SOURCE.json index 125202f7..689d53a1 100644 --- a/plugins/codex/plugins/ui-ux-pro-max/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/ui-ux-pro-max/THIRD_PARTY_SOURCE.json @@ -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" }