diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index 0cb6a15e..465a3e98 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": "3b5df7547964f0cb3424de74cff55b69039250d3", - "syncedAt": "2026-07-27T16:00:00Z" + "commit": "4857a2c5ef989794751a0f66b8545a4a49566286", + "syncedAt": "2026-07-28T15:59:58Z" }, { "id": "caveman", @@ -60,8 +60,8 @@ "repo": "https://github.com/shadcn-ui/ui.git", "ref": "main", "adapter": "claude-skill", - "commit": "bf906bb8aeebc64d374afb54497b822d587ac6d7", - "syncedAt": "2026-07-27T16:00:00Z" + "commit": "47c7f92dbc4dd22a29982986458787000c4e7bc1", + "syncedAt": "2026-07-28T15:59:58Z" }, { "id": "frontend-slides", @@ -96,8 +96,8 @@ "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", "adapter": "claude-skill", - "commit": "5260a14f8467e52d7fc945c5efead3e2090f0f86", - "syncedAt": "2026-07-27T16:00:00Z" + "commit": "cbb6bf9917efb18d787e510fc41a5e9e388bbdf8", + "syncedAt": "2026-07-28T15:59:58Z" }, { "id": "next-skills", @@ -105,8 +105,8 @@ "repo": "https://github.com/vercel/next.js.git", "ref": "canary", "adapter": "skill-collection", - "commit": "1f65c7646eb57660e4eb38899b8197346d0d93c1", - "syncedAt": "2026-07-27T16:00:00Z" + "commit": "ad618bf13fbc4be57d6b6136a20547af50708eb2", + "syncedAt": "2026-07-28T15:59:58Z" } ] } diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index 098f87f8..a32bc266 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-27T16:02:46Z" + "syncedAt": "2026-07-28T16:02:23Z" } diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index e546e5d9..b3bd1f13 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": "1f65c7646eb57660e4eb38899b8197346d0d93c1", + "commit": "ad618bf13fbc4be57d6b6136a20547af50708eb2", "adapter": "skill-collection", "sourcePath": "skills", - "syncedAt": "2026-07-27T16:00:00Z" + "syncedAt": "2026-07-28T15:59:58Z" } diff --git a/plugins/codex/plugins/ppt-master/README.md b/plugins/codex/plugins/ppt-master/README.md index 2dd4cc3c..14a77641 100644 --- a/plugins/codex/plugins/ppt-master/README.md +++ b/plugins/codex/plugins/ppt-master/README.md @@ -13,7 +13,7 @@ English | [中文](./README_CN.md)
-This project is kept free and open source with the support of Kimi, PackyCode, APIKEY.FUN, RunAPI, YouYun ZhiSuan and other sponsors. +This project is kept free and open source with the support of Kimi, PackyCode, APIKEY.FUN, RunAPI, YouYun ZhiSuan and other sponsors.

Kimi @@ -27,8 +27,8 @@ Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring this - - + + @@ -235,7 +235,7 @@ Never used one of these? Don't worry — in this project they play exactly one r > **Model recommendation**: for the best results, use **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)** (or Claude) to drive the pipeline, paired with AI image generation — **`gpt-image-2`** (OpenAI) or **`gemini-3.1-flash-image`** (Google). Kimi Code, the project sponsor, is a great pick for pay-as-you-go access. -**🔑 Want to use Claude / GPT / Gemini but don't have access yet?** Project sponsors **[PackyCode](https://www.packyapi.com/register?aff=ppt-master)**, **[APIKEY.FUN](https://apikey.fun/register?aff=PPT-MASTER)** and **[RunAPI](https://runapi.co/register?aff=WMLJ)** offer pay-as-you-go access to Claude, GPT, Gemini and more — no subscription required, with exclusive discounts for our users (details at the top of this page). +**🔑 Want to use Claude / GPT / Gemini but don't have access yet?** Project sponsors **[PackyCode](https://www.packyapi.ai/register?aff=ppt-master)**, **[APIKEY.FUN](https://apikey.fun/register?aff=PPT-MASTER)** and **[RunAPI](https://runapi.co/register?aff=WMLJ)** offer pay-as-you-go access to Claude, GPT, Gemini and more — no subscription required, with exclusive discounts for our users (details at the top of this page). **🔀 Juggling several providers?** Once you hold keys from more than one of them, [cc-switch](https://github.com/farion1231/cc-switch) — a cross-platform desktop app — lets you one-click switch API providers for Claude Code, Codex, Gemini CLI and more, no manual config editing. @@ -399,7 +399,7 @@ PPT Master is currently built and maintained primarily by me. Every new template Kimi   -PackyCode +PackyCode   APIKEY.FUN   diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json index e633d879..a3bb2f2b 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": "5260a14f8467e52d7fc945c5efead3e2090f0f86", + "commit": "cbb6bf9917efb18d787e510fc41a5e9e388bbdf8", "adapter": "claude-skill", "sourcePath": "skills/ppt-master", - "syncedAt": "2026-07-27T16:00:00Z" + "syncedAt": "2026-07-28T15:59:58Z" } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS.md index 29282a7a..d8865e77 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS.md @@ -24,9 +24,9 @@ Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring PPT M ### PackyCode -PackyCode +PackyCode -[PackyCode](https://www.packyapi.com/register?aff=ppt-master) provides relay access to Claude Code, Codex, Gemini, and other services. Register through the dedicated link and enter the promo code **`ppt-master`** during recharge to receive 10% off. +[PackyCode](https://www.packyapi.ai/register?aff=ppt-master) provides relay access to Claude Code, Codex, Gemini, and other services. Register through the dedicated link and enter the promo code **`ppt-master`** during recharge to receive 10% off. ### APIKEY.FUN diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS_CN.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS_CN.md index 4ec96a94..ec735218 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS_CN.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/SPONSORS_CN.md @@ -24,9 +24,9 @@ PPT Master 始终免费开源。以下赞助方共同支持项目的持续维护 ### PackyCode -PackyCode +PackyCode -[PackyCode](https://www.packyapi.com/register?aff=ppt-master) 提供 Claude Code、Codex、Gemini 等服务的中转接入。通过专属链接注册,并在充值时填写优惠码 **`ppt-master`**,即可享受 9 折优惠。 +[PackyCode](https://www.packyapi.ai/register?aff=ppt-master) 提供 Claude Code、Codex、Gemini 等服务的中转接入。通过专属链接注册,并在充值时填写优惠码 **`ppt-master`**,即可享受 9 折优惠。 ### APIKEY.FUN 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 3b4b7401..783315b1 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,8 +1,9 @@ # Page Transitions & Per-Element Animations Execution contract for generated-PPTX **page transitions** and **per-element -object animations**. This file owns defaults, sidecar semantics, anchor -selection, validation, and package read-back. +object animations**, including deterministic Morph object pairing. This file +owns defaults, sidecar semantics, anchor selection, validation, and package +read-back. ## Capability Menu — Open Here @@ -13,19 +14,19 @@ 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 continuous action — slide-in, flip, camera push-in, progressive reveal, camera pan | **Morph: author the action as two static pages, then select Morph and add explicit pairs when identity must be deterministic.** There is no keyframe timeline anywhere in this pipeline; the difference between two ordinary editable slides *is* the animation | **Page authoring (Step 6), then motion post-processing** — §2.1, §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. +**Hard rule — Morph geometry is an authoring decision; pairing is a later +execution decision**: export cannot invent the two visible endpoint states. +Author both consecutive pages while `svg_output/` is still being built. For +deterministic identity, expose each endpoint as a compatible direct-root group +and declare the pair in `animations.json` (§2.1); the source and destination ids +and geometry may differ. `-t morph` without explicit pairs leaves matching to +PowerPoint's heuristic and is not proof that the intended objects will tween. **Reference — not a constraint**: per-element animation stays off by default (§1). Auto-firing element builds on every page are an unsolicited "AI deck" @@ -48,11 +49,15 @@ To regenerate a deck with different settings, rerun `svg_to_pptx.py` against the Per-element animation is off by default. To enable it deck-wide, pass `-a auto` at export (no config needed). When a deck instead needs specific object timing — for example title first, chart second, annotation last — use the optional `animations.json` sidecar. The SVG remains the visual source; the custom stage may rewrite its grouping hierarchy, ids, and bounds to create better semantic anchors without changing visible output, while the sidecar controls PPTX animation behavior. -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. +Run the [`customize-animations`](../workflows/stages/customize-animations.md) +post-processing stage when Design Spec §IX contains `Motion suggestion`, or +when the project already carries `animations.json`, or when the user asks to +tune animation order, effects, timing, or object-level reveals. -**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. +**Hard rule — semantic anchors before object-targeted sidecar entries**: when +object animation is in scope, 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 object targets. ```bash # Inspect the real anchors after the semantic regrouping pass @@ -114,7 +119,8 @@ Rules: | `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 | + | `font_name` | Change Font; required for `emphasis_change_font`; one installed PowerPoint face, not a CSS list | + | `size` | 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`, @@ -138,11 +144,80 @@ Rules: - Unknown effects, modes, or triggers and invalid numeric/order fields fail validation; no fallback effect is substituted. **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. +`defaults.transition` / `defaults.animation`, then CLI resolution. Explicit CLI +flags override the corresponding sidecar default/slide fields; explicit group +overrides remain unless `-a none` hard-disables all object motion. 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. + +### 2.1 Deterministic Morph Object Pairing + +When one semantic object continues across two adjacent slides, the destination +slide may declare explicit forced-Morph pairs. This is separate from `groups`: +Morph owns cross-slide identity, while `groups` owns Animation Pane rows. +The generated names follow Microsoft's +[forced object-matching convention](https://support.microsoft.com/en-us/powerpoint/morph-transition-tips-and-tricks). + +```json +{ + "version": 1, + "defaults": { + "transition": { "effect": "fade", "duration": 0.4 }, + "animation": { "effect": "none", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" } + }, + "slides": { + "01_overview": { + "transition": { "effect": "fade", "duration": 0.4 }, + "animation": { "effect": "none", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" } + }, + "02_detail": { + "transition": { + "effect": "morph", + "effect_options": { "morph_by": "object" }, + "duration": 0.8 + }, + "animation": { "effect": "none", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" }, + "morph": { + "from": "01_overview", + "pairs": { + "hero-image": { + "from": "hero-overview", + "to": "hero-detail" + } + } + } + } + } +} +``` + +- `morph` belongs to the destination slide. `morph.from` must be the + immediately preceding SVG stem in export order. +- `animation_config.py scaffold` never guesses cross-slide identity. Add pairs + from the semantic motion plan after inspecting the final direct-root ids. +- Each `pairs` key is a stable identity; its `from` and `to` values are unique + direct-root `` values on the source and destination slides. Supply the + key without `!!`; export writes the PowerPoint Selection Pane name + `!!` on both objects. +- A destination with explicit pairs must explicitly set `effect: morph`. + `morph_by` may be omitted for its `object` default or set to `object`; + `word`/`character` are rejected. A CLI transition override that changes the + resolved effect fails export. +- A middle slide may continue the same object into another Morph transition, + but the same group must retain the same key. One key cannot name two objects + on one slide, and one object cannot carry two keys. Every `!!` key shared by + two adjacent Morph pages must be declared in that destination's `pairs`; + undeclared forced matches are rejected. +- Explicit pairing can coexist with in-slide object animation and remains + active when `-a none` disables Animation Pane rows. `--no-animations` + disables the sidecar and all page/object motion. +- The exporter resolves both group ids to final Slide-local PowerPoint shapes, + writes names only after Master/Layout processing, then reopens the package + and verifies adjacency, Morph by object, one name per slide, and matching + OOXML object types. Missing, structural, moved, ambiguous, or mismatched + targets fail instead of falling back to automatic Morph matching. --- @@ -193,7 +268,7 @@ Flags: ### 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. +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 a Morph transition**, with no keyframe timeline anywhere. Duplicate the page, change one property on one object, and PowerPoint interpolates the rest. Use §2.1 explicit pairs when the match must be deterministic. | Change between the two pages | Reads as | |---|---| @@ -205,11 +280,22 @@ Morph tweens objects it can match across consecutive slides. That makes it a gen 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". +**Hard rule — matching needs compatible object identity, not identical SVG +geometry**: for generated decks, prefer §2.1 deterministic pairs. The source +and destination direct-root group ids may differ, and position, size, crop, or +other visible state is expected to change; both endpoints must still resolve to +one compatible top-level PowerPoint object kind. Automatic Morph without pairs +is heuristic and may cross-fade instead of tweening. -**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. +**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 is to 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 and be explicitly paired when deterministic identity matters. -**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. +**When Morph refuses to match**: PowerPoint pairs compatible object kinds; a +shape and a picture will cross-fade instead of tweening. For generated pages, +declare the identity through the destination slide's `morph` block (§2.1). +The exporter writes the shared `!!` name after structure processing and +reads the package back. Do not author `data-pptx-shape-name` for this purpose; +that attribute remains importer metadata for mirror/preserve packages +([`svg-effects.md`](./svg-effects.md) §6.6). **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. @@ -301,9 +387,9 @@ It pairs naturally with a fixed foreground: with image-layout-patterns `#90`, th 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. +**Carousel** (Morph, §2.1 and §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. Explicitly pair each moving content unit across adjacent pages; the fixed frames stay static and need no pair. 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. +**Odometer / counting numerals** (morph or motion path) — build a vertical strip of digits 0–9 and show one through a fixed window formed by background-filled rectangles 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. @@ -350,6 +436,9 @@ 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. +Deterministic Morph additionally checks the final adjacent slide parts for the +requested `!!` names, one-to-one uniqueness, compatible object types, and a +real Morph-by-object transition on the destination. 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. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/artifact-ownership.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/artifact-ownership.md index 784cfe1b..d2d6feb3 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/artifact-ownership.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/artifact-ownership.md @@ -10,7 +10,7 @@ Global artifact ownership rules for PPT Master projects. | Artifact | Owner | Role | Read/write contract | |---|---|---|---| -| `sources/` content-type files | Content contract | Main pipeline factual/text origin for tables, chart data values, SmartArt node wording, and presentation content | Strategist reads content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`), judges by content, and resolves approved on-slide wording into §IX. Executor opens source passages only for explicit verification/resolution; do not replace values with PPTX geometry JSON in the main pipeline. | +| `sources/` content-type files | Content contract | Main pipeline factual/text origin for tables, chart data values, SmartArt node wording, and presentation content | Strategist reads content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`), judges by content, and resolves approved semantic content plus complete preferred on-slide wording into §IX. Executor opens source passages only for explicit verification/resolution; do not replace values with PPTX geometry JSON in the main pipeline. | | `sources/*.facts.json` | Fact provenance contract | Stable external `fact_id` → claim/source mapping created by topic research | Strategist cites IDs in §IX; Executor resolves them for visible footnotes / natural notes attribution. Scenario data never enters this file. | | `sources/` converted-source originals | Source archive | Imported source files that have a converted content contract (`.pdf` / `.pptx` / `.docx` / `.xlsx` / `.html` / `.epub` / `.tex` / `.rst` / `.ipynb` / `.typ`, etc.) and source-adjacent extracted assets | Read via the converted `.md` in the main pipeline; direct-PPTX workflows read the `.pptx` by route | | `sources/*.conversion_profile.json`, `sources/*_files/image_manifest.json` | Pipeline sidecar | Conversion audit record / asset index | NOT read as slide content; open only to audit a conversion or resolve assets | @@ -43,7 +43,7 @@ Global artifact ownership rules for PPT Master projects. | `validation/.report.json` | Published-package audit | PPTX package/resource postflight status, part counts, and quality-gate linkage | Step 7.3 writes after the PPTX passes package validation and emits a compact `[POSTFLIGHT]` receipt. Agents use the receipt on routine success and keep the full JSON cold unless targeted failure/audit evidence is required. | | `exports/` | Delivery artifacts | Native DrawingML PPTX and explicit native-object/narration variants | Step 7.3 writes only final deliverables from `svg_output/`. | | `backup//svg_output/` | Frozen author-source archive | Re-export source without re-running LLM | `svg_to_pptx.py` writes a snapshot during export | -| `animations.json` | Optional animation config | Object-level animation sidecar | Created only by explicit animation workflow/request | +| `animations.json` | Optional animation config | Page-transition and object-animation sidecar | Created only when the conditional animation workflow activates; normal export never creates it | --- @@ -51,7 +51,7 @@ Global artifact ownership rules for PPT Master projects. | Invariant | Rule | |---|---| -| Content authority | Content-type files in `sources/` (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) own the factual/text origin for main-pipeline content, tables, chart values, and SmartArt node wording; Strategist resolves approved on-slide wording into §IX. Executor renders §IX and opens sources only for explicit verification/resolution, never to draft a second outline. `slide_library.json` does not own content values. | +| Content authority | Content-type files in `sources/` (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) own the factual/text origin for main-pipeline content, tables, chart values, and SmartArt node wording; Strategist resolves approved semantic content plus complete preferred on-slide wording into §IX. Executor realizes §IX under [`executor-base.md`](./executor-base.md) §2.1's content-vs-expression contract and opens sources only for explicit verification/resolution, never to draft a second outline. `slide_library.json` does not own content values. | | Sources read policy | In `sources/`, read content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) and judge by content — a `.json` / `.csv` may be core content or just data. Exclude known sidecars: `*.conversion_profile.json` and `*_files/image_manifest.json`. `analysis/` facts (`source_profile.json`, `.slide_library.json`) are read per Step 4 / direct-PPTX workflow, not in the `sources/` content scan. | | PPTX structure | `slide_library.json` owns native geometry, slot facts, and SmartArt layout/relationships for direct PPTX workflows. | | Design contract | Final confirmation once → audited `design_spec.md` → optional same-file refinement/approval → context-authored lock. Never maintain a parallel draft/lock. Executor may apply `Template Application` prose but never replace identity. Repair divergence from the approved Design Spec/context unless it fails active-decision fidelity. | 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 b195683c..840b00f6 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 @@ -35,12 +35,12 @@ Always-loaded Executor authority for flat SVG page authoring and behavior shared | Elevation | shadow, glow, explicit highlights | | 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 | +| Space / constructed style | transform/reuse, hand-drawn, ink/Riso, halftone, isometric, paper cut; custom curves/arcs only when meaning or the locked style requires them | | 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. +**Default — resolve cross-page geometry here, while pages are still being authored (may override when the deck has no continuous action to express)**: object effects, page transitions, and Morph pair keys are post-processing decisions, but the two visible endpoint states are not. A sequence that should read as one continuous action (slide-in, flip, camera push-in, progressive reveal, camera pan) must be authored as consecutive pages in `svg_output/` now. Give each continuing endpoint a compatible direct-root group; source and destination ids or geometry may differ because the later motion stage can bind them explicitly through `animations.json`. A deck that reaches export without both states cannot gain the motion by adding a flag. Adding pages is a §IX roster change and returns to Strategist for Design Spec repair first. --- @@ -66,7 +66,9 @@ Consume stdout directly; stop on non-zero exit. The projection is derived, not a **Hard rule — exact page roster**: `design_spec.md §IX` is the ordered queue: one final slide per entry, with the same id/order. The UI range no longer applies. Never add, drop, merge, split, or reorder; repair/reconfirm the Design Spec first. -**Hard rule — selection vs realization**: use Strategist-selected content, resources/paths, chart/layout keys, core fonts, palette anchors, icons, and crop boundaries. Adapt realization, never selection, except sparse local font/color garnish allowed below. Missing or unresolved material stops execution and returns to Strategist-owned acquisition/failure recovery; never search, generate, download, sync, invent, or substitute it. Selection changes require upstream repair. +**Hard rule — binding selection vs realization**: use Strategist-selected semantic content, resources/paths, chart keys, template/layout routing keys, core fonts, palette anchors, icons, and crop boundaries. Adapt realization, never those binding selections, except sparse local font/color garnish allowed below. A §VIII preferred image pattern is not a template/layout routing key; [`executor-image.md`](./executor-image.md) owns its realization freedom. Missing or unresolved material stops execution and returns to Strategist-owned acquisition/failure recovery; never search, generate, download, sync, invent, or substitute it. Binding selection changes require upstream repair. + +**Hard rule — content vs expression**: `design_spec.md §IX` owns each page's semantic content and supplies complete preferred wording and block texture; those expression choices are not verbatim requirements unless explicitly literal. Executor may paraphrase, condense repetition, regroup or reorder material within the same page, and switch among prose, bullets, keywords, labels, or visual annotation when fit or readability benefits. The result must remain information-equivalent: preserve the `Core message`, `Audience move`, and every substantive claim, fact, data value, proper name, qualifier or caveat, relationship, key argument or evidence, and literal requirement. Never add a claim, move content across pages, or drop information to make the layout fit; return an unfit or underspecified block for Design Spec repair. Use named lock roles literally when that role applies, and use optional `Template Application` from the retained Design Spec. Choose contextual page-local values from the Design Spec, style, content, and current composition rather than forcing every object into a lock row. A page-context delta overrides neither facts nor constraints. Deprecated `page-context --bundle` is a compatibility no-op. @@ -82,12 +84,12 @@ Use named lock roles literally when that role applies, and use optional `Templat | `balanced` | Keep the primary claim and its evidence on the page; let notes add interpretation and transitions. Mix prose, structured evidence, and necessary lists according to their semantic relationship. | | `presentation` | Make one claim and one dominant visual expression legible at projection distance. Keep visible copy concise; put explanation and transitions in notes instead of creating paragraph dumps or compressed bullet prose. | -§IX and sourced facts remain authoritative. Use its wording when it works; adapt it when presentation benefits while preserving intent, necessary content, and explicit literal requirements. Never drop or invent facts to force a mode. When the authored texture materially conflicts with the lock, render the least-destructive faithful composition and surface `warning: P content texture conflicts with consumption_mode ` as an upstream outline issue; do not encode this subjective judgment in the checker. +Apply the content-vs-expression contract above within the selected reading mode. Never drop or invent facts to force a mode. When the authored texture materially conflicts with the lock, render the least-destructive faithful composition and surface `warning: P content texture conflicts with consumption_mode ` as an upstream outline issue; do not encode this subjective judgment in the checker. -**Per-block expression**: render each `design_spec.md §IX Content` block in its written texture — a full-sentence block as wrapped prose, a fragment/label block as bullets/keywords. **Never split a full-sentence block into a bullet list** — splitting loses the information that the block was continuous reasoning, not a set of parallel points; not because a bullet lays out easier, and not because an inherited template slot is shaped as a list. If a block carries no clear texture, infer the mode from its wording and the page layout. +**Default — authored texture (may override when information-equivalent)**: start from each `design_spec.md §IX Content` block's written texture because it is the Strategist's recommended expression. Keep prose when its continuity carries causal, argumentative, narrative, qualification, or emphasis relationships; use bullets or keywords when the material is genuinely parallel or ordered, or another information-equivalent structure is clearer. Never convert solely because a list is easier to lay out or an inherited template exposes a list slot. - **Hard rule — one paragraph, one text frame**: use one `` per prose paragraph, never one sibling `` per visual line. Keep the first line as direct text; each later wrap is a direct `` that repeats the parent `x`, keeps its effective font size, and uses one positive relative `dy`. An all-`` form may start with `dy="0"`. Choose consistent positive line spacing from the typeface, size, density, and reading distance; no fixed ratio overrides legibility or the selected style. -- **Template precedence**: when an inherited template slot is a bullet list but the §IX block is prose, the prose wins — widen or reflow the container to hold the paragraph, or drop that card; do not pour the sentence back into the list slot. +- **Template precedence**: an inherited slot never overrides the content relationship. If faithful expression needs prose, widen or reflow the container, or drop that card; never convert solely to fill a list slot. - **Mode precedence**: the locked mode shapes voice / register, not §IX's authored titles or page order. When a `§IX` title is a user-authored topic label, keep it — do not upgrade it to an assertion just because the mode (e.g. `pyramid`) favors them; mode title-tendencies apply only to AI-drafted titles. > Note: block-level phrasing, applied *within* the page's `page_rhythm` density (below), not against it. @@ -143,55 +145,80 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P` - **Fact provenance**: when a §IX page lists `Fact IDs`, resolve each ID from `sources/*.facts.json` and keep the claim/value unchanged. Render a compact source footnote using the source name and a short URL/domain when space permits; state the attribution naturally in speaker notes. When §IX says `Data class: scenario`, place a visible localized `Scenario data` / `情景数据` label adjacent to the affected KPI/chart and state naturally in notes that the number is illustrative. Never attach an external fact ID to scenario data or let an unlabeled invented KPI look factual. - **Default — stage each page with the style's composition geometry (may override when the content genuinely calls for a plain grid)**: an SVG page is a canvas, not a DOM. Before defaulting to stacked rounded-rect cards or uniform equal columns, pick one page-scale move from the locked visual style's §1 `Composition geometry` (a bleed shape, diagonal split, oversized numeral, orbit rings, …) to stage the page's primary zone. Card grids are one option among many, not the house layout. - **Containers are structural**: cards and grids express grouping, hierarchy, or capacity, not a house style. Preserve meaningful template frames; restyle radius, fill, stroke, and depth from the active Design Spec and `spec_lock.md`. Chart-catalog adaptation is owned by [`executor-chart.md`](./executor-chart.md); preview effects never override project styling or structural roles. -- **Reference — prefer semantic geometry over preset stacks**: for relationships such as ascending, converging, breaking through, or stacking, consider one page-specific polygon/path that expresses the relationship before stacking generic arrows. This does not override §3.0 when one literal stock shape is the semantic object. +- **Reference — prefer semantic geometry over preset stacks**: for relationships such as ascending, converging, breaking through, or stacking, first seek a basic primitive, one exact preset, or a clear Boolean result. Only when none can faithfully express the relationship should one page-specific polygon/path replace a stack of generic arrows. - **Reference — create depth with restraint**: use rhythm, spacing, typography, accent bars, and subtle tints before shadows. Reserve lift for a few genuinely floating elements; keep peer grids, dividers, and body containers flat. - **Phased generation** (recommended): 1. **Visual Construction Phase**: generate all SVG pages sequentially for visual consistency. Use layout judgment for chart marks during the draft. **MUST embed plot-area markers** per [`executor-chart.md`](./executor-chart.md) §2.1 on every §IX-planned data-chart page — coordinate calibration is a post-generation step (see [`verify-charts`](../workflows/stages/verify-charts.md)) that depends on these markers — and **native object metadata** per [`executor-chart.md`](./executor-chart.md) §2.2 on every planned native-ready object. **Reach for native presets** per §3.0 as you draw each page: a block arrow, chevron, banner/ribbon, callout, standard flowchart node, or star is authored through `preset_shape_svg.py` at draw time — decided by the object's intent as you create it, never by scanning finished paths, and never committed to a bare ``/`` when a preset expresses it (a gradient fill/stroke or a pattern fill is the one paint exception — keep those ordinary SVG). **First-page gate (Mandatory)**: after completing the first page, run `python3 scripts/svg_quality_checker.py --stage first-page --json` without output filtering. Review the whole P01 issue set, make one consolidated edit pass for every error and any selected warnings, then perform one verification rerun. If it still fails, treat that complete output as the next batch; never check between individual fixes. After it passes, draw P02 through the last page without checker calls. 2. **Quality Check Gate**: only after every planned SVG exists, run `python3 scripts/svg_quality_checker.py --stage final --json` on `svg_output/` without `tail` / `head` / `grep` filtering. One run already reports all pages. Review its complete issue set, fix every `error` plus any selected advisory warnings in one consolidated edit pass, then perform one verification rerun. If it still fails, its complete output begins the next batch cycle; never use checker calls to discover or fix one next issue at a time. Every `warning` is advisory: it never sends the page back for required modification, never authorizes automatic rewriting of compatible user syntax, and needs no acknowledgement/disposition line. Recommendation warnings describe the generated-SVG default; fidelity/quality warnings may be surfaced when material, while the existing input remains releasable. Prototype-identical diagnostics are recorded as `inherited`, source conversion losses as `source-import`, changed/new advisories as `introduced`, and release failures as `blocking` in `validation/svg_quality_report.json`. If release truly depends on a condition, it belongs in `errors`. On success, use the exit status and terminal summary; do not open or `cat` the complete JSON into model context. If terminal output is truncated on failure, read only the relevant issue arrays from the report written by that same run. Do NOT defer error handling to after `finalize_svg.py` — finalize rewrites SVG and masks some violations. 3. **Logic Construction Phase**: after SVGs pass the quality check, batch-generate speaker notes for narrative continuity. -### 3.0 Native Preset Shape Selection +### 3.0 Native Shape Selection -**Reach for a native preset whenever one expresses a complete object — this is -the default, not the exception.** Block arrows, chevrons, banners / ribbons, -callouts, flowchart nodes, stars, and other Office symbols should be **authored -as presets** via `preset_shape_svg.py`, not drawn as plain ``s or faked -with rectangles: presets are what give the slide real PowerPoint shapes with -adjustment handles and the designed, non-flat-card look. When a page calls for -one of these, use the preset. Apply the decision gate in -[`native-shape-authoring.md`](./native-shape-authoring.md) to pick the right -shape and to keep only the exceptions below as ordinary SVG. +**Use the highest-level native construction that faithfully expresses the +object.** Basic primitives already export as editable PowerPoint shapes. For +anything beyond them, an exact Office preset is the default; when no single +preset suffices but closed operands can express the result, materialize a +Merge Shapes Boolean result. Hand-authored freeform geometry is the final +fallback, not the first drawing convenience. Block arrows, chevrons, banners / +ribbons, callouts, flowchart nodes, stars, and other Office symbols should be +**authored as presets** via `preset_shape_svg.py`, not redrawn as plain +``s or faked with rectangles. Apply the decision gate in +[`native-shape-authoring.md`](./native-shape-authoring.md) before drawing the +object. + +§IX `Native shape suggestion` records a semantic opportunity, not a literal +tool command. Decide from the actual page construction whether a basic +primitive, preset, Boolean result, or necessary freeform best realizes it; a +different implementation is valid when it preserves the intended object and +content. | Decision | Action | |---|---| | Plain rect / symmetric round rect / circle / ellipse | Keep the ordinary SVG primitive; it is already natively editable. | +| Straight relationship / divider / leader | Use ``; add a registered marker only when direction is meaningful. | | Exact single-preset match | Call `preset_shape_svg.py render` and paste its complete stdout fragment into the current hand-authored SVG. | +| Bent / curved relationship exactly expressed by a stock Connector contour, with no required endpoint attachment | Use the matching `bentConnector*` / `curvedConnector*` preset through the helper as an unconnected native Connector shape. | +| Two or more closed operands whose final semantic object depends on union, cutout, overlap-only coverage, symmetric difference, or fragmentation | Evaluate `shape_boolean_svg.py` at draw time and use it when Boolean materialization is the clearest faithful construction; follow [`native-shape-authoring.md`](./native-shape-authoring.md) §6. | | Stock shape that needs a gradient fill/stroke or a pattern fill | Keep ordinary SVG — the helper paints `none` or a solid HEX on both fill and stroke only ([`native-shape-authoring.md`](./native-shape-authoring.md) §5). | -| Page-specific, compound, organic, branded, icon, or data geometry | Keep ordinary SVG path/polygon geometry. | -| Similar-looking contour only | Never guess; keep ordinary SVG. | +| Page-specific freeform, organic, branded, icon, data geometry, or relationship contour that primitives, one preset, and Boolean materialization cannot faithfully express | Keep ordinary SVG path/polygon geometry. | +| Similar-looking contour only | Never infer a preset; continue to the Boolean gate, then use freeform only if no faithful construction exists. | -This automatic decision applies only before drawing a new object. Do not scan -existing SVG, classify path contours, or upgrade ordinary SVG during export. +**Hard rule — freeform is the last construction tier**: before hand-authoring a +stock-looking `` / ``, complete the primitive → exact preset → +Boolean-result decision order above. A freeform is permitted only when those +tiers cannot faithfully express the object; avoiding a helper or drawing the +browser-visible contour faster is not a valid exception. Data-defined geometry +and a genuinely locked organic / hand-drawn contour satisfy the exception by +semantics, not by convenience. + +This decision applies only while drawing a new object. A suggestion never +triggers retrospective scanning, contour classification, or automatic +upgrading of ordinary SVG during export. **Hard rule**: do not hand-write `data-pptx-authoring`, `data-pptx-prst`, -`data-pptx-frame`, adjustment metadata, or registry paths. The helper generates -one compact atomic `` from the shared 187-shape registry, with semantic -metadata and base paint written once. Rerun the helper when geometry or paint -changes; never edit one of its direct paths. +`data-pptx-frame`, adjustment metadata, or registry paths. The preset helper +generates one compact atomic `` from the shared 187-shape registry, with +semantic metadata and base paint written once. Rerun that helper when geometry +or paint changes; never edit one of its direct paths. -For chart-template and diagram authoring, thin relationships use ordinary -`` / supported open `` geometry with registered arrow markers; -solid directional blocks use ordinary `shape` presets such as `rightArrow` or -`chevron`. Do not select a connector-family preset merely because two nodes are -related, and never hand-add endpoint/site metadata. Connector-family presets -remain available only for an explicit request for a standalone unconnected -`p:cxnSp`; imported Connector topology stays under the preserve/mirror contract. -`actionButton*` presets provide visual geometry only, not actions or hyperlinks. +**Default — relationship geometry**: use `` for a straight relationship. +When a relationship genuinely needs a bend or curve and a stock Connector +contour fits, prefer the matching `bentConnector*` / `curvedConnector*` preset +over a hand-authored SVG Bézier. Use an open freeform path only when a straight +line and the native Connector families cannot faithfully express the required +route, data geometry, or locked hand-drawn / organic style. A directional solid +object remains an ordinary `shape` preset such as `rightArrow` or `chevron`. -**Hard rule — narrow helper scope**: the helper prints one shape fragment to -stdout. It does not write a page or choose layout. Read the fragment and insert -it through the normal `apply_patch` page edit; never redirect, loop, or batch it -into `svg_output/`. +Authored Connector presets export as unconnected `p:cxnSp` objects: they do not +bind to node sites or follow moved nodes. Never hand-add endpoint/site metadata +or claim attachment semantics. Imported Connector topology stays under the +preserve/mirror contract. `actionButton*` presets provide visual geometry only, +not actions or hyperlinks. + +**Hard rule — narrow helper scope**: Both helpers print only their documented +stdout fragment(s); neither writes a page or chooses layout. Read every returned +fragment and insert it through the normal `apply_patch` page edit; never +redirect, loop, or batch helper output into `svg_output/`. ### SVG File Naming Convention @@ -254,11 +281,13 @@ test -f "/icons//.svg" ## 5. Font Usage -Structural typography anchors come from `spec_lock.md typography`. Use an exact `_family` when declared; title roles otherwise use `title_family`, and body/support roles otherwise use `body_family`. `font_family` is the legacy/default fallback, not a reason to erase role differences. Sparse accent families follow §2.1; all structural text uses selected families. LaTeX formulas rendered by Strategist are PNG images, not a `code_family` role. +Typography comes from `spec_lock.md`: `_family` wins; otherwise titles use `title_family`, body/support `body_family`, then legacy `font_family`. Sparse accents follow §2.1. LaTeX renders stay PNG, not `code_family`. + +**Default — font-family inheritance (may override where needed)**: Put the common stack on root ``; matching descendants omit it. Override at the nearest clear ``, ``, or ``. Change placement, never lock selection. **Missing required field — `typography.font_family`** → stop and return to Generate Step 4 / [`strategist.md`](strategist.md) §6.2 to repair `spec_lock.md`; do not infer a stack from `design_spec.md`. -**Hard rule**: every SVG `font-family` stack MUST resolve to a pre-installed exported Latin / EA typeface; use the Strategist §g safe set for locked roles and §2.1 for sparse display exceptions. PPTX has no runtime fallback — missing fonts degrade to Calibri. +**Hard rule**: every SVG `font-family` stack MUST resolve to target-installed/approved Latin and EA faces. PPTX writes one face per script; CSS tails affect preview only, and fonts are not embedded. Missing-face substitution is viewer-selected—not guaranteed Calibri or a later stack entry. --- diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-image.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-image.md index 29f9fd0a..064fa7db 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-image.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-image.md @@ -15,7 +15,7 @@ Handle images by their status in the Design Spec's Image Resource List. Status e | **Existing** | User-provided | Reference images directly from `../images/` directory | | **Generated** | Generated by Image_Generator | Reference images directly from `../images/` directory | | **Sourced** | Web-acquired by Image_Searcher | Reference from `../images/`. **Read [`image_sources.json`](image-searcher.md) to decide attribution** — load [`executor-web-image.md`](./executor-web-image.md). | -| **Rendered** | Deterministic formula PNG | Reference from `../images/`; use `preserveAspectRatio="xMidYMid meet"` | +| **Rendered** | Deterministic formula PNG | Reference from `../images/`; use a legal anchor with `meet` (centered default: `xMidYMid meet`) | | **Needs-Manual** | Acquisition failed and file is absent | Use dashed border placeholder unless the expected file exists; the Step 7 readiness gate swaps placeholders for real files before export | | **Placeholder** | Not yet prepared | Use dashed border placeholder | @@ -23,12 +23,31 @@ Handle images by their status in the Design Spec's Image Resource List. Status e **Template-bundled images**: [`apply-template-workspace.md`](../workflows/stages/apply-template-workspace.md) copies them into project `images/`. Outside `mirror`, reference `../images/` and never copy a template SVG's bare sibling href: the rendered page lives in `svg_output/`. `mirror` ([`executor-structured.md`](./executor-structured.md) §1.1) keeps hrefs verbatim; export resolves them against `images/`. -**Mandatory — selected pattern, flexible realization**: Read [`image-layout-patterns.md`](./image-layout-patterns.md) once when this branch loads, then resolve every primary/modifier id in each active §VIII/lock row to its exact catalog entry. Preserve the selected semantic composition. Adapt geometry, ratio, placement, spacing, and hierarchy for the actual page; never replace the pattern, role, file/source, must-use, or crop policy downstream. If another pattern is needed, return upstream to update the Design Spec. Only explicit user/template preservation locks exact geometry. Avoid generic left/right repetition. +**Reference — preferred pattern, flexible realization**: Read [`image-layout-patterns.md`](./image-layout-patterns.md) once and resolve every active §VIII/lock pattern id. Adapt its geometry or composition when the page communicates better, while preserving resource role, source, must-use status, crop policy, content, and explicit user/template constraints. Pattern-only changes need no upstream rewrite. Avoid generic left/right repetition. + +**Reference — motion-ready image layering, not a constraint**: For adopted §IX or an explicit focus, comparison, evidence, reveal-order, or cross-page requirement, decide during SVG authoring whether the final composition needs separate visible units. Keep ordinary stable framing/background static and wrap each independently revealed or continuing Slide-local unit in a descriptive direct-root ``; structured atoms/slots retain their boundaries. Existing units or a page transition may suffice. The motion stage owns effects, pairing, order, and timing. + +**Hard rule — visible-layer timing**: Any crop, lens, scrim, comparison, evidence, or annotation layer required by an adopted motion plan MUST already exist in the final SVG without violating structural contracts. The later stage may regroup ordinary Slide-local content visual-equivalently, but cannot invent or modify missing visible content. If no legal existing unit can serve a non-binding suggestion, simplify it to available units, a page transition, or `none`; an explicit requirement that cannot be represented follows failure recovery. **No semantic re-reading**: §VIII owns image identity, purpose, and focus / crop constraints. Executor uses its `Reference` plus regenerated dimensions; it never opens source images to rediscover subjects, substitute assets, or invent focus. For `adaptive` without reliable focus, use `meet`; missing or contradictory required constraints return upstream. **Placeholder**: Dashed border `` + description text -**Crop policy**: read the §VIII row and matching lock projection. `crop=no-crop` (or a legacy trailing `| no-crop`) requires a native-ratio container and `preserveAspectRatio="xMidYMid meet"`. `crop=adaptive` permits but never requires cropping; choose `meet` or focal-safe `slice` from purpose, ratio, focus, and container. A missing or conflicting projection returns upstream instead of being inferred during execution. +**Crop policy**: read the §VIII row and matching lock projection. On every slide that uses a `crop=no-crop` source (or a legacy trailing `| no-crop`), retain one visible complete instance using one of the nine legal anchors with `meet`, never `none`, and no `clip-path`, `mask`, clipping overflow, or nested `` crop viewport. An auxiliary same-slide detail or lens may crop the same source only while that complete instance remains visible. `crop=adaptive` permits but never requires cropping; choose `meet` or focal-safe `slice` from purpose, ratio, focus, and container. A missing or conflicting `source` / `pattern` / `crop` projection returns upstream instead of being inferred during execution; the accurately projected `pattern` remains a preferred expression that may be adapted without rewriting the lock. + +**Hard rule — same-source addressable crops**: for binding use or pattern +`#100`, reuse one exact `href` without slice assets. Give every +independent/Morph object a stable +page-unique id and a distinct nested crop wrapper under +[`svg-effects.md`](./svg-effects.md) §6.5. Plain rectangles need no crop marker; +shaped frames put `data-pptx-crop="1"` on the wrapper and a matching +`userSpaceOnUse` clip on its inner ``, never the wrapper. Repeated crops +or SVG/PPT visual drift fail. Derive every wrapper `viewBox` from one shared +source-to-page transform over the union of the visible containers. Never run +`cover` / focal cropping independently per container: different container +positions and heights must change the source-unit `x`, `y`, `width`, and +`height` by the same union-relative mapping, so the gaps remove pixels without +rescaling the scene. A compound clip on one `` is pattern `#82`, not a +substitute when the objects must remain independently editable or Morphable. **Formula images — declared-inference fallback for a missing `no-crop` flag**: rows with `Acquire Via: formula` or `Type: Latex Formula` MUST be treated as no-crop. For a rendered file, use dimensions in this order: current `analysis/image_analysis.csv`, `design_spec.md §VIII`, then `images/formula_manifest.json`. For a `Needs-Manual` row, size the dashed placeholder from the planned dimensions in §VIII, then the manifest; the Step 7 readiness gate re-analyzes the supplied file and reconciles the container before export. Do not normalize all formulas to one height unless the spec explicitly states that layout choice. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-web-image.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-web-image.md index e9f580e8..97ad9d67 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-web-image.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-web-image.md @@ -18,8 +18,8 @@ Whenever the slide uses an image with `Status: Sourced`, look up the correspondi The credit text is **not** rendered by post-processing or export — it must be present in the SVG you produce. The shape of the credit element (size, position, color, multi-image source line, hero gradient overlay) is specified in [image-searcher.md §7](./image-searcher.md). Do not invent a different style. -Use `attribution_text` from the manifest entry as the **starting point**, then compress for the small-text constraint (drop URL, drop filename, keep "via Provider / License"). For CC0/PD images that landed in the `attribution-required` tier only because of upstream metadata quirks (rare), credits are still safe to render. +Use `attribution_text` from the manifest entry as the **starting point**, then compress for the small-text constraint: drop URL and filename, but retain that image's author and CC BY / CC BY-SA license so the quality checker can bind the credit to the referenced asset. For CC0/PD images that landed in the `attribution-required` tier only because of upstream metadata quirks (rare), credits are still safe to render. -`svg_quality_checker.py` treats missing CC BY / CC BY-SA inline attribution as an **error**. Fix the offending SVG before post-processing. +`svg_quality_checker.py` treats a missing image-specific author + license credit as an **error**; one generic CC token does not cover multiple files. An unreadable/missing manifest or missing per-file provenance is also blocking. Fix the manifest or SVG before post-processing. **The manifest is the single source of truth for credits.** Do not duplicate license info into speaker notes or any other artifact. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-base.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-base.md index 4e613c2d..c673f8ee 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-base.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-base.md @@ -23,7 +23,7 @@ Defined in `design_spec.md §VIII`. Status enum: see [`svg-image-embedding.md`]( | Filename | Dimensions | Purpose / Type | Layout pattern | Crop Policy | Acquire Via | Status | Reference | |---|---|---|---|---|---|---|---| -| `` | `` | `` | `` | `adaptive` / `no-crop` | `ai` / `web` / `slice` | Pending | `` | +| `` | `` | `` | `` | `adaptive` / `no-crop` | `ai` / `web` / `slice` | Pending | `` | **Required per non-skipped row**: `Acquire Via` and `Status`. `Reference` is required for every `web` / `slice` row and every newly authored `ai` row. An existing `ai` row whose `Reference` is omitted or blank may continue only through the declared inference in [`image-generator.md`](./image-generator.md) §8; no other path may infer it. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-generator.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-generator.md index dceaddac..af7d7f3f 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-generator.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-generator.md @@ -24,7 +24,7 @@ AI images exist to serve the deck's communication goal. Pick whatever combinatio | `text_policy` | Use | |---|---| | `none` | No text inside the image | -| `embedded` | Image contains text as part of the artwork — decorative lettering, designed title, hand-lettered keywords, infographic labels, anything the page needs | +| `embedded` | Image contains stable text as part of the artwork — decorative lettering, artistic wordmarks, hand-lettered keywords, or figure-internal labels | **Hard rule — only what's actually hard**: @@ -46,7 +46,7 @@ Every AI image uses one deck-wide rendering, the deck's stable color anchors/sem |---|---|---| | **Rendering** | Visual style family (vector / sketch-notes / 3d-isometric / corporate-photo / …) | Once per deck — every AI image in the deck shares one rendering | | **Deck colors** | Core background / primary / accent / secondary-accent / text anchors from `spec_lock.md colors`, interpreted with the Design Spec and per-image context; these are not reconfirmed | Anchored after Stage 2 | -| **Type** | What the image's internal composition skeleton looks like — geometric layout of a local infographic block (infographic / flowchart / framework / matrix / cycle / funnel / pyramid / comparison / timeline / map / scene). Only applies to `page_role: local`; for `page_role: hero_page`, describe composition with §4.1 primitives instead of picking a type. | Per image | +| **Type** | What a local structural infographic's internal skeleton looks like (infographic / flowchart / framework / matrix / cycle / funnel / pyramid / comparison / timeline / map / scene). A local single-subject or portrait image may omit type and use §4.1 A/B prose; `hero_page` always omits type. | Per image | > Rendering decides *how the image is drawn* (line quality, texture, depth). Color instructions begin from the deck roles: background / secondary background usually dominate, primary carries main forms, and accents stay scarce. Adjust proportions and derive coherent lighting/material/tint transitions for the image context; do not replace the deck's identity with an unrelated image-only palette. @@ -122,12 +122,12 @@ For each `Acquire Via: ai` row in `design_spec.md §VIII`: 1. **Determine `page_role`** — Strategist's explicit value wins; a blank or omitted value resolves to `local`. `hero_page` must be explicit. 2. **Determine `text_policy`** — Strategist's value wins when set. **Declared-inference fallback for a blank or omitted value**: pick `none` or `embedded` from the row's `Purpose`, `Reference`, and page intent based on whether in-image text serves the page. Long body / data / lists stay in SVG. -3. **Determine type** — only when the resolved `page_role` is `local` (the image sits as a region block on an SVG page). Match the row's `Purpose` against the `_index.md` auto-selection table (methodology visualization → `framework`; process steps → `flowchart`; SWOT/Eisenhower → `matrix`; PDCA / flywheel → `cycle`; etc.). `Purpose` is authoritative for picking among the 11 internal-composition types. **When the resolved `page_role` is `hero_page`, skip type selection** and describe composition directly using §4.1 primitives (single-subject / portrait / typographic / atmospheric). -4. `read_file references/image-type-templates/.md` (only if not already read — types are commonly reused across images in one deck) +3. **Determine type or free composition** — an Illustration Sheet omits manifest `type` and follows §4.3's grid composition. For another local structural infographic, match `Purpose` against the `_index.md` table and choose one of the 11 types. For a local single-subject / portrait image, omit type and describe §4.1 A/B inside its actual region. For `hero_page`, omit type and use §4.1 A/B/C/D/E. +4. `read_file references/image-type-templates/.md` only when a type was selected (and only if not already read). 5. **Assemble the prompt** by combining: - The rendering's style paragraph (from Step 2) - Color-role instructions anchored by the deck HEX values and refined for the image context (from Step 2) - - The type's structural layout (from Step 3) + - The selected type's structural layout, or the no-type composition prose (from Step 3) - The image's specific `Reference` intent (from `design_spec.md §VIII`) - The container sizing guidance from the type file (so the model knows it's painting a local block, not a full canvas) - The hard rules from §5 below (HEX-not-as-text, simplified figures, text policy) @@ -147,9 +147,9 @@ Every assembled prompt follows this paragraph structure. **Write prose, not tag ``` [Rendering style paragraph — 80-120 words from the chosen rendering file]. [Deck color behavior — state the core anchors and any context-justified tonal treatment, e.g. "secondary background #F8F9FA provides the breathing field, primary #1E3A5F carries main forms, accent #D4AF37 marks one emphasis; subtle lighter/darker material transitions remain in the same visual family"]. -[Type-specific composition — from the chosen type file, e.g. "central hub node with four radiating satellite nodes connected by clean lines"]. +[Composition — from the chosen type file or §4.1 no-type prose]. [Image-specific subject — translated from the row's Reference intent into concrete visual nouns]. -[Container note — "composed as a {W}x{H}px image for {page_role} use"; add composition cues only when the page actually needs them. SVG-overlay-reservation cues ("leave the lower band calm — SVG title overlays it", "keep the right third calmer for SVG text") are valid **only** when `page_role: hero_page` (SVG sits on top of the image). For `page_role: local`, the image sits inside a region block and the SVG layer never overlays its interior — never reserve overlay space in a local prompt]. +[Container note — "composed as a {W}x{H}px image for {page_role} use"; add composition cues only when the page actually needs them. SVG-overlay-reservation cues ("leave the lower band calm — SVG title overlays it", "keep the right third calmer for SVG text") are valid when `page_role: hero_page`, or when §VIII `Reference` / §IX `Layout` explicitly plans native labels, hotspots, lenses, or other SVG overlays inside a `local` image region. Otherwise a `local` image is a self-contained region block and reserves no interior overlay space]. [Hard rules — see §5]. ``` @@ -163,33 +163,33 @@ Every assembled prompt follows this paragraph structure. **Write prose, not tag This produces generic, model-average output. The model is not weighting your tags — write **one coherent visual scene** instead. -### 4.1 Hero-page composition primitives +### 4.1 No-type composition primitives -When `page_role: hero_page` (the image is the page's main voice — cover, chapter divider, mood transition, signature stat, closing quote), the image's internal composition does not need its own structural `type` (matrix / cycle / framework etc. are for *local* infographic blocks). Instead, describe the composition directly in the prompt using one of the four primitives below. +Use these when no structural type applies. A/B can describe either a hero image or a local single-subject / portrait region; scale their framing to the actual container. C/D are hero-page compositions, and E is the explicit escape hatch. Local structural infographics still use the 11 type templates. **Primitive A — single dominant subject (product / object / concept hero)** -> One dominant subject occupying 60-70% of the canvas, positioned with intent (centered, rule-of-thirds offset, or slight left/right). Supporting context <30% of canvas weight. Generous negative space — at least 15% padding on the subject's "open" side. No second-place subject competing. +> Start with one dominant subject as the clear focal point, positioned with intent (centered, rule-of-thirds offset, or slight left/right). Scale it to command the container while keeping supporting context subordinate. Leave a deliberate open side when the page composition needs breathing room or an overlay; no fixed padding is implied. No second-place subject competing. -Use for: product reveal, concept introduction, chapter title visual, brand statement. +Use for: product reveal, concept introduction, chapter-opener visual, brand statement, or a local single-object region. **Primitive B — single human subject (portrait)** -> One person, frontal or three-quarter turn, head + upper body. Subject occupies 50-65% of canvas height, centered or rule-of-thirds offset. Eyes at the upper-third horizontal line. Background neutral, minimal, or softly blurred. No competing foreground objects. At least 15% padding above the crown. +> One person, frontal or three-quarter turn, head + upper body. Start with the face as the clear focal point, centered or rule-of-thirds offset, with eyes near the upper-third horizontal line. Background neutral, minimal, or softly blurred. Keep comfortable headroom and no competing foreground objects; adjust framing to the container rather than enforcing fixed padding. -Use for: founder profile, speaker bio, testimonial page, executive intro. Pair with `rendering: corporate-photo` for photographic realism; otherwise the §5.2 simplified-figures rule applies. +Use for: founder profile, speaker bio, testimonial page, or executive intro, including a local bio region. Pair with `rendering: corporate-photo` for photographic realism; otherwise the §5.2 simplified-figures rule applies. **Primitive C — typographic hero (the text *is* the image)** -> The image's central content is one large text element — a short headline, big number, or single word — rendered as art, occupying 40-60% of canvas height. Minimal supporting visual (small icon, geometric anchor, accent line) at <25% weight. At least 20% padding around the text. +> The image's central content is one large text element — a short headline, big number, or single word — rendered as art and carrying dominant visual weight. Keep any supporting visual (small icon, geometric anchor, accent line) clearly subordinate. Give the letterforms enough breathing room for readability, adjusting scale and spacing to the actual text and container. Use with `text_policy: embedded`. Must obey the §5.3 rule — text that is part of the artwork and stable can be embedded; copy that must stay exact or editable goes to SVG overlay (switch to Primitive D). **Primitive D — atmospheric backdrop (no subject)** -> Atmospheric field with no dominant subject — gradients, subtle patterns, or restrained color blocks. Small geometric anchor optional, placed in a corner or along an edge, never centered. The center 60-70% of the canvas must stay calm to receive SVG title/text overlay. +> Atmospheric field with no dominant subject — gradients, subtle patterns, or restrained color blocks. A small geometric anchor may sit in a corner or along an edge. Arrange visual activity around the SVG overlay region named by the page plan so that region stays calm enough for its title or text; its position and extent follow the composition rather than a fixed percentage. -**Applies to `page_role: hero_page` only.** The "calm center for SVG overlay" contract is the defining feature of this primitive — and it only holds when SVG actually sits on top of the image. `page_role: local` images live inside a region block; the SVG layer never overlays their interior, so Primitive D is not a valid choice for local. Local schematic / scene / chart images use the §3 type templates instead. +**Applies to `page_role: hero_page` only.** The "calm center for SVG overlay" contract defines this primitive. A `local` image uses §3 type templates or §4.1 A/B instead; when §VIII / §IX explicitly plans native overlays inside that region, its prompt may reserve only the named focal/quiet area without turning the whole asset into Primitive D. Use for: cover background, chapter divider background, breathing-page background, any page where the SVG layer carries the words and the image only sets tone. @@ -211,21 +211,21 @@ Example opening for a triptych hero: **Fewshot examples per primitive** (one each, deck-context placeholders intact): -> **A — 3d-isometric + tech-neon product reveal, text_policy: none, 600×600** +> **A — 3d-isometric + deck-color product reveal, text_policy: none, 600×600** > -> 3D isometric illustration in true 30°/30°/30° projection. One dominant product-form subject — a stylized device or sleek tech object — occupies the center of the canvas at roughly 65% of the area. The subject is rendered in primary electric blue `#0EA5E9` on its lit faces, with 15% darker tonal shift on shadowed faces. A subtle 8%-opacity outer glow halo surrounds the subject. Small supporting context: three thin connecting lines in accent vivid cyan `#06B6D4` arcing from the subject toward the canvas edges (suggesting connectivity), and a soft 8% drop shadow grounding the subject. Background is deep secondary navy `#0A0E27` (about 30% of canvas, including shadowed plane). The subject is clearly the singular focal element. Composed as a 600×600 hero block with 15% padding around the subject. NO text, letters, numbers, or labels anywhere. Color values are rendering guidance only. +> 3D isometric illustration in true 30°/30°/30° projection. One dominant product-form subject — a stylized device or sleek tech object — commands the center of the canvas. The subject is rendered in primary electric blue `#0EA5E9` on its lit faces, with 15% darker tonal shift on shadowed faces. A subtle 8%-opacity outer glow halo surrounds the subject. Small supporting context: three thin connecting lines in accent vivid cyan `#06B6D4` arcing from the subject toward the canvas edges (suggesting connectivity), and a soft 8% drop shadow grounding the subject. Background is deep secondary navy `#0A0E27`, including the shadowed plane. The subject is clearly the singular focal element, with deliberate breathing room around it. Composed as a 600×600 hero block. NO text, letters, numbers, or labels anywhere. Color values are rendering guidance only. -> **B — corporate-photo + cool-corporate executive headshot, text_policy: none, 600×800** +> **B — corporate-photo + deck-color executive headshot, text_policy: none, 600×800** > -> Editorial corporate portrait photograph of one professional executive. The person is centered slightly left of canvas center, photographed from chest-up at eye level, looking confidently toward the camera with a relaxed natural expression — not posed-stiff, not over-smiling. Professionally attired in a contemporary business setting (a tailored blazer, neutral palette clothing). Soft natural light from the upper left, gentle shadow on the right side of the face. Diverse, professionally attired subject, photorealistically rendered, contemporary styling. Background is a softly out-of-focus office context — secondary light gray `#F8F9FA` wall with a subtle hint of primary deep navy `#1E3A5F` in a blurred architectural element. Color grading is cool-corporate — restrained, professional. Shallow depth of field — subject sharp, background gently blurred. Subject's eyes positioned at the upper-third horizontal line. Composed as a 600×800 bio portrait with 10% padding. NO text, name tags, or captions in the image. Color values are rendering guidance only. +> Editorial corporate portrait photograph of one professional executive. The person is centered slightly left of canvas center, photographed from chest-up at eye level, looking confidently toward the camera with a relaxed natural expression — not posed-stiff, not over-smiling. Professionally attired in a contemporary business setting (a tailored blazer, neutral palette clothing). Soft natural light from the upper left, gentle shadow on the right side of the face. Diverse, professionally attired subject, photorealistically rendered, contemporary styling. Background is a softly out-of-focus office context — secondary light gray `#F8F9FA` wall with a subtle hint of primary deep navy `#1E3A5F` in a blurred architectural element. Color grading is restrained and professional. Shallow depth of field — subject sharp, background gently blurred. Subject's eyes positioned near the upper-third horizontal line, with comfortable headroom. Composed as a 600×800 bio portrait. NO text, name tags, or captions in the image. Color values are rendering guidance only. -> **C — ink-notes + mono-ink big-number stat, text_policy: embedded, 800×500** +> **C — ink-notes + deck-color big-number stat, text_policy: embedded, 800×500** > -> Professional hand-drawn visual-note style on pure white background. The image's central content is the hand-lettered number "100x" — rendered in bold confident ink strokes occupying about 50% of the canvas height, centered with deliberate slight wobble characteristic of hand-lettering. Text is in English/Latin characters only. Beneath the number, a thin hand-drawn underline in ink. To the side of the number, one small hand-drawn doodle decoration — a star or upward arrow — adds visual rhythm. Accent coral `#E8655A` (from the deck's accent) appears only as a tiny emphasis dot, totaling under 4% of canvas. Background is pure white `#FFFFFF`. Composed as an 800×500 typographic hero block with 20% padding around the number. No other text or labels in the image — just the "100x" headline and the small doodle. +> Professional hand-drawn visual-note style on pure white background. The image's central content is the hand-lettered number "100x" — rendered in bold confident ink strokes as the dominant element, centered with deliberate slight wobble characteristic of hand-lettering. Beneath the number, a thin hand-drawn underline in ink. To the side of the number, one small hand-drawn doodle decoration — a star or upward arrow — adds visual rhythm. Accent coral `#E8655A` (from the deck's accent) appears only as a tiny emphasis dot, totaling under 4% of the canvas. Background is pure white `#FFFFFF`. Composed as an 800×500 typographic hero block with enough breathing room for the letterforms to read clearly. No other text or labels in the image — just the "100x" headline and the small doodle. -> **D — vector-illustration + cool-corporate cover background, text_policy: none, 1280×720** +> **D — vector-illustration + deck-color cover background, text_policy: none, 1280×720** > -> Clean flat vector illustration backdrop. Atmospheric composition with no central subject — bold geometric shapes arranged along the canvas edges to leave the center calm. Primary deep navy `#1E3A5F` forms a confident diagonal block across the lower-left third; secondary light gray `#F8F9FA` occupies the upper two-thirds as breathing space; accent gold `#D4AF37` appears only as one thin geometric line near the lower right corner (under 5% of canvas). Crisp 2px outlines, no gradients, single 8% soft drop shadow under the navy block. The central 60% of the canvas is deliberately calm and unbusy — designed to receive a slide title overlaid in SVG. Composed as a 1280×720 full-bleed PPT background. NO text, letters, numbers, signs, watermarks, or written symbols anywhere in the image. Color values are rendering guidance only — do not display HEX codes or color names as text. Simplified geometric shapes only. +> Clean flat vector illustration backdrop. Atmospheric composition with no central subject — bold geometric shapes arranged along the canvas edges to leave the planned central title field calm. Primary deep navy `#1E3A5F` forms a confident diagonal block across the lower-left area; secondary light gray `#F8F9FA` provides the breathing field; accent gold `#D4AF37` appears only as one thin geometric line near the lower right corner, under 5% of the canvas. Crisp 2px outlines, no gradients, a single 8% soft drop shadow under the navy block. The intended SVG title region is deliberately calm and unbusy. Composed as a 1280×720 full-bleed PPT background. NO text, letters, numbers, signs, watermarks, or written symbols anywhere in the image. Color values are rendering guidance only — do not display HEX codes or color names as text. Simplified geometric shapes only. ### 4.2 Prompt depth — expand for subject-domain accuracy @@ -279,9 +279,9 @@ If one deck needs mixed shapes, create separate sheets per shape family unless o **Resource contract — the sheet and its elements are different row kinds.** A sliced element can only be placed if it exists as a resource the Executor is allowed to reference (`spec_lock.md images`). So §VIII carries two row kinds (planning authority: [`strategist-image.md`](./strategist-image.md)): - **Sheet row** — `Acquire Via: ai`, `Type: Illustration Sheet`, the intent prompt, named as the slice source with its intended cell shape and placement purpose (`Reference: landscape footer-vignette spot set`). It is generated in Step 5 but **never placed on a slide** — keep it **out of** `spec_lock.md images`. Image_Generator resolves the exact `aspect_ratio`, grid, and slice command from this intent. -- **Element rows** — one per used element, `Acquire Via: slice`, filename matching a `--names` output, `Reference` naming the parent sheet + cell/element. These **are** placed — list every one in `spec_lock.md images`, normally with `crop=no-crop` (a tight-trimmed transparent spot should be fit, not cover-cropped). Their dimensions are filled in after slicing (Step 5 re-runs `analyze_images.py`). Each row must already carry a Strategist-selected decorative-cutout Layout pattern, never a boxed container — see Placement below. +- **Element rows** — one per used element, `Acquire Via: slice`, filename matching a `--names` output, `Reference` naming the parent sheet + cell/element. These **are** placed — list every one in `spec_lock.md images`, normally with `crop=no-crop` (a tight-trimmed transparent spot should be fit, not cover-cropped). Their dimensions are filled in after slicing (Step 5 re-runs `analyze_images.py`). Each row must already carry a Strategist-recommended decorative-cutout Layout pattern rather than a boxed-container recommendation; Executor owns the actual placement — see Placement below. -For traceability, add optional `slice_grid` and `slice_names` fields to the sheet item in `image_prompts.json` after choosing the geometry. `image_gen.py` ignores unknown item fields but preserves them in the manifest, so these fields document the exact command that must be used for slicing. +For traceability, add optional `slice_grid` and `slice_names` fields to the sheet item in `image_prompts.json` after choosing the geometry. `image_gen.py` validates, preserves, and displays these metadata fields; it does not run the separate slicing command. **Slice** with [`slice_images.py`](../scripts/slice_images.py) — cells are cut row-major into individual files in `images/`. With `--alpha` they are **transparent cutout stickers** (image-layout-patterns `#63`), not rectangular content images. Recommended flags: `--names` (semantic per-cell filenames matching the element rows; the count **must** equal `rows*cols`), `--trim` (tight-crop each cell so imprecise placement inside a cell doesn't leave lopsided margins), `--alpha` (knock the flat background out to transparency so an element drops onto any slide color): @@ -296,7 +296,7 @@ python3 scripts/slice_images.py /images/illus_sheet.png --grid 2x3 \ 2. **Clean grid, or it cuts ugly.** The model will not place every element perfectly; force a clear grid with gutters, and generate **a few sheets** (re-roll the same prompt) to pick the cleanest-laid-out one before slicing. State the exact row/column structure and cell shape so the model does not invent a square matrix. `--trim` absorbs the rest. 3. **Generate only as large as needed.** Each cell is a fraction of the sheet. Pick the smallest sheet size that keeps each sliced cell at least **1.5-2x** the intended display size. `1K` is usually enough for small 80-160px decorative spots; use `2K` for medium 180-320px placements; reserve `4K` for large, cropped, or potentially enlarged elements. -**Placement — these are decorative accessories, not boxed pictures.** Strategist selects each element row's pattern from the decorative-cutout family in [`image-layout-patterns.md`](./image-layout-patterns.md): `#63` sticker/cutout, `#4` bleed off the canvas edge, `#58` corner fragment, `#66` fade into the background, `#69` slight editorial rotation, or `#49` asymmetric cluster. Executor realizes that choice through margin position, off-edge treatment, overlap, scale, and angle rather than reserving a tidy tile. Anchor most pages on one primary element and let the rest stay small ([primary-per-page](./strategist-image.md)). +**Placement — these are decorative accessories, not boxed pictures.** Strategist recommends each element row's pattern from the decorative-cutout family in [`image-layout-patterns.md`](./image-layout-patterns.md): `#63` sticker/cutout, `#4` bleed off the canvas edge, `#58` corner fragment, `#66` fade into the background, `#69` slight editorial rotation, or `#49` asymmetric cluster. Executor uses that recall to choose the actual unboxed composition through margin position, off-edge treatment, overlap, scale, angle, or another suitable decorative placement while preserving the resource role and crop/content constraints. Anchor most pages on one primary element and let the rest stay small ([primary-per-page](./strategist-image.md)). **Through-line — one family, many roles.** A spot sheet pays off more when the same motif family also drives the cover and section dividers. A large cover / divider anchor is not a giant sheet cell—generate it as its own `hero_page` image sharing the sheet's `deck_rendering`, `color_scheme`, and subject world. Plan this only when the deck leans into illustration, never as a quota. @@ -328,12 +328,12 @@ Every AI-image page carries text in two layers: | Layer | Owned by | Examples | |---|---|---| -| Layer 1 (image-owned) | the prompt — baked into the raster | figure-internal annotations (axis labels, A / B / C markers, units, scale bars, panel labels); architecture / schematic module names, node labels, signal-path identifiers; hero typographic or decorative lettering that *is* the visual | -| Layer 2 (SVG-owned) | `` overlay — fully editable | page-level chrome (title, navigation, footer, body bullets, conclusion callout); readable copy, captions | +| Layer 1 (image-owned) | the prompt — baked into the raster | figure-internal annotations (axis labels, A / B / C markers, units, scale bars, panel labels); architecture / schematic module names, node labels, signal-path identifiers; stable artistic lettering that *is* the visual | +| Layer 2 (SVG-owned) | `` overlay — fully editable | authoritative deck/page/chapter titles; navigation, footer, body bullets, conclusion callout; readable copy, captions | `text_policy` controls only Layer 1. AI judges per image; no global default bias. -**When `embedded` is the right call — positive triggers** (any one match flips the row from a `none` starting point to `embedded`; the editability rule at the tail of §5.3 still has final say): +**When `embedded` is the right call — positive triggers** (any one match supports `embedded`; the editability rule at the tail of §5.3 still has final say): | Trigger | Typical Layer 1 text | |---|---| @@ -348,9 +348,9 @@ Defaulting an entire `ai` resource list to `none` because "SVG can always overla | `text_policy` | Prompt cue | |---|---| | `none` | "NO text of any kind anywhere in the image — no letters, numbers, signs, watermarks, labels, or written symbols." | -| `embedded` | Describe the Layer 1 text directly inside the visual scene: the word(s), how they're rendered, and the artistic treatment. | +| `embedded` | Describe the stable Layer 1 lettering directly inside the visual scene: the exact character(s), how they are rendered, and the artistic treatment. | -**Hard rule — cross-cutting**: Layer 2 chrome stays SVG regardless of `text_policy`. Never bake the deck title, navigation, footer, body bullets, or conclusion callout into the image, even when `embedded`. +**Hard rule — cross-cutting**: Authoritative titles and Layer 2 chrome stay SVG regardless of `text_policy`. Bake title-like wording only when the approved plan explicitly treats those exact characters as stable artistic lettering that is part of the artwork rather than editable deck/page/chapter copy. Navigation, footer, body bullets, captions, and conclusion callouts always stay SVG. **Forbidden — text that may be reworded**: any word that may later change belongs in Layer 2, not Layer 1. Layer 1 is for stable visual identifiers and designed lettering that is part of the image itself. @@ -358,7 +358,7 @@ Defaulting an entire `ai` resource list to `none` because "SVG can always overla The font for in-image text is a free natural-language description, not an enum. Pick whatever serves the image: blackletter for a heritage cover, hand-brushed for a manifesto poster, retro chrome 3D for Y2K, art-deco display for a luxury hero, ribbon script for a bookstore zine — any artistic treatment the image earns. -The table below is **a reference for the one case where you want the in-image lettering to read as the same typographic family as the SVG body** (e.g. a clean editorial deck where the cover title in the image should feel like the body Helvetica, not a surprise blackletter). Use it as a starting point, not a constraint. +The table below is **a reference for the one case where stable in-image lettering should read as the same typographic family as the SVG body** (e.g. an artistic cover wordmark should feel like the body Helvetica, not a surprise blackletter). Use it as a starting point, not a constraint. | `spec_lock typography.font_family` contains | Optional descriptor if you want to echo the SVG body | |---|---| @@ -371,11 +371,11 @@ The table below is **a reference for the one case where you want the in-image le **When to ignore the table**: - Decorative / background lettering, posters, large mood words → describe the artistic treatment freely -- Cover hero title that wants its own visual identity (blackletter, retro chrome, art-deco display, brushed script) → describe freely +- Stable artistic cover lettering that wants its own visual identity (blackletter, retro chrome, art-deco display, brushed script) → describe freely - Sketch-notes / ink-notes / hand-drawn renderings where the lettering is part of the rendering itself → describe freely - Any case where rendering already implies a font character (e.g. `vintage-poster` implies period display lettering) → trust the rendering, no need to echo SVG body -**When to use the table**: a designed title (cover main title, chapter heading) on a deck whose visual identity is grounded in the SVG body typography, and where a surprise font choice would feel out of place. +**When to use the table**: stable artistic lettering on a deck whose visual identity is grounded in the SVG body typography, and where a surprise font choice would feel out of place. **In-image text vs SVG text — decide by editability, not by model capability** @@ -383,8 +383,8 @@ Layer 1 text is rasterized into the artwork — once generated it cannot be edit | Text | Layer | |---|---| -| Part of the artwork and stable — decorative lettering, designed title, hand-lettered keyword, figure-internal identifiers (axis labels, panel letters, units) | Layer 1 (image) OK | -| Page chrome, body copy, captions, data values — anything that must stay exact, searchable, or may be reworded | Layer 2 (SVG) | +| Part of the artwork and stable — decorative lettering, artistic wordmark, hand-lettered keyword, figure-internal identifiers (axis labels, panel letters, units) | Layer 1 (image) OK | +| Authoritative titles, page chrome, body copy, captions, data values — anything that must stay exact, searchable, editable, or may be reworded | Layer 2 (SVG) | Generation is non-deterministic on every backend, but **do not pre-judge by script or length** — never push text to SVG, shorten a headline, or downgrade `embedded` to `none` on the assumption that a particular script or a long string "won't render". Decide where text lives by the editability rule above, not by guessed rendering ability. Name the exact characters to bake literally in the prompt; do not re-read the generated image to verify them. @@ -449,23 +449,25 @@ Write `project/images/image_prompts.json` with this shape: | `deck_rendering` | yes | Step 2 lock | Single rendering name shared by all items in this deck | | `color_scheme` | yes | `spec_lock.md colors` | Core deck color anchors shared by every item; prompts may add contextual tonal behavior, but no separate image palette | | `items[].filename` | yes | `§VIII` resource list | Output filename with extension | -| `items[].type` | conditional | Step 3 per-image (only when `page_role: local`) | One of 11 internal-composition types: `infographic`, `flowchart`, `framework`, `matrix`, `cycle`, `funnel`, `pyramid`, `comparison`, `timeline`, `map`, `scene`. **Omit `type` entirely when `page_role: hero_page`** — the composition comes from §4.1 primitives written directly into the prompt, not from a type file. | +| `items[].type` | conditional | Step 3 per-image | One of 11 internal-composition types for a local structural infographic. Omit it for `hero_page`, an Illustration Sheet, and a local single-subject / portrait composition authored with §4.1 A/B prose. | | `items[].page_role` | yes | Step 3 per-image | `local` (default — region block on SVG page) or `hero_page` (image is page's main voice; SVG overlay minimal or empty) | -| `items[].text_policy` | yes | Step 3 per-image | `none` (image carries no text — explicit visual rule) or `embedded` (image contains decorative lettering, designed title, hand-lettered keywords, or stable visual identifiers like axis labels / subplot letters / unit symbols). AI judges per image; no global default bias — see §5.3. | +| `items[].text_policy` | yes | Step 3 per-image | `none` (image carries no text — explicit visual rule) or `embedded` (image contains stable artistic lettering, hand-lettered keywords, or visual identifiers like axis labels / subplot letters / unit symbols). AI judges per image; no global default bias — see §5.3. | | `items[].aspect_ratio` | yes | Container sizing | Passed to `image_gen.py --aspect_ratio` | | `items[].prompt` | yes | §4 assembly | The full assembled paragraph | | `items[].image_size` | no | Container sizing | `512px` / `1K` / `2K` / `4K` | +| `items[].model` | no | Per-item execution override | Backend model for this item; otherwise the CLI/backend default wins | | `items[].alt_text` | no | Accessibility | Short caption | -| `items[].slice_grid` | no | §4.3 sheet geometry | Illustration sheet only; exact `RxC` grid to pass to `slice_images.py --grid` | -| `items[].slice_names` | no | §4.3 sheet geometry | Illustration sheet only; semantic filenames to pass to `slice_images.py --names` | +| `items[].slice_grid` | paired optional | §4.3 sheet geometry | Illustration sheet only; exact `RxC` grid to pass to `slice_images.py --grid`; requires `slice_names` | +| `items[].slice_names` | paired optional | §4.3 sheet geometry | Illustration sheet only; comma-separated safe PNG basenames to pass to `slice_images.py --names`; requires exactly `rows*cols` unique outputs | | `items[].status` | yes | CLI manages | `Pending` initially; CLI updates to `Generated` / `Failed` / `Needs-Manual` | -> **Back-compat for legacy `type` values**: existing manifests using `background` / `hero` / `portrait` / `typography` (the four removed pseudo-types) remain readable. Read them as: `background` → `page_role: hero_page` + no type; `hero` → `page_role: hero_page` + no type (use §4.1 Primitive A in prompt); `portrait` → `page_role: local` + no type (use §4.1 Primitive B); `typography` → `page_role: hero_page` + `text_policy: embedded` + no type (use §4.1 Primitive C). New manifests should follow the rule above (omit `type` when `page_role: hero_page`). +> **Back-compat for legacy `type` values**: existing manifests using `background` / `hero` / `portrait` / `typography` (the four removed pseudo-types) remain readable. Read them as: `background` → `page_role: hero_page` + no type; `hero` → `page_role: hero_page` + no type (use §4.1 Primitive A in prompt); `portrait` → `page_role: local` + no type (use §4.1 Primitive B); `typography` → `page_role: hero_page` + `text_policy: embedded` + no type (use §4.1 Primitive C). New manifests omit `type` for hero pages and local single-subject / portrait prose. > > **Existing manifest compatibility**: > > - **Fixed compatibility defaults**: a missing `page_role` resolves to `local`; a missing `text_policy` resolves to `none`. Emit one aggregate legacy-compatibility warning per manifest. -> - **Declared replay procedure**: an existing manifest may lack `deck_rendering`, or an existing local item may lack `type`, because `items[].prompt` is already assembled. Leave that metadata absent, execute the existing prompt verbatim, and do not reconstruct either value. This exception applies only to replaying an existing manifest; new manifests must satisfy the field table above. A `hero_page` item still omits `type` intentionally. +> - **Declared replay procedure**: an existing manifest may lack `deck_rendering`, or an existing local item may lack `type`, because `items[].prompt` is already assembled. Leave that metadata absent, execute the existing prompt verbatim, and do not reconstruct either value. New manifests follow the field table; hero pages and local single-subject / portrait prose omit `type` intentionally. +> - A legacy non-empty `deck_style_anchor` string or object remains readable for replay and sidecar display but never overrides a current `deck_rendering`. > - A legacy `deck_palette` field may remain but cannot override `color_scheme`. Read legacy `page_role: full_page` as `hero_page`. --- @@ -484,13 +486,13 @@ C (AI-generated) supports three implementation modes sharing one `image_prompts. | `IMAGE_BACKEND` not configured (or Path A fails) AND host has a native image tool | **Path B**: Host-native tool | Agent invokes the host's image capability; outputs land at `project/images/` | | **Both Path A and Path B fail/unavailable** | **Offline Manual Mode** | Manifest stays on disk; user generates externally from `items[].prompt` and places files at `project/images/` | -**Selection logic — declared-procedure fallback when no path is confirmed**: the confirmed user choice wins. When neither channel confirmed a specific path — the effective choice is `auto` (explicitly confirmed or defaulted) or absent — use the automatic A → B → C chain: +**Selection logic — declared-procedure fallback when no path is confirmed**: the confirmed user choice wins. When neither channel confirmed a specific path, Generate Step 4 records the effective choice as `auto`; that explicit durable value uses the automatic A → B → C chain. A missing/blank/unknown project value is not an implicit API authorization: 0. **Confirmed override (wins)** — honor `AI Image Acquisition Path` from `design_spec.md §I`. Generate Step 4 already consumed the final confirmation into that durable artifact; do not reopen `result.json` here. If the recorded choice is set and not `auto`, honor it directly, **even when it contradicts `IMAGE_BACKEND`**: - `api` → **Path A** (`image_gen.py --manifest`). - `host-native` → **Path B** (host's native image tool) — skip A and do **not** run `image_gen.py --manifest`, *even if `IMAGE_BACKEND` is configured*. - `manual` → **Offline Manual** (write prompts, render the Markdown sidecar, hand off; do **not** run `image_gen.py --manifest`). - If an explicitly chosen path is unavailable or still fails after its retry, mark the affected row `Needs-Manual`; do not switch to another automated provider. Only when the Design Spec records `auto` or no specific path applies does the automatic chain decide. A legacy project missing this Design Spec row returns to Step 4 recovery to consume persisted confirmation once and record it; Image_Generator does not inspect the confirmation channel itself. + If an explicitly chosen path is unavailable or still fails after its retry, mark the affected row `Needs-Manual`; do not switch to another automated provider. Only when the Design Spec records `auto` does the automatic chain decide. A legacy project missing this Design Spec row returns to Step 4 recovery to consume persisted confirmation once and record it; Image_Generator does not inspect the confirmation channel itself. 1. **Try Path A** — if `IMAGE_BACKEND` is configured (env or `.env`), run `image_gen.py --manifest`. If it fails twice in a row, fall to Path B. 2. **Try Path B** — if `IMAGE_BACKEND` was not configured (A skipped), or A failed, and the host has a native image tool (Codex / Antigravity / Claude Code / similar), the agent invokes the host's image capability directly. 3. **Fall to C (Offline Manual)** — if B is also unavailable (no host-native tool) or fails, write prompts to `images/image_prompts.json` and hand off to the user. @@ -507,7 +509,7 @@ python3 scripts/image_gen.py \ --output project/images ``` -The CLI iterates `items[]` with adaptive concurrency, writes `status` back per item, and is **idempotent**: re-running only re-processes entries whose status is `Pending` or `Failed`. +The CLI validates the file behind every `Generated` row before skipping it, iterates retryable rows with bounded adaptive concurrency, and atomically writes each status. A missing/corrupt generated file returns to `Failed`; persistent rate limits finish this run as retryable `Failed` instead of looping forever. **Parameters**: @@ -621,7 +623,7 @@ When an existing AI Resource List row omits `Reference` or contains a blank `Ref | Purpose | A reasonable starting point | |---------|-----------------------------| | Cover | `page_role: hero_page` + §4.1 Primitive A (single-subject) or D (atmospheric); choose `text_policy` by what the cover should communicate | -| Chapter divider | `page_role: hero_page` + Primitive D (atmospheric) or A (single-subject); often `text_policy: embedded` with a designed chapter title | +| Chapter divider | `page_role: hero_page` + Primitive D (atmospheric) or A (single-subject); keep the authoritative chapter title in SVG, with `embedded` reserved for separate stable artistic lettering | | Methodology / framework illustration | `type: framework`, `page_role: local` | | Process / workflow illustration | `type: flowchart`, `page_role: local` | | Before/After or two-option page | `type: comparison`, `page_role: local` | 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 39cc1452..511bf7e4 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 @@ -4,7 +4,7 @@ A vocabulary registry of ways images can be placed on a slide. The point of this 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, #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. +> **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–#100). 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. --- @@ -16,7 +16,7 @@ Almost every pattern below is an instance of one underlying split: This is the single most underused move in image-heavy decks. The default reflex is to place image and text in adjacent rectangles. The far more powerful move — especially for content-rich pages — is to let the image **be the canvas** (often full-bleed) and draw native vector elements (annotation cards, flow nodes, KPI tiles, leader lines, network diagrams, dashboards) directly on top. -Anything that must be editable, numerically accurate, contain Chinese, or be styled to the deck's exact palette belongs in the SVG layer regardless of what the image looks like underneath. +Anything that must remain editable, numerically or semantically exact, or styled to the deck's exact typography belongs in the SVG layer regardless of what the image looks like underneath. Script alone never decides ownership. --- @@ -31,7 +31,8 @@ The patterns below are what separate a deck that looks designed from a deck that | 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 | +| One image should span detached containers as one edit object | `#82` one image shattered across separated shapes | Merge-Shapes look; the photo runs continuously behind the gaps | +| Those same-source containers must remain independently editable or animated | `#100` same-source addressable crops | Several native picture objects share one source coordinate system without slice assets | | 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 | @@ -39,9 +40,13 @@ The patterns below are what separate a deck that looks designed from a deck that | 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 | +| One place should be recognized across consecutive pages | `#87` one image panned across pages | The deck reads as one continuous scene; `-t morph` uses heuristic matching, while explicit `morph.pairs` makes the camera pan deterministic | -**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. +**Mandatory**: Pair a modifier-only router result with a content-appropriate Part 1 Primary as the page bones before §VIII. The pairing makes the recommendation complete; it does not lock Executor geometry or create a usage quota. + +**Hard rule — registration is what makes this family work**: in `#82`, `#85`, `#87`, `#89`, `#96`, `#97`, and `#100`, the image stays anchored to the *union* of its containers, or the copies share one source coordinate system. A few pixels of drift reads as a printing error. `#84` alone breaks registration on purpose. + +**Prepared-asset gate**: select `#96` only when a registered cutout PNG already exists, `#97` only when its blurred crop exists, and `#99` only when its desaturated copy exists. If not, keep the original asset and fall back to a native-shape treatment such as `#30` / `#29`; do not invent an image-processing step during execution. **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`. @@ -109,15 +114,15 @@ Pick one or more of these as the page's bones. Cross-primary combinations are en This is the family that opens up the largest design space and the one AI is most likely to skip. The shared pattern: image fills the slide (or a large region), native SVG elements are layered on top to carry the actual information. None of the overlay elements need to be generated by the image model — they are vector primitives you draw yourself. -38. **Background image + annotation cards with bezier leader lines** — full-bleed `` + 2–4 small info cards (`` + icon + title + one-line text) placed in the image's calm regions. From each card, draw a bezier `` ending in a `marker-end` arrow that points to the specific object in the image being annotated. Card text and leader lines are editable; image is the scene. +38. **Background image + annotation cards with Shape-first leaders** — full-bleed `` + 2–4 small info cards (`` + icon + title + one-line text) placed in the image's calm regions. Point to each subject with a straight `` by default, or an authored native bent/curved Connector when its stock contour fits. Use a custom Bézier leader only when neither can route around the subject faithfully. Card text and leader lines remain editable; image is the scene. -39. **Background image + flow nodes drawn over the scene** — the image is a real or rendered scene (workshop, control room, landscape). On top, draw a dashed `` route that traces a workflow through the scene, with numbered `` nodes at each stop. Each node = number + icon + label. The flow is fully editable; the image is atmosphere. +39. **Background image + flow nodes drawn over the scene** — the image is a real or rendered scene (workshop, control room, landscape). On top, connect numbered `` stops with straight `` segments or exact native bent/curved Connector contours. Use a custom dashed route only when the workflow must follow meaningful scene geometry those shapes cannot express. Each node = number + icon + label. The flow is fully editable; the image is atmosphere. 40. **Background image + floating KPI metric cards** — full-bleed image (often an operations photo) + dark scrim + multiple `` cards in negative-space regions. Each card = icon + small label + large metric number. Image gives context; cards give the data. 41. **Background image + measurement lines and module tags (engineering overlay)** — used on technical / blueprint / cross-section images. Draw measurement lines with end-caps (`` + perpendicular ticks) spanning a feature, with a centered label box reading dimensions or part names. Add tagged callouts with `` + monospace text. Reads as engineering drawing markup. -42. **Background image + glassmorphism UI panels** — image is the visual world; on top, draw UI elements (semi-transparent panels, progress arcs, status badges, indicators). Panels use `fill-opacity="0.6–0.8"` + thin light-color strokes; arcs via ``. Looks like a live dashboard floating above the scene. +42. **Background image + glassmorphism UI panels** — image is the visual world; on top, draw UI elements (semi-transparent panels, progress arcs, status badges, indicators). Panels use `fill-opacity="0.6–0.8"` + thin light-color strokes; use exact native `arc` / `blockArc` presets when they fit, and custom `A` geometry only for data-defined arcs they cannot express. Looks like a live dashboard floating above the scene. 43. **Background image + native data chart on top** — AI image generation cannot produce accurate data charts. Solution: use an AI-generated dashboard image as **visual reference only** (clearly labeled as such in a caption), and draw the actual chart with native SVG primitives (`` axes, `` series, `` data points) directly on or next to it. Required marker if exporting: `` inside the chart group. @@ -154,9 +159,11 @@ This is the family that opens up the largest design space and the one AI is most | 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. + **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 render --operation fragment --source --source --id ` 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. + **Choosing between #92 and #82 / #100**: different images per cell (#92) + are peers. One registered source means one edit object (#82) or independent + same-source objects (#100). 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**). @@ -202,27 +209,45 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per 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. +24. **Custom path crop (blob, leaf, silhouette)** — use `` only when circle, ellipse, rounded-rect, and polygonal crops cannot faithfully express the silhouette. PowerPoint export translates the necessary custom contour 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`. +82. **One image shattered across separated shapes (Merge Shapes look)** — clip +one `` with one `` containing disjoint closed subpaths. Size the +image over their union so the scene remains continuous; export yields one +picture with `custGeom`. Use `shape_boolean_svg.py render` `union` / `combine` +for non-trivial contours and obey +[`shared-standards-core.md`](./shared-standards-core.md) §1.2. Distinct from +#24 (one contour), #47–#56 (different sources), and #100 (several pictures). - **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. +100. **Same-source addressable crops** — repeat one exact `href` in independent +nested crop wrappers with different source-unit `viewBox` values. They export +as separate native picture objects for editing and Morph while assembling one +registered scene without slice assets. Follow +[`executor-image.md`](./executor-image.md) §1. Unlike #82 this yields several +pictures; unlike #84 registration remains exact. - **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. + **Registration construction**: choose one visible container union + `U = (ux, uy, uw, uh)` and one source region + `S = (sx, sy, sw, sh)`. For a container + `F = (x, y, w, h)`, derive its source-unit crop as + `Sx = sx + (x-ux)/uw × sw`, `Sy = sy + (y-uy)/uh × sh`, + `Sw = w/uw × sw`, and `Sh = h/uh × sh`. Use that result as the nested + wrapper `viewBox`; do not choose each crop by eye and do not apply + independent `cover`. This makes irregular heights and gaps behave like + windows cut from one continuous image while keeping every window a native + picture object. 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. + **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`. The `subtract` operation of `shape_boolean_svg.py render` emits this contour directly; follow [`native-shape-authoring.md`](./native-shape-authoring.md) §6. **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. + Keep the displacement small and consistent in direction; large or random offsets stop reading as a decision and start reading as a rendering bug. The `fragment` operation of `shape_boolean_svg.py render` returns each atomic region as a separately addressable path when the pieces must be individually positioned; follow [`native-shape-authoring.md`](./native-shape-authoring.md) §6. 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. @@ -230,17 +255,22 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per 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 +## Overlay, Scrim & Vignette Treatments + +**Hard rule — visual masking is not SVG ``**: Masking in a design brief +names the intended appearance only. Realize it with crop/clip geometry, +scrim/overlay shapes, a real cutout path, or a baked-alpha asset; never emit +`` or `mask="url(...)"`. > **Crop displacement (HARD rule for text over images).** `preserveAspectRatio="xMidYMid slice"` center-crops whatever the source aspect ratio does not cover — when source and display aspects differ, the subject can land under the text column even if the prompt asked for it on the "focal side". Before layering text on a slice-cropped image: estimate the crop from the aspect-ratio difference, and keep the **entire text column on the scrim's opaque plateau** — text must never start inside a gradient's transition zone. When the subject position is unverified, fall back to an opaque treatment (`#30` at high opacity, or a solid panel) instead of a two-stop scrim (`#29`). -27. **Linear gradient mask for text legibility** — `` in `` (set `x1/y1/x2/y2` for direction) + overlay ``. Most common is top-to-bottom darkening on full-bleed cover images. +27. **Linear gradient scrim for text legibility** — `` in `` (set `x1/y1/x2/y2` for direction) + overlay ``. Most common is top-to-bottom darkening on full-bleed cover images. 28. **Radial gradient vignette** — `` with dark outer stops; overlay ``. Focuses attention by darkening the periphery. 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–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**). +30–31. **Flat overlay wash** — one `` over the image: neutral `#000000` / `#FFFFFF` 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**). > **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. @@ -281,7 +311,7 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per 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. +33. **Radial spotlight overlay — 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. @@ -301,13 +331,13 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per ## 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. +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 straight `` or an exact native bent/curved Connector contour; use a custom Bézier only when the leader must avoid meaningful image content. 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 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). +64. **Image with embedded text rendered by the AI** — text becomes part of the artwork: decorative lettering, artistic wordmark, 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. Authoritative titles and anything that must stay correct or editable go in the SVG `` layer (#65). 65. **Image with NO text — labels added as native SVG** — generate the image with explicit "no text, no letters, no numbers, no signs" instruction (`text_policy: none`), then place all labels as `` overlays. The right call when labels will be reworded, must stay exact, or carry data that must stay editable — pair with `#64` when stable visual identifiers (axis labels, subplot letters, unit symbols) belong inside the image instead. @@ -321,15 +351,17 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per 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. +72. **Baked-alpha image-to-image blend** — a genuinely soft blend between two images requires a precomposited bitmap or source images with baked alpha. An ordinary gradient overlay can conceal the join only when both images fade through the same solid bridge color; it is not a per-pixel mask and cannot blend arbitrary imagery. -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. +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 carry an edge treatment. **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. + **Registration boundary**: the sampled shape and page background must remain fixed in the same root coordinates. Moving, resizing, rotating, or morphing the sampled shape moves its pixels with it and exposes the seam; animate independent content above or below the stationary shape instead. + 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. + - **A stationary conceal/reveal patch** — it can cover one fixed region while independent content enters or leaves above or below it. - **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. @@ -352,7 +384,7 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per 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. + **Motion contract**: keep the same image file and compatible direct-root group/container geometry on every participating page. Exporting with `-t morph` alone leaves object matching to PowerPoint's heuristic; stable ids and compatible geometry improve the chance of a camera pan but do not prove it. When the pan must be deterministic, run the custom motion stage and declare the adjacent objects in `animations.json` `morph.pairs` ([`animations.md`](./animations.md) §2.1); the pair may bind different source/destination ids while preserving compatible object kinds. Changing the file or endpoint geometry still changes the visual action and may reduce an unpaired Morph to a cross-fade. --- @@ -360,7 +392,9 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per A page is built by layering. Pick one or more **Primary Structures** (Part 1) as the page's bones, then add any number of **Modifier Layers** (Part 2) for finish. Both stack — the question on each page is "is the next layer still earning its place", not "have I exceeded a quota". -**Cross-primary combinations are encouraged.** A side-by-side comparison (#48) where each side is annotated with bezier-leader cards (#38) is one page, not a violation. A 3×3 grid (#9) whose center cell is upgraded to an image-as-canvas with KPI overlay (#40) reads as one composition. The old reflex "one primary per page" tends to under-use the catalog — combine when the page asks for it. +**Cross-primary combinations are encouraged.** A side-by-side comparison (#48) where each side is annotated with Shape-first leader cards (#38) is one page, not a violation. A 3×3 grid (#9) whose center cell is upgraded to an image-as-canvas with KPI overlay (#40) reads as one composition. The old reflex "one primary per page" tends to under-use the catalog — combine when the page asks for it. + +**Reference — motion-aware layer vocabulary, not a constraint**: When focus, comparison, evidence, or reveal order serves the page, the Image-as-Canvas + Native Overlay and Multi-Image Compositions families may expose independently meaningful visible units. `#62` can separate full view from same-source detail; `#63` can isolate a cutout foreground; `#74` / `#77` / `#78` / `#80` can separate image-led navigation or evidence units. These are composition layers, not effect assignments, and no pattern owes animation. `#72` is a static image blend in the fully revealed page, not a PowerPoint page transition. **Modifier stacking pattern that works in practice** — observed on real content pages combining one Primary with four Modifiers: @@ -385,13 +419,13 @@ 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` | -**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. +**Reach for the boolean-geometry family (#82–#100) 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. ## Hard Constraints -- Long body copy, data points, numeric labels, and Chinese text always go in the SVG layer — never baked into the image. +- Page chrome, body copy, captions, and data values that must remain exact or editable stay in SVG. Stable figure-internal identifiers, axis/unit labels, panel markers, or lettering that is deliberately part of the artwork may be image-owned under `text_policy: embedded`, regardless of script or length. - Project-wide SVG compatibility rules start at [`shared-standards-core.md`](./shared-standards-core.md), whose routing table names each conditional owner. This catalog neither restates nor relaxes that contract; each pattern records only its diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-spec.md index 452313d6..7750d180 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-spec.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-spec.md @@ -2,9 +2,9 @@ # Image Layout Specification -Sizing reference for side-by-side or multi-image pages. Use only after Strategist selects the composition; this file never selects layout or crop policy. +Sizing reference for side-by-side or multi-image pages. Use after Strategist proposes a preferred composition; this file never locks layout or crop policy. -**Selected pattern, flexible geometry**: Let original aspect ratio inform the container. A `no-crop` asset displays completely; an `adaptive` asset may use `meet` or a focal-safe `slice`. Rework geometry within the selected pattern when either mode produces weak hierarchy, unsafe cropping, or excessive dead space; changing the pattern requires an upstream Design Spec update. +**Preferred pattern, Executor-owned realization**: Let original aspect ratio inform the container. Every slide using a `no-crop` asset keeps one complete visible instance; a same-slide same-source detail crop may supplement it. An `adaptive` asset may use `meet` or a focal-safe `slice`. Rework geometry or choose another composition when the recommendation produces weak hierarchy, unsafe cropping, excessive dead space, or a poorer communication result. Preserve binding resource/content/crop constraints; a pattern-only change needs no upstream update. > **Scope**: The ratio tables and formulas are calculation aids for a selected side-by-side or multi-image plan. Hero, background, accent, and other compositions stay outside this file. Layout never overrides the `no-crop` boundary owned by [`strategist-image.md`](./strategist-image.md) and [`executor-image.md`](./executor-image.md). @@ -13,13 +13,13 @@ Sizing reference for side-by-side or multi-image pages. Use only after Strategis ## Layout Decision Flow ``` -1. Read the selected narrative intent, hierarchy, and primary/modifier ids from Strategist's plan. -2. If the selected pattern is not side-by-side or multi-image, this spec does not apply. +1. Read the narrative intent, hierarchy, and preferred primary/modifier ids from Strategist's plan. +2. If the preferred or Executor-selected pattern is not side-by-side or multi-image, this spec does not apply. 3. Read the asset's `no-crop` boundary and original dimensions; calculate ratio (width/height). 4. Use the tables as candidate structures, not an automatic selector. 5. Calculate the image/text rectangles, then choose `meet` or focal-safe `slice` within the crop boundary. -6. Revise geometry within the selected ids when the result weakens hierarchy, legibility, or required image content. -7. Return upstream for a different pattern, resource, role, or crop boundary; Executor never rewrites selection. +6. Revise geometry or choose another composition when the result weakens hierarchy, legibility, or required image content. +7. Return upstream only for a different resource, role, must-use decision, crop boundary, or another binding constraint; Executor owns pattern-only realization changes. ``` **When to run**: after `analyze_images.py` has produced current dimensions and a side-by-side or multi-image composition is under consideration. Skip this sizing reference for other page structures. @@ -63,8 +63,8 @@ Image height = W / R = 1160 / R px Text area height = H - image height - gap(20px) Review: if the remaining text area cannot carry the planned copy legibly, -rebalance the rectangles within the selected pattern; otherwise return upstream -for a Design Spec pattern update. +rebalance the rectangles or choose another composition while preserving binding +resource/content/crop constraints. ``` ### Left-Right Layout Calculation @@ -83,7 +83,7 @@ Image height = image width / R Text area width = W - image width - gap(20px) ``` -**Review**: if the remaining text area cannot carry the planned copy legibly, rebalance the image/text rectangles within the selected pattern; otherwise return upstream for a Design Spec pattern update. +**Review**: if the remaining text area cannot carry the planned copy legibly, rebalance the image/text rectangles or choose another composition while preserving binding resource/content/crop constraints. --- @@ -108,7 +108,7 @@ Image: 773x560 (left), Text area: 367x560 (right) → 7:3 left-right ``` Original: 1820x1040, R=1.75 Strategist compares top-bottom: image height=663, text area=-43 ❌ -Strategist selects left-right: image 780x446 (left), text area 360x600 (right) → 7:3 left-right +Strategist recommends left-right: image 780x446 (left), text area 360x600 (right) → 7:3 left-right ``` --- @@ -166,7 +166,7 @@ Image positions: (60, 390) 570x290 (650, 390) 570x290 ``` -> Multi-image slides: decide `meet` or focal-safe `slice` per asset. Keep `no-crop` images complete; do not force every image into the same scaling mode merely for grid uniformity. +> Multi-image slides: decide `meet` or focal-safe `slice` per asset. On every slide using a `no-crop` source, keep one complete instance; a same-slide same-source detail crop may supplement it. Do not force every image into the same scaling mode merely for grid uniformity. --- @@ -176,8 +176,8 @@ Image positions: |-----------|-----------------| | Proportion does not reflect information weight | Rebalance image and text rectangles | | Container conflicts with the native ratio | Change the container, choose `meet`, or use a focal-safe crop | -| Required pixels, labels, identity, or evidence would be cropped | Use `preserveAspectRatio="xMidYMid meet"` and recompose around the complete image | -| Text area cannot carry the planned copy legibly | Increase its area within the selected pattern; otherwise return upstream | +| Required pixels, labels, identity, or evidence would be cropped | Use a legal anchor with `meet` and recompose around the complete image | +| Text area cannot carry the planned copy legibly | Increase its area or choose another composition while preserving binding constraints | --- @@ -188,10 +188,10 @@ This spec only defines layout calculation. Write computed fields into the Image | Field | Meaning | |-------|---------| | `Ratio` | Original image width / height | -| `Layout pattern` | Strategist-selected catalog pattern; semantic composition fixed, geometry flexible | -| `Crop Policy` | `no-crop` protects complete pixels; `adaptive` lets Executor choose `meet` or focal-safe `slice` | +| `Layout pattern` | Strategist-recommended catalog pattern; preferred composition, Executor-owned realization | +| `Crop Policy` | `no-crop` requires one complete instance; `adaptive` lets Executor choose `meet` or focal-safe `slice` | | `Reference` | Optional calculated image/text rectangles, focal notes, and composition intent | -| `spec_lock.md images` value | ` | source= | pattern= | crop=` | +| `spec_lock.md images` value | ` | source= | pattern= | crop=`; source/crop exactly project §VIII, while pattern preserves its ordered catalog ids (or normalized custom prose) as a recommendation, not a geometry/realization lock | For SVG `` syntax, path rules, `preserveAspectRatio`, external refs, and Base64 embedding: see [`svg-image-embedding.md`](svg-image-embedding.md). @@ -205,6 +205,8 @@ Complete display (`no-crop` assets such as data charts): preserveAspectRatio="xMidYMid meet"/> ``` +**Hard rule — no-crop placement**: On every slide using the source, retain one visible complete instance with one of the nine legal anchors plus `meet`, never `none`, and no `clip-path`, `mask`, clipping overflow, or nested `` viewport. An auxiliary same-slide detail or lens may crop the same source only while the complete instance remains visible. Definitions and hidden nodes are not placements; an image materialized through a visible local `` is. + Crop-to-fill (an `adaptive` asset with a verified focal-safe crop): ```xml @@ -218,12 +220,12 @@ Crop-to-fill (an `adaptive` asset with a verified focal-safe crop): ## Automation Tool ```bash -python3 scripts/analyze_images.py /images # Default: PPT 16:9 +python3 scripts/analyze_images.py /images # Infer project canvas; fallback PPT 16:9 python3 scripts/analyze_images.py /images --canvas ppt43 # PPT 4:3 python3 scripts/analyze_images.py /images --canvas xiaohongshu # Xiaohongshu ``` -`--canvas` selects target format (default `ppt169`). The tool computes a top-bottom / left-right candidate, image display area, and text area from the formulas above. Treat its output as planning input; record the composition actually selected for the page. +`--canvas` explicitly overrides the project-derived format; `ppt169` is only the fallback. The tool computes a top-bottom / left-right candidate, image display area, and text area from the formulas above. Treat its output as planning input; record the composition actually selected for the page. --- @@ -231,5 +233,5 @@ python3 scripts/analyze_images.py /images --canvas xiaohongshu # | Role | Responsibility | |------|---------------| -| **Strategist** | Run `analyze_images.py`, select the catalog pattern/resources, and record the crop boundary | -| **Executor** | Realize the selected pattern for the actual asset/page while preserving its ids, role, source, must-use, and `no-crop` constraints | +| **Strategist** | Run `analyze_images.py`, recommend a catalog pattern, select resources, and record the crop boundary | +| **Executor** | Choose the actual composition for the asset/page while preserving role, source, must-use, content, and `no-crop` constraints | diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-renderings/nature.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-renderings/nature.md index fa9d1ff7..e35d0324 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-renderings/nature.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-renderings/nature.md @@ -20,13 +20,13 @@ Organic earthy illustration with botanical and natural motifs. Soft curves, orga ## 3. Using the deck's HEX values -nature has a strong tendency toward **earth-toned natural palette**. When the deck's HEX aligns (warm-earth, nature-organic palette), use directly: +nature has a strong tendency toward earth-toned material cues, but the deck's locked color roles remain authoritative: - Primary HEX: dominant nature color (deep green, deep earth brown) - Secondary HEX: light atmospheric tone (cream, soft sky) - Accent HEX: a small natural pop (a flower, a fruit, a leaf vein) -If the deck's HEX is cool / corporate, nature may be the wrong rendering — consult the compatibility matrix. +If the deck's anchors are cool or corporate, keep their semantic roles and translate the organic material/lighting into that family. If this destroys the intended natural character, choose a different rendering during planning; never invent an image-only palette or alter the deck colors during execution. --- @@ -34,4 +34,4 @@ If the deck's HEX is cool / corporate, nature may be the wrong rendering — con **Snippet A — half-page sustainability scene, text_policy: none** -> Organic illustration style with botanical motifs. A soft natural scene of a single tree on a gentle hill, with curving organic forms and earthy color palette. Tree foliage in primary deep forest green `#166534` with subtle leaf-vein texture at 12% opacity. Tree trunk in warm earth brown using a darker shade. Hill in soft secondary cream `#FEF3C7` with very gentle curve. Sky in pale soft blue suggesting morning. A small accent of warm yellow `#D4AF37` flowers at the base of the tree. No hard outlines — forms defined by color contrast and soft organic edges. Composed as a 600×800 half-page block with 12% inner padding. NO text or labels. Color values are rendering guidance only. \ No newline at end of file +> Organic illustration style with botanical motifs. A soft natural scene of a single tree on a gentle hill, with curving organic forms and earthy color palette. Tree foliage in primary deep forest green `#166534` with subtle leaf-vein texture at 12% opacity. Tree trunk in warm earth brown using a darker shade. Hill in soft secondary cream `#FEF3C7` with very gentle curve. Sky in pale soft blue suggesting morning. A small accent of warm yellow `#D4AF37` flowers at the base of the tree. No hard outlines — forms defined by color contrast and soft organic edges. Composed as a 600×800 half-page block with 12% inner padding. NO text or labels. Color values are rendering guidance only. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-searcher.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-searcher.md index 55fcc9ba..728a13de 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-searcher.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-searcher.md @@ -48,16 +48,14 @@ Strict: provider chain, license filter = cc0,pdm,pexels,pixabay | Provider | Config | Strength | |---|---|---| -| Openverse | zero-config | fallback aggregator: Wikimedia + Flickr + museums + rawpixel | -| Wikimedia Commons | zero-config | educational, scientific, geographic, historical | | Pexels | recommended: `PEXELS_API_KEY` (free, [signup](https://www.pexels.com/api/)) | modern stock photography, people, workplace, lifestyle | | Pixabay | recommended: `PIXABAY_API_KEY` (free, [signup](https://pixabay.com/api/docs/)) | broad type coverage including photos and illustrations | +| Openverse | zero-config | fallback aggregator: Wikimedia + Flickr + museums + rawpixel | +| Wikimedia Commons | zero-config | educational, scientific, geographic, historical | Default chain (when `--provider` is unset): -``` -openverse → wikimedia → pexels (if PEXELS_API_KEY set) → pixabay (if PIXABAY_API_KEY set) -``` +`pexels` (when keyed) → `pixabay` (when keyed) → `openverse` → `wikimedia`. Keyed providers without an API key are silently skipped — not an error. @@ -67,23 +65,16 @@ Keyed providers without an API key are silently skipped — not an error. ## 4. Intent → Query Translation -Web image APIs match keywords against image metadata, not semantic embeddings. `simplify_query` automatically: +Keep two layers distinct: -1. Strips HEX color codes (`#1E3A5F`) and parentheticals (`(corporate vibe)`) -2. Drops hard-noise words: brand names, generic filler -3. Drops soft-noise words (`ai`, `tech`, `platform`, `professional`, `editorial`, `photo`, `background`) — only when concrete nouns remain -4. Caps at 4 words -5. **Fail-open**: if filtering empties the query, return the original - -Then `build_query_progression` tries: original → simplified (4 words) → simplified (3 words). First non-empty hit wins. - -**Per-row web Reference grammar**: - -| Segment | Rule | +| Layer | Owner and grammar | |---|---| -| Subject | Use 1-2 concrete nouns only: `offshore wind farm`, `Xiamen skyline`, `boardroom meeting` | -| Quality cues | **DO NOT ADD QUALITY CUES** like `professional editorial photography` or `clean composition`. These APIs use exact keyword matching; adding long adjectives will result in 0 matches. | -| Language | For Chinese landmarks: use precise Chinese names (e.g., `磁器口古镇`) if specifically targeting `--provider wikimedia`. For general stock providers (Pexels/Pixabay), use simple English nouns (e.g., `Chongqing Jiefangbei`); do NOT use complex Chinese sentences or overly long English descriptive strings which fail on these platforms. | +| Design Spec §VIII `Reference` | Strategist's complete visual intent: exact subject, desired view/mood, focal or quiet region, and crop-safety constraints. Positive quality cues are valid here. | +| `image_queries.json.items[].query` / positional query | Image_Searcher's provider keyword string: 1–4 concrete entity or identity words only; omit mood, quality, composition, HEX, and negative wording. | + +Web APIs match metadata, not semantic intent. For ad-hoc long positional input, `simplify_query` strips noise and caps the fallback at four words, but a pipeline manifest should already contain the short provider query. For Chinese landmarks, use the precise Chinese name with Wikimedia; for stock providers, use short English identity terms. + +Image_Searcher consumes the locked Reference and never rewrites `design_spec.md` or `spec_lock.md`. A candidate either satisfies that existing subject/focal/crop intent, or the role re-queries once and then marks `Needs-Manual`. When the subject is an exact entity (landmark / person / company / product / venue), write `required_terms` at the same time you write the row's `query`. Use one required group per identity anchor and `|` for aliases / translations, e.g. `["Chongqing|重庆", "Jiefangbei|解放碑|Liberation Monument"]`. This keeps the query short for provider search while preventing metadata-ranked wrong entities from being accepted. @@ -93,11 +84,11 @@ Do **not** loosen `required_terms` to generic category words just to improve cov > Note: Keyword APIs search negative words literally. -| ✅ Good Reference (intent) | ❌ Avoid | +| §VIII Reference (intent) | Provider query | |---|---| -| "Offshore wind farm at dusk, aerial view, professional editorial photography" | "professional editorial photography background" | -| "Diverse engineering team collaborating around a laptop, modern office, natural light" | "use Openverse, search 'team'" | -| "Sunlit forest path in autumn, clean composition, high-resolution photography" | "Hero image, dramatic lighting" | +| "Offshore wind farm at dusk, aerial view, quiet sky on the left for safe crop" | `offshore wind farm` | +| "Diverse engineering team around a laptop, modern office, natural light" | `engineering team laptop` | +| "Chongqing Jiefangbei monument, full structure visible, landscape frame" | `Chongqing Jiefangbei monument` | --- @@ -120,13 +111,14 @@ python3 scripts/image_search.py "" \ | `--slide` | no | `""` | Slide ID from resource list (recorded in manifest) | | `--purpose` | no | `""` | `background` / `hero` / `side` / `accent` | | `--orientation` | no | `any` | `any` / `landscape` / `portrait` / `square` | +| `--min-width / --min-height` | no | `1200 / 800` | Actual downloaded-pixel floors; `--from-url` honors explicit lower overrides | | `--provider` | no | (chain) | Pin one provider | | `--strict-no-attribution` | no | off | Restrict to no-attribution licenses; refuse CC BY / CC BY-SA | | `--require-terms` | no | — | Entity-safety gate for exact subjects. Repeatable; comma separates required groups; `A|B` means aliases within one group. Example: `--require-terms Chongqing --require-terms "Jiefangbei|Liberation Monument"` | | `--manifest` | no | (default) | Override manifest path | | `--save-candidates` | no | off | Escalation only: also keep a review pool in `candidates//`. Default downloads just the best match (+ a review copy) | | `--max-candidates` | no | `4` | Pool size when `--save-candidates` is set | -| `--promote` | no | — | Promote a reviewed candidate to the target filename, e.g. `--promote candidate_03.jpg --filename team.jpg -o ` | +| `--promote` | no | — | Human-selected candidate override; low resolution warns but does not block promotion | | `--from-url` | no | — | Manual replace: download a user-supplied image URL into `--filename` (recorded `license_tier: manual`); works without a multimodal model | ### Batch mode (≥ 2 web rows) — preferred @@ -162,7 +154,7 @@ Use `required_terms` for **exact-entity images**: landmarks, people, companies, For less-covered local attractions, keep the strict identity gate rather than progressively deleting location anchors or replacing proper names with category words. If strict metadata cannot prove the entity, mark the row `Needs-Manual` and use the manual URL path when the user supplies a confirmed source. -The runner searches all `Pending` / `Failed` rows concurrently, appends each success to `image_sources.json` (the credit source of truth, idempotent on `filename`), and writes status back into `image_queries.json` — `Sourced` on success, `Needs-Manual` when the full provider/stage chain is exhausted. Status is saved after each completion, so an interrupted run preserves finished rows; re-running skips terminal rows. A single `web` row may still use single-query mode above. +The runner first revalidates every `Sourced` row against its readable file, requested dimensions, and `image_sources.json` entry; drift returns that row to `Failed`. It then searches all `Pending` / `Failed` rows concurrently, appends each success to the provenance manifest, and writes status back into `image_queries.json`: `Sourced` on success, retryable `Failed` on provider/download errors, and terminal `Needs-Manual` only after a clean provider/stage exhaustion. Status is saved after each completion. A single `web` row may still use single-query mode above. **Pacing**: free providers (Wikimedia/Openverse) are rate-sensitive, so batch concurrency defaults to a modest **3** (`--concurrency N`, or `IMAGE_SEARCH_CONCURRENCY` env). Use `--concurrency 1` to restore strict one-at-a-time pacing. Single-query mode is one request at a time by nature. @@ -180,12 +172,12 @@ Do not tune this into a visual taste engine. The scorer prevents obvious metadat ### Suitability review — with or without a multimodal model -A metadata-ranked top hit is *downloadable and token-relevant*, not necessarily *visually suitable* — `score_candidate` never sees pixels. So a web best match should be reviewed before it is trusted, by whichever reviewer is available: +A metadata-ranked top hit is *downloadable and token-relevant*, not necessarily *visually suitable* — `score_candidate` never sees pixels. Review it against the locked §VIII Reference and Crop Policy before it is trusted: -- **Multimodal model**: each download writes a downscaled review copy to `images/.review/.jpg` (the placed asset stays full-resolution; the bounded copy just keeps a very large original safe and quick to open). Read it and judge fit — subject, mood, quality, not just topic overlap. +- **Multimodal model**: each download writes a downscaled review copy to `images/.review/.jpg` (the placed asset stays full-resolution). Judge subject identity, intended mood/view, focal or quiet region, and whether the locked crop policy remains safe. - **Non-multimodal model (no vision)**: do **not** pretend to confirm. Hand off to a human — surface each web image's `source_page_url` from `image_sources.json` (live preview also shows the placed result) and let the user judge. -For exact-entity rows, suitability has two gates: `required_terms` first enforces metadata identity, then the `.review` image confirms the pixels actually show the right subject well enough. A row can still be rejected after passing `required_terms` if the visual is weak, cropped badly, or only tangentially shows the subject. +For exact-entity rows, suitability has two gates: `required_terms` first enforces metadata identity, then the `.review` image confirms the pixels actually show the right subject and satisfy the locked focal/crop intent. Passing metadata never authorizes changing that intent downstream. Never treat a generic `required_terms` pass as acceptance. For example, matching `Ground Fissure` can return an unrelated transit station named Yunlong, and matching `stone pillar` can return a different scenic area. If the proper name / geography cannot be retained, stop at `Needs-Manual`. @@ -266,10 +258,10 @@ Every successful download appends or replaces one entry keyed on `filename`: | `metadata_dimensions` | Present only when upstream-claimed size differs from the saved file (preview vs original). Informational only. | | `license_tier` | Drives Executor's attribution decision: `no-attribution` / `attribution-required` for provider-sourced images, or `manual` for a user-supplied `--from-url` replacement (embed only; rights/credit are the user's responsibility). | | `attribution_required` | Boolean alias of `license_tier == "attribution-required"`. | -| `attribution_text` | Pre-rendered canonical credit string. **Use as-is; do not regenerate.** | +| `attribution_text` | Canonical credit source. Preserve its author/provider/license facts; compress only through §7's visual grammar rather than inventing or dropping identity. | | `stage` | `all` by default, or `no-attribution-only` when strict mode is used. | -> Manifest is **idempotent on `filename`**. Rerunning the CLI replaces that entry; other entries are preserved. +> Manifest is **idempotent on `filename`** and written atomically. Rerunning replaces that entry while preserving all others. An existing unreadable/non-object manifest blocks the write instead of being overwritten as fresh state. --- @@ -324,9 +316,10 @@ Extends [`image-base.md`](./image-base.md) §6. | No candidates from any provider in either stage | Mark row `Needs-Manual`. Suggest: shorter query, drop `--strict-no-attribution`, or set keyed provider's API key. | | Single candidate fails to download (HTTP 403/404) | Dispatcher auto-falls through to the next ranked candidate. No user action. | | All candidates from one provider fail | Dispatcher moves to the next provider in the chain. | +| Provider/network failure remains after dispatch | Mark row `Failed`; a later batch run retries it. | | Keyed provider has no API key | Silently skipped. Not an error. | -CLI exit: `0` on success, `1` only when no acceptable image was found across the entire dispatch matrix. +CLI exit: `0` when all attempted rows resolve; `1` while any row remains `Failed` or `Needs-Manual`. --- @@ -334,7 +327,7 @@ CLI exit: `0` on success, `1` only when no acceptable image was found across the Reference field is **intent description**, not a query. See [`image-base.md`](./image-base.md) §8 for the rule. -If the description is verbose, that's fine — `simplify_query` handles it. +Keep it intact as the acceptance contract. Derive a separate 1–4 word provider query; do not pass the Reference verbatim or rewrite it after search. --- @@ -350,7 +343,7 @@ Executor reads `image_sources.json` per slide that uses a Sourced image. For eac Executor does not interpret raw license strings — `license_tier` is sufficient. -`svg_quality_checker.py` verifies this handoff before post-processing: if an attribution-required image is referenced without visible `CC BY` / `CC BY-SA` credit text, the SVG fails the quality gate. +`svg_quality_checker.py` verifies this handoff before post-processing: a referenced attribution-required image needs its own visible author + CC BY / CC BY-SA credit; one generic deck-level CC token cannot satisfy several images. --- @@ -359,8 +352,8 @@ Executor does not interpret raw license strings — `license_tier` is sufficient In addition to the shared checkpoint in [`image-base.md`](./image-base.md) §10: - [ ] Every web row has a downloaded file at `project/images/` OR is marked `Needs-Manual` -- [ ] Each `Sourced` web image was reviewed for fit — a multimodal model via its `images/.review/.jpg` copy, otherwise handed to the user via `source_page_url`; a poor fit was re-queried, replaced with `--from-url`, escalated via `--save-candidates` + `--promote`, or marked `Needs-Manual` +- [ ] Each `Sourced` web image was reviewed against the locked Reference/Crop Policy — a multimodal model via `images/.review/.jpg`, otherwise handed to the user via `source_page_url`; a mismatch was re-queried, replaced, escalated, or marked `Needs-Manual`, never repaired by rewriting the locked intent - [ ] Each `Sourced` row has a manifest entry with valid `license_tier` and non-empty `attribution_text` (except `manual` `--from-url` rows, which carry no `attribution_text`) -- [ ] Any `attribution-required` image has visible inline credit text in the corresponding SVG +- [ ] Any `attribution-required` image has visible author + license credit in every SVG that references it - [ ] `metadata_dimensions` warnings surfaced when downloaded preview is much smaller than upstream-claimed size - [ ] `Needs-Manual` rows include the failure reason diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/_index.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/_index.md index 2ddc1808..9b160500 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/_index.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/_index.md @@ -9,10 +9,10 @@ A **type** describes the **internal geometric composition skeleton** of a local **Type *is not***: - *not* "what this image is for in the PPT page" — that's `page_role` (`local` vs `hero_page`, see [`image-generator.md`](../image-generator.md) §1) -- *not* "what subject occupies the image" — single subject, single person, big number, no subject: these are all expressible through §4.1 hero-page primitives or natural-language prompt description, not through types +- *not* "what subject occupies the image" — single subject, single person, big number, no subject: these are all expressible through §4.1 no-type primitives or natural-language prompt description, not through types - *not* a high-level asset category — the row's `Purpose` + `Reference` columns in `design_spec.md §VIII` already carry that, no separate vocabulary needed -**When to skip type entirely** — when `page_role: hero_page` (the image is the page's main voice: cover, chapter divider, mood transition, signature stat, closing quote), do **not** pick a type. Instead describe the composition directly using the four primitives in [`image-generator.md`](../image-generator.md) §4.1 (single-subject / portrait / typographic / atmospheric). The 11 types below are for local infographic blocks only. +**When to skip type entirely** — every `hero_page` omits type and uses the prose primitives in [`image-generator.md`](../image-generator.md) §4.1. A local single-subject or single-person region also omits type and uses Primitive A/B sized to that region. The 11 types below are for local structural infographic blocks only. --- @@ -53,7 +53,8 @@ For each row in `design_spec.md §VIII Image Resource List` where `page_role: lo | History / evolution / roadmap / timeline | `timeline` | | Offices / market presence / regions / supply chain / geography | `map` | | Team / lifestyle / story / scenario / case (group, with environment) | `scene` | -| Cover / chapter divider / mood transition / big number / hero quote / single-subject hero / single-person headshot | **No type — use `page_role: hero_page` + [`image-generator.md`](../image-generator.md) §4.1 primitives** | +| Cover / chapter divider / mood transition / big number / hero quote / single-subject hero | **No type — use `page_role: hero_page` + [`image-generator.md`](../image-generator.md) §4.1 primitives** | +| Local single object / single-person headshot / bio portrait | **No type — keep `page_role: local`; use §4.1 Primitive A/B for the region** | `text_policy` and `page_role` are decided per image — see each type file's variants section and the page's communication goal. @@ -83,8 +84,8 @@ For `page_role: hero_page` images, default container is the slide canvas (e.g. 1 ## 4. How to use -1. For each `page_role: local` row in the Image Resource List, pick the type using the auto-selection table above. -2. For each `page_role: hero_page` row, **skip type selection** — go straight to [`image-generator.md`](../image-generator.md) §4.1 primitives. +1. For each local structural infographic row, pick the type using the table above. +2. For each `hero_page` or local single-subject / portrait row, skip type selection and use the applicable [`image-generator.md`](../image-generator.md) §4.1 prose. 3. `read_file image-type-templates/.md` — only the types actually used in this deck. Most decks use 2-4 types; load each at most once. 4. Apply the type's composition skeleton alongside the locked deck-wide rendering and deck color roles. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/cycle.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/cycle.md index 3160a2db..1c08af04 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/cycle.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/cycle.md @@ -39,7 +39,7 @@ Sample fragment: ### 3.2 `text_policy: embedded` -Self-contained cycle diagram with step names typeset into the artwork. Keep step names to single English words ("PLAN", "DO", "CHECK", "ACT") in a font family echoing the deck's body typography. +Self-contained cycle diagram with step names typeset into the artwork. Keep each name concise and stable, use the wording and script required by the content, and echo the deck's body typography. --- @@ -47,4 +47,4 @@ Self-contained cycle diagram with step names typeset into the artwork. Keep step **Snippet A — vector-illustration + cool-corporate PDCA cycle, text_policy: none, 700×700** -> Clean flat vector illustration of a closed-loop process cycle. Four step nodes arranged in a perfect circle on the canvas perimeter — at 12 o'clock, 3 o'clock, 6 o'clock, and 9 o'clock positions. Each node is a rounded square (about 130×130px equivalent) filled with primary deep navy `#1E3A5F`, with one simple white iconic symbol centered inside — a clipboard (plan), a hand (do), a magnifying glass (check), a gear (act). Curved directional arrows in accent gold `#D4AF37` connect each node to the next going clockwise, closing the loop. The center of the circle is calm — secondary light gray `#F8F9FA` field with one small accent gold dot at the exact center as the cycle's anchor. Crisp 2px outlines, soft 8% drop shadow under each node. Composed as a 700×700 half-page cycle block with 15% padding. NO text, letters, or step labels anywhere — SVG will overlay all step names. Color values are rendering guidance only. \ No newline at end of file +> Clean flat vector illustration of a closed-loop process cycle. Four step nodes arranged in a perfect circle on the canvas perimeter — at 12 o'clock, 3 o'clock, 6 o'clock, and 9 o'clock positions. Each node is a rounded square (about 130×130px equivalent) filled with primary deep navy `#1E3A5F`, with one simple white iconic symbol centered inside — a clipboard (plan), a hand (do), a magnifying glass (check), a gear (act). Curved directional arrows in accent gold `#D4AF37` connect each node to the next going clockwise, closing the loop. The center of the circle is calm — secondary light gray `#F8F9FA` field with one small accent gold dot at the exact center as the cycle's anchor. Crisp 2px outlines, soft 8% drop shadow under each node. Composed as a 700×700 half-page cycle block with 15% padding. NO text, letters, or step labels anywhere — SVG will overlay all step names. Color values are rendering guidance only. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/framework.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/framework.md index 19016bac..cc270584 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/framework.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/framework.md @@ -65,7 +65,7 @@ Sample fragment to add to the prompt: ### `text_policy: embedded` -A short keyword (1-2 English words) appears inside or beside each satellite / cell / layer. +A concise, stable keyword in the required script appears inside or beside each satellite / cell / layer. Sample fragment: @@ -77,4 +77,4 @@ Sample fragment: **Snippet A — vector-illustration + cool-corporate, hub-and-spokes, `text_policy: none`, 700×700 half-page** -> Clean flat vector illustration with bold geometric shapes and confident solid fills. Crisp 2px outlines, no gradients, single 8% soft drop shadow under elevated elements. The composition is a hub-and-spokes framework: one central rounded-square node in primary deep navy `#1E3A5F` sits at the exact center; four satellite circles in the same navy are evenly distributed around it (top, right, bottom, left), connected to the center by thin straight lines in a darker neutral. Each satellite contains one simple iconic symbol in white fill — a gear, a chart bar, an upward arrow, a chat bubble — chosen for clear recognition at small sizes. Background is calm secondary light gray `#F8F9FA` carrying 65% of the canvas area. Accent gold `#D4AF37` appears only as one thin emphasis ring around the central node — under 5% of canvas area. Composed as a 700×700 half-page block with 16% inner padding on all sides — satellites breathe well within the canvas, no element touches the edge. No text, letters, numbers, or labels anywhere in the image — SVG labels will be added externally. Color values are rendering guidance only — do not display HEX codes or color names as text. Simplified iconic symbols only, no realistic faces. \ No newline at end of file +> Clean flat vector illustration with bold geometric shapes and confident solid fills. Crisp 2px outlines, no gradients, single 8% soft drop shadow under elevated elements. The composition is a hub-and-spokes framework: one central rounded-square node in primary deep navy `#1E3A5F` sits at the exact center; four satellite circles in the same navy are evenly distributed around it (top, right, bottom, left), connected to the center by thin straight lines in a darker neutral. Each satellite contains one simple iconic symbol in white fill — a gear, a chart bar, an upward arrow, a chat bubble — chosen for clear recognition at small sizes. Background is calm secondary light gray `#F8F9FA` carrying 65% of the canvas area. Accent gold `#D4AF37` appears only as one thin emphasis ring around the central node — under 5% of canvas area. Composed as a 700×700 half-page block with 16% inner padding on all sides — satellites breathe well within the canvas, no element touches the edge. No text, letters, numbers, or labels anywhere in the image — SVG labels will be added externally. Color values are rendering guidance only — do not display HEX codes or color names as text. Simplified iconic symbols only, no realistic faces. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/funnel.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/funnel.md index 54b9af0c..4121e3c1 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/funnel.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/funnel.md @@ -40,7 +40,7 @@ Sample fragment: ### 3.2 `text_policy: embedded` -Self-contained funnel diagram where band names are typeset into the artwork. Keep band names to single English words ("AWARE", "LIKE", "BUY", "REFER") in a font family echoing the deck's body typography. High failure risk on 5+ band funnels — stay at 3-4 bands when going embedded. +Self-contained funnel diagram where band names are typeset into the artwork. Keep each name concise and stable in the required script and echo the deck's body typography. When exact/editable labels or narrow bands cannot preserve legibility, move those labels to SVG; otherwise embedded lettering remains valid after visual review. --- @@ -48,4 +48,4 @@ Self-contained funnel diagram where band names are typeset into the artwork. Kee **Snippet A — vector-illustration + cool-corporate marketing funnel, text_policy: none, 600×800** -> Clean flat vector illustration of a marketing conversion funnel. Four horizontal bands stacked vertically, each band centered on the vertical axis and each ~20% narrower than the one above. Band 1 (top, widest): primary deep navy `#1E3A5F` solid fill, with a simple white megaphone icon centered on the left. Band 2: secondary lighter navy tint, with a heart icon. Band 3: accent gold `#D4AF37`, with a shopping-cart icon. Band 4 (bottom, narrowest): deeper accent gold, with a star icon. Each band has crisp straight edges and 8% drop shadow beneath. Thin secondary cream `#F8F9FA` dividers separate the bands. Background is calm secondary cream. Composed as a 600×800 portrait funnel block with 12% padding. NO text, letters, numbers, or stage labels anywhere — SVG will overlay all band names. Color values are rendering guidance only. \ No newline at end of file +> Clean flat vector illustration of a marketing conversion funnel. Four horizontal bands stacked vertically, each band centered on the vertical axis and each ~20% narrower than the one above. Band 1 (top, widest): primary deep navy `#1E3A5F` solid fill, with a simple white megaphone icon centered on the left. Band 2: secondary lighter navy tint, with a heart icon. Band 3: accent gold `#D4AF37`, with a shopping-cart icon. Band 4 (bottom, narrowest): deeper accent gold, with a star icon. Each band has crisp straight edges and 8% drop shadow beneath. Thin secondary cream `#F8F9FA` dividers separate the bands. Background is calm secondary cream. Composed as a 600×800 portrait funnel block with 12% padding. NO text, letters, numbers, or stage labels anywhere — SVG will overlay all band names. Color values are rendering guidance only. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/map.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/map.md index 3c2dab88..23965449 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/map.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/map.md @@ -40,7 +40,7 @@ Sample fragment: ### 3.2 `text_policy: embedded` -Self-contained reference map where place names are typeset into the design. High failure risk — image models often misspell place names or place them at wrong coordinates. Prefer `none` + SVG overlay unless the design language genuinely requires in-image labels (vintage cartography, atlas-style poster). +Self-contained reference map where place names are typeset into the design. Use this when lettering is genuinely part of the visual language (for example vintage cartography or an atlas-style poster) and review spelling/location before use. Labels that must remain exact or editable belong in SVG. --- @@ -48,4 +48,4 @@ Self-contained reference map where place names are typeset into the design. High **Snippet A — vector-illustration + cool-corporate world office map, text_policy: none, 1280×720** -> Clean flat vector illustration of a stylized world map. Simplified continental landmasses (recognizable but not photorealistic) in primary deep navy `#1E3A5F` solid fill, positioned across the canvas with approximate geographic accuracy. Ocean/negative space is secondary light gray `#F8F9FA`. Eight small accent gold `#D4AF37` circular markers placed at meaningful office locations — three in North America, two in Europe, one in East Asia, one in South Asia, one in Australia. Each marker has a thin pulsing-glow effect at 30% opacity (a subtle ring around the dot). Two thin accent gold connection lines (slightly curved, suggesting flight paths) connect three of the markers. Crisp 1.5px outlines on the landmasses. No country borders within continents — landmasses are single-color solid silhouettes. Composed as a 1280×720 full-bleed world map with 12% padding. NO text, country names, or labels anywhere — SVG will overlay all labels. Color values are rendering guidance only. \ No newline at end of file +> Clean flat vector illustration of a stylized world map. Simplified continental landmasses (recognizable but not photorealistic) in primary deep navy `#1E3A5F` solid fill, positioned across the canvas with approximate geographic accuracy. Ocean/negative space is secondary light gray `#F8F9FA`. Eight small accent gold `#D4AF37` circular markers placed at meaningful office locations — three in North America, two in Europe, one in East Asia, one in South Asia, one in Australia. Each marker has a thin pulsing-glow effect at 30% opacity (a subtle ring around the dot). Two thin accent gold connection lines (slightly curved, suggesting flight paths) connect three of the markers. Crisp 1.5px outlines on the landmasses. No country borders within continents — landmasses are single-color solid silhouettes. Composed as a 1280×720 full-bleed world map with 12% padding. NO text, country names, or labels anywhere — SVG will overlay all labels. Color values are rendering guidance only. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/matrix.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/matrix.md index 54bacbaa..a80ae9f0 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/matrix.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/matrix.md @@ -41,7 +41,7 @@ Sample fragment: ### 3.2 `text_policy: embedded` -When the matrix's identity comes from its in-image lettering — a stylized SWOT poster with the four letters as visuals, a designer-matrix where axis labels are typeset into the artwork. Keep labels to single English words ("HIGH", "LOW", "GROW", "HOLD"). Specify the font family in the prompt to echo the deck's body typography. +When the matrix's identity comes from its in-image lettering — a stylized SWOT poster with the four letters as visuals, or a designer matrix whose axis labels are part of the artwork. Keep labels concise and stable in the required script. Specify a font family that echoes the deck's body typography; move exact/editable labels to SVG instead. --- @@ -49,4 +49,4 @@ When the matrix's identity comes from its in-image lettering — a stylized SWOT **Snippet A — vector-illustration + cool-corporate SWOT matrix, text_policy: none, 800×800** -> Clean flat vector illustration of a 2×2 strategic matrix. Two perpendicular thin lines in primary deep navy `#1E3A5F` cross at the exact canvas center, dividing the canvas into four equal quadrants. Each quadrant has a subtle background tint: upper-left in pale primary navy at 10% opacity, upper-right in pale accent gold `#D4AF37` at 15% opacity, lower-left in pale gray `#F8F9FA`, lower-right in slightly deeper pale navy at 18% opacity. Each quadrant contains one simple iconic symbol in primary navy, centered within its quadrant — a shield (strength), a lightning bolt (opportunity), a target (weakness), an alert triangle (threat). Each icon occupies about 45% of its quadrant. Small accent gold dots sit at the four outer corners. Composed as an 800×800 reference matrix block with 10% padding. NO text, letters, axis labels, or quadrant names anywhere — SVG will overlay all labels. Color values are rendering guidance only. \ No newline at end of file +> Clean flat vector illustration of a 2×2 strategic matrix. Two perpendicular thin lines in primary deep navy `#1E3A5F` cross at the exact canvas center, dividing the canvas into four equal quadrants. Each quadrant has a subtle background tint: upper-left in pale primary navy at 10% opacity, upper-right in pale accent gold `#D4AF37` at 15% opacity, lower-left in pale gray `#F8F9FA`, lower-right in slightly deeper pale navy at 18% opacity. Each quadrant contains one simple iconic symbol in primary navy, centered within its quadrant — a shield (strength), a lightning bolt (opportunity), a target (weakness), an alert triangle (threat). Each icon occupies about 45% of its quadrant. Small accent gold dots sit at the four outer corners. Composed as an 800×800 reference matrix block with 10% padding. NO text, letters, axis labels, or quadrant names anywhere — SVG will overlay all labels. Color values are rendering guidance only. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/pyramid.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/pyramid.md index c25d5ef5..2f78190a 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/pyramid.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-type-templates/pyramid.md @@ -39,7 +39,7 @@ Sample fragment: ### 3.2 `text_policy: embedded` -Self-contained pyramid with tier names typeset into the artwork. Keep tier names to single English words in a font family echoing the deck's body typography. High failure risk on 5+ tier pyramids — stay at 3-4 tiers when going embedded. +Self-contained pyramid with tier names typeset into the artwork. Keep tier names concise and stable in the required script and echo the deck's body typography. When exact/editable labels or the available tier space cannot be preserved, move those labels to SVG; otherwise embedded lettering remains valid after visual review. --- @@ -47,4 +47,4 @@ Self-contained pyramid with tier names typeset into the artwork. Keep tier names **Snippet A — vector-illustration + cool-corporate Maslow pyramid, text_policy: none, 600×800** -> Clean flat vector illustration of a hierarchy pyramid. Five horizontal stepped tiers stacked vertically, each tier centered on the canvas vertical axis and ~18% narrower than the tier below. From bottom to top: tier 1 (foundation, widest) in deeper primary `#1E3A5F`; tier 2 in primary `#3B5478`; tier 3 in lighter primary `#5A7099`; tier 4 in secondary blue tint `#A8BDDD`; tier 5 (apex, narrowest) in accent gold `#D4AF37`. Each tier has one simple white iconic symbol centered — a brick (physiological), a shield (safety), a heart (belonging), a trophy (esteem), a star (self-actualization). Thin secondary cream `#F8F9FA` dividers separate the tiers. Background secondary cream. Composed as a 600×800 portrait pyramid block with 12% padding. NO text or labels — SVG will overlay tier names. Color values are rendering guidance only. \ No newline at end of file +> Clean flat vector illustration of a hierarchy pyramid. Five horizontal stepped tiers stacked vertically, each tier centered on the canvas vertical axis and ~18% narrower than the tier below. From bottom to top: tier 1 (foundation, widest) in deeper primary `#1E3A5F`; tier 2 in primary `#3B5478`; tier 3 in lighter primary `#5A7099`; tier 4 in secondary blue tint `#A8BDDD`; tier 5 (apex, narrowest) in accent gold `#D4AF37`. Each tier has one simple white iconic symbol centered — a brick (physiological), a shield (safety), a heart (belonging), a trophy (esteem), a star (self-actualization). Thin secondary cream `#F8F9FA` dividers separate the tiers. Background secondary cream. Composed as a 600×800 portrait pyramid block with 12% padding. NO text or labels — SVG will overlay tier names. Color values are rendering guidance only. 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 8fa02e69..10a7f706 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 @@ -3,27 +3,30 @@ # 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 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. +template maintenance when basic primitives, one standard PowerPoint shape, or +multiple closed shapes can express the intended object. Prefer, in order: +editable basic primitives, one exact Office preset, then a PowerPoint-style +Boolean result. Hand-authored freeform geometry is allowed only when those +constructions cannot faithfully express the object. 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 -Apply this decision order before drawing a stock geometric object. +Apply this decision order before drawing any new geometric contour. -> This gate is for picking the **right** native shape, not for avoiding presets. -> When a page needs an arrow, chevron, callout, banner, flowchart node, or a -> literal Office symbol, authoring it as a preset is the **default** — the -> ordinary-SVG rows below are deliberate exceptions, not the norm. +> This gate is for picking the **highest-level faithful native construction**. +> Do not hand-author a freeform merely because an SVG path is convenient. | Condition | Action | |---|---| | Plain rectangle, symmetric rounded rectangle, circle, or ellipse | Write the ordinary SVG primitive; the exporter already emits an editable native shape. | +| Straight relationship, divider, or leader | Write ``; use a registered marker only when direction is meaningful. | | 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. | +| A stock `bentConnector*` / `curvedConnector*` contour exactly expresses a bent or curved relationship and endpoint attachment is not required | Run `preset_shape_svg.py render --object-kind connector`; the result is an unconnected native Connector shape. | | 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. | +| Basic primitives, one preset, and Boolean materialization cannot faithfully express the visual meaning or contour | Write ordinary `` / `` geometry; export keeps it as editable custom geometry. | +| The shape only resembles a preset | Never infer a preset; continue to the Boolean gate, then use freeform only if no faithful construction exists. | | Mirror/preserve input already owns native-shape metadata | Keep the existing object and metadata; never reselect its preset. | **Hard rule**: `preset_shape_svg.py` is the only authoring entry for @@ -46,10 +49,10 @@ paths or contours, or upgrade ordinary SVG during export. | Visual intent | Candidate presets | Boundary | |---|---|---| | Literal geometric body | `triangle`, `diamond`, `pentagon`, `hexagon`, `octagon`, `star5` | Use only when the named geometry itself is the intent. | -| Solid block direction | `rightArrow`, `leftArrow`, `upArrow`, `downArrow`, `leftRightArrow`, `upDownArrow`, `chevron` | Thin relationship geometry remains an ordinary SVG `` / `` with no attachment semantics. | +| Solid block direction | `rightArrow`, `leftArrow`, `upArrow`, `downArrow`, `leftRightArrow`, `upDownArrow`, `chevron` | Use `` for a thin straight relationship; do not fake a solid directional object with a stroked path. | | Standard flowchart node | `flowChartProcess`, `flowChartDecision`, `flowChartInputOutput`, `flowChartTerminator`, `flowChartDocument` | Use only for an actual flowchart; ordinary content cards remain cards. | -| Explicit standalone connector | `straightConnector1`, `bentConnector*`, `curvedConnector*` | Use only when the user explicitly requests a PowerPoint Connector object. Diagram relationships otherwise stay ordinary SVG line/path shapes with no attachment semantics. | -| Stock callout | `wedgeRectCallout`, `wedgeRoundRectCallout`, `wedgeEllipseCallout`, `cloudCallout` | Brand-specific or custom-tail callouts remain free SVG. | +| Stock bent / curved relationship contour | `bentConnector*`, `curvedConnector*` | Prefer when the contour fits and endpoint attachment is not required. The authored object is an unconnected native Connector, so moving nodes does not reroute it. | +| Stock callout | `wedgeRectCallout`, `wedgeRoundRectCallout`, `wedgeEllipseCallout`, `cloudCallout` | For a brand-specific or custom tail, continue through the Boolean gate; use freeform only if the result still cannot be expressed faithfully. | | Stock ribbon or scroll | `ribbon*`, `ellipseRibbon*`, `verticalScroll`, `horizontalScroll` | Select only when the stock contour is visually acceptable. | | Standalone math symbol | `mathPlus`, `mathMinus`, `mathMultiply`, `mathDivide`, `mathEqual`, `mathNotEqual` | Inline formulas and prose symbols remain text/formula assets. | | Literal Office symbol | `heart`, `sun`, `moon`, `lightningBolt`, `gear6`, `gear9` | Never replace an icon required by `spec_lock.icons`. | @@ -61,13 +64,14 @@ python3 ${SKILL_DIR}/scripts/preset_shape_svg.py list --search arrow python3 ${SKILL_DIR}/scripts/preset_shape_svg.py describe rightArrow ``` -**Shape-first diagram rule**: chart-template adaptations use ordinary line/path -shapes for thin relationships and ordinary `shape` presets for solid block -directions. Connector-family presets are reserved for an explicit request for -a standalone PowerPoint Connector; they are not the default for architecture, -process, hierarchy, or framework diagrams and do not gain attachment semantics. -Existing Connector topology imported from a source PPTX remains owned by the -preserve/mirror round-trip contract. +**Shape-first diagram rule**: use `` for straight thin relationships; +use an exact connector-family preset for a stock bent or curved contour; use a +block-arrow / chevron preset for a solid direction. Resort to an open freeform +path only when those native constructions cannot faithfully express the +relationship, data geometry, or locked hand-drawn / organic style. Newly +authored connector-family presets remain unconnected and do not gain attachment +semantics. Existing Connector topology imported from a source PPTX remains +owned by the preserve/mirror round-trip contract. **Forbidden — false native semantics**: @@ -95,7 +99,7 @@ python3 ${SKILL_DIR}/scripts/preset_shape_svg.py render rightArrow \ --adjust "adj1=val 50000" ``` -For an explicitly requested standalone native connector only: +For a stock bent / curved contour that does not require endpoint attachment: ```bash python3 ${SKILL_DIR}/scripts/preset_shape_svg.py render bentConnector3 \ @@ -197,9 +201,11 @@ 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. +**Trigger**: Current page construction has two or more closed vector operands +whose faithful result calls for PowerPoint-style Union, Combine, Fragment, +Intersect, or Subtract. A §IX `Native shape suggestion` is a semantic candidate, +not a prerequisite or tool command; Executor may adopt, adapt, or decline it +from the actual content and explicit user/template constraints. ```bash python3 ${SKILL_DIR}/scripts/shape_boolean_svg.py render \ @@ -213,7 +219,9 @@ python3 ${SKILL_DIR}/scripts/shape_boolean_svg.py render \ |---|---| | 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. | +| Coordinates | Ancestor and local transforms are baked into SVG-root coordinate space. Place stdout in the primary operand's z-order with no additional transform; never reinsert it under an original transformed ancestor. Root-coordinate space does not require each result path to be a direct `` child. | +| Placement | Ordinary Slide-local results belong in the applicable untransformed direct-root semantic `` with its normal `id` / `data-pptx-bounds`. Master/Layout results remain direct-root path atoms and redeclare `data-pptx-layer`. One non-fragment result may be the direct `data-pptx-carrier="true"` child of an `object` slot. | +| Fragment roles | Fragment paths may share one ordinary Slide-local semantic group, but remain separate shapes and cannot collectively claim one carrier or one Master/Layout atom. Helper output inherits no structural role metadata from its operands; redeclare only the final layer/carrier/role contract. | | 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. | @@ -226,8 +234,9 @@ 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. +every returned path in root coordinate space at the primary operand's z-order, +using the placement contract above. Fragment paths remain separate shapes; an +ordinary semantic group does not turn them into one structural atom. --- 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 15d483cf..38dffcce 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 @@ -171,9 +171,9 @@ diagnostic behavior are indexed in ### 1.2 Image Clipping (Conditional Contract) -`clip-path` has a native picture-geometry mapping only on SVG-namespace -`` elements (plus the exact imported crop wrapper defined under Images) -and only under this contract: +`clip-path` maps natively only on SVG `` (including an exact crop +wrapper's inner image) under this contract. Legacy imported crops may retain +an outer-wrapper clip as compatible input: | Concern | Required form | |---|---| @@ -181,7 +181,7 @@ and only under this contract: | Contains exactly one direct SVG-namespace supported shape child | Multiple shapes are not composited | | Shape is one of: ``, ``, `` (optional rx/ry), ``, `` | These map to DrawingML geometry (preset or custom) | | No `clip-rule` or `fill-rule`, whether direct or in inline `style` | DrawingML picture geometry has no equivalent winding-rule control | -| Used only on `` or an exact imported crop wrapper | Shapes, groups, text, and generalized nested SVG targets are **forbidden** | +| Used only on `` or a compatible legacy imported crop wrapper | Shapes, groups, text, and generalized nested SVG targets are **forbidden** | | SVG clip shape | DrawingML output | |---|---| 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 1284e33e..a527a1f9 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 @@ -24,7 +24,7 @@ When proposed sources include `ai`, read [`image-renderings/_index.md`](./image- Also write one `custom_candidates.image_strategy` under the Confirm UI contract: localized `name` / `visual` / `mood`, `rendering: custom`, and non-empty localized `behavior` satisfying the catalog grammar. If it combines or borrows existing renderings, name every exact id in the visible proposal and read every corresponding `image-renderings/.md` before writing the synthesis. If it is genuinely novel, read no preset file and name no catalog basis. Keep it unselected unless the user supplied it (`recommend.image_strategy: custom`); under a template it obeys inherited identity and application. Only a selected custom locks its edited behavior as `image_rendering_behavior`; when catalog material is actually used, also project the exact ids as `image_rendering_references`, otherwise omit that field. Discard an unselected candidate downstream. Ignore legacy `image_palette`. -For specialized or regulated paper-figure subjects, preserve the prompt depth required by [`image-generator.md`](./image-generator.md) §4.2 rather than shortening to a generic brief. Scan the outline for genuine image-led pages, list the proposed hero pages in Stage-2 `image_notes` so the user can retain, edit, or remove them in the same confirmation, then mark only the confirmed pages' AI rows `page_role: hero_page`; local is the default. `text_policy: embedded` is reserved for lettering that must be fused into the artwork; ordinary titles, data, labels, and prose remain editable SVG. Resolve confirmed provided assets through the context-first boundary above before writing §VIII. +For specialized or regulated paper-figure subjects, preserve the prompt depth required by [`image-generator.md`](./image-generator.md) §4.2 rather than shortening to a generic brief. Scan the outline for genuine image-led pages, list the proposed hero pages in Stage-2 `image_notes` so the user can retain, edit, or remove them in the same confirmation, then mark only the confirmed pages' AI rows `page_role: hero_page`; local is the default. `text_policy: embedded` is reserved for stable figure-internal identifiers or lettering deliberately fused into the artwork; page titles, editable data values/labels, and prose remain SVG. Resolve confirmed provided assets through the context-first boundary above before writing §VIII. ## 3. Formula Asset Policy @@ -49,13 +49,13 @@ Follow `latex_render.py --help` for the manifest fields. The renderer writes dim ## 4. Image Resource List -Add §VIII rows for the image resources actually planned from the confirmed source boundary and for every selected formula; a formula-only plan contains only formula rows. A permitted but unused source needs no row. Author each row's filename, dimensions/ratio, layout pattern, crop policy, purpose/type, acquisition, status, reference, and conditional AI fields as part of the complete Design Spec. `Acquire Via` is `ai`, `web`, `user`, `formula`, `placeholder`, or `slice`; status follows [`svg-image-embedding.md`](./svg-image-embedding.md). When a planned or explicitly required asset is not yet available, retain its row as `Pending` or `Needs-Manual`; never remove the row or change `Acquire Via` to make the Design Spec look complete. After §VIII passes final confirmation, project every placed row into `spec_lock.md images` as ` | source= | pattern= | crop=` and omit unplaced Illustration Sheets. References describe visual intent: AI uses subject + intent + composition without repeating rendering or HEX; web uses a concrete subject plus a few positive quality descriptors; formula preserves the source LaTeX and placement intent. +Add §VIII rows for the image resources actually planned from the confirmed source boundary and for every selected formula; a formula-only plan contains only formula rows. A permitted but unused source needs no row. Author each row's filename, dimensions/ratio, preferred layout pattern, crop policy, purpose/type, acquisition, status, reference, and conditional AI fields as part of the complete Design Spec. `Acquire Via` is `ai`, `web`, `user`, `formula`, `placeholder`, or `slice`; status follows [`svg-image-embedding.md`](./svg-image-embedding.md). When a planned or explicitly required asset is not yet available, retain its row as `Pending` or `Needs-Manual`; never remove the row or change `Acquire Via` to make the Design Spec look complete. After §VIII passes final confirmation, project every placed row into `spec_lock.md images` as ` | source= | pattern= | crop=` and omit unplaced Illustration Sheets. `source` and `crop` preserve the exact confirmed §VIII text; `pattern` preserves its ordered catalog ids or normalized custom prose while remaining preferred expression, not locked geometry. References describe visual intent: AI uses subject + intent + composition without repeating rendering or HEX; web records exact subject, view/mood, focal/quiet region, and crop safety with positive quality cues; Image_Searcher later derives a separate 1–4 word query without rewriting this locked intent; formula preserves source LaTeX and placement intent. -**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`. +**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 EXIF-corrected `Width` / `Height` / native `AspectRatio` in the latest `analysis/image_analysis.csv`; `SourceDisplayRatio` is source-context metadata, not the bitmap crop ratio. 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), starting from its `High-Yield Patterns` router. Copy one primary `# ` plus any modifier names verbatim into each row; no empty, paraphrased, or invented ids. +🚧 **GATE — non-formula rows**: start at `High-Yield Patterns` and read [`image-layout-patterns.md`](./image-layout-patterns.md) completely. Each row copies a Part 1 Primary `# ` plus any Modifiers verbatim; modifier-only routes add fitting Primary page bones. No blanks, paraphrases, 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. +**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 native boolean-geometry family is the largest single lever on how designed the exported deck looks. Patterns requiring a cutout, blurred crop, or desaturated copy are selectable only when that derived asset is already prepared; otherwise choose a one-asset/native-shape fallback. 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. This is a Strategist recommendation, not a geometry or pattern lock. Executor may adapt or replace it after seeing the actual page while preserving the resource role, file/source, must-use status, crop boundary, content, and explicit user/template constraints; a pattern-only change needs no upstream rewrite. 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/references/strategist.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md index 5d95cb5d..4060fc28 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist.md @@ -214,9 +214,9 @@ See [`../templates/icons/README.md`](../templates/icons/README.md) for the curre - User or active template typography is authoritative. Otherwise ≥3 Stage-2 directions include concord (safe) and contrast (tension); never add a separate font-choice round or pair near-duplicate title/body families. - Every Stage-2 direction carries `heading` / `body` `cjk`, `latin`, `css`, and positive `body_size`; repeat user/template-fixed stacks. -- Use PowerPoint-installed exported faces. Safe anchors: CJK `Microsoft YaHei` / `SimHei` / `SimSun` / `FangSong` / `KaiTi`; Latin sans `Arial` / `Calibri` / `Segoe UI` / `Verdana` / `Trebuchet MS`; Latin serif `Times New Roman` / `Georgia` / `Cambria` / `Garamond` / `Book Antiqua`; mono `Consolas` / `Courier New`; display `Impact` / `Arial Black`. Other export-safe families are limited to sparse short display/ornament; structural or recurring use returns upstream. -- Keep each stack to four families or fewer. A non-installed brand or web face is legal only when the Design Spec explicitly records the install / embed requirement and a safe substitute. -- Avoid splitting roles across near-equivalents such as YaHei↔PingFang, SimSun↔Songti, Arial↔Helvetica↔Segoe UI, or Times New Roman↔Times. A cross-platform counterpart may remain inside one fallback stack. +- Use concrete, target-installed PowerPoint faces. **Examples only, never a catalog/default** (verify locale): Chinese `DengXian` / `SimSun`; Japanese `Meiryo` / `Yu Gothic`; Korean `Malgun Gothic` / `Batang`; Latin `Arial` / `Georgia` / `Consolas` / `Impact`. +- Keep stacks to four families or fewer. A brand/web face may lead only after user-confirmed target installation/approved install; PPT Master does not embed fonts. Otherwise export a safe face and keep the unavailable face as Design Spec reference. +- Avoid near-equivalent role splits such as YaHei↔PingFang, SimSun↔Songti, Arial↔Helvetica↔Segoe UI, or Times New Roman↔Times. Counterparts may aid SVG/browser preview; CSS tails are not deterministic PowerPoint fallbacks. - Choose by locked style and vary the axis instead of defaulting to YaHei/Arial: serif×sans, Kai/FangSong×hei, hei×song, double-serif, display×neutral, same-family weight, or sans+mono. These are recall seeds, not presets. **Strategist-owned role extension after confirmation**: Confirm UI keeps the heading/body choice unchanged. While authoring the complete §IX roster and §IV typography plan, scan the actual content for recurring roles that materially need a different family for character or legibility—such as `annotation`, `footer`, `footnote`, `data`, `emphasis`, `quote`, or `code`. Add a lowercase snake_case role and exact stack only when it recurs; inherited roles and one-off garnish stay omitted. The extension must remain coherent with the confirmed heading/body system and locked visual style, and it does not reopen confirmation. Only when an additional family role is added, record one compact `Role rationale` in §IV naming the added role(s) and why; otherwise omit the line. @@ -270,7 +270,21 @@ Formula policy and formula-asset planning are conditional. If the source contain The module owns formula policy, AI rendering alternatives, acquisition paths, resource rows, prompt depth, page roles, and placement intent. -### Visualization Candidate Recall (Non-blocking — Strategist recommends, no user confirmation needed) +### Presentation Capability & Visualization Recall (Non-blocking — Strategist recommends, no user confirmation needed) + +**Per-page capability recall**: Before §IX, consider this menu without a usage +quota. Use existing fields for semantic intent; omit unused lines and +implementation parameters. Executor may adapt/decline the +two non-literal suggestions while preserving content and intent; explicit +user/template requirements bind. + +| Capability | Opportunity signal | Design Spec handoff | +|---|---|---| +| Image composition | Image-as-canvas, editorial crop, collage, cutout, or meaningful focus / comparison / evidence units carry the page better than an adjacent rectangle | Propose a permitted source; when selected, load [`strategist-image.md`](./strategist-image.md), record exact §VIII `Layout pattern`, and describe page-level image/overlay relationships in §IX `Layout` / `Images` | +| Native paint / overlay | Gradient, translucency, scrim, vignette, or wash supports focus, hierarchy, depth, legibility, or image integration | Record purpose/layering in §IX `Layout`, plus `Images` when imagery participates; no new field or type/stops/opacity/coordinates—Executor chooses realization | +| Native shape / Merge Shapes | A literal Office symbol, a stock bent/curved relationship contour, or a compound silhouette, negative-space cutout, overlap-only region, or meaningful fragmentation strengthens the visual idea | Add an optional §IX `Native shape suggestion` with the semantic result plus a candidate preset/Connector family or Boolean operation/operands | +| Page transition | A section/state change, spatial continuity, recorded/self-running flow, or the same semantic object changing position, scale, crop, or state across adjacent pages benefits from motion | Add an optional §IX `Motion suggestion` describing the communication job and any continuing object's start/end semantic states; leave effect, ids, pairing names, and timing to Executor | +| Object animation | Progressive reveal clarifies sequence, causality, comparison, hierarchy, narration order, full-view → detail, atmosphere → evidence, or hotspot/annotation order | Add an optional §IX `Motion suggestion` describing semantic units/order and any visible image-state relationship; leave group ids, effect, and timing to Executor | Review planned pages through two lenses: @@ -314,7 +328,17 @@ A failed validation must be corrected with a recalled key. `no-template-match` i | P03 | line_chart | Compare the source metrics over time | ``` -**Flag native-preset candidates**: In the affected page's §IX `Layout` / `Visualization`, note when the content calls for a literal stock PowerPoint chevron, block arrow, standard flowchart node, callout, banner, or star. Executor still decides the exact preset under its native-shape branch; this note never creates a §VII row by itself. +**Native-geometry candidate detail**: Add `Native shape suggestion` to the +affected §IX page when the content calls for a literal stock PowerPoint +chevron, block arrow, standard flowchart node, callout, banner, star, or a +stock bent/curved Connector contour. Describe a relationship by its semantic +route and candidate family, not an exact preset key, endpoint/site metadata, or +attachment promise. For a compound silhouette, cutout, common region, or +meaningful fragmentation, name the candidate Union / Combine / Fragment / +Intersect / Subtract operation, semantic operands, and intended result. +Executor still decides the exact basic primitive, preset, Boolean construction, +or necessary freeform under its native-shape branch; the recommendation never +creates a §VII row or lock field. ### Speaker Notes Requirements (Default — no discussion needed) @@ -380,7 +404,7 @@ Content-outline and speaker-notes strategy follow the deck's locked **mode** — **Recommendation signals**: derive the initial reading mode from the confirmed `audience`, `delivery_context`, and `artifact_afterlife`. Asynchronous review, reference, approval, audit, and leave-behind use lean `text`; presenter-led projection, large-room delivery, launch, or classroom explanation lean `presentation`; hybrid review / roadshow use leans `balanced`. When live projection and durable afterlife both matter, recommend `balanced` unless the contract clearly prioritizes one. If the user confirms `presentation`, support afterlife through notes, appendix pages, captions, and visible sources instead of crowding every slide. -**Per-block expression**: let the semantic relationship choose the form. Causal explanation, argument, interpretation, and narrative continuity use prose. Truly parallel, ordered, or enumerable items may use bullets / numbers. Never create bullets merely because copy is long or a template exposes a list slot. In `presentation`, distill one assertion and move its explanation into notes rather than turning every sentence into a fragment. Source texture remains a secondary cue: an article / transcript / talk leans prose, while a data sheet or inventory may lean structured labels. Write complete, usable phrasing into §IX; do not leave skeletons for Executor. It is preferred wording unless literal preservation applies. +**Per-block expression**: let the semantic relationship choose the form. Causal explanation, argument, interpretation, and narrative continuity use prose. Truly parallel, ordered, or enumerable items may use bullets / numbers. Never create bullets merely because copy is long or a template exposes a list slot. In `presentation`, distill one assertion and move its explanation into notes rather than turning every sentence into a fragment. Source texture remains a secondary cue: an article / transcript / talk leans prose, while a data sheet or inventory may lean structured labels. Write complete, usable phrasing into §IX; do not leave skeletons for Executor. It is preferred wording unless literal preservation applies; Executor owns faithful expression adaptation under [`executor-base.md`](./executor-base.md) §2.1's content-vs-expression contract. This is what makes the axis meaningful: a `presentation` deck and a `text` deck built from the **same source and communication contract** must differ in page grammar, page count recommendation, per-page text volume, visual burden, layout density, rhythm, and notes—not only in font size. Page count stays the user's call; reading mode informs the recommendation when the user has not fixed one. Record it as **Reading Mode** in `design_spec.md §I` (compatibility key `delivery_purpose`, lock key `consumption_mode`). Separately, `communication_intent` / `audience_outcome` determine what the outline must accomplish, while `delivery_context` and `artifact_afterlife` help select the reading mode and still remain independent constraints after selection. The `page_rhythm` leans are a bias, not a quota. Preservation paths keep source wording and structure verbatim: honor reading mode only in styling and notes, never by rephrasing or re-paginating. @@ -391,7 +415,7 @@ This is what makes the axis meaningful: a `presentation` deck and a `text` deck Generate Step 4 owns this sequence. `design_spec.md` is the complete human-readable decision; `spec_lock.md` is its context-selected execution subset/routing contract. Consume `result.json` once into the initial Design Spec and never reopen it for the lock. Refinement edits that same Design Spec; affected user revisions become the latest authority. Never treat the planning files as parallel interpretations. 1. Use the retained complete final-confirmation state already read once by Generate Step 4, then read `templates/design_spec_reference.md`. -2. Compose the whole Design Spec in active context before touching the target path. Create `design_spec.md` once from the schema marker through §X; do not copy a scaffold into the project or patch placeholder fields. Record production mechanics in §I. In §IX, create the complete ordered roster; each entry carries layout, title, core message, **Audience move**, final wording, visualization/image references, sourced `Fact IDs`, and `Data class: scenario` for invented demo data. After Gate 1 plus conditional refine approval, roster ids/count/order and content are authoritative; layout, cover/closing composition, and image/chart patterns remain References unless promoted. +2. Compose the whole Design Spec in active context before touching the target path. Create `design_spec.md` once from the schema marker through §X; do not copy a scaffold into the project or patch placeholder fields. Record production mechanics in §I. In §IX, create the complete ordered roster; each entry carries layout, title, core message, **Audience move**, complete preferred wording, applicable capability recommendations, visualization/image references, sourced `Fact IDs`, and `Data class: scenario` for invented demo data. After Gate 1 plus conditional refine approval, roster ids/count/order and semantic content are authoritative; non-literal wording, block texture, layout, cover/closing composition, capability recommendations, and image/chart patterns remain References unless promoted. 3. Compare `design_spec.md` against the final confirmation field by field. Repair every omission or deviation before entering an enabled refine-spec review or authoring `spec_lock.md`. 4. If enabled, run [`refine-spec`](../workflows/stages/refine-spec.md) after Gate 1; edit only that Design Spec and create no lock before explicit approval. 5. Read `templates/spec_lock_reference.md`. From the approved Design Spec plus context, create the lock once or resynchronize stale derived state. Retain identity/refinements, select stable roles/routing, omit unnamed page-local values, and do not reopen evidence. This is implementation judgment, not another recommendation. @@ -413,9 +437,9 @@ Generate Step 4 owns this sequence. `design_spec.md` is the complete human-reada ⛔ **GATE 2 — lock context fidelity.** After Gate 1 closes, author machine-relevant anchors/routing into `spec_lock.md`. The lock may normalize syntax and add justified recurring roles, but must not change identity, discard a refinement, introduce a direction, or become a field copy/allowlist. On contradiction, return to Gate 1 using retained confirmation by default or the approved revised Design Spec after refinement; fresh recovery reads persisted final evidence once only when active state is absent. -**Execution lock content**: `spec_lock.md` compactly carries communication, stable color/type anchors, icons, images, page rhythm, chart choices, and route-specific PowerPoint structure. Name every recurring typography role; a planned short non-structural Hero/Display size may stay omitted only while the same value appears at most twice, and its third occurrence requires a named role. Never re-derive a confirmed anchor. New locks keep `font_family` as the body/default compatibility stack and also write explicit `title_family` + `body_family`; every additional recurring Design Spec role projects to `_family`. Collapsing distinct Design Spec stacks into `font_family`, or dropping an extra role, fails Gate 2. Keep core fonts/palette roles stable; page authoring varies treatment and may add sparse local garnish. Project every placed §VIII image's source, pattern, and crop policy; omit unplaced sheets and planning provenance. Free-design, brand-only, and `template_reuse_scope: style` use `pptx_structure.mode: flat`; the template module owns structured mappings. Executor context policy lives in [executor-base.md](executor-base.md) §2.1. Repair from Gate 2's active decision authority, then re-author affected lock rows. +**Execution lock content**: `spec_lock.md` compactly carries communication, stable color/type anchors, icons, images, page rhythm, chart choices, and route-specific PowerPoint structure. Name every recurring typography role; a planned short non-structural Hero/Display size may stay omitted only while the same value appears at most twice, and its third occurrence requires a named role. Never re-derive a confirmed anchor. New locks keep `font_family` as the body/default compatibility stack and also write explicit `title_family` + `body_family`; every additional recurring Design Spec role projects to `_family`. Collapsing distinct Design Spec stacks into `font_family`, or dropping an extra role, fails Gate 2. Keep core fonts/palette roles stable; page authoring varies treatment and may add sparse local garnish. Project every placed §VIII image's source, preferred-pattern reference, and crop policy; omit unplaced sheets and planning provenance. Free-design, brand-only, and `template_reuse_scope: style` use `pptx_structure.mode: flat`; the template module owns structured mappings. Executor context policy lives in [executor-base.md](executor-base.md) §2.1. Repair from Gate 2's active decision authority, then re-author affected lock rows. -**Contextual extension**: derived paint or sparse local font/color garnish may stay in one SVG while non-structural and non-recurring. New base/semantic colors, structural/recurring fonts, resources, or patterns require upstream repair; Executor never reverse-projects a choice as fact. Promote garnish upstream before reuse, read back and validate the affected planning fragments, and never add values to silence a comparison. +**Contextual extension**: derived paint or sparse local font/color garnish may stay in one SVG while non-structural and non-recurring. New base/semantic colors, structural/recurring fonts, resources, or recurring cross-page identity patterns require upstream repair; a page-local §VIII preferred image pattern follows [`executor-image.md`](./executor-image.md) and may change during realization. Executor never reverse-projects a local choice as planning fact. Promote recurring garnish upstream before reuse, read back and validate the affected planning fragments, and never add values to silence a comparison. - **Communication trace is mandatory**: Keep the full confirmed communication contract in `design_spec.md §I`, then project only `audience`, `objective`, `core_message`, and canonical `consumption_mode` into `spec_lock.md communication`. Write `objective` as one concise execution sentence that preserves both the confirmed `communication_intent` and the success condition in `audience_outcome`; do not copy `delivery_context`, `artifact_afterlife`, dates, provenance, or conflict-resolution commentary into the lock. Before finalizing §IX, check that every named purpose has at least one outline obligation and **every Slide block**, including cover / divider / closing pages, has an `Audience move` that advances the global outcome. A page that advances no purpose or outcome should be merged, rewritten, or cut. `project_manager.py validate` and `svg_quality_checker.py` enforce the compact lock fields and per-page move presence, not their subjective quality. - **Custom behavior is concise and executable**: For confirmed `custom` mode or visual style, project one resolved `mode_behavior` / `visual_style_behavior` sentence or short paragraph. When the direction actually combines or borrows catalog entries, also project the exact, comma-separated `mode_references` / `visual_style_references`; omit the field for a genuinely novel direction and never fabricate a nearby reference. Preserve the confirmed direction, reference locked role names such as `colors.primary` when needed, and omit selection history, contradictions, precedence explanations, or other Design Spec provenance. Executor reads these fields from the retained lock and loads every referenced catalog entry once per valid context. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/svg-effects.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/svg-effects.md index b48c9ed2..c9c095c3 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/svg-effects.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/svg-effects.md @@ -103,8 +103,8 @@ closed parser checks. See |---|---| | Definition | Direct `` / `` child of `` with unique `id` | | Reference | Exact local `url(#id)` | -| Stops | Direct `` children; explicit color; finite offset `0..1` or `0%..100%`; optional stop alpha | -| Coordinates | Normalized values / percentages; do not depend on `gradientUnits` user-space geometry | +| Stops | ≥2 direct `` children; explicit color; finite non-decreasing offset in `0..1` or `0%..100%` (ties form hard edges); optional alpha | +| Coordinates | `objectBoundingBox` only. Generated values: `0..1`; omitted linear axis = `(0,0) → (1,0)`. Only import-normalized linear projections may reach `-0.105..1.105`; radial values stay in `0..1` | | Forbidden | External/quoted refs, `href` inheritance, `gradientTransform`, `spreadMethod`, CSS gradients | | Target | Contract and fidelity | @@ -114,16 +114,14 @@ closed parser checks. See | `` / non-positional `` | Gradient fill only; no gradient text outline | | `` | No gradient paint; use §6.5 overlays | -Linear export preserves stops/alpha/direction but reduces coordinates to an -angle. Radial export becomes a centered circular gradient and does not preserve -`cx/cy/r/fx/fy`. Gradient strokes remain editable, but PPTX-to-SVG re-import may -retain only the first stop. Stop alpha and element opacity multiply. -PPTX import normalizes compatible gradients and records any property-level -degradation without aborting the deck; `--strict` keeps the closed parser -contract. See +Linear export preserves stops/alpha and reduces direction to an angle; +coincident endpoints are invalid. Radial export centers a circular +approximation, dropping `cx/cy/r/fx/fy`. Gradient strokes stay editable; +reverse import may keep the first stop only. Stop alpha multiplies element opacity. +PPTX import normalizes gradients and reports degradation; +`--strict` keeps the closed parser contract. See [`conversion.md`](../scripts/docs/conversion.md#import-compatibility-and-recovery-boundary). -The quality checker and exporter preflight both validate definition location, -references, gradient structure, and paint context from the same closed contract. +Checker/exporter preflight share this validation. Gradient-stop colors are contextual paint values. Keep them coherent with the deck anchors and page intent; they are not required to duplicate existing `spec_lock.colors` literals. @@ -280,34 +278,23 @@ unresolved only during template checking; export requires the resolved image. Missing, ambiguous, corrupt, mislabeled, or unsupported sources are errors and must never be dropped or packaged as invalid zero-byte media. -**Hard rule — nested SVG is an imported crop transport, not a general -viewport**: every non-root `` must be the exact picture-crop wrapper emitted -by `pptx_to_svg`. The outer element has explicit registered project-geometry -`x`, `y`, positive `width`/`height`, a unit-coordinate `viewBox` made of four -ordinary decimal values, and -`preserveAspectRatio="none"`; it contains exactly one direct, empty `` -with exactly one non-empty `href` or `xlink:href`, `x="0"`, `y="0"`, `width="1"`, -`height="1"`, and `preserveAspectRatio="none"`. Its ancestor chain contains -only the root SVG and ordinary visual `` wrappers; definitions, text, -render-only geometry details, and other non-visual containers cannot own this -transport. The outer wrapper may additionally carry `id`, a supported -`transform`, registered structure metadata (`data-pptx-layer` or -`data-pptx-carrier`), and the importer metadata -`data-pptx-frame`, `data-pptx-object`, `data-pptx-shape-id`, -`data-pptx-shape-name`, and `data-pptx-shape-scope`. A shape clip is present -only when exact `data-pptx-crop="1"` and a registered image-only `clip-path` -occur together and the local clip definition resolves. The inner image may -add only registered `opacity`. The `viewBox` must quantize without clamping to -a DrawingML `srcRect` with a positive visible region: each signed crop value -must fit the OOXML percentage integer range `-2147483648..2147483647`, while -`l + r < 100000` and -`t + b < 100000` preserve a positive visible region. Negative crop values and -crop windows extending outside the source unit rectangle are retained exactly, -not clamped. `0 0 1 1` is redundant and must be written as a plain ``. -Extra visual children, indirect images, character data, unknown attributes, -malformed or unrepresentable crop coordinates, and generalized nested -viewports are errors. Checker and the converter share this parser so a nested -subtree cannot pass validation and then silently disappear during export. +**Hard rule — nested SVG is picture-crop transport, not a general viewport**: +every non-root `` is the exact wrapper accepted by the shared crop parser: + +| Part | Required form | +|---|---| +| Outer | Registered `x`, `y`, positive `width`/`height`; four ordinary-decimal unit coordinates in `viewBox`; `preserveAspectRatio="none"`; `overflow="hidden"` | +| Child | Exactly one direct empty `` with one non-empty `href`/`xlink:href`, `x="0" y="0" width="1" height="1" preserveAspectRatio="none"` | +| Context | Only root SVG / ordinary visual `` ancestors; outer may add `id`, supported `transform`, registered layer/carrier metadata, and `data-pptx-frame`, `data-pptx-object`, `data-pptx-shape-id`, `data-pptx-shape-name`, `data-pptx-shape-scope` | +| Shape crop | Exact outer `data-pptx-crop="1"`; authored wrappers put the registered, locally resolving image-only clip on the inner image, using `userSpaceOnUse` geometry matching the visible `viewBox`; legacy imported outer clips remain compatible | + +The inner image may add only registered `opacity` and that clip. Quantize the +`viewBox` without clamping: every signed crop fits +`-2147483648..2147483647`, with `l + r < 100000` and `t + b < 100000`. +Retain negative/outside-source crops exactly; write redundant `0 0 1 1` as a +plain ``. Extra, indirect, or character content; unknown attributes; +malformed or unrepresentable crops; and general nested viewports fail. Checker +and converter share this parser. | Overlay | Construction | Typical stops / alpha | |---|---|---| @@ -564,12 +551,19 @@ least two. bounds reuse its normalized commands rather than a second path grammar. Command identity, relative coordinates, shorthand, arc parameters, and original -handles are not retained. Geometry needs non-zero bounds. Use a closed cubic -path for organic silhouettes, polygon/closed path for ribbons/facets, open path -for curved connectors, multi-`M` path for exact linework, and a [`shared-standards-core.md`](./shared-standards-core.md) §1.2 path clip -for organic pictures. Filled silhouettes end with `Z`; open paths use -`fill="none"`. Do not depend on `fill-rule="evenodd"`; build explicit visible -geometry or bake an essential knockout. +handles are not retained. Geometry needs non-zero bounds. Before authoring a +freeform, apply [`native-shape-authoring.md`](./native-shape-authoring.md): +prefer an editable basic primitive, one exact Office preset, or a Boolean +materialization. Use a closed cubic path only for an organic silhouette those +cannot express, polygon/closed path for unmatched ribbons/facets, and an open +path only for a required data curve, custom route, or locked hand-drawn / +organic style. Straight relationships use ``; exact stock bends/curves +use an authored native Connector preset. Multi-`M` paths remain available for +exact linework, and a [`shared-standards-core.md`](./shared-standards-core.md) +§1.2 path clip for unmatched organic pictures. Filled silhouettes end with +`Z`; open paths use `fill="none"`. Do not depend on +`fill-rule="evenodd"`; build explicit visible geometry or bake an essential +knockout. For a fixed background, a background-colored overlay is also valid. | Rounded rect input | Result | @@ -657,6 +651,8 @@ filled `Native-normalized` arrowhead. Example: browser-filter permissions. **Reference — not a constraint**: use them only when they match the locked style. +Their curve recipes are explicit exceptions to the Shape-first default above; +they do not authorize decorative freeforms in another style. | Intent | Construction | Boundary / fidelity | |---|---|---| @@ -744,9 +740,9 @@ subsection; this table only routes scenarios. | Hand/print | Annotation → highlighter/curve; ink wash → layered alpha paths; Riso → offset duplicate | §6.11; no turbulence, true bleed, or blend mode | | Pixel/halftone | Pixel accent → integer rect grid; sparse screen → circles | §6.11; dense screen → §6.12 | | Faceted/layered | Pseudo-3D → 2D facets; paper cut → direct shadow per layer | §6.11; no 3D transform/group composite shadow | -| Data/freeform | Series depth → area first + line above; organic card → closed cubic; shaped image → [`shared-standards-core.md`](./shared-standards-core.md) §1.2 path clip | §6.11 / §6.9 | +| Data/freeform | Series depth → area first + line above; unmatched organic silhouette → closed cubic; shaped image → [`shared-standards-core.md`](./shared-standards-core.md) §1.2 path clip | §6.11 / §6.9 | | Radial | Donut/gauge → explicit arcs; sunburst → sector per node; position-insensitive ring → shorthand | §6.10; shorthand has 90° preview/native offset | -| Arrow | Manual diagonal arrowhead → calculated triangle; ordinary connector → marker | §6.10 / §1.1 | +| Arrow | Straight relationship → `` + marker; stock bend/curve → native Connector; unmatched custom route → separate calculated arrowhead if needed | §6.10 / §1.1 / native-shape authoring | | Unsupported | Dense grain, complex composite, or skew → explicit alternative or baked asset | §6.12; foreground text/data stay editable SVG | --- diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/svg-image-embedding.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/svg-image-embedding.md index 51795d1c..45cd1a18 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/svg-image-embedding.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/svg-image-embedding.md @@ -22,10 +22,11 @@ Defined in the Design Specification & Content Outline; each image carries an `Ac | Status | Meaning | Executor Handling | |--------|---------|-------------------| | **Pending** | Acquisition needed (`Acquire Via: ai` / `web`) or derivation needed (`Acquire Via: slice`); not yet attempted | Image Acquisition Phase (Step 5) consumes this; must not remain after Step 5 | +| **Failed** | The latest automatic acquisition attempt failed; this is retryable and non-terminal | Step 5 reruns the owning manifest or explicitly resolves the row to `Needs-Manual`; Executor must never treat `Failed` as usable content | | **Generated** | AI-generated file exists at expected path, or sliced element file exists at expected path | Reference from `../images/`; no on-slide credit needed. **Exception**: an `Illustration Sheet` row is only a slice source — it lives in §VIII but never in `spec_lock.md images`, so the Executor never places it | | **Sourced** | Web-sourced file exists at expected path | Reference from `../images/`; check `image_sources.json` for `license_tier` — if `attribution-required`, render an inline credit element on the slide (see [`executor-web-image.md`](./executor-web-image.md) §1 and [`image-searcher.md`](./image-searcher.md) §7 for the visual spec) | -| **Rendered** | Deterministic formula PNG exists at expected path (`Acquire Via: formula`) | Reference from `../images/`; use `preserveAspectRatio="xMidYMid meet"` and do not crop | -| **Needs-Manual** | Acquisition attempted once + one retry, failed; for `slice`, parent sheet is unavailable | Dashed placeholder unless user has manually supplied the file. For `slice` rows, place the parent sheet and rerun `slice_images.py`; do not hand-place individual element files | +| **Rendered** | Deterministic formula PNG exists at expected path (`Acquire Via: formula`) | Reference from `../images/`; use a legal anchor with `meet` for the complete placement (centered default: `xMidYMid meet`) and do not crop | +| **Needs-Manual** | Automatic acquisition is unavailable/exhausted or the confirmed path requires manual fulfillment; for `slice`, the parent sheet is unavailable | Dashed placeholder unless the user has supplied the expected file. For `slice` rows, supply the parent sheet and rerun `slice_images.py`; do not hand-place individual element files | | **Existing** | User already has image (`Acquire Via: user`) | Place in `images/`, reference with `` | | **Placeholder** | Intentionally not prepared yet (`Acquire Via: placeholder`) | Dashed border placeholder; replace later | @@ -36,8 +37,8 @@ Defined in the Design Specification & Content Outline; each image carries an `Ac ``` 1. Strategist defines image needs → Add image resource list with Acquire Via + Status per row 2. Image Acquisition (Step 5): - - Pending + ai → Image_Generator runs image_gen.py → Generated - - Pending + web → Image_Searcher runs image_search.py → Sourced + - Pending / Failed + ai → Image_Generator runs image_gen.py → Generated + - Pending / Failed + web → Image_Searcher runs image_search.py → Sourced - Pending + slice → after parent AI sheet is Generated, slice_images.py cuts element files → Generated - formula / user / placeholder rows are skipped 3. Executor generates SVGs (svg_output/) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/template-designer.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/template-designer.md index 6ddc367b..801276e4 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/template-designer.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/template-designer.md @@ -64,7 +64,7 @@ ownership. | Mode | Output structure contract | |---|---| -| `standard` / `fidelity` | Author project-canonical SVG prototypes and an intentional new Master/Layout/slot system. Source visual language and assets may guide the design, but source ownership, keys, picker names, parent relationships, placeholders, and repeated Slide-local elements do not define or seed the output topology. Use the compact authored-preset group only for exact registered preset matches. | +| `standard` / `fidelity` | Author project-canonical SVG prototypes and an intentional new Master/Layout/slot system. Source visual language and assets may guide the design, but source ownership, keys, picker names, parent relationships, placeholders, and repeated Slide-local elements do not define or seed the output topology. For every new contour, use an editable basic primitive, then an exact compact authored preset, then a Boolean result; use freeform only when those cannot express it faithfully. | | `mirror` | Materialize a new workspace from the validated source graph one-to-one: keep the Master/Layout identities and parentage, slide assignments, placeholder type/index/bounds, and supported visual/native-object facts that are actually present. Edit the authoring IR; materialization may rehydrate converter-supported native payload only for unchanged source refs. Mechanical normalization maps fixed-layer source groups into the direct atoms required by the current explicit SVG contract while preserving ownership, paint order, and appearance; it must not invent missing facts or semantically redesign the graph. | Every page remains a complete standalone SVG preview. @@ -83,6 +83,8 @@ bundle into an authored template. `mirror` instead preserves the supported expanded lossless source representation. The exact syntax and validation contract remain owned by [`shared-standards-core.md`](./shared-standards-core.md) and the native-shape reference. +When one preset is insufficient, apply the same reference's Boolean gate before +hand-authoring a freeform. **Hard rule — complete mirror graph**: Preserve every supported source Layout represented by the validated import, including Layouts unused by source Slides. Emit one complete source-page @@ -236,14 +238,15 @@ page_count: - HEX values with role labels (primary / accent / background / text / etc.) - Brand-specific application rules when present (e.g. "KPI cards rotate blue→green→red→yellow") -## III. Typography (omit when using the default `Arial, "Microsoft YaHei", sans-serif` stack) -- Per-role font stacks ONLY when the template intentionally diverges (display serif title, brand typeface, etc.) -- Font-install or embedding requirement when a non-preinstalled font leads any stack +## III. Typography (omit without template-owned typeface identity) +- Per-role stacks for identity (display serif, brand face, etc.) +- A non-preinstalled face may lead only after user-confirmed target installation/approved install; no auto-embedding +- Otherwise export a safe face; unavailable proprietary faces stay references. CSS tails aid preview, not deterministic PowerPoint fallback - Body baseline px (informational; `spec_lock.md` owns the actual values per project) ## IV. Signature Design Elements - Decorative motifs that ARE this template — top bar, gradient underline, logo treatment, brand emblem placement -- Source-derived layout grammar — grid / column rhythm, page chrome, image zones, mask / crop behavior, overlay treatment, and density rhythm that make the template recognizable +- Source-derived layout grammar — grid / column rhythm, page chrome, image zones, crop/clip behavior, scrim/overlay or baked-alpha treatment, and density rhythm that make the template recognizable - Optional XML snippet for any reusable component unique to this template ## V. Page Roster @@ -335,7 +338,7 @@ Templates must strictly follow the finalized template brief and the generated `d - **Color scheme**: Uses primary, secondary, and accent colors from the spec - **Font plan**: Uses the per-role font families declared in the spec - **Layout principles**: Margins and spacing conform to the spec -- **Image system**: Image placement, crop / mask behavior, full-bleed zones, and overlay rules follow the source-derived norms in the spec +- **Image system**: Image placement, crop/clip behavior, full-bleed zones, and scrim/overlay or baked-alpha treatment follow the source-derived norms in the spec - **Deck application**: Template Overview describes the recurring situations, audiences/outcomes, and representative roles; Page Roster factually describes the actual prototypes and reusable slots without prescribing future use If PPTX import output exists: @@ -372,7 +375,7 @@ template. |---|---|---| | Lossless import SVG | Native-payload backing | Retain complete imported metadata, native object boundaries, hidden carriers, and source-scope identity. Keep it immutable and resolve it only through validated source refs. | | Authoring IR bundle | Editable template-creation source | Omit opaque native payload and duplicate hidden carriers from model context; retain visible shape intent and stable document-local source refs. Models read `authoring_summary.json`; tools read `authoring_manifest.json` for source paths and initial hashes. | -| `standard` / `fidelity` output | Newly authored contract | Use `preset_shape_svg.py` compact canonical `` output for exact preset matches, with paint from the confirmed brief / `design_spec.md`; use ordinary project SVG for other geometry. Reuse exported image/vector assets, not opaque source shape payload or source topology. | +| `standard` / `fidelity` output | Newly authored contract | Use editable basic primitives directly, `preset_shape_svg.py` compact canonical `` output for exact preset matches, and `shape_boolean_svg.py` for compound closed contours before allowing a necessary freeform. Paint comes from the confirmed brief / `design_spec.md`. Reuse exported image/vector assets, not opaque source shape payload or source topology. | | `mirror` output | Materialized preserved contract | Preserve currently supported imported metadata on unchanged Slide-local/slot refs, use the edited SVG fallback otherwise, and normalize fixed structural layers into semantic atoms. Strip IR-only source refs from final templates. | **Validation**: Mirror does not silently use stale metadata. Materialization @@ -381,8 +384,9 @@ hash before reusing native payload. If an imported object cannot use the converter's supported native metadata after normalization, keep its current SVG fallback and report the limitation. For exact registered preset matches, `standard` / `fidelity` regenerate the compact helper group instead of transplanting opaque source -payload; other geometry stays ordinary project SVG. `data-pptx-replace-with` remains -reserved for optional PowerPoint-native Chart/Table replacement markers. +payload; otherwise they apply the Boolean/freeform fallback gate above. +`data-pptx-replace-with` remains reserved for optional PowerPoint-native +Chart/Table replacement markers. **Explicit template SVG contract**: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/glassmorphism.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/glassmorphism.md index 0d035548..b1f8a030 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/glassmorphism.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/glassmorphism.md @@ -13,9 +13,9 @@ Frosted-glass SaaS — translucent layered panels, flowing gradient light, float ## 2. Typography character -- Clean modern sans; light / medium weights; airy. Headlines can carry a luminous gradient on the dark field. +- Clean modern sans; regular with selective bold; airy. Exact Light/Black requires a user-confirmed installed face. Headlines can carry a luminous gradient on the dark field. -> Families are chosen at confirmation `g`; this style asks for a clean, modern, slightly-light sans *character*. +> Families are chosen at confirmation `g`; this style asks for a clean, modern, airy sans *character*. ## 3. Using the deck's colors diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/soft-rounded.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/soft-rounded.md index a66073a9..1caf771b 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/soft-rounded.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/soft-rounded.md @@ -7,7 +7,7 @@ Approachable and modern. Rounded cards, gentle elevation, friendly rhythm. For p ## 1. Shape & decoration - Shape language: rounded rectangles (`rx` 12-16), pill tags, soft containers. Consistent radius deck-wide. -- Composition geometry: a large soft disc or blob bleeding off one edge as the color field; a pill chain or arc path replacing the boxed step row; one hero panel overlapping a full-width tinted band; an oversized rounded numeral behind the point. Cards are the container language, not the composition — vary the stage they sit on. +- Composition geometry: a large soft disc or blob bleeding off one edge as the color field; a pill chain or exact native `arc` / `blockArc` preset replacing the boxed step row; one hero panel overlapping a full-width tinted band; an oversized rounded numeral behind the point. Use a custom arc path only when the presets cannot faithfully express the intended contour. Cards are the container language, not the composition — vary the stage they sit on. - Decoration: cards as the primary container; icon accents; numbered circles; gentle dividers. Moderate, in service of clarity. - Whitespace: comfortable padding inside cards; even gutters; balanced rather than austere. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/swiss-minimal.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/swiss-minimal.md index d4ac2527..0d133640 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/swiss-minimal.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/visual-styles/swiss-minimal.md @@ -14,7 +14,7 @@ Strict Swiss-grid discipline. Modular grid, sharp geometry, aggressive whitespac ## 2. Typography character -- Sans-serif, single family; weight contrast (e.g. 900 / 300) over family contrast. Tight, rigorous spacing. +- Sans-serif, single family; regular/bold contrast. Exact Light/Black requires a user-confirmed installed face. Tight, rigorous spacing. - Strong size hierarchy — large headlines, small precise body. Left-aligned, flush. > Family is chosen at confirmation `g` by subject fit — this style asks for a grotesque / neo-grotesque *character*, not a specific font. 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 45dea1b4..7b243b7e 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 @@ -45,7 +45,7 @@ python3 scripts/update_repo.py | Area | Primary scripts | Documentation | |------|-----------------|---------------| | 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) | +| 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`, `pptx_delivery_check.py` | [docs/project.md](./docs/project.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) | @@ -183,6 +183,7 @@ python3 scripts/native_enhance_pptx.py init --name python3 scripts/native_enhance_pptx.py plan python3 scripts/native_enhance_pptx.py validate python3 scripts/native_enhance_pptx.py apply +python3 scripts/pptx_delivery_check.py ``` Native preset shape authoring (one registry-backed fragment on stdout): @@ -254,7 +255,7 @@ python3 scripts/finalize_svg.py python3 scripts/svg_to_pptx.py ``` -`finalize_svg.py` optimizes raster images by default using `2x` display pixels and max `2560px`. Native `svg_to_pptx.py` defaults to `--image-sizing cap`: only oversized full source images are reduced to max `2560px`, so later PowerPoint resizing keeps more image detail. Use `svg_to_pptx.py --image-sizing display --image-scale 2` only for aggressive size reduction, or `--no-image-optimize` when the native PPTX must embed original image bytes. +`finalize_svg.py` optimizes raster images by default using `2x` display pixels and max `2560px`. Native `svg_to_pptx.py` defaults to `--image-sizing cap`: oversized full sources normally reduce toward `2560px`, but cropped or stretched placements (including imported picture crops) retain enough source pixels to avoid undersupplying the visible frame. Use `svg_to_pptx.py --image-sizing display --image-scale 2` only for aggressive size reduction, or `--no-image-optimize` when the native PPTX must embed original image bytes. `finalize_svg.py` remains mandatory because it creates the self-contained `svg_final/` visual preview. Those SVGs may be opened directly or inserted into PowerPoint as SVG pictures. The only supported generated-PPTX path is `svg_output/` through the project SVG-to-DrawingML converter; `-s final` is diagnostic-only, and PowerPoint's manual Convert-to-Shape operation is unsupported. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/analyze_images.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/analyze_images.py index aa501b66..9a98c194 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/analyze_images.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/analyze_images.py @@ -7,9 +7,10 @@ images in a folder. Intentionally does NOT prescribe a layout — the Strategist decides narrative intent (hero / atmosphere / side-by-side / accent) per references/strategist-image.md; this tool only supplies the numbers. -When a canvas is specified, also reports the reference image/text area sizes -that would apply *if* an image is placed side-by-side with body text. Those -numbers are conditional on the Strategist picking the side-by-side intent. +After resolving the canvas from the project, an explicit override, or the +ppt169 fallback, also reports the reference image/text area sizes that would +apply *if* an image is placed side-by-side with body text. Those numbers are +conditional on the Strategist picking the side-by-side intent. Usage: python scripts/analyze_images.py @@ -23,9 +24,11 @@ Output: """ import argparse +import csv import json import os import sys +import tempfile from pathlib import Path from console_encoding import configure_utf8_stdio @@ -33,7 +36,7 @@ from console_encoding import configure_utf8_stdio configure_utf8_stdio() try: - from PIL import Image + from PIL import Image, ImageOps except ImportError: print("Error: PIL/Pillow not installed. Run: pip install Pillow") sys.exit(1) @@ -55,6 +58,8 @@ except ImportError: }, } +from project_utils import get_project_info, normalize_canvas_format + IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".tif"} OFFICE_VECTOR_EXTENSIONS = {".emf", ".wmf"} REPORT_WIDTH = 100 @@ -93,32 +98,6 @@ def _load_image_manifest(images_dir: str) -> dict[str, dict]: return manifest -def _warn_office_vectors_without_manifest(images_dir: str, manifest: dict[str, dict]) -> None: - """Warn when EMF/WMF files cannot be reported because manifest metadata is absent.""" - if manifest: - return - - vector_files = [ - path for path in Path(images_dir).iterdir() - if path.is_file() and path.suffix.lower() in OFFICE_VECTOR_EXTENSIONS - ] - if not vector_files: - return - - print( - f"[WARN] Found {len(vector_files)} office vector file(s) (.emf/.wmf) " - "but no image_manifest.json found." - ) - print( - " Dimensions and metadata unknown — these assets will NOT appear " - "in the analysis report." - ) - print( - " To fix: ensure image_manifest.json is present in images/ " - "(auto-generated by doc_to_md.py for DOCX sources)." - ) - - def _manifest_ratio(meta: dict | None) -> float | None: """Return a positive display ratio from manifest metadata.""" if not meta: @@ -197,6 +176,29 @@ def _apply_manifest_metadata(result: ImageAnalysis, meta: dict | None) -> None: result["pptx_native_supported"] = meta.get("pptx_native_supported", True) +def _has_transparent_pixels(image: Image.Image) -> bool: + """Return whether any frame contains a pixel with alpha below 255.""" + original_frame = image.tell() + frame_count = int(getattr(image, "n_frames", 1)) + try: + for frame_index in range(frame_count): + image.seek(frame_index) + if "A" not in image.getbands() and "transparency" not in image.info: + continue + rgba = image.convert("RGBA") + alpha = rgba.getchannel("A") + try: + extrema = alpha.getextrema() + finally: + alpha.close() + rgba.close() + if extrema and extrema[0] < 255: + return True + finally: + image.seek(original_frame) + return False + + def _result_from_manifest( filename: str, filepath: str, @@ -212,12 +214,26 @@ def _result_from_manifest( 'width': width, 'height': height, 'aspect_ratio': ratio, - 'pixel_aspect_ratio': meta.get('pixel_ratio') or ratio, + 'pixel_aspect_ratio': None, + 'source_display_ratio': ratio, 'ratio_source': 'manifest', + 'format': Path(filename).suffix.lstrip('.').upper(), + 'has_transparent_pixels': None, 'layout_hint': classify_ratio(ratio), 'filesize_kb': os.path.getsize(filepath) / 1024, } _apply_manifest_metadata(result, meta) + suffix = Path(filename).suffix.lower() + is_office_vector = suffix in OFFICE_VECTOR_EXTENSIONS + result["asset_kind"] = meta.get( + "asset_kind", + "office_vector" if is_office_vector else "vector", + ) + result["svg_renderable"] = meta.get("svg_renderable", suffix == ".svg") + result["pptx_native_supported"] = meta.get( + "pptx_native_supported", + is_office_vector or suffix == ".svg", + ) return result @@ -311,66 +327,87 @@ def compute_layout_dimensions( return _try_left_right_width_constrained() -def analyze_images(images_dir: str) -> list[ImageAnalysis]: +def _analyze_images(images_dir: str) -> tuple[list[ImageAnalysis], list[str]]: """Analyze all image files in a directory. Args: images_dir: Directory that contains image files. Returns: - A list of image analysis records sorted by filename. + Sorted image analysis records and supported files that could not be read. """ results: list[ImageAnalysis] = [] + errors: list[str] = [] manifest = _load_image_manifest(images_dir) - seen_filenames: set[str] = set() - # Iterate through all files in the directory for filename in sorted(os.listdir(images_dir)): filepath = os.path.join(images_dir, filename) + if not os.path.isfile(filepath): + continue + + suffix = Path(filename).suffix.lower() meta = manifest.get(filename) - # Check if it is an image file - if os.path.isfile(filepath) and Path(filename).suffix.lower() in IMAGE_EXTENSIONS: + if suffix in IMAGE_EXTENSIONS: try: with Image.open(filepath) as img: - width, height = img.size - pixel_ratio = width / height - aspect_ratio = _manifest_ratio(meta) or pixel_ratio - layout_hint = classify_ratio(aspect_ratio) + image_format = img.format or suffix.lstrip(".").upper() + has_transparent_pixels = _has_transparent_pixels(img) + oriented = ImageOps.exif_transpose(img) + try: + width, height = oriented.size + finally: + if oriented is not img: + oriented.close() + + aspect_ratio = width / height result: ImageAnalysis = { 'filename': filename, 'width': width, 'height': height, 'aspect_ratio': aspect_ratio, - 'pixel_aspect_ratio': pixel_ratio, - 'ratio_source': 'manifest' if meta else 'pixel', - 'layout_hint': layout_hint, + 'pixel_aspect_ratio': aspect_ratio, + 'source_display_ratio': _manifest_ratio(meta), + 'ratio_source': 'native', + 'format': image_format, + 'has_transparent_pixels': has_transparent_pixels, + 'layout_hint': classify_ratio(aspect_ratio), 'filesize_kb': os.path.getsize(filepath) / 1024 } _apply_manifest_metadata(result, meta) results.append(result) - seen_filenames.add(filename) - except Exception as e: - print(f"[WARN] Cannot read {filename}: {e}") - elif os.path.isfile(filepath) and meta: + except ( + EOFError, + OSError, + SyntaxError, + ValueError, + ZeroDivisionError, + Image.DecompressionBombError, + ) as exc: + message = f"{filename}: {exc}" + errors.append(message) + print(f"[WARN] Cannot read {message}") + elif meta: result = _result_from_manifest(filename, filepath, meta) if result: results.append(result) - seen_filenames.add(filename) + else: + message = f"{filename}: manifest has no valid display_ratio" + errors.append(message) + print(f"[WARN] Cannot analyze {message}") + elif suffix in OFFICE_VECTOR_EXTENSIONS: + message = f"{filename}: image_manifest.json metadata is required" + errors.append(message) + print(f"[WARN] Cannot analyze {message}") - for filename, meta in sorted(manifest.items()): - if filename in seen_filenames: - continue - filepath = os.path.join(images_dir, filename) - if not os.path.isfile(filepath): - continue - result = _result_from_manifest(filename, filepath, meta) - if result: - results.append(result) + return results, errors - _warn_office_vectors_without_manifest(images_dir, manifest) + +def analyze_images(images_dir: str) -> list[ImageAnalysis]: + """Analyze readable image files while preserving the existing public API.""" + results, _ = _analyze_images(images_dir) return results @@ -379,7 +416,6 @@ def enrich_with_layout( canvas_key: str, ) -> None: """Add computed layout dimensions to each result in-place.""" - fmt = CANVAS_FORMATS.get(canvas_key, {}) margins = LAYOUT_MARGINS.get(canvas_key) if not margins: @@ -413,7 +449,7 @@ def print_results(results: list[ImageAnalysis]) -> None: print("-" * REPORT_WIDTH) for i, img in enumerate(results, 1): - ratio_source = str(img.get('ratio_source', 'pixel')) + ratio_source = str(img.get('ratio_source', 'native')) usage_count = int(img.get('usage_count', 1)) base = f"{i:<4} {img['width']:<7} {img['height']:<7} {img['aspect_ratio']:<7.2f} {ratio_source:<8} {usage_count:<5} {img['filesize_kb']:<10.1f}KB {img['layout_hint']:<20}" if has_layout: @@ -515,26 +551,115 @@ def generate_markdown(results: list[ImageAnalysis], canvas_key: str) -> None: print("\n" + "=" * REPORT_WIDTH + "\n") -def save_csv(results: list[ImageAnalysis], csv_path: str) -> None: - """Save analysis results to a CSV file.""" - has_layout = 'layout_type' in results[0] if results else False - - # NOTE: ImageArea_SxS / TextArea_SxS apply only if Strategist picks the - # side-by-side intent for this image (see strategist-image.md). The tool - # does not prescribe a layout. - with open(csv_path, 'w', encoding='utf-8') as f: - if has_layout: - f.write("No,Filename,Width,Height,AspectRatio,PixelAspectRatio,RatioSource,UsageCount,DisplayRatioVariants,AssetKind,SvgRenderable,PptxNativeSupported,SizeKB,Category,ImageArea_SxS,TextArea_SxS\n") - for i, img in enumerate(results, 1): - f.write(f"{i},{img['filename']},{img['width']},{img['height']},{img['aspect_ratio']:.2f},{img.get('pixel_aspect_ratio', img['aspect_ratio']):.2f},{img.get('ratio_source', 'pixel')},{img.get('usage_count', 1)},{img.get('display_ratio_variants', '')},{img.get('asset_kind', 'bitmap')},{img.get('svg_renderable', True)},{img.get('pptx_native_supported', True)},{img['filesize_kb']:.1f},{img['layout_hint']},{img['image_w']}x{img['image_h']},{img['text_w']}x{img['text_h']}\n") - else: - f.write("No,Filename,Width,Height,AspectRatio,PixelAspectRatio,RatioSource,UsageCount,DisplayRatioVariants,AssetKind,SvgRenderable,PptxNativeSupported,SizeKB,Category\n") - for i, img in enumerate(results, 1): - f.write(f"{i},{img['filename']},{img['width']},{img['height']},{img['aspect_ratio']:.2f},{img.get('pixel_aspect_ratio', img['aspect_ratio']):.2f},{img.get('ratio_source', 'pixel')},{img.get('usage_count', 1)},{img.get('display_ratio_variants', '')},{img.get('asset_kind', 'bitmap')},{img.get('svg_renderable', True)},{img.get('pptx_native_supported', True)},{img['filesize_kb']:.1f},{img['layout_hint']}\n") - print(f"\nCSV saved to: {csv_path}") +def _format_optional_number(value: object, digits: int = 2) -> str: + """Format a numeric value for CSV, leaving unavailable facts blank.""" + if not isinstance(value, (int, float)): + return "" + return f"{float(value):.{digits}f}" -def main() -> None: +def save_csv( + results: list[ImageAnalysis], + csv_path: str | Path, + include_layout: bool | None = None, +) -> None: + """Atomically save analysis results to a standards-compliant CSV file.""" + target = Path(csv_path) + target.parent.mkdir(parents=True, exist_ok=True) + if include_layout is None: + include_layout = bool(results and "layout_type" in results[0]) + header = [ + "No", + "Filename", + "Width", + "Height", + "AspectRatio", + "PixelAspectRatio", + "SourceDisplayRatio", + "RatioSource", + "Format", + "HasTransparentPixels", + "UsageCount", + "DisplayRatioVariants", + "AssetKind", + "SvgRenderable", + "PptxNativeSupported", + "SizeKB", + "Category", + ] + if include_layout: + header.extend(["ImageArea_SxS", "TextArea_SxS"]) + + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + newline="", + prefix=f".{target.name}.", + suffix=".tmp", + dir=target.parent, + delete=False, + ) as handle: + temporary_path = Path(handle.name) + writer = csv.writer(handle, lineterminator="\n") + writer.writerow(header) + for index, image in enumerate(results, 1): + row = [ + index, + image["filename"], + image["width"], + image["height"], + _format_optional_number(image["aspect_ratio"]), + _format_optional_number(image.get("pixel_aspect_ratio")), + _format_optional_number(image.get("source_display_ratio")), + image.get("ratio_source", "native"), + image.get("format", ""), + image.get("has_transparent_pixels", ""), + image.get("usage_count", 1), + image.get("display_ratio_variants", ""), + image.get("asset_kind", "bitmap"), + image.get("svg_renderable", True), + image.get("pptx_native_supported", True), + _format_optional_number(image["filesize_kb"], digits=1), + image["layout_hint"], + ] + if include_layout: + image_area = ( + f"{image['image_w']}x{image['image_h']}" + if "image_w" in image + else "" + ) + text_area = ( + f"{image['text_w']}x{image['text_h']}" + if "text_w" in image + else "" + ) + row.extend([image_area, text_area]) + writer.writerow(row) + os.replace(temporary_path, target) + temporary_path = None + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + print(f"\nCSV saved to: {target}") + + +def _resolve_canvas_key(images_dir: Path, override: str | None) -> tuple[str, str]: + """Resolve canvas from an explicit override, project context, or fallback.""" + if override: + return normalize_canvas_format(override), "--canvas" + + project_dir = images_dir.parent if images_dir.name == "images" else images_dir + project_info = get_project_info(str(project_dir)) + project_canvas = normalize_canvas_format(str(project_info.get("format", ""))) + if project_canvas in CANVAS_FORMATS: + return project_canvas, "project" + return "ppt169", "fallback" + + +def main(argv: list[str] | None = None) -> int: """Run the CLI entry point.""" parser = argparse.ArgumentParser( description="Analyze image sizes and compute PPT layout dimensions" @@ -545,48 +670,59 @@ def main() -> None: ) parser.add_argument( "--canvas", - default="ppt169", - help=f"Canvas format key (default: ppt169). Available: {', '.join(sorted(CANVAS_FORMATS.keys()))}" + help=( + "Canvas format override. By default, infer it from the project " + f"directory and fall back to ppt169. Available: " + f"{', '.join(sorted(CANVAS_FORMATS.keys()))}" + ), ) - args = parser.parse_args() + args = parser.parse_args(argv) + images_dir = Path(args.images_dir).resolve() - images_dir = os.path.abspath(args.images_dir) - - if not os.path.exists(images_dir): + if not images_dir.exists(): print(f"Error: Directory not found: {images_dir}") - sys.exit(1) + return 1 - if not os.path.isdir(images_dir): + if not images_dir.is_dir(): print(f"Error: Not a directory: {images_dir}") - sys.exit(1) + return 1 - canvas_key = args.canvas + canvas_key, canvas_source = _resolve_canvas_key(images_dir, args.canvas) if canvas_key not in CANVAS_FORMATS: - print(f"Error: Unknown canvas format '{canvas_key}'. Available: {', '.join(sorted(CANVAS_FORMATS.keys()))}") - sys.exit(1) + available = ", ".join(sorted(CANVAS_FORMATS.keys())) + print(f"Error: Unknown canvas format '{canvas_key}'. Available: {available}") + return 1 fmt = CANVAS_FORMATS[canvas_key] print(f"Analyzing: {images_dir}") - print(f"Canvas: {fmt.get('name', canvas_key)} ({fmt.get('width', '?')}x{fmt.get('height', '?')})") + print( + f"Canvas: {fmt.get('name', canvas_key)} " + f"({fmt.get('width', '?')}x{fmt.get('height', '?')}; {canvas_source})" + ) - results = analyze_images(images_dir) + results, errors = _analyze_images(str(images_dir)) + enrich_with_layout(results, canvas_key) if results: - enrich_with_layout(results, canvas_key) print_results(results) generate_markdown(results, canvas_key) - - # Save to CSV file (saved under the project's analysis/ directory, - # alongside the PPTX intake bundle) - parent_dir = os.path.dirname(images_dir) - analysis_dir = os.path.join(parent_dir, "analysis") - os.makedirs(analysis_dir, exist_ok=True) - csv_path = os.path.join(analysis_dir, "image_analysis.csv") - save_csv(results, csv_path) else: - print("No image files found in the directory.") + print("No readable supported image files found in the directory.") + + analysis_dir = images_dir.parent / "analysis" + csv_path = analysis_dir / "image_analysis.csv" + save_csv(results, csv_path, include_layout=canvas_key in LAYOUT_MARGINS) + + if errors: + print( + f"[ERROR] {len(errors)} supported image file(s) could not be analyzed; " + "the current report was still written.", + file=sys.stderr, + ) + return 1 + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/animation_config.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/animation_config.py index 7a1c9506..8793f9f1 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/animation_config.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/animation_config.py @@ -2,7 +2,7 @@ """ PPT Master - Animation Config Tool -Create and validate optional per-object PPTX animation sidecar files. +Create and validate optional PPTX animation and deterministic Morph sidecars. Usage: python3 scripts/animation_config.py scaffold @@ -43,7 +43,7 @@ configure_utf8_stdio() def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description='Create or validate PPTX animation sidecar configuration.', + description='Create or validate PPTX motion sidecar configuration.', formatter_class=argparse.RawDescriptionHelpFormatter, ) subparsers = parser.add_subparsers(dest='command', required=True) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/beautify_identity.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/beautify_identity.py index a5ac51c4..20dd54b4 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/beautify_identity.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/beautify_identity.py @@ -46,9 +46,9 @@ from pptx_to_svg.ooxml_loader import OoxmlPackage # noqa: E402 configure_utf8_stdio() -def _font_pair(theme_root, font_tag: str) -> dict[str, str]: - """Read one / into {latin, ea} (skip empties).""" - out: dict[str, str] = {} +def _font_pair(theme_root, font_tag: str) -> dict[str, object]: + """Read one theme font family, including explicit CJK script mappings.""" + out: dict[str, object] = {} font = theme_root.find(f".//a:fontScheme/a:{font_tag}", NS) if font is None: return out @@ -58,6 +58,14 @@ def _font_pair(theme_root, font_tag: str) -> dict[str, str]: face = (elem.attrib.get("typeface") or "").strip() if face: out[key] = face + scripts: dict[str, str] = {} + for elem in font.findall("a:font", NS): + script = (elem.attrib.get("script") or "").strip() + face = (elem.attrib.get("typeface") or "").strip() + if script in {"Hans", "Hant", "Jpan", "Hang"} and face: + scripts[script] = face + if scripts: + out["scripts"] = scripts return out diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md index 4b60815b..f62fc6e3 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md @@ -44,16 +44,16 @@ Output files land directly under `project/images/`. Formula filenames should use Unified image generation entry point. This script is the **Path A** API/proxy executor for generated images. In the -PPT pipeline, always check the confirmed `image_ai_path` before running manifest -mode: `host-native` uses the host's image tool directly and must not run -`image_gen.py --manifest`; use `image_gen.py --render-md` only for its -read-only Markdown sidecar. +PPT pipeline, always check `design_spec.md §I / AI Image Acquisition Path` +before running manifest mode: only `api` / `auto` permits Path A; +`host-native` uses the host's image tool directly and `manual` uses the +read-only Markdown sidecar. For a project manifest, a missing or unknown value +fails closed and returns to Generate Step 4 recovery. ```bash python3 scripts/image_gen.py "A modern futuristic workspace" python3 scripts/image_gen.py "Abstract tech background" --aspect_ratio 16:9 --image_size 4K python3 scripts/image_gen.py "Concept car" -o projects/demo/images -python3 scripts/image_gen.py "Beautiful landscape" -n "low quality, blurry, watermark" python3 scripts/image_gen.py --list-backends ``` @@ -162,8 +162,11 @@ Analyze images in a project directory before writing the design spec or composin ```bash python3 scripts/analyze_images.py /images +python3 scripts/analyze_images.py /images --canvas ppt43 ``` +Without `--canvas`, the tool resolves the project format and falls back to `ppt169`; the flag is an explicit override. The atomic CSV records EXIF-corrected native dimensions/`AspectRatio`, optional source `SourceDisplayRatio`, format, and actual transparent-pixel presence. Native ratio—not source display metadata—drives bitmap layout/crop. An empty folder rewrites a header-only report; unreadable supported files still refresh the report and produce a non-zero exit. + Use this as the default inventory and geometry source; it does not perform semantic image understanding. Generate planning follows the Strategist's context-first boundary: source context, captions / alt text / titles, filenames, user notes, and existing resource records come first. Only a specific asset whose meaning or safe placement remains materially ambiguous may be inspected, and the workflow never bulk-opens the image folder. ## `image_search.py` @@ -178,25 +181,27 @@ python3 scripts/image_search.py "offshore wind farm" \ For multiple web rows, `--batch images/image_queries.json` searches them concurrently (modest default, `--concurrency N` / `IMAGE_SEARCH_CONCURRENCY` to tune) instead of one call per row — the web sister of `image_gen.py --manifest`. Schema and status semantics: [`image-searcher.md`](../../references/image-searcher.md) §5. -Providers (Openverse and Wikimedia work with no key; configure Pexels / Pixabay for better stock-photo quality): +Providers (Pexels / Pixabay are tried first when keyed; Openverse and Wikimedia are zero-config fallbacks): | Provider | Config | Strength | |---|---|---| -| `openverse` | zero-config | fallback aggregator: Wikimedia + Flickr + museums + rawpixel | -| `wikimedia` | zero-config | educational, scientific, geographic, historical | | `pexels` | recommended: `PEXELS_API_KEY` | modern stock photography, people, workplace, lifestyle | | `pixabay` | recommended: `PIXABAY_API_KEY` | broad type coverage including photos and illustrations | +| `openverse` | zero-config | fallback aggregator: Wikimedia + Flickr + museums + rawpixel | +| `wikimedia` | zero-config | educational, scientific, geographic, historical | -Default search chain (when `--provider` is unset): zero-config providers first, then keyed providers whose API key is set in the environment. Keyed providers without a key are silently skipped. For polished visual decks, configure at least one keyed provider. +Default search chain (when `--provider` is unset): configured Pexels, configured Pixabay, Openverse, then Wikimedia. Missing keyed credentials are silently skipped. For polished visual decks, configure at least one keyed provider. `image_search.py` uses the same `.env` lookup order as `image_gen.py`, so skill installs can keep `PEXELS_API_KEY` / `PIXABAY_API_KEY` in `~/.ppt-master/.env`. Query guidance: +Keep the Design Spec §VIII `Reference` as the full visual/crop intent; write a separate 1–4 word concrete provider query for this CLI. + | Case | Pattern | |---|---| -| Generic stock concept | `boardroom meeting, professional editorial photography, natural light` | -| China-specific landmark | Official Chinese place name + concrete scene | +| Generic stock concept | `boardroom meeting` | +| China-specific landmark | 1–4 official place/identity words | | Avoid | Negative prompt wording such as `not tourist snapshot` | License filter: @@ -231,7 +236,7 @@ Output: - Image saved to the specified output directory (auto-converts webp → jpg via Pillow when the filename extension demands) - `image_sources.json` manifest with full provenance (provider, license, license_tier, author, source URL, dimensions, attribution_text) -- Manifest is idempotent on `filename` — rerunning replaces that entry only +- Manifest is idempotent on `filename` and written atomically; damaged existing provenance blocks replacement Allowed licenses (default): CC0, Public Domain, Pexels License, Pixabay Content License, CC BY, CC BY-SA. Auto-rejected: CC BY-NC, CC BY-ND, CC BY-NC-SA, CC BY-NC-ND, all rights reserved, unknown. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/mask-gradient-smoke.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/mask-gradient-smoke.md new file mode 100644 index 00000000..4f125065 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/mask-gradient-smoke.md @@ -0,0 +1,210 @@ +# Mask and Gradient Maintenance Smoke + +Run this manual smoke from the repository root after changing gradient +validation, gradient import/export, native background promotion, icon +expansion, or mask rejection. It keeps XML in memory except for temporary SVG +fixtures; do not turn it into a test framework or example deck. + +```bash +python3 - <<'PY' +import math +import re +import sys +import tempfile +from pathlib import Path +from xml.etree import ElementTree as ET + +scripts = Path("skills/ppt-master/scripts").resolve() +sys.path.insert(0, str(scripts)) + +from pptx_to_svg.fill_to_svg import _angle_to_unit_endpoints +from svg_quality_checker import SVGQualityChecker +from svg_to_pptx.drawingml.converter import ( + SvgNativeConversionError, + convert_svg_to_slide_shapes, +) +from svg_to_pptx.drawingml.styles import build_gradient_fill +from svg_to_pptx.drawingml.utils import ( + parse_project_linear_gradient_coordinate, + project_gradient_errors, + project_mask_errors, +) + +SVG_NS = "http://www.w3.org/2000/svg" + + +def svg(fragment): + return ET.fromstring( + f'{fragment}' + ) + + +valid = svg( + """ + + + + + + + + + +""" +) +assert not project_gradient_errors(valid) +linear, radial = list(valid.find(f"{{{SVG_NS}}}defs")) +linear_xml = build_gradient_fill(linear) +radial_xml = build_gradient_fill(radial) +assert '' in linear_xml +assert '' in linear_xml +assert '' in radial_xml + +with tempfile.TemporaryDirectory(prefix="ppt-master-gradient-smoke-") as tmp: + source = Path(tmp) / "gradient.svg" + source.write_text( + ET.tostring(valid, encoding="unicode"), + encoding="utf-8", + ) + trace = [] + slide_xml, *_ = convert_svg_to_slide_shapes(source, trace_out=trace) +assert slide_xml.count("") == 1 +assert "" in slide_xml +assert trace[0]["summary"]["promoted_backgrounds"] == 1 +assert any( + event.get("decision") == "native-background" + for event in trace[0]["events"] +) + +invalid_gradients = [ + ( + """ + +""", + "requires at least two direct children", + ), + ( + """ + + +""", + "offsets must be non-decreasing", + ), + ( + """ + + +""", + "linear gradient axis must not collapse to one point", + ), +] +for definition, expected in invalid_gradients: + errors = project_gradient_errors(svg(f"{definition}")) + assert any(expected in error for error in errors), errors + +mask_cases = [ + """""", + """""", + """""", +] +for fragment in mask_cases: + errors = project_mask_errors(svg(fragment)) + assert any("unsupported SVG mask" in error for error in errors), errors + +checker = SVGQualityChecker() +with tempfile.TemporaryDirectory(prefix="ppt-master-mask-smoke-") as tmp: + for index, fragment in enumerate(mask_cases, start=1): + source = Path(tmp) / f"mask-{index}.svg" + source.write_text( + ET.tostring(svg(fragment), encoding="unicode"), + encoding="utf-8", + ) + checked = checker.check_file(str(source)) + assert any("mask" in error.lower() for error in checked["errors"]) + try: + convert_svg_to_slide_shapes(source) + except SvgNativeConversionError as exc: + assert "invalid project mask" in str(exc) + else: + raise AssertionError(f"native export accepted {source.name}") + +with tempfile.TemporaryDirectory(prefix="ppt-master-icon-mask-smoke-") as tmp: + project = Path(tmp) + icon_dir = project / "icons" / "imported" + icon_dir.mkdir(parents=True) + (icon_dir / "masked.svg").write_text( + f""" + + + + + + +""", + encoding="utf-8", + ) + source = project / "icon-mask.svg" + source.write_text( + ET.tostring( + svg( + """""" + ), + encoding="unicode", + ), + encoding="utf-8", + ) + checked = checker.check_file(str(source)) + assert any( + "Icon imported/masked" in error and "mask" in error.lower() + for error in checked["errors"] + ) + try: + convert_svg_to_slide_shapes(source) + except SvgNativeConversionError as exc: + assert "invalid project mask" in str(exc) + else: + raise AssertionError("native export accepted a masked icon") + +x1, y1, x2, y2 = _angle_to_unit_endpoints(30) +assert any(value < 0 or value > 1 for value in (x1, y1, x2, y2)) +for value in (x1, y1, x2, y2): + assert math.isclose( + parse_project_linear_gradient_coordinate(str(value)), + value, + abs_tol=1e-9, + ) +roundtrip = svg( + f""" + + + + +""" +) +assert not project_gradient_errors(roundtrip) +roundtrip_gradient = roundtrip.find( + f"{{{SVG_NS}}}defs/{{{SVG_NS}}}linearGradient" +) +angle = int( + re.search( + r'.morph` block to +bind direct-root SVG groups across adjacent slides. The sidecar stable key is +lowered to the same top-level `p:cNvPr@name="!!"` on both final +Slide-local objects. This does not create an Animation Pane row and does not +change either object's numeric shape id. + +The full plan is resolved before any SVG conversion so a source group named by +the following slide remains a stable top-level target. Names are written only +after flat/structured/preserve processing has finished; structured slide-shape +roster expectations are then refreshed. Package read-back requires: + +- the declared source to be the immediately preceding public slide; +- exactly one `!!` object on each side; +- the same OOXML object container type on both sides; +- Morph by object on the destination; and +- no structural target, same-slide name collision, group/key conflict, or + undeclared shared `!!` name on a Morph edge. + +Morph without an explicit pair block retains PowerPoint's automatic matching +behavior. Explicit pairing is generated-route authoring; direct-PPTX routes +continue to preserve existing object names and transition XML. + --- ## 4. Route Mapping @@ -211,9 +235,9 @@ failure rather than a silent downgrade. | Generated PPTX CLI | fade, 0.4s | click | auto-advance maps to both | | Recorded narration | Preserve resolved enter | narration | none remains visually none | | 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 | +| Native Enhance | Confirmed global/per-slide plan effect | Confirmed timing module | Explicit page entries override scope; disabled global transitions preserve unless the plan selected none | -Template Fill and Native Enhance keep their v1 route defaults. +Template Fill and Native Enhance keep their established 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. @@ -270,6 +294,8 @@ Reject: - booleans passed as numeric API values; - multiple logical transition carriers; - unresolved MCE Requires or Ignorable prefixes. +- invalid forced-Morph adjacency, identity uniqueness, object type, or + destination effect. Read-back must report the canonical native effect and complete effective options, while keeping the primary Choice child separate from the fallback. It diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/project.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/project.md index f5d75e9b..a3c3ab63 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/project.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/project.md @@ -24,6 +24,8 @@ python3 scripts/project_manager.py page-context-report Notes: - Files outside `projects/` are always copied into `sources/` - `--move` applies only to sources under the repository's `projects/` tree +- A directly supplied supported bitmap is also copied into `images/` with a + collision-safe basename while its original remains archived in `sources/` - Directory inputs are expanded non-recursively. After Step 1 conversion, pass the source file/directory once when generated Markdown lives beside the original source. If Step 1 used `-o` to write Markdown elsewhere, pass both 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 21e9cb62..c96158ec 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 @@ -67,6 +67,231 @@ is owned by [`shared-standards-core.md`](../../references/shared-standards-core. authoring guidance in [`native-shape-authoring.md`](../../references/native-shape-authoring.md). +## Shape Boolean maintenance smoke + +Run this manual smoke from the repository root after changing +`shape_boolean_svg.py`, preset geometry, path conversion, or custom-geometry +import/export. It uses only a gitignored `projects/_smoke_*` workspace and the +inline-smoke convention from [`code-style.md`](../../../../docs/rules/code-style.md) +§11; do not turn it into a test file or example deck. + +```bash +python3 - <<'PY' +import re +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path +from xml.etree import ElementTree as ET + +import pathops + +project = Path(tempfile.mkdtemp(prefix="_smoke_shape_boolean_", dir="projects")) +scripts = Path("skills/ppt-master/scripts") +svg_output = project / "svg_output" +svg_output.mkdir() +(project / "spec_lock.md").write_text( + """ +# Execution Lock + +## canvas +- viewBox: 0 0 1280 720 +- format: ppt169 +## communication +- audience: +- objective: +- core_message: +## mode +- mode: briefing +## visual_style +- visual_style: Boolean maintenance smoke +## colors +- bg: #FFFFFF +- primary: #2563EB +- accent: #F97316 +- text: #0F172A +## typography +- font_family: Arial, sans-serif +- title_family: Arial, sans-serif +- body_family: Arial, sans-serif +- title: 36 +- body: 20 +## icons +- library: none +- inventory: none +## page_rhythm +- P01: dense +## pptx_structure +- mode: flat +## forbidden +- Unsupported SVG constructs +""", + encoding="utf-8", +) + + +def run_tool(script, *args): + result = subprocess.run( + [sys.executable, str(scripts / script), *map(str, args)], + capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr or result.stdout + return result.stdout.strip() + +preset = run_tool( + "preset_shape_svg.py", "render", "rightArrow", + "--id", "preset-source", "--frame", "500", "120", "240", "120", + "--fill", "#2563EB", "--stroke", "none", +) +source = project / "operands.svg" +source.write_text( + f""" + + + + + + + + + + + + + {preset} + + +""", + encoding="utf-8", +) + +operations = [ + ("union", "preset-source", "preset-cut"), + ("combine", "body", "cutout"), + ("fragment", "body", "cutout"), + ("intersect", "body", "cutout"), + ("subtract", "body", "cutout"), +] +expected_custom_shapes = 0 +for index, (operation, first, second) in enumerate(operations, start=1): + fragment = run_tool( + "shape_boolean_svg.py", "render", source, "--operation", operation, + "--source", first, "--source", second, "--id", f"result-{operation}", + ) + paths = list( + ET.fromstring( + f'{fragment}' + ) + ) + assert paths and all(path.tag.endswith("}path") for path in paths) + assert all( + token not in fragment + for token in ("clip-path=", "fill-rule=", "mask=", "transform=") + ) + if operation == "fragment": + assert len(paths) > 1 + assert [path.get("id") for path in paths] == [ + f"result-fragment-{piece}" + for piece in range(1, len(paths) + 1) + ] + else: + assert len(paths) == 1 + assert paths[0].get("id") == f"result-{operation}" + if operation == "combine": + assert all(path.get("stroke-width") == "6" for path in paths) + assert all(path.get("stroke-dasharray") == "12 4.8" for path in paths) + if operation == "subtract": + assert (paths[0].get("d") or "").count("M ") >= 2 + + expected_custom_shapes += len(paths) + (svg_output / f"{index:02d}_{operation}.svg").write_text( + '' + '' + f"{fragment}\n", + encoding="utf-8", + ) + +non_scaling = ET.fromstring( + run_tool( + "shape_boolean_svg.py", "render", source, "--operation", "union", + "--source", "non-scaling", "--source", "non-scaling-cut", + "--id", "result-non-scaling", + ) +) +assert non_scaling.get("stroke-width") == "5" +assert non_scaling.get("stroke-dasharray") == "10 4" +assert non_scaling.get("vector-effect") == "non-scaling-stroke" + +rejections = [ + ("union", "body", "open", "open subpath"), + ("union", "body", "clipped", "uses clip-path"), + ("union", "body", "imported", "PPTX import/round-trip metadata"), + ("union", "body", "dashoffset", "stroke-dashoffset"), + ("intersect", "body", "far", "produced no filled area"), +] +for operation, first, second, expected_error in rejections: + rejected = subprocess.run( + [ + sys.executable, + str(scripts / "shape_boolean_svg.py"), + "render", str(source), "--operation", operation, + "--source", first, "--source", second, + "--id", f"reject-{second}", + ], + capture_output=True, text=True, + ) + assert rejected.returncode != 0 + assert expected_error in rejected.stderr, rejected.stderr + +run_tool("svg_quality_checker.py", svg_output, "--format", "ppt169") +pptx = project / "boolean-smoke.pptx" +run_tool("svg_to_pptx.py", project, "--quick-test", "-o", pptx) +with zipfile.ZipFile(pptx) as archive: + slides = [ + name + for name in archive.namelist() + if re.fullmatch(r"ppt/slides/slide\d+\.xml", name) + ] + custom_shapes = sum( + archive.read(name).count(b"") + for name in slides + ) +assert len(slides) == 5, slides +assert custom_shapes == expected_custom_shapes + +readback = project / "readback" +run_tool( + "pptx_to_svg.py", pptx, "-o", readback, + "--inheritance-mode", "flat", "--strict", +) +slides = sorted((readback / "svg").glob("slide_*.svg")) +readback_custom_shapes = sum( + slide.read_text(encoding="utf-8").count('data-pptx-custgeom="') + for slide in slides +) +assert len(slides) == 5, slides +assert readback_custom_shapes == expected_custom_shapes +print( + f"Shape Boolean smoke: passed " + f"({expected_custom_shapes} custom shapes; {project})" +) +PY +``` + +The five inline negative cases must return nonzero and match their expected +errors; every other command must pass. Open the printed +`boolean-smoke.pptx` path in PowerPoint: the Subtract result must have a real +hole, and every Fragment sibling must remain separately selectable. + ## `compact_svg_coordinates.py` Compact safe model-facing page-space coordinates without rewriting unrelated @@ -335,8 +560,9 @@ Behavior: structured-package validation, transitions, and animations are enforced before the builder publishes the PPTX and are reported as `enforced-at-build`, not as repeated postflight checks. -- `font_portability` warns only when a complete font stack contains generic CSS families - and no concrete family name. A recommended stack such as +- `font_portability` warns when a complete font stack has no concrete family or when + the converter resolves its Latin / East Asian role to a typeface that normally + requires a custom installation. A recommended stack such as `"Microsoft YaHei", Arial, sans-serif` does not warn merely because it ends with a generic fallback. - Paragraph merging is enabled by default and trades some SVG line-layout fidelity for PowerPoint editability: @@ -368,7 +594,10 @@ Behavior: - 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. +- Authored SVG clip-path restrictions remain. Crop wrappers use an + overflow-hidden viewport; preview-safe shape clips target the inner image in + viewBox coordinates, while legacy imported wrapper clips remain compatible. + Both map to native picture crop/geometry when possible. - 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/` diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/update_spec.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/update_spec.md index f88ac29c..c0202a38 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/update_spec.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/update_spec.md @@ -1,8 +1,9 @@ # update_spec.py > **Scope boundary**: this tool updates only deterministic global color and font -> substitutions, writes the authoritative `spec_lock.md` first, and relies on -> version control for rollback instead of creating parallel backups. +> substitutions, writes the authoritative `spec_lock.md` only after the SVG +> updates succeed, and relies on version control for rollback instead of +> creating parallel backups. Propagate a `spec_lock.md` value change to both the lock file and every `svg_output/*.svg`. The single edit surface for bulk style tweaks after generation. @@ -17,8 +18,9 @@ Bare `=` (no dot) is treated as `colors.=` for backward One invocation = one change. The tool: 1. Reads the old value from `/spec_lock.md` -2. Writes the new value into `spec_lock.md` -3. Propagates the change into every `.svg` under `svg_output/` +2. Plans and propagates the change into every `.svg` under `svg_output/` +3. Writes the new value into `spec_lock.md`; a global font replacement updates + every existing `typography.*_family` row together 4. Prints the list of files touched ## Examples @@ -32,14 +34,16 @@ python3 skills/ppt-master/scripts/update_spec.py projects/acme_ppt169_20260301 c # change the deck-wide font family python3 skills/ppt-master/scripts/update_spec.py projects/acme_ppt169_20260301 \ - 'typography.font_family="Inter", Arial, sans-serif' + 'typography.font_family=Arial, "Microsoft YaHei", sans-serif' ``` ## v2 scope - **Supported**: - `colors.*` — HEX value replacement across `svg_output/*.svg` (case-insensitive). - - `typography.font_family` — replaces the inner value of every `font-family="..."` / `font-family='...'` attribute. + - `typography.font_family` — replaces the inner value of every + `font-family="..."` / `font-family='...'` attribute and sets all existing + `typography.*_family` lock rows to that universal family. - **Not supported**: typography sizes, icons, images, canvas, forbidden — these involve attribute-scoped or semantic replacements whose risk/benefit does not warrant bulk propagation. Edit `spec_lock.md` and the affected SVGs by hand, or re-author the pages. ## When to use @@ -53,6 +57,8 @@ python3 skills/ppt-master/scripts/update_spec.py projects/acme_ppt169_20260301 \ - HEX values (e.g. `#005587`) are unique enough in SVG content that literal replacement is safe - `font-family` substitution is scoped to the attribute; the outer quote character is preserved, and switched automatically if the new value contains the same quote +- a global font substitution rewrites all existing family-role lock rows in one + file write, so the universal SVG result cannot leave stale title/body roles - The tool refuses non-HEX inputs, unknown keys, and unsupported sections - No backups are created — the project folder should be under git so you can diff / revert diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/finalize_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/finalize_svg.py index 9e3fd38d..ffc88d45 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/finalize_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/finalize_svg.py @@ -39,6 +39,7 @@ import sys import shutil import argparse from pathlib import Path +from typing import TextIO from xml.etree import ElementTree as ET from console_encoding import configure_utf8_stdio @@ -63,10 +64,11 @@ from svg_to_pptx.use_expander import ( ) -def safe_print(text: str) -> None: +def safe_print(text: str, *, file: TextIO | None = None) -> None: """Print text while tolerating Windows terminal encoding limits.""" + stream = file or sys.stdout try: - print(text) + print(text, file=stream) except UnicodeEncodeError: replacements = { chr(0x23F3): "[..]", @@ -79,7 +81,7 @@ def safe_print(text: str) -> None: } for source, target in replacements.items(): text = text.replace(source, target) - print(text) + print(text, file=stream) def process_flatten_text(svg_file: Path, verbose: bool = False) -> bool: @@ -236,11 +238,17 @@ def finalize_project( ) img_count += count img_errors += errs + if img_errors: + safe_print( + f"[ERROR] Image alignment/embedding failed for " + f"{img_errors} image(s); svg_final was not published", + file=sys.stderr, + ) + shutil.rmtree(svg_final, ignore_errors=True) + return False if not quiet: if img_count > 0: msg = f" {img_count} image(s) aligned + embedded" - if img_errors: - msg += f" ({img_errors} error(s))" safe_print(msg) if office_vector_count: safe_print( diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_backends/backend_common.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_backends/backend_common.py index f34a47c9..50bba666 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_backends/backend_common.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_backends/backend_common.py @@ -26,7 +26,7 @@ import time import requests try: - from PIL import Image as PILImage + from PIL import Image as PILImage, ImageOps as PILImageOps HAS_PIL = True except ImportError: HAS_PIL = False @@ -78,11 +78,6 @@ EXT_TO_PIL_FORMAT = { def detect_image_extension(image_bytes: bytes, content_type: str = None) -> str | None: """Best-effort detection of the real image format.""" - if content_type: - clean_type = content_type.split(";", 1)[0].strip().lower() - if clean_type in CONTENT_TYPE_TO_EXT: - return CONTENT_TYPE_TO_EXT[clean_type] - if image_bytes.startswith(b"\x89PNG\r\n\x1a\n"): return ".png" if image_bytes.startswith(b"\xff\xd8\xff"): @@ -95,6 +90,10 @@ def detect_image_extension(image_bytes: bytes, content_type: str = None) -> str return ".bmp" if image_bytes.startswith((b"II*\x00", b"MM\x00*")): return ".tiff" + if content_type: + clean_type = content_type.split(";", 1)[0].strip().lower() + if clean_type in CONTENT_TYPE_TO_EXT: + return CONTENT_TYPE_TO_EXT[clean_type] return None @@ -140,10 +139,35 @@ def save_image_bytes(image_bytes: bytes, path: str, content_type: str = None) -> if not target_format: raise ValueError(f"Unsupported output image extension: {target_ext}") - image = PILImage.open(io.BytesIO(image_bytes)) - if target_format == "JPEG" and image.mode in ("RGBA", "LA", "P"): - image = image.convert("RGB") - image.save(path, format=target_format) + with PILImage.open(io.BytesIO(image_bytes)) as source: + image = PILImageOps.exif_transpose(source) + try: + if target_format == "JPEG": + has_alpha = ( + image.mode in ("RGBA", "LA") + or "transparency" in getattr(image, "info", {}) + ) + if has_alpha: + rgba = image.convert("RGBA") + alpha = rgba.getchannel("A") + rgb = rgba.convert("RGB") + converted = PILImage.new("RGB", image.size, (255, 255, 255)) + converted.paste(rgb, mask=alpha) + rgb.close() + alpha.close() + rgba.close() + if image is not source: + image.close() + image = converted + elif image.mode != "RGB": + converted = image.convert("RGB") + if image is not source: + image.close() + image = converted + image.save(path, format=target_format) + finally: + if image is not source: + image.close() if actual_ext and actual_ext != target_ext: print(f" Converted: {actual_ext} -> {target_ext}") @@ -152,6 +176,27 @@ def save_image_bytes(image_bytes: bytes, path: str, content_type: str = None) -> return path +def validate_image_file(path: str) -> str: + """Require an existing regular file that Pillow can read as an image.""" + image_path = Path(path) + if not image_path.exists(): + raise RuntimeError(f"Image output path does not exist: {path}") + if not image_path.is_file(): + raise RuntimeError(f"Image output path is not a file: {path}") + if not HAS_PIL: + raise RuntimeError( + "Pillow is required to verify generated images. " + "Install it with: pip install Pillow" + ) + + try: + with PILImage.open(image_path) as image: + image.verify() + except (OSError, ValueError, SyntaxError) as exc: + raise RuntimeError(f"Image output is not readable: {path}: {exc}") from exc + return str(image_path) + + def report_resolution(path: str) -> None: """Try to report image resolution using PIL.""" if HAS_PIL: @@ -176,11 +221,27 @@ def normalize_image_size(image_size: str) -> str: def is_rate_limit_error(exc: Exception) -> bool: """Check whether the exception appears to be rate limiting.""" err_str = str(exc).lower() + status_code = getattr(exc, "status_code", None) + error_code = getattr(exc, "code", None) + response = getattr(exc, "response", None) + error_name = type(exc).__name__.lower() + if ( + status_code == 429 + or error_code == 429 + or getattr(response, "status_code", None) == 429 + or error_name in {"ratelimiterror", "toomanyrequestserror"} + ): + return True return ( "429" in err_str - or "rate" in err_str + or "rate limit" in err_str + or "rate-limit" in err_str + or "rate_limit" in err_str + or "too many requests" in err_str or "quota" in err_str or "resource_exhausted" in err_str + or "resource exhausted" in err_str + or "throttl" in err_str ) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_gen.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_gen.py index 3645cbf8..ce01a34f 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_gen.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_gen.py @@ -44,11 +44,12 @@ Usage: python3 image_gen.py --list-backends """ +import argparse import concurrent.futures import json import os +import re import sys -import argparse import tempfile import threading import time @@ -344,32 +345,73 @@ def _resolve_backend() -> tuple[object, str]: sys.exit(1) -def _confirmed_image_ai_path_for_manifest(manifest_path: str) -> str | None: - """Return confirmed image_ai_path for a project manifest, if present.""" +_AI_IMAGE_PATH_ROW_RE = re.compile( + r"^\s*\|\s*AI Image Acquisition Path\s*\|\s*([^|]+?)\s*\|\s*$", + re.MULTILINE, +) +VALID_AI_IMAGE_ACQUISITION_PATHS = { + "api", + "auto", + "host-native", + "manual", +} + + +def _project_design_spec_for_manifest(manifest_path: str) -> Path | None: + """Return a project Design Spec for an images/ manifest, when present.""" path = Path(manifest_path).resolve() if path.parent.name != "images": return None - result_file = path.parent.parent / "confirm_ui" / "result.json" - if not result_file.exists(): + design_spec = path.parent.parent / "design_spec.md" + return design_spec if design_spec.is_file() else None + + +def _confirmed_image_acquisition_path_for_manifest( + manifest_path: str, +) -> str | None: + """Return the Design Spec's AI Image Acquisition Path, if present.""" + design_spec = _project_design_spec_for_manifest(manifest_path) + if design_spec is None: return None try: - data = json.loads(result_file.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): + text = design_spec.read_text(encoding="utf-8") + except OSError: return None - value = data.get("image_ai_path") - if not isinstance(value, str): + match = _AI_IMAGE_PATH_ROW_RE.search(text) + if not match: return None - return value.strip().lower().replace("_", "-") + value = match.group(1).strip().lstrip("`*_ ").strip() + token_match = re.match( + r"^(host[\s_-]*native|api|auto|manual)(?![A-Za-z0-9_-])", + value, + re.IGNORECASE, + ) + selected = token_match.group(1) if token_match else value + return re.sub(r"[\s_]+", "-", selected.strip().lower()) def _guard_confirmed_non_api_path(manifest_path: str) -> None: - """Prevent accidental Path A execution after host-native/manual was confirmed.""" - image_ai_path = _confirmed_image_ai_path_for_manifest(manifest_path) - if image_ai_path not in {"host-native", "manual"}: + """Allow project Path A only when its Design Spec explicitly permits it.""" + design_spec = _project_design_spec_for_manifest(manifest_path) + if design_spec is None: return - if image_ai_path == "host-native": + acquisition_path = _confirmed_image_acquisition_path_for_manifest(manifest_path) + if acquisition_path not in VALID_AI_IMAGE_ACQUISITION_PATHS: + shown = acquisition_path or "(missing)" + valid = ", ".join(sorted(VALID_AI_IMAGE_ACQUISITION_PATHS)) print( - "Error: confirmed image_ai_path is 'host-native'.\n" + "Error: project manifest mode requires a valid " + "AI Image Acquisition Path in design_spec.md §I.\n" + f"Found: {shown!r}. Valid values: {valid}.\n" + "Return to Generate Step 4 recovery and record the durable " + "selection before running Path A." + ) + sys.exit(1) + if acquisition_path in {"api", "auto"}: + return + if acquisition_path == "host-native": + print( + "Error: Design Spec confirms AI Image Acquisition Path as 'host-native'.\n" "\n" "Do NOT run image_gen.py --manifest for this project. That command is Path A\n" "and may use the configured API/proxy backend. Use the host's native image\n" @@ -379,7 +421,7 @@ def _guard_confirmed_non_api_path(manifest_path: str) -> None: ) else: print( - "Error: confirmed image_ai_path is 'manual'.\n" + "Error: Design Spec confirms AI Image Acquisition Path as 'manual'.\n" "\n" "Do NOT run image_gen.py --manifest for this project. Render the Markdown\n" "sidecar and hand images/image_prompts.md to the user for external generation:\n" @@ -389,6 +431,7 @@ def _guard_confirmed_non_api_path(manifest_path: str) -> None: DEFAULT_MANIFEST_CONCURRENCY = 3 +MAX_MANIFEST_RATE_LIMIT_ATTEMPTS = 3 STATUS_PENDING = "Pending" STATUS_GENERATED = "Generated" @@ -397,18 +440,70 @@ STATUS_NEEDS_MANUAL = "Needs-Manual" VALID_STATUSES = {STATUS_PENDING, STATUS_GENERATED, STATUS_FAILED, STATUS_NEEDS_MANUAL} RETRYABLE_STATUSES = {STATUS_PENDING, STATUS_FAILED} REQUIRED_ITEM_FIELDS = ("filename", "prompt", "aspect_ratio", "status") +VALID_PAGE_ROLES = {"local", "hero_page", "full_page"} +VALID_TEXT_POLICIES = {"none", "embedded"} +STRUCTURAL_IMAGE_TYPES = { + "infographic", + "flowchart", + "framework", + "matrix", + "cycle", + "funnel", + "pyramid", + "comparison", + "timeline", + "map", + "scene", +} +LEGACY_IMAGE_TYPES = {"background", "hero", "portrait", "typography"} +EARLY_LEGACY_IMAGE_TYPES = {"illustration", "photography"} +VALID_IMAGE_TYPES = ( + STRUCTURAL_IMAGE_TYPES + | LEGACY_IMAGE_TYPES + | EARLY_LEGACY_IMAGE_TYPES +) + + +def _validate_bare_output_name( + value: str, + *, + field_name: str, + require_extension: bool = False, + reject_parent_marker: bool = False, +) -> Path: + """Require one cross-platform-safe basename, optionally with an extension.""" + value_path = Path(value) + if ( + not value.strip() + or value in {".", ".."} + or reject_parent_marker and ".." in value + or "/" in value + or "\\" in value + or ":" in value + or value_path.is_absolute() + or value_path.name != value + ): + raise ValueError( + f"{field_name} must be a bare filename without path components, " + f"got {value!r}" + ) + if require_extension and not value_path.suffix: + raise ValueError(f"{field_name} must include an extension, got {value!r}") + return value_path def load_manifest(path: str) -> dict: """Load and validate an `image_prompts.json` manifest. Schema (top level): {"items": [ ... ]}, optionally with - `deck_style_anchor`, `color_scheme`, `generated_at`. + `deck_rendering`, `color_scheme`, `generated_at`. Each item requires: `filename`, `prompt`, `aspect_ratio`, `status`. Optional: `image_size`, `model`, `alt_text`, `purpose`, `type`, - `last_error`. + `page_role`, `text_policy`, `slice_grid`, `slice_names`, `last_error`. """ + from image_backends.backend_common import normalize_image_size + try: data = json.loads(Path(path).read_text(encoding="utf-8")) except json.JSONDecodeError as exc: @@ -423,11 +518,51 @@ def load_manifest(path: str) -> dict: f"got {type(data).__name__}" ) + for field in ("project", "generated_at", "deck_rendering"): + if field not in data: + continue + if not isinstance(data[field], str) or not data[field].strip(): + raise ValueError( + f"{path}: field '{field}' must be a non-empty string when present" + ) + if "deck_style_anchor" in data: + legacy_anchor = data["deck_style_anchor"] + if not ( + isinstance(legacy_anchor, str) + and legacy_anchor.strip() + or isinstance(legacy_anchor, dict) + and legacy_anchor + ): + raise ValueError( + f"{path}: legacy field 'deck_style_anchor' must be a " + "non-empty string or object when present" + ) + + if "color_scheme" in data: + color_scheme = data["color_scheme"] + if not isinstance(color_scheme, dict) or not color_scheme: + raise ValueError( + f"{path}: field 'color_scheme' must be a non-empty object when present" + ) + for key, value in color_scheme.items(): + if ( + not isinstance(key, str) + or not key.strip() + or not isinstance(value, str) + or not value.strip() + ): + raise ValueError( + f"{path}: color_scheme keys and values must be non-empty strings" + ) + items = data.get("items") if not isinstance(items, list) or not items: raise ValueError(f"{path}: 'items' must be a non-empty array") - seen_filenames: set[str] = set() + claimed_outputs: dict[str, str] = {} + seen_stems: set[str] = set() + missing_page_role = 0 + missing_text_policy = 0 for i, item in enumerate(items): prefix = f"{path}: items[{i}]" if not isinstance(item, dict): @@ -444,10 +579,171 @@ def load_manifest(path: str) -> dict: f"{prefix} status '{item['status']}' is invalid. " f"Valid: {sorted(VALID_STATUSES)}" ) + if item["aspect_ratio"] not in ALL_ASPECT_RATIOS: + raise ValueError( + f"{prefix} aspect_ratio '{item['aspect_ratio']}' is invalid. " + f"Valid: {ALL_ASPECT_RATIOS}" + ) + if "image_size" in item: + image_size = item["image_size"] + if not isinstance(image_size, str) or not image_size.strip(): + raise ValueError( + f"{prefix} field 'image_size' must be a non-empty string" + ) + normalized_size = normalize_image_size(image_size) + if normalized_size not in ALL_IMAGE_SIZES: + raise ValueError( + f"{prefix} image_size '{image_size}' is invalid. " + f"Valid: {ALL_IMAGE_SIZES}" + ) + + page_role = item.get("page_role") + if page_role is None: + missing_page_role += 1 + elif not isinstance(page_role, str) or page_role not in VALID_PAGE_ROLES: + raise ValueError( + f"{prefix} page_role '{page_role}' is invalid. " + f"Valid: {sorted(VALID_PAGE_ROLES)}" + ) + + text_policy = item.get("text_policy") + if text_policy is None: + missing_text_policy += 1 + elif ( + not isinstance(text_policy, str) + or text_policy not in VALID_TEXT_POLICIES + ): + raise ValueError( + f"{prefix} text_policy '{text_policy}' is invalid. " + f"Valid: {sorted(VALID_TEXT_POLICIES)}" + ) + + image_type = item.get("type") + if image_type is not None: + normalized_type = ( + image_type.strip().lower() + if isinstance(image_type, str) + else "" + ) + if normalized_type not in VALID_IMAGE_TYPES: + raise ValueError( + f"{prefix} type '{image_type}' is invalid. " + f"Valid current/legacy values: {sorted(VALID_IMAGE_TYPES)}" + ) + + for field in ("model", "alt_text", "purpose"): + if field in item and ( + not isinstance(item[field], str) or not item[field].strip() + ): + raise ValueError( + f"{prefix} field '{field}' must be a non-empty string when present" + ) + if "last_error" in item and not isinstance(item["last_error"], str): + raise ValueError(f"{prefix} field 'last_error' must be a string") + has_slice_grid = "slice_grid" in item + has_slice_names = "slice_names" in item + slice_outputs: list[str] = [] + if has_slice_grid != has_slice_names: + raise ValueError( + f"{prefix} fields 'slice_grid' and 'slice_names' must appear together" + ) + if has_slice_grid: + slice_grid = item["slice_grid"] + grid_match = ( + re.fullmatch(r"([1-9]\d*)[xX]([1-9]\d*)", slice_grid.strip()) + if isinstance(slice_grid, str) + else None + ) + if grid_match is None: + raise ValueError( + f"{prefix} field 'slice_grid' must use positive RxC notation" + ) + slice_names = item["slice_names"] + if not isinstance(slice_names, str) or not slice_names.strip(): + raise ValueError( + f"{prefix} field 'slice_names' must be a non-empty string" + ) + names = [name.strip() for name in slice_names.split(",")] + if any(not name for name in names): + raise ValueError( + f"{prefix} field 'slice_names' contains an empty name" + ) + rows, cols = map(int, grid_match.groups()) + if len(names) != rows * cols: + raise ValueError( + f"{prefix} field 'slice_names' has {len(names)} names but " + f"slice_grid {rows}x{cols} requires {rows * cols}" + ) + normalized_outputs: set[str] = set() + for name in names: + name_path = _validate_bare_output_name( + name, + field_name=f"{prefix} slice output name", + reject_parent_marker=True, + ) + if name_path.suffix and name_path.suffix.lower() != ".png": + raise ValueError( + f"{prefix} slice output name {name!r} must omit its " + "extension or use .png" + ) + output_name = ( + name if name_path.suffix else f"{name}.png" + ).casefold() + if output_name in normalized_outputs: + raise ValueError( + f"{prefix} field 'slice_names' repeats output " + f"{output_name!r}" + ) + normalized_outputs.add(output_name) + slice_outputs.append(output_name) + fname = item["filename"] - if fname in seen_filenames: - raise ValueError(f"{prefix} duplicate filename '{fname}'") - seen_filenames.add(fname) + filename_path = _validate_bare_output_name( + fname, + field_name=f"{prefix} field 'filename'", + require_extension=True, + ) + normalized_filename = fname.casefold() + if normalized_filename in claimed_outputs: + raise ValueError( + f"{prefix} output filename {fname!r} conflicts with " + f"{claimed_outputs[normalized_filename]} (case-insensitive)" + ) + claimed_outputs[normalized_filename] = f"manifest output {fname!r}" + + stem = filename_path.stem.casefold() + if stem in seen_stems: + raise ValueError( + f"{prefix} duplicate filename stem '{filename_path.stem}' " + "would reuse backend output" + ) + seen_stems.add(stem) + + for output_name in slice_outputs: + if output_name in claimed_outputs: + raise ValueError( + f"{prefix} slice output {output_name!r} conflicts with " + f"{claimed_outputs[output_name]} (case-insensitive)" + ) + claimed_outputs[output_name] = ( + f"slice output {output_name!r} from items[{i}]" + ) + + legacy_parts = [] + if missing_page_role: + legacy_parts.append( + f"{missing_page_role} item(s) missing page_role (resolved as local)" + ) + if missing_text_policy: + legacy_parts.append( + f"{missing_text_policy} item(s) missing text_policy (resolved as none)" + ) + if legacy_parts: + print( + f"Warning: {path}: legacy manifest compatibility: " + + "; ".join(legacy_parts), + file=sys.stderr, + ) return data @@ -473,6 +769,29 @@ def save_manifest(path: str, data: dict) -> None: raise +def _materialize_manifest_image(saved_path: str, target_path: Path) -> str: + """Validate backend output and place it at the manifest's exact target path.""" + from image_backends.backend_common import ( + save_image_bytes, + validate_image_file, + ) + + source_path = Path(saved_path) + validate_image_file(str(source_path)) + + if source_path.resolve() != target_path.resolve(): + try: + image_bytes = source_path.read_bytes() + except OSError as exc: + raise RuntimeError( + f"Could not read image output {source_path}: {exc}" + ) from exc + save_image_bytes(image_bytes, str(target_path)) + + validate_image_file(str(target_path)) + return str(target_path) + + def _run_manifest(manifest: dict, manifest_path: str, backend_module, *, initial_concurrency: int, image_size: str, @@ -481,9 +800,14 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *, """Run Pending/Failed items through the backend with adaptive concurrency. Strategy: + - Verify every `Generated` item's target before treating it as done; + missing or unreadable output returns to `Failed` for this run. - Start at `initial_concurrency` workers per batch. - On any rate-limit error in a batch, halve concurrency (min 1) and - requeue the rate-limited items. + requeue the rate-limited items within a fixed attempt budget. + - A rate limit at concurrency 1 or after the budget is exhausted is + recorded as `status: Failed` + `last_error`; the current run then stops + without switching providers. - Per-item failures are recorded as `status: Failed` + `last_error` and not retried within this run. `Failed` remains retryable and non-terminal; the Step 5 gate must resolve it by rerunning this @@ -494,9 +818,40 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *, Returns (ok_count, failed_count, skipped_count). """ - from image_backends.backend_common import is_rate_limit_error + manifest_output_dir = Path(manifest_path).resolve().parent + if Path(output_dir).resolve() != manifest_output_dir: + raise ValueError( + "Manifest outputs must stay beside image_prompts.json: " + f"expected {manifest_output_dir}, got {Path(output_dir).resolve()}" + ) + output_dir = str(manifest_output_dir) + + from image_backends.backend_common import ( + is_rate_limit_error, + validate_image_file, + ) items = manifest["items"] + repaired_generated = False + for item in items: + if item["status"] != STATUS_GENERATED: + continue + target_path = Path(output_dir) / item["filename"] + try: + validate_image_file(str(target_path)) + except RuntimeError as exc: + item["status"] = STATUS_FAILED + item["last_error"] = ( + f"Generated file validation failed: {exc}" + )[:500] + repaired_generated = True + print( + f" [RETRY] {item['filename']} was marked Generated but its " + f"target is invalid: {exc}" + ) + if repaired_generated: + save_manifest(manifest_path, manifest) + pending_idx = [ i for i, it in enumerate(items) if it["status"] in RETRYABLE_STATUSES ] @@ -520,6 +875,8 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *, fail_count = 0 current = max(1, initial_concurrency) state_lock = threading.Lock() + rate_limit_attempts: dict[int, int] = {} + stopped_for_rate_limit = False def _one(idx: int): item = items[idx] @@ -532,6 +889,10 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *, filename=Path(item["filename"]).stem, model=item.get("model", model), ) + saved_path = _materialize_manifest_image( + saved_path, + Path(output_dir) / item["filename"], + ) return idx, saved_path, None except Exception as exc: # noqa: BLE001 — backend raises arbitrary types return idx, None, exc @@ -560,8 +921,35 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *, print(f" [OK] {item['filename']}") elif is_rate_limit_error(exc): rate_limited = True - queue.append(idx) - print(f" [RATE] {item['filename']} — requeued") + attempts = rate_limit_attempts.get(idx, 0) + 1 + rate_limit_attempts[idx] = attempts + if ( + current == 1 + or attempts >= MAX_MANIFEST_RATE_LIMIT_ATTEMPTS + ): + boundary = ( + "serial concurrency reached" + if current == 1 + else "rate-limit attempt budget exhausted" + ) + item["status"] = STATUS_FAILED + item["last_error"] = ( + f"Rate limit persisted ({boundary}; " + f"attempt {attempts}): {exc}" + )[:500] + fail_count += 1 + stopped_for_rate_limit = True + print( + f" [FAIL] {item['filename']}: {exc} " + f"({boundary}; status=Failed)" + ) + else: + queue.append(idx) + print( + f" [RATE] {item['filename']} — requeued " + f"(attempt {attempts}/" + f"{MAX_MANIFEST_RATE_LIMIT_ATTEMPTS})" + ) else: item["status"] = STATUS_FAILED item["last_error"] = str(exc)[:500] @@ -572,7 +960,13 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *, ) save_manifest(manifest_path, manifest) - if rate_limited and current > 1: + if stopped_for_rate_limit: + print( + "\n Persistent rate limit reached the run boundary. " + "Stopping without switching providers; untouched items remain retryable.\n" + ) + break + if rate_limited and current > 1 and queue: new_current = max(1, current // 2) print( f"\n ⚠ Rate-limit hit — concurrency {current} → {new_current}, " @@ -583,9 +977,17 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *, elif queue: time.sleep(2) + run_state = "Stopped" if stopped_for_rate_limit else "Done" + remaining_note = "" + if stopped_for_rate_limit: + remaining = sum( + 1 for item in items if item["status"] in RETRYABLE_STATUSES + ) + remaining_note = f"; {remaining} item(s) remain retryable" print( - f"\n[Manifest] Done: {ok_count} ok / {fail_count} failed " - f"({skipped} pre-skipped). Manifest written to {manifest_path}" + f"\n[Manifest] {run_state}: {ok_count} ok / {fail_count} failed " + f"({skipped} pre-skipped{remaining_note}). " + f"Manifest written to {manifest_path}" ) if fail_count: print( @@ -623,7 +1025,16 @@ def render_manifest_md(manifest: dict) -> str: project = manifest.get("project") generated_at = manifest.get("generated_at") color_scheme = manifest.get("color_scheme") or {} - anchor = manifest.get("deck_style_anchor") + deck_rendering = manifest.get("deck_rendering") + if not deck_rendering: + legacy_anchor = manifest.get("deck_style_anchor") + if isinstance(legacy_anchor, dict): + deck_rendering = ( + legacy_anchor.get("visual_style") + or json.dumps(legacy_anchor, ensure_ascii=False, sort_keys=True) + ) + else: + deck_rendering = legacy_anchor if project: lines.append(f"> Project: {project}") @@ -634,8 +1045,8 @@ def render_manifest_md(manifest: dict) -> str: f"{k.capitalize()} {v}" for k, v in color_scheme.items() ) lines.append(f"> Color scheme: {cs}") - if anchor: - lines.append(f"> Deck Style Anchor: {anchor}") + if deck_rendering: + lines.append(f"> Deck Rendering: {deck_rendering}") lines.append("") lines.append("---") lines.append("") @@ -648,11 +1059,20 @@ def render_manifest_md(manifest: dict) -> str: for label, key in ( ("Purpose", "purpose"), ("Type", "type"), + ("Page role", "page_role"), + ("Text policy", "text_policy"), ("Aspect ratio", "aspect_ratio"), ("Image size", "image_size"), + ("Model", "model"), + ("Slice grid", "slice_grid"), + ("Slice names", "slice_names"), ("Status", "status"), ): value = item.get(key) + if not value and key == "page_role": + value = "local (legacy default)" + elif not value and key == "text_policy": + value = "none (legacy default)" if value: lines.append(f"| {label} | {value} |") if item.get("last_error"): @@ -759,6 +1179,15 @@ def main() -> None: args = parser.parse_args() + if args.filename is not None: + try: + _validate_bare_output_name( + args.filename, + field_name="--filename", + ) + except ValueError as exc: + parser.error(str(exc)) + if args.reference_image is not None: # Reference editing is a single-image-only enhancement; keep it out of # the manifest / sidecar / list surfaces entirely. @@ -800,8 +1229,25 @@ def main() -> None: print(f"Rendered Markdown sidecar: {md_path}") return + manifest = None + manifest_output_dir = None if args.manifest: + if not os.path.isfile(args.manifest): + print(f"Error: manifest file not found: {args.manifest}") + sys.exit(1) _guard_confirmed_non_api_path(args.manifest) + try: + manifest = load_manifest(args.manifest) + except ValueError as e: + print(f"Error: {e}") + sys.exit(1) + manifest_output_dir = Path(args.manifest).resolve().parent + if args.output and Path(args.output).resolve() != manifest_output_dir: + print( + "Error: --output cannot redirect manifest items outside the " + f"manifest directory ({manifest_output_dir})" + ) + sys.exit(1) try: _load_image_env_file() @@ -818,22 +1264,13 @@ def main() -> None: print(f"Using backend: {backend_name}\n") if args.manifest: - if not os.path.isfile(args.manifest): - print(f"Error: manifest file not found: {args.manifest}") - sys.exit(1) - try: - manifest = load_manifest(args.manifest) - except ValueError as e: - print(f"Error: {e}") - sys.exit(1) - concurrency = _resolve_concurrency(args.concurrency) try: _, failed, _ = _run_manifest( manifest, args.manifest, backend, initial_concurrency=concurrency, image_size=args.image_size, - output_dir=args.output or str(Path(args.manifest).parent), + output_dir=str(manifest_output_dir), model=args.model, ) except KeyboardInterrupt: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_search.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_search.py index af1da0f6..adea9ae0 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_search.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_search.py @@ -46,9 +46,10 @@ import os import sys import tempfile import threading +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Optional +from typing import Callable, Optional import requests @@ -113,6 +114,8 @@ SEARCH_VALID_STATUSES = { # (see image-searcher.md §8); only Pending/Failed rows are retried on re-run. SEARCH_RETRYABLE_STATUSES = {SEARCH_STATUS_PENDING, SEARCH_STATUS_FAILED} SEARCH_REQUIRED_ITEM_FIELDS = ("filename", "query", "status") +SEARCH_FAILURE_NO_MATCH = "no-match" +SEARCH_FAILURE_RETRYABLE = "retryable" _WEAK_REQUIRED_TERM_PARTS = frozenset({ "ancient town", @@ -232,52 +235,127 @@ def _is_keyed_provider_unconfigured(provider_name: str, exc: Exception) -> bool: return "API_KEY" in str(exc) +@dataclass +class SearchDownloadResult: + """Carry one search result without collapsing no-match and retryable failures.""" + + candidate: Optional[AssetCandidate] = None + provider_name: Optional[str] = None + stage: Optional[str] = None + actual_dimensions: Optional[tuple[int, int]] = None + staged_path: Optional[Path] = None + output_path: Optional[Path] = None + failure_kind: Optional[str] = None + error: Optional[str] = None + + +class DownloadQualityError(ValueError): + """Signal a readable candidate that fails the requested image contract.""" + + +def _is_pillow_decompression_error(exc: BaseException) -> bool: + """Recognize Pillow's safety exception without making Pillow a hard import.""" + cls = type(exc) + return ( + cls.__name__ == "DecompressionBombError" + and cls.__module__.startswith("PIL.") + ) + + +def _is_recoverable_image_error(exc: BaseException) -> bool: + """Return whether a provider/download/image failure can be reported cleanly.""" + return isinstance( + exc, + ( + requests.RequestException, + OSError, + RuntimeError, + SyntaxError, + ValueError, + ), + ) or _is_pillow_decompression_error(exc) + + def _try_provider( name: str, request: ImageSearchRequest, license_tier_filter: str, -) -> Optional[list[AssetCandidate]]: - """Run one provider; print and swallow recoverable errors, return None - so the dispatcher can try the next provider.""" + *, + provider_is_explicit: bool = False, +) -> tuple[Optional[list[AssetCandidate]], Optional[str]]: + """Run one provider while preserving whether it errored or returned no rows. + + An explicitly selected provider is required; a missing key is retryable + instead of an optional-provider skip. + """ try: module = _load_provider(name) - return module.search(request, license_tier_filter=license_tier_filter) + return module.search(request, license_tier_filter=license_tier_filter), None except RuntimeError as exc: - if _is_keyed_provider_unconfigured(name, exc): + if ( + not provider_is_explicit + and _is_keyed_provider_unconfigured(name, exc) + ): print( f" [{name}] skipped: {exc}", file=sys.stderr, ) + return None, None else: print(f" [{name}] error: {exc}", file=sys.stderr) - return None - except (requests.RequestException, ValueError) as exc: + return None, f"{name}: {exc}" + except (requests.RequestException, OSError, ValueError) as exc: print(f" [{name}] error: {exc}", file=sys.stderr) - return None + return None, f"{name}: {exc}" + except ImportError as exc: + print(f" [{name}] error: {exc}", file=sys.stderr) + return None, f"{name}: provider import failed: {exc}" # --------------------------------------------------------------------------- # Post-download quality validation # --------------------------------------------------------------------------- -_MIN_DOWNLOAD_PIXELS = 800 * 600 # reject anything below ~480K px +_MIN_DOWNLOAD_PIXELS = 800 * 600 # preserve the existing absolute thumbnail floor -def _validate_downloaded_quality(path: Path) -> bool: - """Reject images that are too small after download. +def _validate_downloaded_quality( + path: Path, + *, + min_width: int = 0, + min_height: int = 0, + enforce_thumbnail_floor: bool = True, +) -> bool: + """Reject unreadable images and actual EXIF-oriented dimensions below contract. Upstream metadata can be inaccurate (e.g. Openverse aggregates rawpixel which only exposes a preview). This function checks what was actually - written to disk and rejects thumbnails / previews. + written to disk. Automated paths also reject thumbnails/previews; explicit + manual paths may disable that absolute floor while retaining their own + requested dimensions. """ try: - from PIL import Image # type: ignore - except ImportError: - return True # can't check without Pillow; assume OK + from PIL import Image, ImageOps # type: ignore + except ImportError as exc: + raise RuntimeError( + "Pillow is required to validate downloaded image dimensions. " + "Install it with: pip install Pillow" + ) from exc try: with Image.open(path) as im: - w, h = im.size - if w * h < _MIN_DOWNLOAD_PIXELS: + oriented = ImageOps.exif_transpose(im) + oriented.load() + w, h = oriented.size + if oriented is not im: + oriented.close() + if w < min_width or h < min_height: + print( + f" rejected: downloaded image dimensions {w}x{h} are below " + f"the requested minimum {min_width}x{min_height}", + file=sys.stderr, + ) + return False + if enforce_thumbnail_floor and w * h < _MIN_DOWNLOAD_PIXELS: print( f" rejected: downloaded image too small " f"({w}x{h} = {w*h:,} px < {_MIN_DOWNLOAD_PIXELS:,} px minimum)", @@ -285,8 +363,126 @@ def _validate_downloaded_quality(path: Path) -> bool: ) return False return True - except (OSError, ValueError): - return True # unreadable image; let downstream handle it + except Exception as exc: + if not _is_recoverable_image_error(exc): + raise + print( + f" rejected: downloaded file is not a readable image ({exc})", + file=sys.stderr, + ) + return False + + +def _stage_validated_image( + url: str, + output_path: Path, + *, + min_width: int, + min_height: int, + enforce_thumbnail_floor: bool = True, +) -> tuple[Path, tuple[int, int]]: + """Download and validate beside the target without changing the canonical.""" + output_path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp( + prefix=f".{output_path.stem}.", + suffix=output_path.suffix, + dir=str(output_path.parent), + ) + os.close(fd) + temp_path = Path(temp_name) + keep_temp = False + try: + download_image( + url, + str(temp_path), + headers={"User-Agent": USER_AGENT}, + ) + if not _validate_downloaded_quality( + temp_path, + min_width=min_width, + min_height=min_height, + enforce_thumbnail_floor=enforce_thumbnail_floor, + ): + raise DownloadQualityError( + "downloaded image did not satisfy the requested dimensions/readability" + ) + actual_dimensions = _measure_actual_image(temp_path) + if actual_dimensions is None: + raise DownloadQualityError( + "downloaded image dimensions could not be measured" + ) + keep_temp = True + return temp_path, actual_dimensions + finally: + if not keep_temp: + try: + temp_path.unlink(missing_ok=True) + except OSError: + pass + + +def _commit_staged_image( + staged_path: Path, + target_path: Path, + manifest_writer: Callable[[], Path], +) -> Path: + """Install a staged image and roll it back if provenance cannot be written.""" + target_path.parent.mkdir(parents=True, exist_ok=True) + backup_path: Optional[Path] = None + installed = False + + try: + if target_path.exists(): + if not target_path.is_file(): + raise RuntimeError( + f"image target exists but is not a regular file: {target_path}" + ) + fd, backup_name = tempfile.mkstemp( + prefix=f".{target_path.stem}.backup.", + suffix=target_path.suffix, + dir=str(target_path.parent), + ) + os.close(fd) + reserved_backup_path = Path(backup_name) + reserved_backup_path.unlink() + os.replace(target_path, reserved_backup_path) + backup_path = reserved_backup_path + + os.replace(staged_path, target_path) + installed = True + written = manifest_writer() + except Exception as exc: + rollback_errors: list[str] = [] + if installed: + try: + target_path.unlink(missing_ok=True) + except OSError as rollback_exc: + rollback_errors.append(f"cannot remove new target: {rollback_exc}") + if backup_path is not None and backup_path.exists(): + try: + os.replace(backup_path, target_path) + except OSError as rollback_exc: + rollback_errors.append(f"cannot restore prior target: {rollback_exc}") + if rollback_errors: + raise RuntimeError( + f"{exc}; image rollback also failed: {'; '.join(rollback_errors)}" + ) from exc + raise + else: + if backup_path is not None: + try: + backup_path.unlink(missing_ok=True) + except OSError as exc: + print( + f" warning: could not remove image backup {backup_path}: {exc}", + file=sys.stderr, + ) + return written + finally: + try: + staged_path.unlink(missing_ok=True) + except OSError: + pass def _write_review_copy( @@ -300,18 +496,30 @@ def _write_review_copy( returns None (non-fatal) if Pillow or the source is unavailable. """ try: - from PIL import Image # type: ignore + from PIL import Image, ImageOps # type: ignore except ImportError: return None + review_path: Optional[Path] = None try: dest_dir.mkdir(parents=True, exist_ok=True) review_path = dest_dir / f"{Path(name).stem}.jpg" with Image.open(src) as im: - im = im.convert("RGB") - im.thumbnail((max_side, max_side)) - im.save(review_path, "JPEG", quality=85) + oriented = ImageOps.exif_transpose(im) + review = oriented.convert("RGB") + review.thumbnail((max_side, max_side)) + review.save(review_path, "JPEG", quality=85) + review.close() + if oriented is not im: + oriented.close() return review_path - except (OSError, ValueError): + except Exception as exc: + if not _is_recoverable_image_error(exc): + raise + if review_path is not None: + try: + review_path.unlink(missing_ok=True) + except OSError: + pass return None @@ -320,6 +528,8 @@ def _save_candidates_pool( output_dir: Path, stem: str, selected_filename: str, + min_width: int, + min_height: int, max_candidates: int = 4, ) -> None: """Download top-N candidates into ``candidates//`` and write @@ -335,38 +545,60 @@ def _save_candidates_pool( suffix = Path(candidate.download_url.split("?")[0]).suffix or ".jpg" cand_filename = f"candidate_{idx + 1:02d}{suffix}" cand_path = cand_dir / cand_filename + keep_candidate = False try: download_image( candidate.download_url, str(cand_path), headers={"User-Agent": USER_AGENT}, ) - if not _validate_downloaded_quality(cand_path): - cand_path.unlink(missing_ok=True) + if not _validate_downloaded_quality( + cand_path, + min_width=min_width, + min_height=min_height, + ): continue - except (requests.RequestException, OSError, RuntimeError, ValueError): + actual_dim = _measure_actual_image(cand_path) + review_path = _write_review_copy( + cand_path, + cand_dir / "review", + cand_filename, + ) + idx += 1 + pool.append({ + "rank": idx, + "score": round(score, 2), + "filename": cand_filename, + "review": f"review/{review_path.name}" if review_path else None, + "provider": provider_name, + "title": candidate.title, + "author": candidate.author, + "source_page_url": candidate.source_page_url, + "download_url": candidate.download_url, + "license_name": candidate.license_name, + "license_url": candidate.license_url, + "license_tier": candidate.license_tier, + "attribution_required": ( + candidate.license_tier == "attribution-required" + ), + "attribution_text": build_attribution_text( + selected_filename, + candidate, + ), + "width": actual_dim[0] if actual_dim else candidate.width, + "height": actual_dim[1] if actual_dim else candidate.height, + }) + keep_candidate = True + except Exception as exc: + if not _is_recoverable_image_error(exc): + raise continue - idx += 1 - actual_dim = _measure_actual_image(cand_path) - review_path = _write_review_copy(cand_path, cand_dir / "review", cand_filename) - pool.append({ - "rank": idx, - "score": round(score, 2), - "filename": cand_filename, - "review": f"review/{review_path.name}" if review_path else None, - "provider": provider_name, - "title": candidate.title, - "author": candidate.author, - "source_page_url": candidate.source_page_url, - "download_url": candidate.download_url, - "license_name": candidate.license_name, - "license_url": candidate.license_url, - "license_tier": candidate.license_tier, - "attribution_required": candidate.license_tier == "attribution-required", - "attribution_text": build_attribution_text(selected_filename, candidate), - "width": actual_dim[0] if actual_dim else candidate.width, - "height": actual_dim[1] if actual_dim else candidate.height, - }) + finally: + if not keep_candidate: + try: + cand_path.unlink(missing_ok=True) + except OSError: + pass if pool: meta = { @@ -376,10 +608,7 @@ def _save_candidates_pool( "candidates": pool, } meta_path = cand_dir / "candidates.json" - meta_path.write_text( - json.dumps(meta, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) + _write_json_atomic(meta_path, meta) print(f" candidates: {cand_dir}/ ({len(pool)} saved)", file=sys.stderr) @@ -391,7 +620,8 @@ def search_and_download( strict_no_attribution: bool, save_candidates: bool = False, max_candidates: int = 4, -) -> tuple[Optional[AssetCandidate], Optional[str], Optional[str]]: + provider_is_explicit: bool = False, +) -> SearchDownloadResult: """Find a candidate AND successfully download it. By default only the best match is downloaded. When ``save_candidates`` @@ -399,19 +629,31 @@ def search_and_download( ``candidates//`` so the agent can review and ``--promote`` a better fit when the best match does not pass visual confirmation. - Returns ``(candidate, provider_name, stage)`` for the successfully - downloaded image, or ``(None, None, None)`` if every combination - failed. + Returns a structured result so batch mode can keep transient/provider + failures retryable while treating a complete no-match as terminal. + ``provider_is_explicit`` distinguishes a required provider from an optional + member of the default fallback chain. """ license_filters: list[str] = ( ["no-attribution-only"] if strict_no_attribution else ["all"] ) + provider_errors: list[str] = [] + download_errors: list[str] = [] + quality_rejections = 0 + for stage in license_filters: ranked: list[tuple[float, str, AssetCandidate]] = [] for provider_name in providers: print(f" -> trying {provider_name} ({stage}) ...", file=sys.stderr) - candidates = _try_provider(provider_name, request, stage) + candidates, provider_error = _try_provider( + provider_name, + request, + stage, + provider_is_explicit=provider_is_explicit, + ) + if provider_error: + provider_errors.append(provider_error) if not candidates: continue @@ -437,10 +679,23 @@ def search_and_download( # --- Save candidate pool (before picking the winner) --- if save_candidates and sorted_ranked: stem = Path(output_path).stem - _save_candidates_pool( - sorted_ranked, output_path.parent, stem, output_path.name, - max_candidates=max_candidates, - ) + try: + _save_candidates_pool( + sorted_ranked, + output_path.parent, + stem, + output_path.name, + request.min_width, + request.min_height, + max_candidates=max_candidates, + ) + except Exception as exc: + if not _is_recoverable_image_error(exc): + raise + print( + f" warning: candidate pool could not be saved: {exc}", + file=sys.stderr, + ) # --- Pick the best downloadable candidate --- for _score, provider_name, candidate in sorted_ranked: @@ -448,28 +703,47 @@ def search_and_download( # exist in the candidates dir — but we still need the # primary copy at output_path. try: - download_image( + staged_path, actual_dimensions = _stage_validated_image( candidate.download_url, - str(output_path), - headers={"User-Agent": USER_AGENT}, + output_path, + min_width=request.min_width, + min_height=request.min_height, ) - if not _validate_downloaded_quality(output_path): - output_path.unlink(missing_ok=True) - continue - review = _write_review_copy( - output_path, output_path.parent / ".review", output_path.name + return SearchDownloadResult( + candidate=candidate, + provider_name=provider_name, + stage=stage, + actual_dimensions=actual_dimensions, + staged_path=staged_path, + output_path=output_path, ) - if review is not None: - print(f" review copy: {review}", file=sys.stderr) - return candidate, provider_name, stage - except (requests.RequestException, OSError, RuntimeError, ValueError) as exc: + except DownloadQualityError: + quality_rejections += 1 + continue + except Exception as exc: + if not _is_recoverable_image_error(exc): + raise print( f" download failed for {candidate.title!r}: {exc}", file=sys.stderr, ) + download_errors.append(f"{provider_name}/{candidate.title}: {exc}") continue - return None, None, None + retryable_errors = provider_errors + download_errors + if retryable_errors: + return SearchDownloadResult( + failure_kind=SEARCH_FAILURE_RETRYABLE, + error="; ".join(retryable_errors)[:500], + ) + + detail = "no acceptable candidate across all providers/stages" + if quality_rejections: + detail += f" ({quality_rejections} candidate(s) failed actual-size/readability gates)" + return SearchDownloadResult( + failure_kind=SEARCH_FAILURE_NO_MATCH, + error=detail, + ) # --------------------------------------------------------------------------- @@ -481,6 +755,22 @@ def default_manifest_path(output_dir: str) -> Path: return Path(output_dir) / "image_sources.json" +def _validate_bare_filename(value: str, *, field_name: str = "filename") -> str: + """Require a bare filename with no absolute or parent path components.""" + if ( + not value.strip() + or value in {".", ".."} + or "/" in value + or "\\" in value + or ":" in value + or Path(value).is_absolute() + ): + raise ValueError( + f"{field_name} must be a bare filename without path components: {value!r}" + ) + return value + + def _measure_actual_image(path: Path) -> Optional[tuple[int, int]]: """Return ``(width, height)`` of the file actually saved at ``path``. @@ -494,13 +784,20 @@ def _measure_actual_image(path: Path) -> Optional[tuple[int, int]]: Returns ``None`` if Pillow is unavailable or the file is unreadable. """ try: - from PIL import Image # type: ignore + from PIL import Image, ImageOps # type: ignore except ImportError: return None try: with Image.open(path) as im: - return int(im.width), int(im.height) - except (OSError, ValueError): + oriented = ImageOps.exif_transpose(im) + try: + return int(oriented.width), int(oriented.height) + finally: + if oriented is not im: + oriented.close() + except Exception as exc: + if not _is_recoverable_image_error(exc): + raise return None @@ -572,24 +869,106 @@ def _read_existing_manifest(path: Path) -> dict: if not path.exists(): return {} try: - return json.loads(path.read_text(encoding="utf-8")) + payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: - print( - f" warning: existing manifest at {path} is unreadable, " - f"starting fresh ({exc})", - file=sys.stderr, + raise RuntimeError( + f"existing image sources manifest is unreadable: {path} ({exc}); " + "repair or restore it before continuing" + ) from exc + if not isinstance(payload, dict): + raise RuntimeError( + f"existing image sources manifest must be a JSON object: {path}" ) - return {} + items = payload.get("items") + if not isinstance(items, list): + raise RuntimeError( + f"existing image sources manifest must contain an 'items' array: {path}" + ) + if any(not isinstance(item, dict) for item in items): + raise RuntimeError( + f"existing image sources manifest contains a non-object item: {path}" + ) + seen_filenames: dict[str, str] = {} + for index, item in enumerate(items): + filename = item.get("filename") + if not isinstance(filename, str): + raise RuntimeError( + f"existing image sources manifest items[{index}].filename " + f"must be a non-empty bare filename: {path}" + ) + try: + _validate_bare_filename(filename) + except ValueError as exc: + raise RuntimeError( + f"existing image sources manifest items[{index}]: {exc}: {path}" + ) from exc + normalized_filename = filename.casefold() + if normalized_filename in seen_filenames: + raise RuntimeError( + f"existing image sources manifest filename {filename!r} conflicts " + f"with {seen_filenames[normalized_filename]!r} " + f"(case-insensitive): {path}" + ) + seen_filenames[normalized_filename] = filename + return payload + + +def _write_json_atomic(path: str | Path, payload: dict) -> Path: + """Write JSON through a same-directory temporary file and atomic rename.""" + target = ensure_json_parent(path) + fd, tmp_path = tempfile.mkstemp( + prefix=target.stem + ".", suffix=".tmp", dir=str(target.parent) + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + os.replace(tmp_path, target) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + return target def write_sources_manifest(path: Path, item: dict) -> Path: """Append ``item`` to the manifest at ``path``, replacing any prior entry that targets the same filename.""" - manifest_path = ensure_json_parent(path) + manifest_path = Path(path) payload = _read_existing_manifest(manifest_path) + filename = item.get("filename") + if not isinstance(filename, str): + raise RuntimeError("new image source item requires a string filename") + try: + _validate_bare_filename(filename) + except ValueError as exc: + raise RuntimeError(f"new image source item: {exc}") from exc items: list[dict] = list(payload.get("items") or []) - items = [i for i in items if i.get("filename") != item["filename"]] + normalized_filename = filename.casefold() + differently_cased = next( + ( + existing["filename"] + for existing in items + if isinstance(existing.get("filename"), str) + and existing["filename"].casefold() == normalized_filename + and existing["filename"] != filename + ), + None, + ) + if differently_cased is not None: + raise RuntimeError( + f"new image source filename {filename!r} conflicts with existing " + f"{differently_cased!r} (case-insensitive)" + ) + items = [ + i + for i in items + if not isinstance(i.get("filename"), str) + or i["filename"].casefold() != normalized_filename + ] items.append(item) payload["items"] = items @@ -599,11 +978,7 @@ def write_sources_manifest(path: Path, item: dict) -> Path: "provider metadata used; manual review recommended for external delivery", ) - manifest_path.write_text( - json.dumps(payload, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - return manifest_path + return _write_json_atomic(manifest_path, payload) # --------------------------------------------------------------------------- @@ -620,26 +995,61 @@ def promote_candidate( """Replace the primary image with a candidate from the pool. Steps: - 1. Copy ``candidates//`` → ```` - 2. Update ``candidates.json`` selected field - 3. Update ``image_sources.json`` with the candidate's metadata + 1. Stage and validate ``candidates//`` + 2. Replace the canonical image and its provenance as one rollback unit + 3. Advance ``candidates.json`` only after provenance succeeds """ import shutil + target_filename = _validate_bare_filename( + target_filename, field_name="target filename" + ) + candidate_filename = _validate_bare_filename( + candidate_filename, field_name="candidate filename" + ) + mpath = manifest_path or default_manifest_path(str(output_dir)) + try: + manifest = _read_existing_manifest(mpath) + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + stem = Path(target_filename).stem cand_dir = output_dir / "candidates" / stem cand_meta_path = cand_dir / "candidates.json" - if not cand_meta_path.exists(): + if not cand_meta_path.is_file(): print(f"Error: {cand_meta_path} not found.", file=sys.stderr) return 1 - meta = json.loads(cand_meta_path.read_text(encoding="utf-8")) + try: + meta = json.loads(cand_meta_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"Error: cannot read {cand_meta_path}: {exc}", file=sys.stderr) + return 1 + if not isinstance(meta, dict) or not isinstance(meta.get("candidates"), list): + print( + f"Error: {cand_meta_path} must contain a candidates array.", + file=sys.stderr, + ) + return 1 candidates = meta.get("candidates", []) - entry = next((c for c in candidates if c["filename"] == candidate_filename), None) + entry = next( + ( + candidate + for candidate in candidates + if isinstance(candidate, dict) + and candidate.get("filename") == candidate_filename + ), + None, + ) if entry is None: - names = [c["filename"] for c in candidates] + names = [ + str(candidate.get("filename")) + for candidate in candidates + if isinstance(candidate, dict) and candidate.get("filename") + ] print( f"Error: '{candidate_filename}' not found. Available: {', '.join(names)}", file=sys.stderr, @@ -648,69 +1058,135 @@ def promote_candidate( src_path = cand_dir / candidate_filename dst_path = output_dir / target_filename - if not src_path.exists(): + if not src_path.is_file(): print(f"Error: {src_path} does not exist on disk.", file=sys.stderr) return 1 - shutil.copy2(str(src_path), str(dst_path)) + items: list[dict] = list(manifest.get("items") or []) + target_item: Optional[dict] = None + for item in items: + filename = item.get("filename") + if ( + isinstance(filename, str) + and filename.casefold() == target_filename.casefold() + ): + target_item = item + break + if target_item is None: + print( + f"Error: {mpath} has no provenance entry for {target_filename!r}.", + file=sys.stderr, + ) + return 1 + if target_item["filename"] != target_filename: + print( + f"Error: target filename casing {target_filename!r} conflicts with " + f"provenance filename {target_item['filename']!r}.", + file=sys.stderr, + ) + return 1 + + output_dir.mkdir(parents=True, exist_ok=True) + fd, staged_name = tempfile.mkstemp( + prefix=f".{dst_path.stem}.promote.", + suffix=dst_path.suffix, + dir=str(dst_path.parent), + ) + os.close(fd) + staged_path = Path(staged_name) + try: + shutil.copy2(src_path, staged_path) + if not _validate_downloaded_quality( + staged_path, + enforce_thumbnail_floor=False, + ): + raise DownloadQualityError( + "candidate image did not satisfy the readability/size gate" + ) + actual_dim = _measure_actual_image(staged_path) + if actual_dim is None: + raise DownloadQualityError("candidate dimensions could not be measured") + w, h = actual_dim + if w < 1 or h < 1: + raise DownloadQualityError("candidate dimensions must be positive") + if w * h < _MIN_DOWNLOAD_PIXELS: + print( + f" warning: explicitly promoted image is low resolution ({w}x{h})", + file=sys.stderr, + ) + + target_item["provider"] = entry.get("provider", "") + target_item["title"] = entry.get("title", "") + target_item["author"] = entry.get("author", "") + target_item["source_page_url"] = entry.get("source_page_url", "") + target_item["download_url"] = entry.get("download_url", "") + target_item["license_name"] = entry.get("license_name", "") + target_item["license_url"] = entry.get("license_url", "") + target_item["license_tier"] = entry.get("license_tier", "") + target_item["attribution_required"] = entry.get( + "attribution_required", + False, + ) + # Recompute the credit from the promoted candidate — never carry the + # replaced image's attribution_text (wrong author/title/source). + target_item["attribution_text"] = build_attribution_text( + target_filename, + AssetCandidate( + provider=entry.get("provider", ""), + title=entry.get("title", ""), + source_page_url=entry.get("source_page_url", ""), + license_name=entry.get("license_name", ""), + license_url=entry.get("license_url", ""), + license_tier=entry.get("license_tier", ""), + width=w, + height=h, + download_url=entry.get("download_url", ""), + author=entry.get("author", ""), + ), + ) + target_item["width"] = w + target_item["height"] = h + target_item.pop("metadata_dimensions", None) + target_item["status"] = "promoted" + manifest["items"] = items + manifest["generated_at"] = ( + datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + ) + + _commit_staged_image( + staged_path, + dst_path, + lambda: _write_json_atomic(mpath, manifest), + ) + except Exception as exc: + if not _is_recoverable_image_error(exc): + raise + print(f"Error: candidate promotion failed: {exc}", file=sys.stderr) + return 1 + finally: + try: + staged_path.unlink(missing_ok=True) + except OSError: + pass + print(f" promoted: {candidate_filename} → {target_filename}", file=sys.stderr) + print(f" manifest updated: {mpath}", file=sys.stderr) + + # The selection marker must never move ahead of canonical provenance. + meta["selected"] = candidate_filename + try: + _write_json_atomic(cand_meta_path, meta) + except OSError as exc: + print( + f"Error: image was promoted, but {cand_meta_path} could not be updated: " + f"{exc}", + file=sys.stderr, + ) + return 1 + review = _write_review_copy(dst_path, output_dir / ".review", target_filename) if review is not None: print(f" review copy: {review}", file=sys.stderr) - - # Update candidates.json - meta["selected"] = candidate_filename - cand_meta_path.write_text( - json.dumps(meta, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", - ) - - # Update image_sources.json - mpath = manifest_path or default_manifest_path(str(output_dir)) - actual_dim = _measure_actual_image(dst_path) - w = actual_dim[0] if actual_dim else entry.get("width", 0) - h = actual_dim[1] if actual_dim else entry.get("height", 0) - - manifest = _read_existing_manifest(mpath) - items: list[dict] = list(manifest.get("items") or []) - for item in items: - if item.get("filename") == target_filename: - item["provider"] = entry["provider"] - item["title"] = entry["title"] - item["author"] = entry["author"] - item["source_page_url"] = entry["source_page_url"] - item["download_url"] = entry["download_url"] - item["license_name"] = entry["license_name"] - item["license_url"] = entry.get("license_url", "") - item["license_tier"] = entry["license_tier"] - item["attribution_required"] = entry.get("attribution_required", False) - # Recompute the credit from the promoted candidate — never carry the - # replaced image's attribution_text (wrong author/title/source). - item["attribution_text"] = build_attribution_text( - target_filename, - AssetCandidate( - provider=entry.get("provider", ""), - title=entry.get("title", ""), - source_page_url=entry.get("source_page_url", ""), - license_name=entry.get("license_name", ""), - license_url=entry.get("license_url", ""), - license_tier=entry.get("license_tier", ""), - width=w, - height=h, - download_url=entry.get("download_url", ""), - author=entry.get("author", ""), - ), - ) - item["width"] = w - item["height"] = h - item.pop("metadata_dimensions", None) - item["status"] = "promoted" - break - manifest["items"] = items - manifest["generated_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - mpath.write_text( - json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", - ) - print(f" manifest updated: {mpath}", file=sys.stderr) return 0 @@ -730,6 +1206,8 @@ def fetch_url_replace( search_query: str = "", orientation: str = "", required_terms: tuple[str, ...] = (), + min_width: int = 1200, + min_height: int = 800, ) -> int: """Download a user-supplied image URL into the target and record it. @@ -739,32 +1217,66 @@ def fetch_url_replace( for an arbitrary URL, so the manifest marks it ``manual`` and notes that verifying usage rights is the user's responsibility. """ + target_filename = _validate_bare_filename( + target_filename, field_name="target filename" + ) + mpath = manifest_path or default_manifest_path(str(output_dir)) + try: + existing_manifest = _read_existing_manifest(mpath) + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + prior = next( + ( + item + for item in existing_manifest.get("items", []) + if isinstance(item.get("filename"), str) + and item["filename"].casefold() == target_filename.casefold() + ), + {}, + ) + if prior and prior["filename"] != target_filename: + print( + f"Error: target filename casing {target_filename!r} conflicts with " + f"provenance filename {prior['filename']!r}.", + file=sys.stderr, + ) + return 1 + try: + inherited_required_terms = _parse_required_terms( + prior.get("required_terms") + ) + except ValueError as exc: + print( + f"Error: existing provenance has invalid required_terms: {exc}", + file=sys.stderr, + ) + return 1 + final_required_terms = inherited_required_terms or required_terms + output_dir.mkdir(parents=True, exist_ok=True) dst_path = output_dir / target_filename try: - download_image(url, str(dst_path), headers={"User-Agent": USER_AGENT}) - except (requests.RequestException, OSError, RuntimeError, ValueError) as exc: + staged_path, actual_dim = _stage_validated_image( + url, + dst_path, + min_width=min_width, + min_height=min_height, + enforce_thumbnail_floor=False, + ) + except ( + DownloadQualityError, + requests.RequestException, + OSError, + RuntimeError, + ValueError, + ) as exc: print(f"Error: failed to download {url}: {exc}", file=sys.stderr) return 1 - if not dst_path.exists(): - print(f"Error: download produced no file at {dst_path}", file=sys.stderr) - return 1 - print(f" fetched: {url} -> {target_filename}", file=sys.stderr) - review = _write_review_copy(dst_path, output_dir / ".review", target_filename) - if review is not None: - print(f" review copy: {review}", file=sys.stderr) - - actual_dim = _measure_actual_image(dst_path) - mpath = manifest_path or default_manifest_path(str(output_dir)) # Inherit page context (which slide / purpose / query this image serves) # from the entry being replaced; override only source / license / size / # status so the audit trail survives a manual swap. - prior = next( - (i for i in _read_existing_manifest(mpath).get("items", []) - if i.get("filename") == target_filename), - {}, - ) item = { "filename": target_filename, "slide": prior.get("slide") or slide, @@ -780,18 +1292,45 @@ def fetch_url_replace( "license_url": "", "license_tier": "manual", "attribution_required": False, - "width": actual_dim[0] if actual_dim else 0, - "height": actual_dim[1] if actual_dim else 0, + "width": actual_dim[0], + "height": actual_dim[1], "attribution_text": "", "status": "manual", - "note": "Manually supplied image URL; verifying usage rights is the user's responsibility.", + "note": ( + "Manually supplied image URL; verifying usage rights is the user's " + "responsibility." + ), } - inherited_required_terms = _parse_required_terms(prior.get("required_terms")) - final_required_terms = inherited_required_terms or required_terms if final_required_terms: item["required_terms"] = list(final_required_terms) - written = write_sources_manifest(mpath, item) + + try: + written = _commit_staged_image( + staged_path, + dst_path, + lambda: write_sources_manifest(mpath, item), + ) + except ( + OSError, + RuntimeError, + ValueError, + ) as exc: + print( + f"Error: failed to replace {target_filename} and record provenance: {exc}", + file=sys.stderr, + ) + return 1 + finally: + try: + staged_path.unlink(missing_ok=True) + except OSError: + pass + + print(f" fetched: {url} -> {target_filename}", file=sys.stderr) print(f" manifest updated: {written}", file=sys.stderr) + review = _write_review_copy(dst_path, output_dir / ".review", target_filename) + if review is not None: + print(f" review copy: {review}", file=sys.stderr) return 0 @@ -810,6 +1349,8 @@ def load_search_manifest(path: str) -> dict: """ try: data = json.loads(Path(path).read_text(encoding="utf-8")) + except OSError as exc: + raise ValueError(f"Cannot read {path}: {exc}") from exc except json.JSONDecodeError as exc: raise ValueError( f"Invalid JSON in {path}: {exc.msg} " @@ -825,7 +1366,7 @@ def load_search_manifest(path: str) -> dict: if not isinstance(items, list) or not items: raise ValueError(f"{path}: 'items' must be a non-empty array") - seen_filenames: set[str] = set() + seen_filenames: dict[str, str] = {} for i, item in enumerate(items): prefix = f"{path}: items[{i}]" if not isinstance(item, dict): @@ -847,31 +1388,42 @@ def load_search_manifest(path: str) -> dict: _parse_required_terms(item["required_terms"]) except ValueError as exc: raise ValueError(f"{prefix} {exc}") from exc + for dimension_field in ("min_width", "min_height"): + if dimension_field not in item: + continue + value = item[dimension_field] + if ( + not isinstance(value, int) + or isinstance(value, bool) + or value < 1 + ): + raise ValueError( + f"{prefix} field '{dimension_field}' must be a positive integer" + ) fname = item["filename"] - if fname in seen_filenames: - raise ValueError(f"{prefix} duplicate filename '{fname}'") - seen_filenames.add(fname) + try: + _validate_bare_filename(fname) + except ValueError as exc: + raise ValueError(f"{prefix} {exc}") from exc + normalized_filename = fname.casefold() + if normalized_filename in seen_filenames: + raise ValueError( + f"{prefix} filename {fname!r} conflicts with " + f"{seen_filenames[normalized_filename]!r} (case-insensitive)" + ) + seen_filenames[normalized_filename] = fname return data def save_search_manifest(path: str, data: dict) -> None: """Atomically write the batch manifest back (tmp file + rename).""" - target = Path(path) - fd, tmp_path = tempfile.mkstemp( - prefix=target.stem + ".", suffix=".tmp", dir=str(target.parent) - ) try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - f.write("\n") - os.replace(tmp_path, target) - except Exception: - try: - os.unlink(tmp_path) - except OSError: - pass - raise + _write_json_atomic(path, data) + except OSError as exc: + raise RuntimeError( + f"cannot update image query manifest {path}: {exc}" + ) from exc def _resolve_search_concurrency(cli_value: Optional[int]) -> int: @@ -894,12 +1446,20 @@ def _search_one_item( default_strict: bool, default_min_width: int, default_min_height: int, -) -> tuple[Optional[dict], Optional[str]]: +) -> tuple[ + Optional[dict], + Optional[str], + bool, + Optional[Path], + Optional[Path], +]: """Run the full search + download for one batch item (thread worker). - Returns ``(manifest_item, error)``. Only the network/disk work happens - here; all manifest writes are serialized by the caller. + Returns ``(manifest_item, error, retryable, staged_path, output_path)``. + Only network and staged-file work happens here; canonical replacement and + all manifest writes are serialized by the caller. """ + filename = _validate_bare_filename(item["filename"]) orientation = item.get("orientation", "any") or "any" strict = bool(item.get("strict_no_attribution", default_strict)) required_terms = _parse_required_terms(item.get("required_terms")) @@ -908,7 +1468,7 @@ def _search_one_item( query=item["query"], purpose=item.get("purpose", ""), orientation="" if orientation == "any" else orientation, - filename=item["filename"], + filename=filename, slide=item.get("slide", ""), min_width=int(item.get("min_width", default_min_width)), min_height=int(item.get("min_height", default_min_height)), @@ -917,36 +1477,64 @@ def _search_one_item( pinned = item.get("provider") or default_provider providers = [pinned] if pinned else _default_provider_chain() - output_path = output_dir / item["filename"] + output_path = output_dir / filename - candidate, provider_name, stage = search_and_download( + result = search_and_download( providers, request, output_path=output_path, strict_no_attribution=strict, save_candidates=save_candidates, max_candidates=max_candidates, + provider_is_explicit=bool(pinned), ) - if candidate is None: - return None, "no acceptable candidate across all providers/stages" + if result.candidate is None: + return ( + None, + result.error or "search failed", + result.failure_kind == SEARCH_FAILURE_RETRYABLE, + None, + None, + ) + if result.staged_path is None or result.output_path is None: + return ( + None, + "search succeeded without a staged output", + True, + None, + None, + ) - actual_dimensions = _measure_actual_image(output_path) - item_args = argparse.Namespace( - filename=item["filename"], - slide=item.get("slide", ""), - purpose=item.get("purpose", ""), - query=item["query"], - orientation=orientation, - required_terms=request.required_terms, - ) - manifest_item = _candidate_to_manifest_item( - candidate, - item_args, - provider_name=provider_name, - stage=stage, - actual_dimensions=actual_dimensions, - ) - return manifest_item, None + try: + item_args = argparse.Namespace( + filename=filename, + slide=item.get("slide", ""), + purpose=item.get("purpose", ""), + query=item["query"], + orientation=orientation, + required_terms=request.required_terms, + ) + manifest_item = _candidate_to_manifest_item( + result.candidate, + item_args, + provider_name=result.provider_name or "", + stage=result.stage or "", + actual_dimensions=result.actual_dimensions, + ) + return ( + manifest_item, + None, + False, + result.staged_path, + result.output_path, + ) + except Exception: + if result.staged_path is not None: + try: + result.staged_path.unlink(missing_ok=True) + except OSError: + pass + raise def run_search_manifest( @@ -962,16 +1550,66 @@ def run_search_manifest( default_strict: bool, default_min_width: int, default_min_height: int, -) -> tuple[int, int, int]: +) -> tuple[int, int, int, int]: """Process all Pending/Failed rows concurrently with a bounded pool. On success the rich provenance entry is appended to ``image_sources.json`` (the credit source of truth) and the row's status flips to ``Sourced``. A row that exhausts the provider/stage chain becomes ``Needs-Manual`` (terminal). Status is written back after each completion, so an interrupt - preserves finished rows. Returns ``(sourced, needs_manual, skipped)``. + preserves finished rows. Returns ``(sourced, needs_manual, failed, skipped)``. """ + sources_manifest = _read_existing_manifest(sources_manifest_path) items = manifest["items"] + + provenance_filenames = { + item["filename"].casefold(): item["filename"] + for item in sources_manifest.get("items", []) + if isinstance(item.get("filename"), str) + } + repaired_sourced = False + for item in items: + if item["status"] != SEARCH_STATUS_SOURCED: + continue + filename = item["filename"] + target_path = output_dir / filename + reasons: list[str] = [] + provenance_filename = provenance_filenames.get(filename.casefold()) + if provenance_filename is None: + reasons.append("image_sources.json has no matching provenance entry") + elif provenance_filename != filename: + reasons.append( + "image_sources.json filename casing does not match " + f"({provenance_filename!r} vs {filename!r})" + ) + if not target_path.is_file(): + reasons.append("target file is missing") + else: + min_width = int(item.get("min_width", default_min_width)) + min_height = int(item.get("min_height", default_min_height)) + try: + target_is_valid = _validate_downloaded_quality( + target_path, + min_width=min_width, + min_height=min_height, + ) + except RuntimeError as exc: + reasons.append(f"target validation unavailable: {exc}") + else: + if not target_is_valid: + reasons.append( + "target file is unreadable or below requested dimensions" + ) + if reasons: + item["status"] = SEARCH_STATUS_FAILED + item["last_error"] = ( + "Sourced state validation failed: " + "; ".join(reasons) + )[:500] + repaired_sourced = True + print(f" [RETRY] {filename} — {item['last_error']}") + if repaired_sourced: + save_search_manifest(manifest_path, manifest) + pending_idx = [ i for i, it in enumerate(items) if it["status"] in SEARCH_RETRYABLE_STATUSES @@ -984,7 +1622,7 @@ def run_search_manifest( f"[Batch] Nothing to do — all {len(items)} row(s) already in a " "terminal state (Sourced / Needs-Manual)." ) - return 0, 0, skipped + return 0, 0, 0, skipped print( f"\n[Batch] {total} row(s) to search, {skipped} already done. " @@ -993,50 +1631,137 @@ def run_search_manifest( sourced_count = 0 needs_manual_count = 0 + failed_count = 0 write_lock = threading.Lock() def _one(idx: int): try: - manifest_item, error = _search_one_item( - items[idx], - output_dir=output_dir, - save_candidates=save_candidates, - max_candidates=max_candidates, - default_provider=default_provider, - default_strict=default_strict, - default_min_width=default_min_width, - default_min_height=default_min_height, + manifest_item, error, retryable, staged_path, target_path = ( + _search_one_item( + items[idx], + output_dir=output_dir, + save_candidates=save_candidates, + max_candidates=max_candidates, + default_provider=default_provider, + default_strict=default_strict, + default_min_width=default_min_width, + default_min_height=default_min_height, + ) + ) + return ( + idx, + manifest_item, + error, + retryable, + staged_path, + target_path, ) - return idx, manifest_item, error except Exception as exc: # noqa: BLE001 — provider code raises freely - return idx, None, str(exc)[:500] + return idx, None, str(exc)[:500], True, None, None - with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex: - futures = [ex.submit(_one, i) for i in pending_idx] - for fut in concurrent.futures.as_completed(futures): - idx, manifest_item, error = fut.result() - item = items[idx] - with write_lock: - if manifest_item is not None: - write_sources_manifest(sources_manifest_path, manifest_item) - item["status"] = SEARCH_STATUS_SOURCED - item["provider"] = manifest_item.get("provider", "") - item["license_tier"] = manifest_item.get("license_tier", "") - item.pop("last_error", None) - sourced_count += 1 - print(f" [OK] {item['filename']} ({item['provider']})") - else: - item["status"] = SEARCH_STATUS_NEEDS_MANUAL - item["last_error"] = error or "search failed" - needs_manual_count += 1 - print(f" [MANUAL] {item['filename']} — {item['last_error']}") - save_search_manifest(manifest_path, manifest) + futures: list[concurrent.futures.Future] = [] + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex: + futures = [ex.submit(_one, i) for i in pending_idx] + for fut in concurrent.futures.as_completed(futures): + ( + idx, + manifest_item, + error, + retryable, + staged_path, + target_path, + ) = fut.result() + item = items[idx] + with write_lock: + if ( + manifest_item is not None + and staged_path is not None + and target_path is not None + ): + try: + _commit_staged_image( + staged_path, + target_path, + lambda: write_sources_manifest( + sources_manifest_path, + manifest_item, + ), + ) + except Exception as exc: + if not _is_recoverable_image_error(exc): + raise + item["status"] = SEARCH_STATUS_FAILED + item["last_error"] = ( + f"canonical/provenance commit failed: {exc}" + )[:500] + failed_count += 1 + print( + f" [FAIL] {item['filename']} — " + f"{item['last_error']}" + ) + else: + item["status"] = SEARCH_STATUS_SOURCED + item["provider"] = manifest_item.get("provider", "") + item["license_tier"] = manifest_item.get( + "license_tier", + "", + ) + item.pop("last_error", None) + sourced_count += 1 + review = _write_review_copy( + target_path, + output_dir / ".review", + target_path.name, + ) + if review is not None: + print( + f" review copy: {review}", + file=sys.stderr, + ) + print( + f" [OK] {item['filename']} " + f"({item['provider']})" + ) + elif retryable: + item["status"] = SEARCH_STATUS_FAILED + item["last_error"] = error or "provider/download failure" + failed_count += 1 + print( + f" [FAIL] {item['filename']} — {item['last_error']}" + ) + else: + item["status"] = SEARCH_STATUS_NEEDS_MANUAL + item["last_error"] = error or "search failed" + needs_manual_count += 1 + print( + f" [MANUAL] {item['filename']} — " + f"{item['last_error']}" + ) + save_search_manifest(manifest_path, manifest) + finally: + # Workers only stage files. Any result not committed because of an + # interrupt or a later manifest error must not leave candidate residue. + for future in futures: + if not future.done(): + continue + try: + outcome = future.result() + except BaseException: + continue + staged_path = outcome[4] + if staged_path is not None: + try: + staged_path.unlink(missing_ok=True) + except OSError: + pass print( - f"\n[Batch] Done: {sourced_count} sourced / {needs_manual_count} " - f"needs-manual ({skipped} pre-skipped). Manifest: {manifest_path}" + f"\n[Batch] Done: {sourced_count} sourced / {failed_count} failed / " + f"{needs_manual_count} needs-manual ({skipped} pre-skipped). " + f"Manifest: {manifest_path}" ) - return sourced_count, needs_manual_count, skipped + return sourced_count, needs_manual_count, failed_count, skipped # --------------------------------------------------------------------------- @@ -1056,7 +1781,7 @@ def build_parser() -> argparse.ArgumentParser: "query", nargs="?", default=None, - help="Search query (2-5 keywords work best). Omit in --batch mode.", + help="Search query (1-4 concrete keywords work best). Omit in --batch mode.", ) parser.add_argument( "--filename", @@ -1212,6 +1937,18 @@ def main(argv: Optional[list[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) + try: + if args.filename: + args.filename = _validate_bare_filename(args.filename) + if args.promote: + args.promote = _validate_bare_filename( + args.promote, field_name="--promote candidate filename" + ) + except ValueError as exc: + parser.error(str(exc)) + if args.min_width < 1 or args.min_height < 1: + parser.error("--min-width and --min-height must both be positive integers") + output_dir = Path(args.output) # --- Promote mode --- @@ -1239,6 +1976,8 @@ def main(argv: Optional[list[str]] = None) -> int: search_query=args.query or "", orientation="" if args.orientation == "any" else args.orientation, required_terms=_parse_required_terms(args.require_terms), + min_width=args.min_width, + min_height=args.min_height, ) # --- Batch mode --- @@ -1260,7 +1999,7 @@ def main(argv: Optional[list[str]] = None) -> int: else default_manifest_path(str(batch_output_dir)) ) try: - _, needs_manual, _ = run_search_manifest( + _, needs_manual, failed, _ = run_search_manifest( manifest, args.batch, output_dir=batch_output_dir, @@ -1276,10 +2015,12 @@ def main(argv: Optional[list[str]] = None) -> int: except KeyboardInterrupt: print("\n\nInterrupted by user. Partial progress preserved in manifest.") return 130 - # Mirror image_gen.py: a non-zero code flags rows that need manual - # attention. It is a signal, not a halt — the workflow (image-base.md - # §6) surfaces Needs-Manual rows and continues regardless. - return 1 if needs_manual else 0 + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + # Mirror image_gen.py: any unresolved retryable or manual row keeps the + # command non-zero. It is a gate signal, not a request to hide progress. + return 1 if needs_manual or failed else 0 # --- Single-query search mode --- if not args.query: @@ -1301,67 +2042,104 @@ def main(argv: Optional[list[str]] = None) -> int: providers = [args.provider] if args.provider else _default_provider_chain() + manifest_path = ( + Path(args.manifest) if args.manifest else default_manifest_path(args.output) + ) + try: + _read_existing_manifest(manifest_path) + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + output_dir.mkdir(parents=True, exist_ok=True) output_path = output_dir / args.filename print(f"Searching providers: {', '.join(providers)}", file=sys.stderr) - candidate, provider_name, stage = search_and_download( + result = search_and_download( providers, request, output_path=output_path, strict_no_attribution=args.strict_no_attribution, save_candidates=args.save_candidates, max_candidates=args.max_candidates, + provider_is_explicit=bool(args.provider), ) - if candidate is None: + if result.candidate is None: print( - "No acceptable candidates could be downloaded across all " - "providers/filters. Try a shorter query, use default attribution " - "mode if strict mode is enabled, or set an API key for a keyed provider.", + f"{result.error or 'Image search failed'}. " + "Try a shorter query, use default attribution mode if strict mode " + "is enabled, or set an API key for a keyed provider.", file=sys.stderr, ) return 1 print( - f" picked: {candidate.title!r} from {provider_name} " - f"({candidate.license_name or 'no license string'}, " - f"{candidate.license_tier})", + f" picked: {result.candidate.title!r} from {result.provider_name} " + f"({result.candidate.license_name or 'no license string'}, " + f"{result.candidate.license_tier})", file=sys.stderr, ) - # Measure what was actually written to disk; upstream metadata can be + # The staged file has already been measured; upstream metadata can still be # off (e.g. Openverse aggregates rawpixel which only exposes previews). - actual_dimensions = _measure_actual_image(output_path) + actual_dimensions = result.actual_dimensions if ( actual_dimensions is not None - and candidate.width - and candidate.height + and result.candidate.width + and result.candidate.height and actual_dimensions[0] * actual_dimensions[1] - < 0.5 * candidate.width * candidate.height + < 0.5 * result.candidate.width * result.candidate.height ): print( f"\n[!] Downloaded image is much smaller than upstream metadata " f"({actual_dimensions[0]}x{actual_dimensions[1]} vs " - f"{candidate.width}x{candidate.height}). The provider likely " - f"only exposes a preview here. Layout based on the manifest's " + f"{result.candidate.width}x{result.candidate.height}). The provider " + f"likely only exposes a preview here. Layout based on the manifest's " f"width/height will be accurate; the metadata_dimensions field " f"is preserved for reference.", file=sys.stderr, ) - item = _candidate_to_manifest_item( - candidate, - args, - provider_name=provider_name, - stage=stage, - actual_dimensions=actual_dimensions, + if result.staged_path is None or result.output_path is None: + print("Error: image search returned no staged output.", file=sys.stderr) + if result.staged_path is not None: + try: + result.staged_path.unlink(missing_ok=True) + except OSError: + pass + return 1 + try: + item = _candidate_to_manifest_item( + result.candidate, + args, + provider_name=result.provider_name or "", + stage=result.stage or "", + actual_dimensions=actual_dimensions, + ) + written = _commit_staged_image( + result.staged_path, + result.output_path, + lambda: write_sources_manifest(manifest_path, item), + ) + except (OSError, RuntimeError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + finally: + try: + result.staged_path.unlink(missing_ok=True) + except OSError: + pass + print(f" manifest: {written}", file=sys.stderr) + review = _write_review_copy( + result.output_path, + result.output_path.parent / ".review", + result.output_path.name, ) - manifest_path = Path(args.manifest) if args.manifest else default_manifest_path(args.output) - write_sources_manifest(manifest_path, item) - print(f" manifest: {manifest_path}", file=sys.stderr) + if review is not None: + print(f" review copy: {review}", file=sys.stderr) - if candidate.license_tier == "attribution-required": + if result.candidate.license_tier == "attribution-required": print( "\n[!] This image requires on-slide attribution. " "Executor should add a small credit element to the slide using " diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_sources/provider_common.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_sources/provider_common.py index 6a438f80..90db6885 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_sources/provider_common.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_sources/provider_common.py @@ -446,6 +446,11 @@ def score_candidate(candidate: AssetCandidate, request: ImageSearchRequest) -> f """ if not candidate.license_tier: return float("-inf") + if ( + candidate.license_tier == LICENSE_TIER_ATTRIBUTION_REQUIRED + and not candidate.author.strip() + ): + return float("-inf") required_misses = missing_required_terms(candidate, request.required_terms) if required_misses: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_enhance_pptx.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_enhance_pptx.py index 51bc779d..6aaa5cbe 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_enhance_pptx.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/native_enhance_pptx.py @@ -2,9 +2,9 @@ """ PPT Master - Native Enhance PPTX Entrypoint -Public CLI wrapper for native enhancement of existing PPTX decks. V1 delegates -to the narration/timings implementation while keeping the stable command name -aligned with the native-enhance workflow. +Public CLI wrapper for native enhancement of existing PPTX decks. It delegates +to the shared native core for delivery checks, notes, narration, timings, and +global or per-slide transitions while keeping the stable workflow command. Usage: python3 scripts/native_enhance_pptx.py init [--name project_name] 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 19c88706..0c8ef1fd 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 @@ -6,8 +6,8 @@ Implementation core for the public native enhancement CLI and its legacy narration compatibility entrypoint. It enhances an existing PPTX without entering the SVG generation pipeline or modifying the original file. -V1 enhancement modules: speaker notes, narration audio, slide auto-advance -timings, and optional page transitions. +Enhancement modules: read-only delivery checks, speaker notes, narration audio, +slide auto-advance timings, and optional global or per-slide page transitions. Usage: python3 scripts/native_enhance_pptx.py init [--name project_name] @@ -20,19 +20,22 @@ Examples: python3 scripts/native_enhance_pptx.py validate projects/fire_station_native_enhance_20260626 Dependencies: - ffprobe for audio-duration-based auto-advance timings. + ffprobe for narration decodability and audio-duration validation. """ from __future__ import annotations import argparse +import hashlib import json +import posixpath import re import shutil import subprocess import sys import tempfile import zipfile +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -43,6 +46,7 @@ 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_delivery_check import audit_pptx_delivery # noqa: E402 from pptx_animations import ( # noqa: E402 object_animation_fingerprint, validate_pptx_animation_package, @@ -84,7 +88,10 @@ configure_utf8_stdio() PROJECT_SCHEMA = "native_pptx_enhancement_project.v1" +PLAN_SCHEMA = "native_pptx_enhancement_plan.v1" +VALIDATION_SCHEMA = "native_pptx_enhancement_validation.v1" LEGACY_PROJECT_SCHEMAS = {"native_narration_pptx_project.v1"} +_WRITABLE_MODULES = ("notes", "audio", "timings", "transitions") NOTES_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" @@ -96,8 +103,29 @@ CONTENT_TYPE_NOTES_MASTER = ( "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml" ) CONTENT_TYPE_THEME = "application/vnd.openxmlformats-officedocument.theme+xml" - - +_NOTES_SLIDE_PART_RE = re.compile( + r"^ppt/notesSlides/notesSlide([1-9]\d*)\.xml$" +) +_LEGACY_MISSING_NOTES_MASTER_RE = re.compile( + r"^ppt/notesSlides/_rels/notesSlide[1-9]\d*\.xml\.rels" + r" -> ppt/notesmasters/notesmaster[1-9]\d*\.xml$", + re.IGNORECASE, +) +_TRANSITION_MODULE_FIELDS = frozenset( + { + "enabled", + "requires_confirmation", + "status", + "effect", + "duration", + "effect_options", + "apply_without_audio", + "slides", + } +) +_TRANSITION_OVERRIDE_FIELDS = frozenset( + {"effect", "duration", "effect_options"} +) @dataclass(frozen=True) class SlidePart: index: int @@ -105,6 +133,37 @@ class SlidePart: slide_number: int +@dataclass +class MaterialReadiness: + note_paths: dict[int, Path] + audio_paths: dict[int, Path] + audio_durations: dict[int, float] + notes_count: int + audio_count: int + missing_notes: list[int] + invalid_notes: dict[int, str] + missing_audio: list[int] + invalid_audio: dict[int, str] + module_errors: list[str] + + @property + def ready(self) -> bool: + return not ( + self.missing_notes + or self.invalid_notes + or self.missing_audio + or self.invalid_audio + or self.module_errors + ) + + +@dataclass(frozen=True) +class ResolvedTransitionPlan: + global_enter: EnterUpdate + slide_enters: Mapping[int, EnterUpdate] + apply_without_audio: bool + + def _sanitize_slug(value: str) -> str: slug = re.sub(r"[^0-9A-Za-z_-]+", "_", value).strip("_") return slug or "native_enhance" @@ -125,7 +184,10 @@ def _non_negative_seconds_arg(value: str) -> float: def _read_json(path: Path) -> dict: - return json.loads(path.read_text(encoding="utf-8")) + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"JSON root must be an object: {path}") + return data def _write_json(path: Path, data: dict) -> None: @@ -135,6 +197,105 @@ def _write_json(path: Path, data: dict) -> None: ) +def _write_preflight_report( + project_path: Path, + plan: dict, + modules: set[str], + *, + status: str, + **details: object, +) -> dict: + report = { + "schema": VALIDATION_SCHEMA, + "status": status, + "phase": "preflight", + "plan_status": plan.get("status") or "missing", + "enabled_modules": sorted(modules), + **details, + } + validation_dir = project_path / "validation" + validation_dir.mkdir(exist_ok=True) + _write_json(validation_dir / "report.json", report) + return report + + +def _delivery_issues(report: dict, field: str) -> list[dict]: + issues = report.get(field) + if not isinstance(issues, list): + return [] + return [issue for issue in issues if isinstance(issue, dict)] + + +def _fatal_source_delivery_messages(report: dict) -> list[str]: + fatal = [ + issue + for issue in _delivery_issues(report, "errors") + if not ( + issue.get("code") == "dangling_internal_relationship" + and isinstance(issue.get("message"), str) + and _LEGACY_MISSING_NOTES_MASTER_RE.fullmatch( + issue["message"] + ) + is not None + ) + ] + if fatal: + return [ + str(issue.get("message") or issue) + for issue in fatal + ] + if report.get("status") == "failed" and not _delivery_issues( + report, + "errors", + ): + return ["delivery check failed without structured error details"] + return [] + + +def _new_delivery_errors(source: dict, candidate: dict) -> list[dict]: + source_keys = { + json.dumps(issue, ensure_ascii=False, sort_keys=True) + for issue in _delivery_issues(source, "errors") + } + return [ + issue + for issue in _delivery_issues(candidate, "errors") + if json.dumps(issue, ensure_ascii=False, sort_keys=True) + not in source_keys + ] + + +def _delivery_has_findings(report: dict) -> bool: + return bool( + _delivery_issues(report, "errors") + or _delivery_issues(report, "advisories") + ) + + +def _delivery_hidden_slide_indices( + report: dict, +) -> tuple[int, ...] | None: + slides = report.get("slides") + hidden = slides.get("hidden") if isinstance(slides, dict) else None + if not isinstance(hidden, list): + return None + indices: list[int] = [] + for item in hidden: + index = item.get("index") if isinstance(item, dict) else None + if isinstance(index, bool) or not isinstance(index, int): + return None + indices.append(index) + return tuple(indices) + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + def _is_relative_to(path: Path, parent: Path) -> bool: try: path.resolve().relative_to(parent.resolve()) @@ -171,18 +332,6 @@ def _ensure_rels_file(path: Path) -> None: ) -def _remove_relationships_by_type(rels_path: Path, rel_type: str) -> None: - if not rels_path.exists(): - return - content = rels_path.read_text(encoding="utf-8") - content = re.sub( - rf'\s*]*\bType="{re.escape(rel_type)}"[^>]*/>', - "", - content, - ) - rels_path.write_text(content, encoding="utf-8") - - def _target_to_part(target: str) -> str: target = target.lstrip("/") if target.startswith("ppt/"): @@ -197,6 +346,118 @@ def _slide_number_from_part(part_name: str) -> int: return int(match.group(1)) +def _resolve_relationship_part(source_part: str, target: str) -> str: + """Resolve an internal relationship target to a package part name.""" + target_path = target.split("#", 1)[0] + if target_path.startswith("/"): + return posixpath.normpath(target_path.lstrip("/")) + return posixpath.normpath( + posixpath.join(posixpath.dirname(source_part), target_path) + ) + + +def _notes_slide_index(part_name: str) -> int | None: + match = _NOTES_SLIDE_PART_RE.fullmatch(part_name) + return int(match.group(1)) if match else None + + +def _is_notes_slide_part(part_name: str) -> bool: + """Return whether a relationship target stays in the notesSlides folder.""" + return ( + posixpath.dirname(part_name) == "ppt/notesSlides" + and posixpath.basename(part_name).endswith(".xml") + and posixpath.basename(part_name) != ".xml" + ) + + +def _notes_slide_part_for_slide( + extract_dir: Path, + slide: SlidePart, +) -> str | None: + """Return the notes part currently related to a slide, if present.""" + slide_rels = _relationship_file_for_part(extract_dir, slide.part_name) + if not slide_rels.exists(): + return None + + related_parts: list[str] = [] + for rel in ET.parse(slide_rels).getroot(): + if rel.attrib.get("Type") != NOTES_REL_TYPE: + continue + if rel.attrib.get("TargetMode", "").lower() == "external": + raise RuntimeError( + f"Slide {slide.index} has an external notesSlide relationship" + ) + target = rel.attrib.get("Target") + if not target: + raise RuntimeError( + f"Slide {slide.index} notesSlide relationship has no Target" + ) + part_name = _resolve_relationship_part(slide.part_name, target) + if not _is_notes_slide_part(part_name): + raise RuntimeError( + f"Slide {slide.index} has an unsupported notesSlide target: {target}" + ) + related_parts.append(part_name) + + if len(related_parts) > 1: + raise RuntimeError( + f"Slide {slide.index} has multiple notesSlide relationships" + ) + return related_parts[0] if related_parts else None + + +def _used_notes_slide_indices(extract_dir: Path) -> set[int]: + """Collect every notesSlide number already reserved in the package.""" + used: set[int] = set() + notes_dir = extract_dir / "ppt" / "notesSlides" + for path in notes_dir.glob("notesSlide*.xml"): + index = _notes_slide_index(f"ppt/notesSlides/{path.name}") + if index is not None: + used.add(index) + for path in (notes_dir / "_rels").glob("notesSlide*.xml.rels"): + index = _notes_slide_index( + f"ppt/notesSlides/{path.name.removesuffix('.rels')}" + ) + if index is not None: + used.add(index) + + content_types_path = extract_dir / "[Content_Types].xml" + if content_types_path.exists(): + content_types = content_types_path.read_text(encoding="utf-8") + for match in re.finditer( + r'PartName="/(ppt/notesSlides/notesSlide[1-9]\d*\.xml)"', + content_types, + ): + index = _notes_slide_index(match.group(1)) + if index is not None: + used.add(index) + + slides_rels_dir = extract_dir / "ppt" / "slides" / "_rels" + for rels_path in slides_rels_dir.glob("slide*.xml.rels"): + source_part = f"ppt/slides/{rels_path.name.removesuffix('.rels')}" + for rel in ET.parse(rels_path).getroot(): + if ( + rel.attrib.get("Type") != NOTES_REL_TYPE + or rel.attrib.get("TargetMode", "").lower() == "external" + ): + continue + target = rel.attrib.get("Target") + if not target: + continue + index = _notes_slide_index( + _resolve_relationship_part(source_part, target) + ) + if index is not None: + used.add(index) + return used + + +def _allocate_notes_slide_part(extract_dir: Path) -> str: + used = _used_notes_slide_indices(extract_dir) + index = max(used, default=0) + 1 + return f"ppt/notesSlides/notesSlide{index}.xml" + + def read_slide_parts(extract_dir: Path) -> list[SlidePart]: presentation_path = extract_dir / "ppt" / "presentation.xml" rels_path = extract_dir / "ppt" / "_rels" / "presentation.xml.rels" @@ -233,6 +494,98 @@ def read_slide_parts(extract_dir: Path) -> list[SlidePart]: return slide_parts +def _source_state_errors( + project_path: Path, + project: dict, + source_pptx: Path, + slides: list[SlidePart], +) -> list[str]: + """Return source-drift and slide-index consistency errors.""" + errors: list[str] = [] + actual_count = len(slides) + actual_roster = [slide.part_name for slide in slides] + + expected_sha256 = project.get("source_sha256") + if expected_sha256 is not None: + if ( + not isinstance(expected_sha256, str) + or re.fullmatch(r"[0-9a-f]{64}", expected_sha256) is None + ): + errors.append("project.json source_sha256 is not a lowercase SHA-256 digest") + elif _file_sha256(source_pptx) != expected_sha256: + errors.append("archived source PPTX SHA-256 no longer matches project.json") + + expected_project_count = project.get("slide_count") + if isinstance(expected_project_count, bool) or not isinstance( + expected_project_count, + int, + ): + errors.append("project.json slide_count is not an integer") + elif expected_project_count != actual_count: + errors.append( + "archived source slide count no longer matches project.json: " + f"{actual_count} != {expected_project_count}" + ) + + expected_project_roster = project.get("slide_part_roster") + if expected_project_roster is not None: + if ( + not isinstance(expected_project_roster, list) + or any( + not isinstance(part_name, str) or not part_name + for part_name in expected_project_roster + ) + ): + errors.append("project.json slide_part_roster is not an array of part names") + elif expected_project_roster != actual_roster: + errors.append( + "archived source ordered slide-part roster no longer matches " + "project.json" + ) + + slide_index_path = project_path / "analysis" / "slide_index.json" + if not slide_index_path.is_file(): + errors.append(f"slide index is missing: {slide_index_path}") + return errors + try: + slide_index = _read_json(slide_index_path) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"unable to read slide index: {exc}") + return errors + + expected_index_count = slide_index.get("slide_count") + if isinstance(expected_index_count, bool) or not isinstance( + expected_index_count, + int, + ): + errors.append("slide_index.json slide_count is not an integer") + elif expected_index_count != actual_count: + errors.append( + "archived source slide count no longer matches slide_index.json: " + f"{actual_count} != {expected_index_count}" + ) + + indexed_slides = slide_index.get("slides") + if not isinstance(indexed_slides, list): + errors.append("slide_index.json slides is not an array") + return errors + expected_roster: list[str] = [] + for index, item in enumerate(indexed_slides, 1): + part_name = item.get("part_name") if isinstance(item, dict) else None + if not isinstance(part_name, str) or not part_name: + errors.append( + f"slide_index.json slides[{index - 1}].part_name is invalid" + ) + continue + expected_roster.append(part_name) + if len(expected_roster) == len(indexed_slides) and expected_roster != actual_roster: + errors.append( + "archived source ordered slide-part roster no longer matches " + "slide_index.json" + ) + return errors + + def _zip_dir(source_dir: Path, output_path: Path) -> None: output_path.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: @@ -278,6 +631,131 @@ def _audio_path(audio_dir: Path, index: int) -> Path | None: return None +def _collect_material_readiness( + slides: list[SlidePart], + notes_dir: Path, + audio_dir: Path, + modules: set[str], +) -> MaterialReadiness: + """Inspect enabled-module inputs once for both validation and application.""" + notes_required = "notes" in modules + audio_required = "audio" in modules or "timings" in modules + timings_enabled = "timings" in modules + note_paths: dict[int, Path] = {} + audio_paths: dict[int, Path] = {} + audio_durations: dict[int, float] = {} + missing_notes: list[int] = [] + invalid_notes: dict[int, str] = {} + missing_audio: list[int] = [] + invalid_audio: dict[int, str] = {} + + for slide in slides: + note = _note_path(notes_dir, slide.index) + if note is None: + if notes_required: + missing_notes.append(slide.index) + else: + try: + note_text = markdown_to_plain_text( + note.read_text(encoding="utf-8") + ) + except (OSError, UnicodeError) as exc: + if notes_required: + invalid_notes[slide.index] = f"unable to read {note.name}: {exc}" + else: + if note_text: + note_paths[slide.index] = note + elif notes_required: + invalid_notes[slide.index] = f"{note.name} has no spoken text" + + audio = _audio_path(audio_dir, slide.index) + if audio is None: + if audio_required: + missing_audio.append(slide.index) + continue + try: + if not audio.is_file() or audio.stat().st_size <= 0: + raise ValueError(f"{audio.name} is not a non-empty file") + except (OSError, ValueError) as exc: + if audio_required: + invalid_audio[slide.index] = str(exc) + continue + + if audio_required: + duration = probe_audio_duration(audio) + if duration is None: + invalid_audio[slide.index] = ( + f"unable to decode {audio.name} with ffprobe" + ) + continue + if timings_enabled: + audio_durations[slide.index] = duration + audio_paths[slide.index] = audio + + module_errors: list[str] = [] + if timings_enabled and "audio" not in modules: + module_errors.append("timings requires the audio module") + return MaterialReadiness( + note_paths=note_paths, + audio_paths=audio_paths, + audio_durations=audio_durations, + notes_count=len(note_paths), + audio_count=len(audio_paths), + missing_notes=missing_notes, + invalid_notes=invalid_notes, + missing_audio=missing_audio, + invalid_audio=invalid_audio, + module_errors=module_errors, + ) + + +def _material_readiness_messages(readiness: MaterialReadiness) -> list[str]: + messages = list(readiness.module_errors) + if readiness.missing_notes: + messages.append( + "missing notes for slide(s): " + + ", ".join(str(index) for index in readiness.missing_notes) + ) + if readiness.invalid_notes: + messages.append( + "invalid notes: " + + "; ".join( + f"slide {index}: {reason}" + for index, reason in sorted(readiness.invalid_notes.items()) + ) + ) + if readiness.missing_audio: + messages.append( + "missing audio for slide(s): " + + ", ".join(str(index) for index in readiness.missing_audio) + ) + if readiness.invalid_audio: + messages.append( + "invalid audio: " + + "; ".join( + f"slide {index}: {reason}" + for index, reason in sorted(readiness.invalid_audio.items()) + ) + ) + return messages + + +def _material_readiness_report_fields( + readiness: MaterialReadiness, +) -> dict[str, object]: + return { + "notes_count": readiness.notes_count, + "audio_count": readiness.audio_count, + "missing_notes": readiness.missing_notes, + "invalid_notes": sorted(readiness.invalid_notes), + "invalid_note_reasons": readiness.invalid_notes, + "missing_audio": readiness.missing_audio, + "invalid_audio": sorted(readiness.invalid_audio), + "invalid_audio_reasons": readiness.invalid_audio, + "module_errors": readiness.module_errors, + } + + def _add_override(content_types: str, part_name: str, content_type: str) -> str: if re.search( rf']*\bPartName="/{re.escape(part_name)}"[^>]*/>', @@ -288,52 +766,112 @@ def _add_override(content_types: str, part_name: str, content_type: str) -> str: return content_types.replace("", override + "\n") -def _add_notes_content_types(content_types: str, note_indices: set[int]) -> str: +def _add_notes_content_types(content_types: str, note_parts: set[str]) -> str: content_types = _add_override(content_types, "ppt/theme/theme2.xml", CONTENT_TYPE_THEME) content_types = _add_override( content_types, "ppt/notesMasters/notesMaster1.xml", CONTENT_TYPE_NOTES_MASTER, ) - for index in sorted(note_indices): + for part_name in sorted(note_parts): content_types = _add_override( content_types, - f"ppt/notesSlides/notesSlide{index}.xml", + part_name, CONTENT_TYPE_NOTES_SLIDE, ) return content_types -def _apply_notes(extract_dir: Path, slide: SlidePart, note_md: Path) -> None: +def _apply_notes( + extract_dir: Path, + slide: SlidePart, + note_md: Path, +) -> str | None: notes_text = markdown_to_plain_text(note_md.read_text(encoding="utf-8")) if not notes_text: - return + return None _ensure_notes_master(extract_dir) - notes_dir = extract_dir / "ppt" / "notesSlides" - notes_dir.mkdir(parents=True, exist_ok=True) - notes_xml_path = notes_dir / f"notesSlide{slide.index}.xml" + slide_rels = _relationship_file_for_part(extract_dir, slide.part_name) + _ensure_rels_file(slide_rels) + notes_part = _notes_slide_part_for_slide(extract_dir, slide) + if notes_part is None: + notes_part = _allocate_notes_slide_part(extract_dir) + target = posixpath.relpath( + notes_part, + start=posixpath.dirname(slide.part_name), + ) + _append_relationship(slide_rels, NOTES_REL_TYPE, target) + + notes_xml_path = extract_dir / notes_part + notes_xml_path.parent.mkdir(parents=True, exist_ok=True) notes_xml_path.write_text( create_notes_slide_xml(slide.slide_number, notes_text), encoding="utf-8", ) - notes_rels_dir = notes_dir / "_rels" - notes_rels_dir.mkdir(parents=True, exist_ok=True) - notes_rels_path = notes_rels_dir / f"notesSlide{slide.index}.xml.rels" + notes_rels_path = _relationship_file_for_part(extract_dir, notes_part) + notes_rels_path.parent.mkdir(parents=True, exist_ok=True) notes_rels_path.write_text( create_notes_slide_rels_xml(slide.slide_number), encoding="utf-8", ) + return notes_part - slide_rels = _relationship_file_for_part(extract_dir, slide.part_name) - _ensure_rels_file(slide_rels) - _remove_relationships_by_type(slide_rels, NOTES_REL_TYPE) - _append_relationship( - slide_rels, - NOTES_REL_TYPE, - f"../notesSlides/notesSlide{slide.index}.xml", - ) + +def _native_audio_carriers( + extract_dir: Path, + slides: list[SlidePart], +) -> dict[int, list[str]]: + """Return existing tool-owned narration carrier names by public slide.""" + carriers: dict[int, list[str]] = {} + for slide in slides: + slide_root = ET.parse(extract_dir / slide.part_name).getroot() + names = sorted( + { + name + for element in slide_root.iter( + f"{{{PRESENTATION_NS}}}cNvPr" + ) + if (name := element.attrib.get("name", "")).startswith( + "native_enhance_audio_" + ) + } + ) + if names: + carriers[slide.index] = names + return carriers + + +def _allocate_media_name(media_dir: Path, preferred_name: str) -> str: + preferred_path = media_dir / preferred_name + if not preferred_path.exists(): + return preferred_name + stem = preferred_path.stem + suffix = preferred_path.suffix + index = 2 + while True: + candidate_name = f"{stem}_{index}{suffix}" + if not (media_dir / candidate_name).exists(): + return candidate_name + index += 1 + + +def _ensure_audio_poster(media_dir: Path) -> str: + preferred_name = "native_enhance_audio_poster.png" + for candidate in sorted( + media_dir.glob("native_enhance_audio_poster*.png") + ): + if not candidate.is_file(): + continue + try: + if candidate.read_bytes() == AUDIO_MARKER_PNG_BYTES: + return candidate.name + except OSError: + continue + poster_name = _allocate_media_name(media_dir, preferred_name) + (media_dir / poster_name).write_bytes(AUDIO_MARKER_PNG_BYTES) + return poster_name def _apply_audio( @@ -344,18 +882,19 @@ def _apply_audio( enter: EnterUpdate, timings_enabled: bool, narration_padding: float, + audio_duration: float | None = None, ) -> bool: media_dir = extract_dir / "ppt" / "media" media_dir.mkdir(parents=True, exist_ok=True) ext = audio_path.suffix.lower() - media_name = f"native_enhance_audio_{slide.index:03d}{ext}" + media_name = _allocate_media_name( + media_dir, + f"native_enhance_audio_{slide.index:03d}{ext}", + ) shutil.copy2(audio_path, media_dir / media_name) - poster_name = "native_enhance_audio_poster.png" - poster_path = media_dir / poster_name - if not poster_path.exists(): - poster_path.write_bytes(AUDIO_MARKER_PNG_BYTES) + poster_name = _ensure_audio_poster(media_dir) slide_rels = _relationship_file_for_part(extract_dir, slide.part_name) _ensure_rels_file(slide_rels) @@ -378,7 +917,9 @@ def _apply_audio( advance = AdvanceUpdate(mode="preserve") if timings_enabled: - duration = probe_audio_duration(audio_path) + duration = audio_duration + if duration is None: + duration = probe_audio_duration(audio_path) if duration is None: raise RuntimeError(f"Unable to read narration duration with ffprobe: {audio_path}") advance = AdvanceUpdate( @@ -401,11 +942,15 @@ def _apply_audio( return timings_enabled and wrote_advance -def _update_content_types(extract_dir: Path, note_indices: set[int], audio_exts: set[str]) -> None: +def _update_content_types( + extract_dir: Path, + note_parts: set[str], + audio_exts: set[str], +) -> None: content_types_path = extract_dir / "[Content_Types].xml" content_types = content_types_path.read_text(encoding="utf-8") - if note_indices: - content_types = _add_notes_content_types(content_types, note_indices) + if note_parts: + content_types = _add_notes_content_types(content_types, note_parts) for ext in sorted(audio_exts): content_type = AUDIO_CONTENT_TYPES.get(ext) if content_type: @@ -424,6 +969,44 @@ def _project_paths(project_path: Path) -> tuple[Path, Path, Path, Path]: return source_pptx, notes_dir, audio_dir, exports_dir +def _output_path_error( + project_path: Path, + project: dict, + source_pptx: Path, + output_path: Path, +) -> str | None: + if output_path.suffix.lower() != ".pptx": + return f"output must use a .pptx extension: {output_path}" + if output_path == source_pptx.resolve(): + return "output must not overwrite the archived source PPTX" + + source_import = project.get("source_import") + if isinstance(source_import, dict): + original_path = source_import.get("original_path") + if isinstance(original_path, str) and original_path: + try: + original = Path(original_path).expanduser().resolve() + except OSError: + original = None + if original is not None and output_path == original: + return "output must not overwrite the original source PPTX" + + protected = ( + project_path / "sources", + project_path / "analysis", + project_path / "notes", + project_path / "audio", + project_path / "validation", + ) + for directory in protected: + if _is_relative_to(output_path, directory): + return ( + "output must not be written inside native-enhance control " + f"directory: {directory}" + ) + return None + + def _plan_path(project_path: Path) -> Path: return project_path / "analysis" / "enhancement_plan.json" @@ -438,11 +1021,12 @@ def _load_enhancement_plan(project_path: Path) -> dict: def _enabled_modules(plan: dict) -> set[str]: modules = plan.get("modules") if not isinstance(modules, dict): - return {"notes", "audio", "timings", "transitions"} + return set(_WRITABLE_MODULES) enabled: set[str] = set() - for name, config in modules.items(): + for name in _WRITABLE_MODULES: + config = modules.get(name) if isinstance(config, dict) and config.get("enabled") is True: - enabled.add(str(name)) + enabled.add(name) return enabled @@ -491,11 +1075,14 @@ def _plan_confirmed(plan: dict) -> bool: def _native_transition_config( transition: str, duration: float, + effect_options: object = None, ) -> dict[str, object]: if transition == "none": + normalize_transition_effect_request(transition, effect_options) return {"effect": "none", "duration": duration} effect, effect_options = normalize_transition_effect_request( transition, + effect_options, allow_none=False, ) config: dict[str, object] = { @@ -507,52 +1094,203 @@ def _native_transition_config( return config +def _module_config(plan: dict, name: str) -> dict: + modules = plan.get("modules") + if not isinstance(modules, dict): + return {} + config = modules.get(name) + return config if isinstance(config, dict) else {} + + +def _preserved_enabled(plan: dict, name: str, default: bool) -> bool: + config = _module_config(plan, name) + if "enabled" not in config: + return default + value = config["enabled"] + if not isinstance(value, bool): + raise ValueError( + f"enhancement plan module {name}.enabled must be a boolean" + ) + return value + + +def _resolved_draft_transition_config( + project: dict, + existing_plan: dict, + *, + transition: str | None, + transition_duration: float | None, + apply_transition_without_audio: bool | None, +) -> tuple[bool, dict[str, object]]: + existing = _module_config(existing_plan, "transitions") + project_default = ( + project.get("transition") + if isinstance(project.get("transition"), dict) + else {} + ) + + if transition is not None: + raw_effect: object = transition + raw_options: object = None + elif "effect" in existing: + raw_effect = existing["effect"] + raw_options = existing.get("effect_options") + elif "effect" in project_default: + raw_effect = project_default["effect"] + raw_options = project_default.get("effect_options") + else: + raw_effect = "fade" + raw_options = None + + if not isinstance(raw_effect, str): + raise ValueError("transition effect must be a string") + if transition_duration is not None: + raw_duration: object = transition_duration + elif "duration" in existing: + raw_duration = existing["duration"] + elif "duration" in project_default: + raw_duration = project_default["duration"] + else: + raw_duration = 0.5 + duration = validate_seconds( + raw_duration, + "transition duration", + allow_zero=False, + ) + config = _native_transition_config( + raw_effect, + duration, + raw_options, + ) + + if apply_transition_without_audio is None: + raw_apply_without_audio = existing.get( + "apply_without_audio", + False, + ) + else: + raw_apply_without_audio = apply_transition_without_audio + if not isinstance(raw_apply_without_audio, bool): + raise ValueError("transition apply_without_audio must be a boolean") + config["apply_without_audio"] = raw_apply_without_audio + + if "slides" in existing: + slides = existing["slides"] + if not isinstance(slides, dict): + raise ValueError("transition slides must be an object") + config["slides"] = { + str(key): dict(value) if isinstance(value, dict) else value + for key, value in slides.items() + } + + if transition is not None: + enabled = transition != "none" + else: + enabled = _preserved_enabled( + existing_plan, + "transitions", + raw_effect != "none", + ) + return enabled, config + + def _build_enhancement_plan( project: dict, *, slide_count: int, notes_count: int, audio_count: int, - transition: str, - transition_duration: float, - narration_padding: float, - apply_transition_without_audio: bool, + transition: str | None, + transition_duration: float | None, + narration_padding: float | None, + apply_transition_without_audio: bool | None, + existing_plan: dict | None = None, ) -> dict: - transition_config = _native_transition_config( - transition, - transition_duration, + previous = existing_plan or {} + notes_enabled = _preserved_enabled(previous, "notes", True) + audio_enabled = _preserved_enabled(previous, "audio", True) + timings_enabled = _preserved_enabled(previous, "timings", True) + previous_timings = _module_config(previous, "timings") + raw_padding: object + if narration_padding is not None: + raw_padding = narration_padding + else: + raw_padding = previous_timings.get("narration_padding", 0.4) + resolved_padding = validate_seconds( + raw_padding, + "narration padding", + allow_zero=True, + ) + transitions_enabled, transition_config = _resolved_draft_transition_config( + project, + previous, + transition=transition, + transition_duration=transition_duration, + apply_transition_without_audio=apply_transition_without_audio, ) return { - "schema": "native_pptx_enhancement_plan.v1", + "schema": PLAN_SCHEMA, "status": "draft", "source_pptx": project.get("source_pptx"), "slide_count": slide_count, "modules": { "notes": { - "enabled": True, + "enabled": notes_enabled, "requires_confirmation": True, - "status": "ready" if notes_count == slide_count else "needs_notes", - "coverage": {"ready": notes_count, "total": slide_count}, + "status": ( + "disabled" + if not notes_enabled + else ( + "coverage_complete" + if notes_count == slide_count + else "needs_notes" + ) + ), + "coverage": {"present": notes_count, "total": slide_count}, }, "audio": { - "enabled": True, + "enabled": audio_enabled, "requires_confirmation": True, - "status": "ready" if audio_count == slide_count else "needs_audio", - "coverage": {"ready": audio_count, "total": slide_count}, + "status": ( + "disabled" + if not audio_enabled + else ( + "coverage_complete" + if audio_count == slide_count + else "needs_audio" + ) + ), + "coverage": {"present": audio_count, "total": slide_count}, + "decodability": "unchecked", }, "timings": { - "enabled": True, + "enabled": timings_enabled, "requires_confirmation": True, - "status": "ready" if audio_count == slide_count else "blocked_until_audio", + "status": ( + "disabled" + if not timings_enabled + else ( + "audio_coverage_complete" + if audio_enabled and audio_count == slide_count + else "blocked_until_audio" + ) + ), "source": "audio_duration", - "narration_padding": narration_padding, + "narration_padding": resolved_padding, }, "transitions": { - "enabled": transition != "none", + "enabled": transitions_enabled, "requires_confirmation": True, - "status": "ready", + "status": ( + "ready" + if ( + transitions_enabled + or transition_config.get("effect") == "none" + or bool(transition_config.get("slides")) + ) + else "disabled" + ), **transition_config, - "apply_without_audio": apply_transition_without_audio, }, }, "not_in_v1": [ @@ -565,12 +1303,294 @@ def _build_enhancement_plan( } +def _resolve_slide_enter( + base: EnterUpdate, + override: dict, + *, + slide_index: int, +) -> EnterUpdate: + unknown = sorted(set(override) - _TRANSITION_OVERRIDE_FIELDS) + if unknown: + raise ValueError( + f"transition slides.{slide_index} has unknown field(s): " + + ", ".join(unknown) + ) + + raw_duration = override.get("duration", base.duration) + duration = validate_seconds( + raw_duration, + f"transition slides.{slide_index}.duration", + allow_zero=False, + ) + effect = override.get("effect") + if effect == "preserve": + if "effect_options" in override: + raise ValueError( + f"transition slides.{slide_index} preserve cannot have " + "effect_options" + ) + return EnterUpdate(policy="preserve", duration=duration) + + if effect is None: + if base.policy == "preserve": + if "effect_options" in override: + raise ValueError( + f"transition slides.{slide_index} effect_options requires " + "a native effect" + ) + return EnterUpdate(policy="preserve", duration=duration) + if base.policy == "none": + if "effect_options" in override: + raise ValueError( + f"transition slides.{slide_index} none cannot have " + "effect_options" + ) + return EnterUpdate(policy="none", effect=None, duration=duration) + effect = base.effect + effect_options = override.get( + "effect_options", + base.effect_options, + ) + else: + if not isinstance(effect, str): + raise ValueError( + f"transition slides.{slide_index}.effect must be a string" + ) + effect_options = override.get("effect_options") + + return _resolve_enter_update( + cli_effect=None, + configured_effect=effect, + configured_effect_options=effect_options, + transitions_enabled=True, + duration=duration, + ) + + +def _validate_plan_modules(plan: dict) -> None: + if plan and plan.get("schema") != PLAN_SCHEMA: + raise ValueError( + f"unsupported enhancement plan schema: {plan.get('schema')!r}" + ) + modules_cfg = plan.get("modules") + if modules_cfg is None: + return + if not isinstance(modules_cfg, dict): + raise ValueError("enhancement plan modules must be an object") + + unknown_modules = sorted(set(modules_cfg) - set(_WRITABLE_MODULES)) + if unknown_modules: + raise ValueError( + "enhancement plan has unknown module(s): " + + ", ".join(unknown_modules) + ) + if plan.get("schema") == PLAN_SCHEMA: + missing_modules = [ + name + for name in _WRITABLE_MODULES + if name not in modules_cfg + ] + if missing_modules: + raise ValueError( + "enhancement plan is missing module(s): " + + ", ".join(missing_modules) + ) + for name in _WRITABLE_MODULES: + config = modules_cfg.get(name) + if config is not None and not isinstance(config, dict): + raise ValueError( + f"enhancement plan module {name} must be an object" + ) + if ( + isinstance(config, dict) + and ( + "enabled" not in config + or not isinstance(config["enabled"], bool) + ) + ): + raise ValueError( + f"enhancement plan module {name}.enabled must be a boolean" + ) + + +def _resolve_transition_plan( + project: dict, + plan: dict, + slides: list[SlidePart], + *, + cli_effect: str | None = None, + cli_duration: float | None = None, + cli_apply_without_audio: bool = False, +) -> ResolvedTransitionPlan: + _validate_plan_modules(plan) + plan_slide_count = plan.get("slide_count") + if plan_slide_count is not None and plan_slide_count != len(slides): + raise ValueError( + "enhancement plan slide_count no longer matches the archived " + f"source: {plan_slide_count!r} != {len(slides)}" + ) + + modules = _enabled_modules(plan) + transitions_cfg = _module_config(plan, "transitions") + unknown = sorted(set(transitions_cfg) - _TRANSITION_MODULE_FIELDS) + if unknown: + raise ValueError( + "transition module has unknown field(s): " + ", ".join(unknown) + ) + + project_transition = ( + project.get("transition") + if isinstance(project.get("transition"), dict) + else {} + ) + if ( + cli_effect is None + and "effect_options" in transitions_cfg + and "effect" not in transitions_cfg + ): + raise ValueError("transition effect_options requires an explicit effect") + if ( + cli_effect is None + and "effect_options" in project_transition + and "effect" not in project_transition + and "effect" not in transitions_cfg + ): + raise ValueError("transition effect_options requires an explicit effect") + + if "effect" in transitions_cfg: + configured_effect = transitions_cfg["effect"] + configured_options = transitions_cfg.get("effect_options") + elif "effect" in project_transition: + configured_effect = project_transition["effect"] + configured_options = project_transition.get("effect_options") + else: + configured_effect = "fade" + configured_options = None + + if cli_duration is not None: + raw_duration: object = cli_duration + elif "duration" in transitions_cfg: + raw_duration = transitions_cfg["duration"] + elif "duration" in project_transition: + raw_duration = project_transition["duration"] + else: + raw_duration = 0.5 + duration = validate_seconds( + raw_duration, + "transition duration", + allow_zero=False, + ) + + selected_base = _resolve_enter_update( + cli_effect=cli_effect, + configured_effect=configured_effect, + configured_effect_options=configured_options, + transitions_enabled=True, + duration=duration, + ) + global_enter = _resolve_enter_update( + cli_effect=cli_effect, + configured_effect=configured_effect, + configured_effect_options=configured_options, + transitions_enabled="transitions" in modules, + duration=duration, + ) + + raw_apply_without_audio = transitions_cfg.get( + "apply_without_audio", + False, + ) + if not isinstance(raw_apply_without_audio, bool): + raise ValueError("transition apply_without_audio must be a boolean") + apply_without_audio = ( + cli_apply_without_audio or raw_apply_without_audio + ) + + raw_slides = transitions_cfg.get("slides", {}) + if not isinstance(raw_slides, dict): + raise ValueError("transition slides must be an object") + valid_indices = {slide.index for slide in slides} + slide_enters: dict[int, EnterUpdate] = {} + for raw_index, override in raw_slides.items(): + if ( + not isinstance(raw_index, str) + or re.fullmatch(r"[1-9]\d*", raw_index) is None + ): + raise ValueError( + f"transition slide key must be a canonical 1-based index: " + f"{raw_index!r}" + ) + slide_index = int(raw_index) + if slide_index not in valid_indices: + raise ValueError( + f"transition slide index is outside the source roster: " + f"{slide_index}" + ) + if not isinstance(override, dict): + raise ValueError( + f"transition slides.{slide_index} must be an object" + ) + slide_enters[slide_index] = _resolve_slide_enter( + selected_base, + override, + slide_index=slide_index, + ) + + if ( + ("transitions" in modules or cli_effect is not None) + and "audio" not in modules + and not apply_without_audio + and not slide_enters + ): + raise ValueError( + "transitions is enabled, but no slides are reachable: enable audio, " + "set apply_without_audio=true, or add transitions.slides entries" + ) + + return ResolvedTransitionPlan( + global_enter=global_enter, + slide_enters=slide_enters, + apply_without_audio=apply_without_audio, + ) + + +def _apply_transition_only( + extract_dir: Path, + slide: SlidePart, + enter: EnterUpdate, +) -> bool: + if enter.policy == "preserve": + return False + slide_xml_path = extract_dir / slide.part_name + slide_xml = slide_xml_path.read_text(encoding="utf-8") + source_animation_fingerprint = object_animation_fingerprint(slide_xml) + slide_xml, _uses_timings = apply_slide_motion_xml( + slide_xml, + enter=enter, + advance=AdvanceUpdate(mode="preserve"), + ) + if object_animation_fingerprint(slide_xml) != source_animation_fingerprint: + raise RuntimeError( + f"Slide {slide.index} object animations changed while updating " + "the transition" + ) + slide_xml_path.write_text(slide_xml, encoding="utf-8") + return True + + def init_project(args: argparse.Namespace) -> int: source_pptx = Path(args.source_pptx).expanduser().resolve() if not source_pptx.exists() or source_pptx.suffix.lower() != ".pptx": print(f"error: expected an existing .pptx file: {source_pptx}", file=sys.stderr) return 1 + source_delivery = audit_pptx_delivery(source_pptx) + fatal_delivery_messages = _fatal_source_delivery_messages(source_delivery) + if fatal_delivery_messages: + for message in fatal_delivery_messages: + print(f"error: {message}", file=sys.stderr) + return 1 + stem = _sanitize_slug(args.name or source_pptx.stem) date = datetime.now().strftime("%Y%m%d") project_path = ( @@ -605,6 +1625,7 @@ def init_project(args: argparse.Namespace) -> int: extract_dir = Path(tmp) / "pptx" _extract_pptx(archived_pptx, extract_dir) slide_parts = read_slide_parts(extract_dir) + source_sha256 = _file_sha256(archived_pptx) slide_index = { "schema": "native_pptx_enhancement_slide_index.v1", @@ -626,14 +1647,22 @@ def init_project(args: argparse.Namespace) -> int: project = { "schema": PROJECT_SCHEMA, "kind": "native_pptx_enhancement", - "modules": ["notes", "audio", "timings", "transitions"], + "modules": [ + "notes", + "audio", + "timings", + "transitions", + "delivery.check", + ], "source_pptx": f"sources/{source_pptx.name}", "source_markdown": f"sources/{source_pptx.stem}.md", "source_import": { "mode": source_import_mode, "original_path": str(source_pptx), }, + "source_sha256": source_sha256, "slide_count": len(slide_parts), + "slide_part_roster": [slide.part_name for slide in slide_parts], "notes_dir": "notes", "audio_dir": "audio", "exports_dir": "exports", @@ -659,6 +1688,23 @@ def init_project(args: argparse.Namespace) -> int: apply_transition_without_audio=args.apply_transition_without_audio, ) _write_json(_plan_path(project_path), plan) + source_delivery_file = source_delivery.get("file") + if isinstance(source_delivery_file, dict): + source_delivery_file["path"] = str(archived_pptx.resolve()) + _write_json( + project_path / "validation" / "report.json", + { + "schema": VALIDATION_SCHEMA, + "status": ( + "passed-with-advisories" + if _delivery_has_findings(source_delivery) + else "passed" + ), + "phase": "intake", + "source_delivery_policy": "preserve-baseline", + "delivery_check": source_delivery, + }, + ) print(f"Project: {project_path}", file=sys.stderr) print(f"Slides: {len(slide_parts)}", file=sys.stderr) @@ -680,23 +1726,53 @@ def plan_project(args: argparse.Namespace) -> int: return 1 source_pptx, notes_dir, audio_dir, _exports_dir = _project_paths(project_path) + source_delivery = audit_pptx_delivery(source_pptx) + fatal_delivery_messages = _fatal_source_delivery_messages(source_delivery) + if fatal_delivery_messages: + for message in fatal_delivery_messages: + print(f"error: {message}", file=sys.stderr) + return 1 + with tempfile.TemporaryDirectory(prefix="native-enhance-plan-") as tmp: extract_dir = Path(tmp) / "pptx" _extract_pptx(source_pptx, extract_dir) slides = read_slide_parts(extract_dir) - notes_count = sum(1 for slide in slides if _note_path(notes_dir, slide.index) is not None) - audio_count = sum(1 for slide in slides if _audio_path(audio_dir, slide.index) is not None) - plan = _build_enhancement_plan( + source_errors = _source_state_errors( + project_path, project, - slide_count=len(slides), - notes_count=notes_count, - audio_count=audio_count, - transition=args.transition, - transition_duration=args.transition_duration, - narration_padding=args.narration_padding, - apply_transition_without_audio=args.apply_transition_without_audio, + source_pptx, + slides, ) + if source_errors: + for error in source_errors: + print(f"error: {error}", file=sys.stderr) + return 1 + + existing_plan = _load_enhancement_plan(project_path) + try: + _validate_plan_modules(existing_plan) + readiness = _collect_material_readiness( + slides, + notes_dir, + audio_dir, + set(), + ) + plan = _build_enhancement_plan( + project, + slide_count=len(slides), + notes_count=readiness.notes_count, + audio_count=readiness.audio_count, + transition=args.transition, + transition_duration=args.transition_duration, + narration_padding=args.narration_padding, + apply_transition_without_audio=args.apply_transition_without_audio, + existing_plan=existing_plan, + ) + _resolve_transition_plan(project, plan, slides) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 _write_json(_plan_path(project_path), plan) print(json.dumps(plan, ensure_ascii=False, indent=2)) print(f"Plan written: {_plan_path(project_path)}", file=sys.stderr) @@ -715,55 +1791,64 @@ def apply_project(args: argparse.Namespace) -> int: return 1 source_pptx, notes_dir, audio_dir, exports_dir = _project_paths(project_path) - transition_cfg = project.get("transition", {}) if isinstance(project.get("transition"), dict) else {} plan = _load_enhancement_plan(project_path) - if not _plan_confirmed(plan) and not args.force: - print( - f"error: enhancement plan is not confirmed: {_plan_path(project_path)} " - "(run plan, get user confirmation, set status to \"confirmed\", or pass --force)", - file=sys.stderr, + modules = _enabled_modules(plan) + + def fail_preflight( + messages: list[str], + *, + status: str = "failed", + **details: object, + ) -> int: + _write_preflight_report( + project_path, + plan, + modules, + status=status, + errors=messages, + **details, ) + for message in messages: + print(f"error: {message}", file=sys.stderr) return 1 - modules = _enabled_modules(plan) + _write_preflight_report( + project_path, + plan, + modules, + status="running", + ) + if not _plan_confirmed(plan) and not args.force: + return fail_preflight( + [ + f"enhancement plan is not confirmed: {_plan_path(project_path)} " + "(run plan, get user confirmation, set status to " + "\"confirmed\", or pass --force)" + ] + ) + + try: + _validate_plan_modules(plan) + except ValueError as exc: + return fail_preflight( + [str(exc)], + plan_errors=[str(exc)], + ) + + source_delivery = audit_pptx_delivery(source_pptx) + fatal_delivery_messages = _fatal_source_delivery_messages(source_delivery) + if fatal_delivery_messages: + return fail_preflight( + fatal_delivery_messages, + fatal_delivery_errors=fatal_delivery_messages, + delivery_check=source_delivery, + ) + modules_cfg = plan.get("modules") if isinstance(plan.get("modules"), dict) else {} - transitions_cfg = modules_cfg.get("transitions", {}) - if not isinstance(transitions_cfg, dict): - transitions_cfg = {} timings_cfg = modules_cfg.get("timings", {}) 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 - elif "duration" in transitions_cfg: - raw_transition_duration = transitions_cfg["duration"] - elif "duration" in transition_cfg: - raw_transition_duration = transition_cfg["duration"] - else: - raw_transition_duration = 0.5 - if args.narration_padding is not None: raw_narration_padding = args.narration_padding elif "narration_padding" in timings_cfg: @@ -772,18 +1857,6 @@ 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, - "transition duration", - allow_zero=False, - ) - else: - transition_duration = 0.5 if "timings" in modules: narration_padding = validate_seconds( raw_narration_padding, @@ -792,48 +1865,120 @@ def apply_project(args: argparse.Namespace) -> int: ) else: narration_padding = 0.4 - 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, - ) except ValueError as exc: - print(f"error: {exc}", file=sys.stderr) - return 1 - - apply_transition_without_audio = ( - args.apply_transition_without_audio - or bool(transitions_cfg.get("apply_without_audio")) - ) + return fail_preflight([str(exc)]) output_path = ( Path(args.output).expanduser().resolve() if args.output else exports_dir / f"{source_pptx.stem}_enhanced.pptx" ) + output_error = _output_path_error( + project_path, + project, + source_pptx, + output_path, + ) + if output_error: + return fail_preflight([output_error]) if output_path.exists() and not args.overwrite: - print(f"error: output already exists, pass --overwrite: {output_path}", file=sys.stderr) - return 1 + return fail_preflight( + [f"output already exists, pass --overwrite: {output_path}"] + ) with tempfile.TemporaryDirectory(prefix="native-enhance-pptx-") as tmp: extract_dir = Path(tmp) / "pptx" _extract_pptx(source_pptx, extract_dir) slides = read_slide_parts(extract_dir) - note_indices: set[int] = set() + source_errors = _source_state_errors( + project_path, + project, + source_pptx, + slides, + ) + if source_errors: + return fail_preflight( + source_errors, + source_errors=source_errors, + delivery_check=source_delivery, + ) + + try: + resolved_transitions = _resolve_transition_plan( + project, + plan, + slides, + cli_effect=args.transition, + cli_duration=args.transition_duration, + cli_apply_without_audio=args.apply_transition_without_audio, + ) + except ValueError as exc: + return fail_preflight( + [str(exc)], + transition_errors=[str(exc)], + delivery_check=source_delivery, + ) + + readiness = _collect_material_readiness( + slides, + notes_dir, + audio_dir, + modules, + ) + if not readiness.ready: + return fail_preflight( + _material_readiness_messages(readiness), + status=( + "failed" + if readiness.module_errors + else "needs-materials" + ), + notes_required="notes" in modules, + audio_required=( + "audio" in modules or "timings" in modules + ), + delivery_check=source_delivery, + **_material_readiness_report_fields(readiness), + ) + + if "audio" in modules: + existing_carriers = _native_audio_carriers(extract_dir, slides) + if existing_carriers: + details = "; ".join( + f"slide {index}: {', '.join(names)}" + for index, names in sorted(existing_carriers.items()) + ) + return fail_preflight( + [ + "source PPTX already contains native-enhance narration " + "carrier(s); refusing to append duplicate audio: " + + details + ], + existing_native_audio_carriers=existing_carriers, + delivery_check=source_delivery, + ) + + note_parts: set[str] = set() audio_exts: set[str] = set() audio_count = 0 transition_only_count = 0 wrote_auto_advance = False for slide in slides: - note = _note_path(notes_dir, slide.index) + has_slide_transition = ( + slide.index in resolved_transitions.slide_enters + ) + enter_update = resolved_transitions.slide_enters.get( + slide.index, + resolved_transitions.global_enter, + ) + note = readiness.note_paths.get(slide.index) if "notes" in modules and note: - _apply_notes(extract_dir, slide, note) - note_indices.add(slide.index) + notes_part = _apply_notes(extract_dir, slide, note) + if notes_part is not None: + note_parts.add(notes_part) - audio = _audio_path(audio_dir, slide.index) + audio = readiness.audio_paths.get(slide.index) if "audio" in modules and audio: wrote_auto_advance = _apply_audio( extract_dir, @@ -842,40 +1987,25 @@ def apply_project(args: argparse.Namespace) -> int: enter=enter_update, timings_enabled="timings" in modules, narration_padding=narration_padding, + audio_duration=readiness.audio_durations.get(slide.index), ) or wrote_auto_advance audio_exts.add(audio.suffix.lower()) audio_count += 1 continue if ( - apply_transition_without_audio - and enter_update.policy != "preserve" + has_slide_transition + or resolved_transitions.apply_without_audio ): - slide_xml_path = extract_dir / slide.part_name - slide_xml = slide_xml_path.read_text(encoding="utf-8") - source_animation_fingerprint = object_animation_fingerprint( - slide_xml - ) - slide_xml, _uses_timings = apply_slide_motion_xml( - slide_xml, - enter=enter_update, - advance=AdvanceUpdate(mode="preserve"), - ) - if ( - object_animation_fingerprint(slide_xml) - != source_animation_fingerprint - ): - raise RuntimeError( - f"Slide {slide.index} object animations changed while " - "updating the transition" + transition_only_count += int( + _apply_transition_only( + extract_dir, + slide, + enter_update, ) - slide_xml_path.write_text( - slide_xml, - encoding="utf-8", ) - transition_only_count += 1 - _update_content_types(extract_dir, note_indices, audio_exts) + _update_content_types(extract_dir, note_parts, audio_exts) if wrote_auto_advance: set_directory_use_timings(extract_dir) output_path.parent.mkdir(parents=True, exist_ok=True) @@ -903,10 +2033,96 @@ def apply_project(args: argparse.Namespace) -> int: raise RuntimeError( f"PPTX animation/timing package validation failed: {exc}" ) from exc + candidate_delivery = audit_pptx_delivery(candidate_path) + introduced_delivery_errors = _new_delivery_errors( + source_delivery, + candidate_delivery, + ) + if introduced_delivery_errors: + raise RuntimeError( + "PPTX delivery postflight introduced structural error(s): " + + "; ".join( + str(issue.get("message") or issue) + for issue in introduced_delivery_errors + ) + ) + candidate_slides = candidate_delivery.get("slides") + candidate_slide_count = ( + candidate_slides.get("count") + if isinstance(candidate_slides, dict) + else None + ) + if candidate_slide_count != len(slides): + raise RuntimeError( + "PPTX delivery postflight slide count changed: " + f"{candidate_slide_count!r} != {len(slides)}" + ) + source_hidden_slides = _delivery_hidden_slide_indices( + source_delivery + ) + candidate_hidden_slides = _delivery_hidden_slide_indices( + candidate_delivery + ) + if ( + source_hidden_slides is None + or candidate_hidden_slides is None + or candidate_hidden_slides != source_hidden_slides + ): + raise RuntimeError( + "PPTX delivery postflight changed or could not verify " + "hidden-slide state" + ) candidate_path.replace(output_path) + candidate_file = candidate_delivery.get("file") + if isinstance(candidate_file, dict): + candidate_file["path"] = str(output_path.resolve()) + report_status = ( + "passed-with-advisories" + if ( + _delivery_has_findings(source_delivery) + or _delivery_has_findings(candidate_delivery) + ) + else "passed" + ) + validation_dir = project_path / "validation" + validation_dir.mkdir(exist_ok=True) + _write_json( + validation_dir / "report.json", + { + "schema": VALIDATION_SCHEMA, + "status": report_status, + "phase": "postflight", + "plan_status": plan.get("status") or "missing", + "enabled_modules": sorted(modules), + "slide_count": len(slides), + "applied": { + "notes": len(note_parts), + "audio": audio_count, + "transition_only_slides": transition_only_count, + "automatic_advance": wrote_auto_advance, + }, + "transition_scope": { + "global_enabled": "transitions" in modules, + "global_policy": ( + resolved_transitions.global_enter.policy + ), + "apply_without_audio": ( + resolved_transitions.apply_without_audio + ), + "selected_slides": sorted( + resolved_transitions.slide_enters + ), + }, + "source_delivery_check": source_delivery, + "output_delivery_check": candidate_delivery, + "source_delivery_policy": "preserve-baseline", + "introduced_delivery_errors": introduced_delivery_errors, + }, + ) + print(f"Output: {output_path}", file=sys.stderr) - print(f"Notes applied: {len(note_indices)}", file=sys.stderr) + print(f"Notes applied: {len(note_parts)}", file=sys.stderr) print(f"Audio embedded: {audio_count}", file=sys.stderr) if transition_only_count: print(f"Transition-only slides: {transition_only_count}", file=sys.stderr) @@ -921,42 +2137,124 @@ def validate_project(args: argparse.Namespace) -> int: return 1 source_pptx, notes_dir, audio_dir, _exports_dir = _project_paths(project_path) + plan = _load_enhancement_plan(project_path) + modules = _enabled_modules(plan) + source_delivery = audit_pptx_delivery(source_pptx) + validation_dir = project_path / "validation" + validation_dir.mkdir(exist_ok=True) + fatal_delivery_messages = _fatal_source_delivery_messages(source_delivery) + if fatal_delivery_messages: + report = _write_preflight_report( + project_path, + plan, + modules, + status="failed", + fatal_delivery_errors=fatal_delivery_messages, + delivery_check=source_delivery, + ) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 1 + with tempfile.TemporaryDirectory(prefix="native-enhance-validate-") as tmp: extract_dir = Path(tmp) / "pptx" _extract_pptx(source_pptx, extract_dir) slides = read_slide_parts(extract_dir) + existing_carriers = ( + _native_audio_carriers(extract_dir, slides) + if "audio" in modules + else {} + ) - plan = _load_enhancement_plan(project_path) - modules = _enabled_modules(plan) - notes_count = sum(1 for slide in slides if _note_path(notes_dir, slide.index) is not None) - audio_count = sum(1 for slide in slides if _audio_path(audio_dir, slide.index) is not None) - missing_notes = ( - [slide.index for slide in slides if _note_path(notes_dir, slide.index) is None] - if "notes" in modules - else [] + source_errors = _source_state_errors( + project_path, + project, + source_pptx, + slides, ) - missing_audio = ( - [slide.index for slide in slides if _audio_path(audio_dir, slide.index) is None] - if "audio" in modules - else [] + try: + _validate_plan_modules(plan) + except ValueError as exc: + plan_errors = [str(exc)] + else: + plan_errors = [] + try: + resolved_transitions = ( + _resolve_transition_plan( + project, + plan, + slides, + ) + if not plan_errors + else None + ) + except ValueError as exc: + transition_errors = [str(exc)] + transition_slide_count = 0 + transition_scope = None + else: + transition_errors = [] + if resolved_transitions is None: + transition_slide_count = 0 + transition_scope = None + else: + transition_slide_count = len( + resolved_transitions.slide_enters + ) + transition_scope = { + "global_enabled": "transitions" in modules, + "global_policy": resolved_transitions.global_enter.policy, + "apply_without_audio": ( + resolved_transitions.apply_without_audio + ), + "selected_slides": sorted( + resolved_transitions.slide_enters + ), + } + readiness = _collect_material_readiness( + slides, + notes_dir, + audio_dir, + modules, + ) + hard_failure = bool( + source_errors + or readiness.module_errors + or plan_errors + or transition_errors + or existing_carriers + ) + if hard_failure: + status = "failed" + elif not readiness.ready: + status = "needs-materials" + else: + status = ( + "passed-with-advisories" + if _delivery_has_findings(source_delivery) + else "passed" + ) + report = _write_preflight_report( + project_path, + plan, + modules, + status=status, + slide_count=len(slides), + notes_required="notes" in modules, + audio_required="audio" in modules or "timings" in modules, + plan_errors=plan_errors, + transition_errors=transition_errors, + transition_override_count=transition_slide_count, + transition_scope=transition_scope, + source_errors=source_errors, + existing_native_audio_carriers=existing_carriers, + source_delivery_policy="preserve-baseline", + delivery_check=source_delivery, + **_material_readiness_report_fields(readiness), ) - report = { - "schema": "native_pptx_enhancement_validation.v1", - "slide_count": len(slides), - "plan_status": plan.get("status") or "missing", - "enabled_modules": sorted(modules), - "notes_required": "notes" in modules, - "audio_required": "audio" in modules, - "notes_count": notes_count, - "audio_count": audio_count, - "missing_notes": missing_notes, - "missing_audio": missing_audio, - } - validation_dir = project_path / "validation" - validation_dir.mkdir(exist_ok=True) - _write_json(validation_dir / "report.json", report) print(json.dumps(report, ensure_ascii=False, indent=2)) - return 0 if not missing_notes and not missing_audio else 2 + if hard_failure: + return 1 + return 0 if readiness.ready else 2 def build_parser() -> argparse.ArgumentParser: @@ -990,20 +2288,35 @@ def build_parser() -> argparse.ArgumentParser: plan.add_argument("project_path", help="native enhancement project directory") plan.add_argument( "--transition", - default="fade", + default=None, choices=[*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS, "none"], - help="PowerPoint-native effect; old names are compatibility inputs", + help=( + "replace the saved global PowerPoint-native effect; omitted values " + "preserve the current plan" + ), + ) + plan.add_argument( + "--transition-duration", + type=_positive_seconds_arg, + default=None, + ) + plan.add_argument( + "--narration-padding", + type=_non_negative_seconds_arg, + default=None, ) - 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( "--apply-transition-without-audio", action="store_true", + default=None, help="include page transitions for slides without audio", ) plan.set_defaults(func=plan_project) - apply = subparsers.add_parser("apply", help="patch notes/audio/timings into a copied PPTX") + apply = subparsers.add_parser( + "apply", + help="patch confirmed notes/audio/timings/transitions into a copied PPTX", + ) 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") @@ -1023,16 +2336,82 @@ def build_parser() -> argparse.ArgumentParser: ) apply.set_defaults(func=apply_project) - validate = subparsers.add_parser("validate", help="check notes/audio coverage") + validate = subparsers.add_parser( + "validate", + help="check source integrity, plan semantics, and material readiness", + ) validate.add_argument("project_path", help="native enhancement project directory") validate.set_defaults(func=validate_project) return parser +def _record_preflight_exception( + args: argparse.Namespace, + exc: Exception, +) -> None: + command = str(args.command) + try: + project_path = Path(args.project_path).expanduser().resolve() + project = _read_json(project_path / "project.json") + except (OSError, ValueError, KeyError, json.JSONDecodeError) as project_exc: + if ( + project_path.is_dir() + and (project_path / "validation").is_dir() + ): + try: + _write_preflight_report( + project_path, + {}, + set(), + status="failed", + errors=[f"{command} aborted: {exc}"], + project_errors=[ + f"unable to read project.json: {project_exc}" + ], + ) + except OSError: + pass + return + if project.get("schema") not in { + PROJECT_SCHEMA, + *LEGACY_PROJECT_SCHEMAS, + }: + return + plan_errors: list[str] = [] + try: + plan = _load_enhancement_plan(project_path) + except (OSError, ValueError, json.JSONDecodeError) as plan_exc: + plan = {} + plan_errors.append(f"unable to read enhancement plan: {plan_exc}") + try: + _write_preflight_report( + project_path, + plan, + set() if plan_errors else _enabled_modules(plan), + status="failed", + errors=[f"{command} aborted: {exc}"], + plan_errors=plan_errors, + ) + except OSError: + return + + def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) - return args.func(args) + try: + return args.func(args) + except ( + OSError, + RuntimeError, + ValueError, + zipfile.BadZipFile, + ET.ParseError, + ) as exc: + if args.command in {"apply", "validate"}: + _record_preflight_exception(args, exc) + print(f"error: {exc}", file=sys.stderr) + return 1 if __name__ == "__main__": 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 index 03d9d289..87489309 100644 --- 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 @@ -938,7 +938,7 @@ "effect_options": { "font_name": { "type": "string", - "default": "??" + "required": true } } }, 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 525565b9..f24746c8 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 @@ -171,6 +171,27 @@ ANIMATION_TIMING_OPTION_FIELDS = ( ) ANIMATION_RESTARTS = ('always', 'when-not-active', 'never') ANIMATION_AFTER_EFFECTS = ('none', 'dim', 'hide', 'hide-on-next-click') +_NON_CONCRETE_FONT_NAMES = frozenset({ + '-apple-system', + 'blinkmacsystemfont', + 'cursive', + 'emoji', + 'fantasy', + 'inherit', + 'initial', + 'math', + 'monospace', + 'revert', + 'revert-layer', + 'sans-serif', + 'serif', + 'system-ui', + 'ui-monospace', + 'ui-rounded', + 'ui-sans-serif', + 'ui-serif', + 'unset', +}) # Legacy directional names retain their historical semantics by desugaring # into one canonical effect plus the matching PowerPoint EffectParameters @@ -267,6 +288,17 @@ def _load_native_animations() -> dict[str, dict[str, Any]]: f'native animation preset {key!r} option {option_name!r} ' 'must be an object' ) + required = option_spec.get('required', False) + if not isinstance(required, bool): + raise RuntimeError( + f'native animation preset {key!r} option {option_name!r} ' + 'required must be a boolean' + ) + if required and 'default' in option_spec: + raise RuntimeError( + f'native animation preset {key!r} option {option_name!r} ' + 'cannot define both required and default' + ) option_type = option_spec.get('type') if option_type == 'enum': values = option_spec.get('values') @@ -492,6 +524,28 @@ def _normalize_animation_color(value: object, field: str) -> str: ) +def _normalize_powerpoint_font_name(value: object, field: str) -> str: + """Return one concrete PowerPoint font name without checking installation.""" + if not isinstance(value, str) or not value.strip(): + raise ValueError( + f'{field} must be one concrete PowerPoint font name: {value!r}' + ) + normalized = value.strip() + if len(normalized) > 255: + raise ValueError(f'{field} exceeds 255 characters') + if ',' in normalized: + raise ValueError( + f'{field} must be one concrete PowerPoint font name, ' + f'not a CSS font stack: {value!r}' + ) + if normalized.casefold() in _NON_CONCRETE_FONT_NAMES: + raise ValueError( + f'{field} must be one concrete PowerPoint font name, ' + f'not a generic family or CSS-wide keyword: {value!r}' + ) + return normalized + + def normalize_animation_effect_options( effect: str, options: object = None, @@ -518,6 +572,18 @@ def normalize_animation_effect_options( f'animation effect {effect!r} does not support effect option(s): ' f'{unsupported}; supported options: {supported}' ) + missing_required = sorted( + name + for name, spec in option_specs.items() + if spec.get('required') and name not in options + ) + if missing_required: + required_fields = ', '.join( + f'effect_options.{name}' for name in missing_required + ) + raise ValueError( + f'animation effect {effect!r} requires {required_fields}' + ) normalized: dict[str, object] = {} for name, value in options.items(): @@ -550,11 +616,17 @@ def normalize_animation_effect_options( ) 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 + if name == 'font_name': + normalized[name] = _normalize_powerpoint_font_name(value, field) + else: + if not isinstance(value, str) or not value.strip(): + raise ValueError( + f'{field} must be a non-empty string: {value!r}' + ) + normalized_value = value.strip() + if len(normalized_value) > 255: + raise ValueError(f'{field} exceeds 255 characters') + normalized[name] = normalized_value elif option_type == 'boolean': if not isinstance(value, bool): raise ValueError(f'{field} must be a boolean: {value!r}') @@ -2252,10 +2324,18 @@ def _read_effect_options( 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] + if len(fonts) != 1: + errors.append( + 'emphasis_change_font row must contain one font name' + ) + continue + try: + values[name] = _normalize_powerpoint_font_name( + fonts[0], + 'emphasis_change_font row font name', + ) + except ValueError as exc: + errors.append(str(exc)) elif name == 'relative': motions = list(row.iter(_qn(PML_NS, 'animMotion'))) if len(motions) != 1: @@ -3445,11 +3525,17 @@ def get_animation_help() -> str: 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( + canonical = normalize_animation_effect( effect, allow_none=False, allow_modes=False, ) + assert canonical is not None + implied_options = ( + dict(ANIMATION_ALIAS_OPTIONS.get(effect, {})) + if isinstance(effect, str) + else {} + ) option_contract: dict[str, Any] = {} for name, raw_spec in NATIVE_ANIMATIONS[canonical]['effectOptions'].items(): spec = { diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_delivery_check.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_delivery_check.py new file mode 100644 index 00000000..d889c31c --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_delivery_check.py @@ -0,0 +1,1133 @@ +#!/usr/bin/env python3 +""" +PPT Master - PPTX Delivery Check + +Inspect a finished PPTX without modifying it and report package integrity, +delivery portability, media footprint, hidden slides, and motion presence. + +Usage: + python3 scripts/pptx_delivery_check.py + +Examples: + python3 scripts/pptx_delivery_check.py projects/demo/exports/demo.pptx + +Dependencies: + Same repository dependencies as beautify_identity.py and svg_to_pptx.py. +""" + +from __future__ import annotations + +import argparse +import json +import mimetypes +import re +import sys +import tempfile +import unicodedata +import zipfile +from collections import Counter, defaultdict +from pathlib import Path, PurePosixPath +from xml.etree import ElementTree as ET + +_SCRIPTS_DIR = Path(__file__).resolve().parent +if str(_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPTS_DIR)) + +from console_encoding import configure_utf8_stdio # noqa: E402 + +configure_utf8_stdio() + +from beautify_identity import extract_identity # noqa: E402 +from pptx_opc_validation import ( # noqa: E402 + canonical_opc_part_path, + resolve_internal_opc_target, + verify_internal_relationships, +) +from pptx_animations import ( # noqa: E402 + object_animation_fingerprint, + read_slide_animation_sequence, + validate_pptx_animation_package, +) +from pptx_to_svg.ooxml_loader import ( # noqa: E402 + OoxmlPackage, + parse_ooxml_boolean, +) +from pptx_transitions import ( # noqa: E402 + read_slide_transition_xml, + validate_slide_transition_xml, +) +from svg_to_pptx.drawingml.utils import PPT_SAFE_FONTS # noqa: E402 + + +REPORT_SCHEMA = "ppt-master.pptx-delivery-check.v1" +PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types" +DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" +PRESENTATION_NS = ( + "http://schemas.openxmlformats.org/presentationml/2006/main" +) +_SLIDE_PART_RE = re.compile(r"ppt/slides/slide[1-9]\d*\.xml") +_NOTES_PART_RE = re.compile(r"ppt/notesSlides/notesSlide[1-9]\d*\.xml") +_MASTER_PART_RE = re.compile(r"ppt/slideMasters/slideMaster[1-9]\d*\.xml") +_LAYOUT_PART_RE = re.compile(r"ppt/slideLayouts/slideLayout[1-9]\d*\.xml") +_MEDIA_REL_KINDS = frozenset({"audio", "image", "media", "video"}) +_PPT_SAFE_FONT_ALIASES = { + "等线": "DengXian", + "等线 light": "DengXian Light", + "宋体": "SimSun", + "新細明體": "PMingLiU", + "맑은 고딕": "Malgun Gothic", + "ms pゴシック": "MS PGothic", +} +_PPT_DELIVERY_SAFE_FONTS = PPT_SAFE_FONTS | frozenset( + {"dengxian light", "pmingliu"} +) + + +def _issue(code: str, message: str, **details: object) -> dict[str, object]: + issue: dict[str, object] = {"code": code, "message": message} + issue.update(details) + return issue + + +def _empty_report(path: Path) -> dict[str, object]: + return { + "schema": REPORT_SCHEMA, + "status": "failed", + "file": { + "path": str(path), + "bytes": path.stat().st_size if path.is_file() else None, + }, + "package": { + "zip_integrity": "not-checked", + "corrupt_member": None, + "duplicate_parts": [], + "canonical_part_collisions": [], + "parts": {}, + "relationships": {"problems": []}, + }, + "slides": { + "count": 0, + "hidden_count": 0, + "hidden": [], + }, + "fonts": { + "theme": {}, + "declared": {}, + "embedded_parts": [], + "portability_advisory_faces": [], + }, + "media": { + "embedded_count": 0, + "external_count": 0, + "embedded": [], + "external": [], + "uncompressed_bytes": 0, + "archive_compressed_bytes": 0, + "share_of_archive": 0.0, + "share_of_uncompressed_parts": 0.0, + "top_contributors": [], + }, + "motion": { + "transitions": { + "carrier_slide_count": 0, + "visual_effect_slide_count": 0, + "timed_advance_slide_count": 0, + "carrier_slides": [], + "visual_effect_slides": [], + "timed_advance_slides": [], + "effects": {}, + }, + "object_animations": { + "timing_slide_count": 0, + "object_animation_slide_count": 0, + "audio_timing_slide_count": 0, + "timing_slides": [], + "object_animation_slides": [], + "audio_timing_slides": [], + }, + }, + "errors": [], + "advisories": [], + } + + +def _content_type_maps( + archive: zipfile.ZipFile, + errors: list[dict[str, object]], +) -> tuple[dict[str, str], dict[str, str]]: + try: + root = ET.fromstring(archive.read("[Content_Types].xml")) + except KeyError: + errors.append( + _issue( + "missing_content_types", + "PPTX package is missing [Content_Types].xml.", + ) + ) + return {}, {} + except ET.ParseError as exc: + errors.append( + _issue( + "invalid_content_types", + f"PPTX content-types XML is invalid: {exc}.", + ) + ) + return {}, {} + expected_root = f"{{{CONTENT_TYPES_NS}}}Types" + if root.tag != expected_root: + errors.append( + _issue( + "invalid_content_types", + "PPTX content-types XML uses an invalid Types namespace.", + ) + ) + return {}, {} + + defaults: dict[str, str] = {} + overrides: dict[str, str] = {} + default_tag = f"{{{CONTENT_TYPES_NS}}}Default" + override_tag = f"{{{CONTENT_TYPES_NS}}}Override" + for node in root: + if node.tag == default_tag: + raw_extension = (node.attrib.get("Extension") or "").strip() + content_type = (node.attrib.get("ContentType") or "").strip() + extension = raw_extension.lower() + if not extension or extension.startswith(".") or not content_type: + errors.append( + _issue( + "invalid_content_types", + "PPTX content-types Default entry is incomplete.", + ) + ) + continue + if extension in defaults: + errors.append( + _issue( + "invalid_content_types", + "PPTX content-types XML has a duplicate Default extension.", + extension=raw_extension, + ) + ) + continue + defaults[extension] = content_type + continue + if node.tag == override_tag: + raw_part_name = (node.attrib.get("PartName") or "").strip() + content_type = (node.attrib.get("ContentType") or "").strip() + part_name = raw_part_name.lstrip("/") + if ( + not raw_part_name.startswith("/") + or not part_name + or ".." in PurePosixPath(part_name).parts + or not content_type + ): + errors.append( + _issue( + "invalid_content_types", + "PPTX content-types Override entry is incomplete or invalid.", + part=raw_part_name or None, + ) + ) + continue + if part_name in overrides: + errors.append( + _issue( + "invalid_content_types", + "PPTX content-types XML has a duplicate Override part.", + part=raw_part_name, + ) + ) + continue + overrides[part_name] = content_type + continue + errors.append( + _issue( + "invalid_content_types", + "PPTX content-types XML contains an unexpected child.", + child=node.tag, + ) + ) + return defaults, overrides + + +def _content_type_for_part( + part_name: str, + defaults: dict[str, str], + overrides: dict[str, str], +) -> str: + declared = _declared_content_type_for_part( + part_name, + defaults, + overrides, + ) + if declared is not None: + return declared + guessed, _encoding = mimetypes.guess_type(part_name) + return guessed or "application/octet-stream" + + +def _declared_content_type_for_part( + part_name: str, + defaults: dict[str, str], + overrides: dict[str, str], +) -> str | None: + if part_name in overrides: + return overrides[part_name] + filename = PurePosixPath(part_name).name + extension = ( + "rels" + if filename == ".rels" or filename.endswith(".rels") + else PurePosixPath(part_name).suffix.lstrip(".").lower() + ) + return defaults.get(extension) + + +def _xml_and_font_inventory( + archive: zipfile.ZipFile, + part_names: set[str], + errors: list[dict[str, object]], +) -> dict[str, list[dict[str, object]]]: + font_counts: dict[str, Counter[str]] = { + "latin": Counter(), + "ea": Counter(), + "cs": Counter(), + } + for part_name in sorted( + name + for name in part_names + if name.endswith(".xml") and name != "[Content_Types].xml" + ): + try: + root = ET.fromstring(archive.read(part_name)) + except (KeyError, ET.ParseError) as exc: + errors.append( + _issue( + "invalid_xml_part", + f"Cannot parse XML part {part_name}: {exc}.", + part=part_name, + ) + ) + continue + for role in font_counts: + for element in root.iter( + f"{{{DRAWINGML_NS}}}{role}" + ): + face = (element.attrib.get("typeface") or "").strip() + if face: + font_counts[role][face] += 1 + return { + role: [ + {"value": face, "count": count} + for face, count in sorted( + counts.items(), + key=lambda item: (-item[1], item[0].casefold()), + ) + ] + for role, counts in font_counts.items() + } + + +def _relationship_kind(relationship_type: str) -> str | None: + kind = relationship_type.rstrip("/").rsplit("/", 1)[-1].lower() + return kind if kind in _MEDIA_REL_KINDS else None + + +def _relationship_inventory( + archive: zipfile.ZipFile, + part_names: set[str], + errors: list[dict[str, object]], +) -> tuple[ + Counter[str], + dict[str, set[str]], + list[dict[str, object]], +]: + reference_counts: Counter[str] = Counter() + relationship_types: dict[str, set[str]] = defaultdict(set) + external_counts: Counter[tuple[str, str, str]] = Counter() + + for rels_name in sorted( + name for name in part_names if name.endswith(".rels") + ): + try: + root = ET.fromstring(archive.read(rels_name)) + except (KeyError, ET.ParseError) as exc: + errors.append( + _issue( + "invalid_relationship_part", + f"Cannot parse relationship part {rels_name}: {exc}.", + part=rels_name, + ) + ) + continue + for relationship in root.findall( + f"{{{PACKAGE_REL_NS}}}Relationship" + ): + relationship_type = ( + relationship.attrib.get("Type") or "" + ).strip() + target = (relationship.attrib.get("Target") or "").strip() + target_mode = ( + relationship.attrib.get("TargetMode") or "" + ).strip().lower() + kind = _relationship_kind(relationship_type) + if target_mode == "external": + if kind and target: + external_counts[(kind, target, relationship_type)] += 1 + continue + resolved = resolve_internal_opc_target(rels_name, target) + if resolved is None: + continue + reference_counts[resolved] += 1 + if relationship_type: + relationship_types[resolved].add(relationship_type) + + external = [ + { + "target": target, + "kind": kind, + "relationship_type": relationship_type, + "bytes": None, + "compressed_bytes": None, + "reference_count": count, + } + for (kind, target, relationship_type), count in sorted( + external_counts.items() + ) + ] + return reference_counts, relationship_types, external + + +def _embedded_part_record( + info: zipfile.ZipInfo, + *, + content_type: str, + reference_counts: Counter[str], + relationship_types: dict[str, set[str]], +) -> dict[str, object]: + kind = content_type.split("/", 1)[0] + if kind not in {"audio", "image", "video"}: + kind = "media" + part_key = canonical_opc_part_path(info.filename) + return { + "part": info.filename, + "kind": kind, + "content_type": content_type, + "bytes": info.file_size, + "compressed_bytes": info.compress_size, + "reference_count": ( + reference_counts.get(part_key, 0) + if part_key is not None + else 0 + ), + "relationship_types": sorted( + relationship_types.get(part_key, set()) + if part_key is not None + else set() + ), + } + + +def _archive_member_problems( + infos: list[zipfile.ZipInfo], +) -> list[str]: + problems: list[str] = [] + for info in infos: + name = info.filename + path = PurePosixPath(name) + if ( + name.startswith("/") + or "\\" in name + or ".." in path.parts + ): + problems.append(name) + return sorted(set(problems)) + + +def _relationship_problem_code(problem: str) -> str: + if " <" in problem: + return "invalid_internal_relationship" + return "dangling_internal_relationship" + + +def _font_faces_for_advisory( + theme_fonts: object, + declared_fonts: object, +) -> list[str]: + faces: set[str] = set() + if isinstance(theme_fonts, dict): + for role in ("title", "body"): + value = theme_fonts.get(role) + if not isinstance(value, dict): + continue + for field in ("latin", "ea", "cs"): + face = value.get(field) + if isinstance(face, str) and face.strip(): + faces.add(face.strip()) + scripts = value.get("scripts") + if isinstance(scripts, dict): + for face in scripts.values(): + if isinstance(face, str) and face.strip(): + faces.add(face.strip()) + if isinstance(declared_fonts, dict): + for role in ("latin", "ea", "cs"): + values = declared_fonts.get(role) + if not isinstance(values, list): + continue + for value in values: + if not isinstance(value, dict): + continue + face = value.get("value") + if isinstance(face, str) and face.strip(): + faces.add(face.strip()) + return sorted(faces, key=str.casefold) + + +def _unsafe_font_faces(faces: list[str]) -> list[str]: + unsafe: list[str] = [] + for face in faces: + normalized = unicodedata.normalize("NFKC", face).strip().casefold() + canonical = _PPT_SAFE_FONT_ALIASES.get(normalized, normalized) + canonical = unicodedata.normalize( + "NFKC", + canonical, + ).strip().casefold() + if canonical.startswith("+") or canonical in _PPT_DELIVERY_SAFE_FONTS: + continue + unsafe.append(face) + return unsafe + + +def _motion_and_hidden_summary( + path: Path, + errors: list[dict[str, object]], +) -> tuple[int, list[dict[str, object]], dict[str, object]]: + hidden: list[dict[str, object]] = [] + transition_carriers: list[int] = [] + visual_transitions: list[int] = [] + timed_advances: list[int] = [] + transition_effects: Counter[str] = Counter() + timing_slides: list[int] = [] + object_animation_slides: list[int] = [] + audio_timing_slides: list[int] = [] + slide_count = 0 + + try: + validate_pptx_animation_package( + path, + require_supported_effects=False, + ) + except ValueError as exc: + errors.append( + _issue( + "invalid_animation_structure", + str(exc), + ) + ) + + try: + with OoxmlPackage(path) as package: + if package.zip is None: + raise RuntimeError("PPTX package closed during slide audit") + if package.presentation is None: + raise RuntimeError("PPTX package has no presentation part") + slide_roster = package.presentation.xml.find( + f"{{{PRESENTATION_NS}}}sldIdLst" + ) + roster_count = ( + len( + slide_roster.findall( + f"{{{PRESENTATION_NS}}}sldId" + ) + ) + if slide_roster is not None + else 0 + ) + loaded_count = package.slide_count + inspected_count = 0 + slide_count = roster_count + for slide in package.iter_slides(): + inspected_count += 1 + try: + visible = parse_ooxml_boolean( + slide.part.xml.attrib.get("show"), + default=True, + context=f"{slide.part.path} show", + ) + except RuntimeError as exc: + errors.append( + _issue( + "invalid_slide_visibility", + str(exc), + slide_index=slide.index, + part=slide.part.path, + ) + ) + visible = True + if not visible: + hidden.append( + { + "index": slide.index, + "part": slide.part.path, + } + ) + + slide_xml = package.zip.read(slide.part.path) + transition_problems = validate_slide_transition_xml(slide_xml) + for problem in transition_problems: + errors.append( + _issue( + "invalid_transition_structure", + f"{slide.part.path}: {problem}", + slide_index=slide.index, + part=slide.part.path, + ) + ) + try: + transition = read_slide_transition_xml(slide_xml) + except (ET.ParseError, ValueError) as exc: + if not transition_problems: + errors.append( + _issue( + "transition_readback_failed", + f"{slide.part.path}: {exc}", + slide_index=slide.index, + part=slide.part.path, + ) + ) + else: + if transition.logical_count: + transition_carriers.append(slide.index) + effect = ( + transition.canonical_effect + or transition.effect + or "timing-only" + ) + transition_effects[effect] += 1 + if transition.effect is not None: + visual_transitions.append(slide.index) + if transition.advance_after_ms is not None: + timed_advances.append(slide.index) + + try: + animation = read_slide_animation_sequence( + slide_xml, + require_supported_effects=False, + ) + except (ET.ParseError, ValueError): + animation = None + + try: + has_object_animation = ( + object_animation_fingerprint(slide_xml) is not None + ) + except ValueError as exc: + errors.append( + _issue( + "animation_presence_readback_failed", + f"{slide.part.path}: {exc}", + slide_index=slide.index, + part=slide.part.path, + ) + ) + has_object_animation = False + + root = ET.fromstring(slide_xml) + has_timing = any( + node.tag == f"{{{PRESENTATION_NS}}}timing" + for node in root + ) + has_audio_timing = any( + node.tag == f"{{{PRESENTATION_NS}}}audio" + for node in root.iter() + ) + if animation is not None: + has_timing = has_timing or bool(animation.timing_count) + has_object_animation = ( + has_object_animation or bool(animation.rows) + ) + has_audio_timing = ( + has_audio_timing or bool(animation.audio_target_ids) + ) + if has_timing: + timing_slides.append(slide.index) + if has_object_animation: + object_animation_slides.append(slide.index) + if has_audio_timing: + audio_timing_slides.append(slide.index) + if ( + roster_count != loaded_count + or loaded_count != inspected_count + ): + errors.append( + _issue( + "slide_inventory_failed", + ( + f"Presentation declares {roster_count} slides, " + f"but the loader resolved {loaded_count} and the " + f"delivery audit inspected {inspected_count}." + ), + roster_count=roster_count, + loaded_count=loaded_count, + inspected_count=inspected_count, + ) + ) + except (KeyError, OSError, RuntimeError, ValueError, zipfile.BadZipFile) as exc: + errors.append( + _issue( + "slide_inventory_failed", + f"Cannot inspect presentation slides: {exc}.", + ) + ) + + return slide_count, hidden, { + "transitions": { + "carrier_slide_count": len(transition_carriers), + "visual_effect_slide_count": len(visual_transitions), + "timed_advance_slide_count": len(timed_advances), + "carrier_slides": transition_carriers, + "visual_effect_slides": visual_transitions, + "timed_advance_slides": timed_advances, + "effects": dict(sorted(transition_effects.items())), + }, + "object_animations": { + "timing_slide_count": len(timing_slides), + "object_animation_slide_count": len(object_animation_slides), + "audio_timing_slide_count": len(audio_timing_slides), + "timing_slides": timing_slides, + "object_animation_slides": object_animation_slides, + "audio_timing_slides": audio_timing_slides, + }, + } + + +def _deduplicate_issues( + issues: list[dict[str, object]], +) -> list[dict[str, object]]: + seen: set[str] = set() + output: list[dict[str, object]] = [] + for issue in issues: + key = json.dumps(issue, ensure_ascii=False, sort_keys=True) + if key in seen: + continue + seen.add(key) + output.append(issue) + return output + + +def audit_pptx_delivery(path: str | Path) -> dict[str, object]: + """Return a JSON-safe, read-only delivery audit for one PPTX file.""" + pptx_path = Path(path).expanduser().resolve() + report = _empty_report(pptx_path) + errors = report["errors"] + advisories = report["advisories"] + if not isinstance(errors, list) or not isinstance(advisories, list): + raise AssertionError("delivery report issue containers are invalid") + + if not pptx_path.is_file(): + errors.append( + _issue( + "file_not_found", + f"PPTX file not found: {pptx_path}.", + ) + ) + return report + + try: + with zipfile.ZipFile(pptx_path) as archive: + infos = archive.infolist() + file_infos = [info for info in infos if not info.is_dir()] + names = [info.filename for info in file_infos] + name_counts = Counter(names) + duplicate_parts = sorted( + name for name, count in name_counts.items() if count > 1 + ) + info_by_name = {info.filename: info for info in file_infos} + part_names = set(info_by_name) + canonical_names: dict[str, set[str]] = defaultdict(set) + invalid_part_names: list[str] = [] + for name in part_names: + canonical = canonical_opc_part_path(name) + if canonical is None: + invalid_part_names.append(name) + else: + canonical_names[canonical].add(name) + canonical_collisions = sorted( + sorted(raw_names) + for raw_names in canonical_names.values() + if len(raw_names) > 1 + ) + + package = report["package"] + if not isinstance(package, dict): + raise AssertionError("delivery report package container is invalid") + try: + corrupt_member = archive.testzip() + except (NotImplementedError, RuntimeError) as exc: + package["zip_integrity"] = "failed" + errors.append( + _issue( + "unsupported_zip_compression", + f"PPTX ZIP members cannot be decoded: {exc}.", + ) + ) + report["errors"] = _deduplicate_issues(errors) + return report + package["zip_integrity"] = ( + "passed" if corrupt_member is None else "failed" + ) + package["corrupt_member"] = corrupt_member + package["duplicate_parts"] = duplicate_parts + package["canonical_part_collisions"] = canonical_collisions + package["parts"] = { + "entries": len(file_infos), + "unique": len(part_names), + "slides": sum(bool(_SLIDE_PART_RE.fullmatch(name)) for name in part_names), + "notes": sum(bool(_NOTES_PART_RE.fullmatch(name)) for name in part_names), + "masters": sum(bool(_MASTER_PART_RE.fullmatch(name)) for name in part_names), + "layouts": sum(bool(_LAYOUT_PART_RE.fullmatch(name)) for name in part_names), + "media": sum( + (canonical_opc_part_path(name) or "").startswith( + "ppt/media/" + ) + for name in part_names + ), + } + if corrupt_member is not None: + errors.append( + _issue( + "zip_integrity_failed", + f"PPTX ZIP integrity failed at {corrupt_member}.", + part=corrupt_member, + ) + ) + if duplicate_parts: + errors.append( + _issue( + "duplicate_package_parts", + "PPTX package contains duplicate part names.", + parts=duplicate_parts, + ) + ) + if canonical_collisions: + errors.append( + _issue( + "duplicate_opc_part_names", + ( + "PPTX package contains case- or encoding-equivalent " + "part names." + ), + parts=canonical_collisions, + ) + ) + if invalid_part_names: + errors.append( + _issue( + "invalid_opc_part_name", + "PPTX package contains invalid OPC part names.", + parts=sorted(invalid_part_names), + ) + ) + + defaults, overrides = _content_type_maps(archive, errors) + content_type_registry_valid = not any( + issue.get("code") in { + "missing_content_types", + "invalid_content_types", + } + for issue in errors + if isinstance(issue, dict) + ) + if content_type_registry_valid: + missing_content_types = sorted( + name + for name in part_names + if name != "[Content_Types].xml" + and _declared_content_type_for_part( + name, + defaults, + overrides, + ) + is None + ) + if missing_content_types: + errors.append( + _issue( + "missing_part_content_type", + ( + "PPTX package parts are missing a declared " + "Default or Override content type." + ), + parts=missing_content_types, + ) + ) + declared_fonts = _xml_and_font_inventory( + archive, + part_names, + errors, + ) + reference_counts, relationship_types, external_media = ( + _relationship_inventory(archive, part_names, errors) + ) + + media_infos = [ + info_by_name[name] + for name in sorted(part_names) + if ( + canonical_opc_part_path(name) or "" + ).startswith("ppt/media/") + ] + embedded_media = [ + _embedded_part_record( + info, + content_type=_content_type_for_part( + info.filename, + defaults, + overrides, + ), + reference_counts=reference_counts, + relationship_types=relationship_types, + ) + for info in media_infos + ] + embedded_media.sort( + key=lambda item: ( + -int(item["bytes"]), + str(item["part"]), + ) + ) + + embedded_font_infos = [ + info_by_name[name] + for name in sorted(part_names) + if name.startswith("ppt/fonts/") + or name.lower().endswith(".fntdata") + ] + embedded_fonts = [ + { + "part": info.filename, + "content_type": _content_type_for_part( + info.filename, + defaults, + overrides, + ), + "bytes": info.file_size, + "compressed_bytes": info.compress_size, + "reference_count": reference_counts.get( + canonical_opc_part_path(info.filename) or "", + 0, + ), + } + for info in embedded_font_infos + ] + + media_bytes = sum(info.file_size for info in media_infos) + media_compressed_bytes = sum( + info.compress_size for info in media_infos + ) + total_uncompressed_bytes = sum( + info.file_size for info in info_by_name.values() + ) + file_bytes = pptx_path.stat().st_size + media = report["media"] + if not isinstance(media, dict): + raise AssertionError("delivery report media container is invalid") + media.update( + { + "embedded_count": len(embedded_media), + "external_count": len(external_media), + "embedded": embedded_media, + "external": external_media, + "uncompressed_bytes": media_bytes, + "archive_compressed_bytes": media_compressed_bytes, + "share_of_archive": ( + round(media_compressed_bytes / file_bytes, 6) + if file_bytes + else 0.0 + ), + "share_of_uncompressed_parts": ( + round(media_bytes / total_uncompressed_bytes, 6) + if total_uncompressed_bytes + else 0.0 + ), + "top_contributors": embedded_media[:10], + } + ) + fonts = report["fonts"] + if not isinstance(fonts, dict): + raise AssertionError("delivery report fonts container is invalid") + fonts["embedded_parts"] = embedded_fonts + fonts["declared"] = declared_fonts + + unsafe_members = _archive_member_problems(infos) + if unsafe_members: + errors.append( + _issue( + "unsafe_package_member", + "PPTX package contains unsafe member paths.", + parts=unsafe_members, + ) + ) + relationship_problems: list[str] = [] + else: + with tempfile.TemporaryDirectory( + prefix="pptx-delivery-check-" + ) as tmp: + extract_dir = Path(tmp) / "pptx" + archive.extractall(extract_dir) + relationship_problems = verify_internal_relationships( + extract_dir + ) + relationships = package["relationships"] + if not isinstance(relationships, dict): + raise AssertionError( + "delivery report relationships container is invalid" + ) + relationships["problems"] = relationship_problems + for problem in relationship_problems: + errors.append( + _issue( + _relationship_problem_code(problem), + problem, + ) + ) + except zipfile.BadZipFile as exc: + errors.append( + _issue( + "invalid_zip_package", + f"File is not a readable PPTX ZIP package: {exc}.", + ) + ) + return report + except (NotImplementedError, RuntimeError) as exc: + errors.append( + _issue( + "unsupported_zip_compression", + f"PPTX ZIP members cannot be decoded: {exc}.", + ) + ) + return report + except OSError as exc: + errors.append( + _issue( + "package_read_failed", + f"Cannot read PPTX package: {exc}.", + ) + ) + return report + + try: + identity = extract_identity(pptx_path) + except (KeyError, OSError, RuntimeError, ValueError, zipfile.BadZipFile) as exc: + advisories.append( + _issue( + "font_inventory_unavailable", + f"Could not resolve theme and declared font facts: {exc}.", + ) + ) + else: + identity_theme = identity.get("theme") + theme_fonts = ( + identity_theme.get("fonts", {}) + if isinstance(identity_theme, dict) + else {} + ) + fonts = report["fonts"] + if not isinstance(fonts, dict): + raise AssertionError("delivery report fonts container is invalid") + declared_fonts = fonts.get("declared", {}) + if not isinstance(declared_fonts, dict): + declared_fonts = {} + fonts["theme"] = theme_fonts + unsafe_faces = _unsafe_font_faces( + _font_faces_for_advisory(theme_fonts, declared_fonts) + ) + fonts["portability_advisory_faces"] = unsafe_faces + if unsafe_faces: + advisories.append( + _issue( + "font_portability", + ( + "Some fonts declared or referenced by the presentation " + "are outside the common Office/OS portability set; " + "target-system availability was not verified." + ), + faces=unsafe_faces, + ) + ) + + slide_count, hidden_slides, motion = _motion_and_hidden_summary( + pptx_path, + errors, + ) + slides = report["slides"] + if not isinstance(slides, dict): + raise AssertionError("delivery report slides container is invalid") + slides.update( + { + "count": slide_count, + "hidden_count": len(hidden_slides), + "hidden": hidden_slides, + } + ) + report["motion"] = motion + if hidden_slides: + advisories.append( + _issue( + "hidden_slides", + "The presentation contains hidden slides; their state was not changed.", + slides=[item["index"] for item in hidden_slides], + ) + ) + + media = report["media"] + if isinstance(media, dict) and media.get("external_count"): + advisories.append( + _issue( + "external_media", + ( + "The presentation contains externally linked media; " + "offline delivery may depend on those targets." + ), + targets=[ + item["target"] + for item in media.get("external", []) + if isinstance(item, dict) and "target" in item + ], + ) + ) + + report["errors"] = _deduplicate_issues(errors) + report["advisories"] = _deduplicate_issues(advisories) + report["status"] = ( + "failed" + if report["errors"] + else ( + "passed-with-advisories" + if report["advisories"] + else "passed" + ) + ) + return report + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Inspect a finished PPTX for delivery risks without modifying it.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("pptx", help="Finished .pptx file to inspect") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + report = audit_pptx_delivery(args.pptx) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 1 if report["status"] == "failed" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_intake.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_intake.py index f9366be9..97abd1a5 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_intake.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_intake.py @@ -200,6 +200,36 @@ def build_source_profile( SOURCE_INDEX_NAME = "source_profile.json" +def _load_source_index(index_path: Path) -> dict[str, Any]: + """Load and validate an existing multi-deck source index.""" + if not index_path.exists(): + return {} + if not index_path.is_file(): + raise RuntimeError(f"Source index is not a file: {index_path}") + + try: + loaded = json.loads(index_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, UnicodeError) as exc: + raise RuntimeError( + f"Source index contains invalid JSON and was left unchanged: {index_path}" + ) from exc + except OSError as exc: + raise RuntimeError(f"Cannot read source index: {index_path}: {exc}") from exc + + if not isinstance(loaded, dict) or not isinstance(loaded.get("decks"), list): + raise RuntimeError( + f"Source index must be a JSON object with a decks array and was left unchanged: " + f"{index_path}" + ) + for index, deck in enumerate(loaded["decks"]): + if not isinstance(deck, dict): + raise RuntimeError( + f"Source index decks[{index}] must be an object and was left unchanged: " + f"{index_path}" + ) + return loaded + + def upsert_source_index(output_dir: Path, digest: dict[str, Any]) -> Path: """Merge one deck's digest into the single multi-deck index `source_profile.json`. @@ -209,14 +239,7 @@ def upsert_source_index(output_dir: Path, digest: dict[str, Any]) -> Path: with the same stem replaces its entry in place. """ index_path = output_dir / SOURCE_INDEX_NAME - index: dict[str, Any] = {} - if index_path.is_file(): - try: - loaded = json.loads(index_path.read_text(encoding="utf-8")) - if isinstance(loaded, dict) and isinstance(loaded.get("decks"), list): - index = loaded - except (json.JSONDecodeError, OSError): - index = {} + index = _load_source_index(index_path) stem = digest.get("stem") decks = [d for d in index.get("decks", []) if d.get("stem") != stem] decks.append(digest) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_opc_validation.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_opc_validation.py new file mode 100644 index 00000000..5d159ba9 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_opc_validation.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Shared, dependency-light OPC package relationship validation.""" + +from __future__ import annotations + +import posixpath +import re +from pathlib import Path +from urllib.parse import urlsplit +from xml.etree import ElementTree as ET + + +PACKAGE_REL_NS = ( + "http://schemas.openxmlformats.org/package/2006/relationships" +) +_RELATIONSHIPS_TAG = f"{{{PACKAGE_REL_NS}}}Relationships" +_RELATIONSHIP_TAG = f"{{{PACKAGE_REL_NS}}}Relationship" +_OPC_UNRESERVED = frozenset( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" +) +_ASCII_LOWER_TRANSLATION = str.maketrans( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "abcdefghijklmnopqrstuvwxyz", +) + + +def canonical_opc_part_path(path: str) -> str | None: + """Return an OPC-equivalent package path key, or None when invalid.""" + if ( + not path + or "\\" in path + or "?" in path + or "#" in path + or path.endswith("/") + or "//" in path + or any(ord(char) <= 0x20 for char in path) + ): + return None + output: list[str] = [] + index = 0 + while index < len(path): + char = path[index] + if char != "%": + output.append(char) + index += 1 + continue + if ( + index + 2 >= len(path) + or re.fullmatch( + r"[0-9A-Fa-f]{2}", + path[index + 1:index + 3], + ) + is None + ): + return None + value = int(path[index + 1:index + 3], 16) + decoded = chr(value) + if value in {0, ord("/"), ord("\\")}: + return None + output.append( + decoded + if decoded in _OPC_UNRESERVED + else f"%{value:02X}" + ) + index += 3 + + decoded_path = "".join(output) + if decoded_path.rsplit("/", 1)[-1] in {".", ".."}: + return None + normalized = posixpath.normpath(decoded_path) + if ( + not normalized + or normalized in {".", ".."} + or normalized.startswith("/") + or normalized.startswith("../") + ): + return None + return normalized.translate(_ASCII_LOWER_TRANSLATION) + + +def _source_part_for_rels(rels_path: str) -> str | None: + filename = posixpath.basename(rels_path) + if filename == ".rels" or not filename.endswith(".rels"): + return None + source_dir = posixpath.dirname(posixpath.dirname(rels_path)) + source_name = filename.removesuffix(".rels") + return ( + posixpath.join(source_dir, source_name) + if source_dir + else source_name + ) + + +def resolve_internal_opc_target( + rels_path: str, + target: str, +) -> str | None: + """Resolve one valid internal OPC Target to its canonical package key.""" + target_path_query = target.split("#", 1)[0] + if ( + "\\" in target + or "?" in target_path_query + or any(ord(char) <= 0x20 for char in target) + ): + return None + try: + parsed = urlsplit(target) + except ValueError: + return None + if parsed.scheme or parsed.netloc or parsed.query: + return None + + source_part = _source_part_for_rels(rels_path) + if parsed.path.startswith("/"): + resolved = parsed.path[1:] + elif parsed.path: + base_dir = posixpath.dirname(source_part) if source_part else "" + resolved = ( + posixpath.join(base_dir, parsed.path) + if base_dir + else parsed.path + ) + elif source_part and "#" in target: + resolved = source_part + else: + return None + return canonical_opc_part_path(resolved) + + +def verify_internal_relationships(extract_dir: Path) -> list[str]: + """Return invalid or dangling internal relationships in an OPC package.""" + package_parts: set[str] = set() + for path in extract_dir.rglob("*"): + if not path.is_file(): + continue + key = canonical_opc_part_path( + path.relative_to(extract_dir).as_posix() + ) + if key is not None: + package_parts.add(key) + + problems: list[str] = [] + for rels_path in sorted(extract_dir.rglob("*.rels")): + rels_rel = rels_path.relative_to(extract_dir).as_posix() + try: + root = ET.parse(rels_path).getroot() + except ET.ParseError as exc: + problems.append( + f"{rels_rel} -> " + ) + continue + if root.tag != _RELATIONSHIPS_TAG: + problems.append( + f"{rels_rel} -> " + ) + continue + + seen_ids: set[str] = set() + for element in root: + if element.tag != _RELATIONSHIP_TAG: + problems.append( + f"{rels_rel} -> " + ) + continue + relationship_id = (element.attrib.get("Id") or "").strip() + relationship_type = ( + element.attrib.get("Type") or "" + ).strip() + target = (element.attrib.get("Target") or "").strip() + target_mode = ( + element.attrib.get("TargetMode") or "" + ).strip() + + if not relationship_id: + problems.append(f"{rels_rel} -> ") + elif relationship_id in seen_ids: + problems.append( + f"{rels_rel} -> " + ) + else: + seen_ids.add(relationship_id) + if not relationship_type: + problems.append( + f"{rels_rel} -> " + ) + if not target: + problems.append(f"{rels_rel} -> ") + continue + if target_mode and target_mode.lower() not in { + "internal", + "external", + }: + problems.append( + f"{rels_rel} -> " + ) + continue + if target_mode.lower() == "external": + continue + + resolved = resolve_internal_opc_target(rels_rel, target) + if resolved is None: + problems.append( + f"{rels_rel} -> " + ) + elif resolved not in package_parts: + problems.append(f"{rels_rel} -> {resolved}") + return problems diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_template_import.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_template_import.py index df359572..bd0bc4e7 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_template_import.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_template_import.py @@ -23,16 +23,25 @@ from __future__ import annotations import argparse import json +import shutil +import tempfile from pathlib import Path from xml.etree import ElementTree as ET from zipfile import BadZipFile from console_encoding import configure_utf8_stdio from template_import.manifest import build_manifest -from template_import.native_structure import write_native_structure_bundle +from template_import.native_structure import ( + CONTRACT_NAME, + SOURCE_TEMPLATE_NAME, + write_native_structure_bundle, +) configure_utf8_stdio() +_MANIFEST_NAME = "manifest.json" +_CONVERSION_REPORT_NAME = "conversion-report.json" + def parse_args() -> argparse.Namespace: """Build the CLI argument parser for the import entry point.""" @@ -84,6 +93,37 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() +def _managed_asset_paths(output_dir: Path) -> set[Path]: + """Read the previous manifest's exact exported-asset roster.""" + manifest_path = output_dir / _MANIFEST_NAME + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return set() + if not isinstance(manifest, dict): + return set() + assets = manifest.get("assets") + if not isinstance(assets, dict): + return set() + export_dir = assets.get("exportDir") + asset_names = assets.get("allAssets") + if export_dir != "assets" or not isinstance(asset_names, list): + return set() + if any( + not isinstance(name, str) + or not name + or name in {".", ".."} + or "/" in name + or "\\" in name + for name in asset_names + ): + return set() + return { + Path("assets") / name + for name in asset_names + } + + def main() -> int: """CLI entry point: write the PPTX reference workspace to disk.""" args = parse_args() @@ -100,100 +140,138 @@ def main() -> int: if args.output else pptx_path.with_name(f"{pptx_path.stem}_template_import") ) - output_dir.mkdir(parents=True, exist_ok=True) if args.skip_manifest and args.manifest_only: print("Error: --skip-manifest and --manifest-only cannot be used together") return 1 - manifest = None - native_structure = None - manifest_path = output_dir / "manifest.json" - if not args.skip_manifest: - try: - manifest = build_manifest( - pptx_path, - output_dir, - include_flat_svg=( - not args.manifest_only and args.inheritance_mode == "both" + previous_assets = _managed_asset_paths(output_dir) + output_dir.parent.mkdir(parents=True, exist_ok=True) + staging_root = Path(tempfile.mkdtemp( + prefix=f".{output_dir.name}.import-", + dir=output_dir.parent, + )) + staged_dir = staging_root / "generated" + staged_dir.mkdir() + + try: + manifest = None + native_structure = None + manifest_path = staged_dir / _MANIFEST_NAME + if not args.skip_manifest: + try: + manifest = build_manifest( + pptx_path, + staged_dir, + include_flat_svg=( + not args.manifest_only and args.inheritance_mode == "both" + ), + ) + except (RuntimeError, OSError, ValueError) as exc: + print(f"Error: failed to extract PPTX metadata: {exc}") + return 1 + + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + try: + native_structure = write_native_structure_bundle( + pptx_path, + staged_dir, + manifest, + ) + except (OSError, ValueError) as exc: + print(f"Error: failed to write native structure bundle: {exc}") + return 1 + + result = None + total_bytes = 0 + if not args.manifest_only: + from pptx_to_svg import convert_pptx_to_svg + from pptx_to_svg.converter import ConvertOptions + + options = ConvertOptions( + media_subdir="assets", + embed_images=args.embed_images, + keep_hidden=False, + inheritance_mode=args.inheritance_mode, + asset_name_map=( + manifest.get("assets", {}).get("assetMap", {}) + if manifest else {} ), ) - except (RuntimeError, OSError, ValueError) as exc: - print(f"Error: failed to extract PPTX metadata: {exc}") - return 1 - - manifest_path.write_text( - json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - try: - native_structure = write_native_structure_bundle( - pptx_path, - output_dir, - manifest, + try: + result = convert_pptx_to_svg(pptx_path, staged_dir, options) + except (BadZipFile, ET.ParseError, OSError, RuntimeError, ValueError) as exc: + print(f"Error: failed to convert PPTX template source: {exc}") + return 1 + total_bytes = sum( + len(art.svg.encode("utf-8")) + for art in result.slides ) - except (OSError, ValueError) as exc: - print(f"Error: failed to write native structure bundle: {exc}") + + from pptx_to_svg.converter import publish_staged_workspace + + try: + publish_staged_workspace( + output_dir, + staged_dir, + managed_root_files={ + _MANIFEST_NAME, + CONTRACT_NAME, + SOURCE_TEMPLATE_NAME, + _CONVERSION_REPORT_NAME, + }, + managed_relative_paths=previous_assets, + ) + except (OSError, RuntimeError, ValueError) as exc: + print(f"Error: failed to publish PPTX template workspace: {exc}") return 1 - if args.manifest_only: - print(f"Imported PPTX template source: {pptx_path.name}") + if args.manifest_only: + print(f"Imported PPTX template source: {pptx_path.name}") + print(f"Output directory: {output_dir}") + if manifest is not None: + print(f"Manifest: {manifest_path.name}") + print(f"Native structure: {CONTRACT_NAME}") + print(f"Source package analysis copy: {SOURCE_TEMPLATE_NAME}") + print( + "Source structure assessment: " + f"{native_structure['strategy']['recommendedMode']}" + ) + print("Template output mode: explicit SVG structure") + print(f"Assets exported: {len(manifest['assets']['allAssets'])}") + print(f"Common assets: {len(manifest['assets']['commonAssets'])}") + print(f"Slides analyzed: {len(manifest['slides'])}") + print(f"Layouts (unique): {len(manifest.get('layouts', []))}") + print(f"Masters (unique): {len(manifest.get('masters', []))}") + return 0 + + print(f"Inheritance mode: {args.inheritance_mode}") + print(f"Exported SVG slides: {len(result.slides)}") + if args.inheritance_mode in {"layered", "both"}: + print(f"Exported masters: {len(result.masters)}") + print(f"Exported layouts: {len(result.layouts)}") + print("Inheritance graph: svg/inheritance.json") + if result.flat_slides: + print(f"Flat companion slides: {len(result.flat_slides)} (svg-flat/)") + if result.diagnostics: + print( + f"Source recovery warnings: {len(result.diagnostics)} " + f"({_CONVERSION_REPORT_NAME})" + ) + print(f"SVG bytes (primary): {total_bytes}") print(f"Output directory: {output_dir}") - if manifest is not None: - print(f"Manifest: {manifest_path.name}") - print("Native structure: native_structure.json") - print("Source package analysis copy: source_template.pptx") + if native_structure is not None: print( "Source structure assessment: " - f"{native_structure['strategy']['recommendedMode']}" + f"{native_structure['strategy']['recommendedMode']}; " + "create-template rebuilds explicit SVG structure" ) - print("Template output mode: explicit SVG structure") - print(f"Assets exported: {len(manifest['assets']['allAssets'])}") - print(f"Common assets: {len(manifest['assets']['commonAssets'])}") - print(f"Slides analyzed: {len(manifest['slides'])}") - print(f"Layouts (unique): {len(manifest.get('layouts', []))}") - print(f"Masters (unique): {len(manifest.get('masters', []))}") return 0 - - from pptx_to_svg import convert_pptx_to_svg - from pptx_to_svg.converter import ConvertOptions - - options = ConvertOptions( - media_subdir="assets", - embed_images=args.embed_images, - keep_hidden=False, - inheritance_mode=args.inheritance_mode, - asset_name_map=manifest.get("assets", {}).get("assetMap", {}) if manifest else {}, - ) - try: - result = convert_pptx_to_svg(pptx_path, output_dir, options) - except (BadZipFile, ET.ParseError, OSError, RuntimeError, ValueError) as exc: - print(f"Error: failed to convert PPTX template source: {exc}") - return 1 - total_bytes = sum(len(art.svg.encode("utf-8")) for art in result.slides) - - print(f"Inheritance mode: {args.inheritance_mode}") - print(f"Exported SVG slides: {len(result.slides)}") - if args.inheritance_mode in {"layered", "both"}: - print(f"Exported masters: {len(result.masters)}") - print(f"Exported layouts: {len(result.layouts)}") - print("Inheritance graph: svg/inheritance.json") - if result.flat_slides: - print(f"Flat companion slides: {len(result.flat_slides)} (svg-flat/)") - if result.diagnostics: - print( - f"Source recovery warnings: {len(result.diagnostics)} " - "(conversion-report.json)" - ) - print(f"SVG bytes (primary): {total_bytes}") - print(f"Output directory: {output_dir}") - if native_structure is not None: - print( - "Source structure assessment: " - f"{native_structure['strategy']['recommendedMode']}; " - "create-template rebuilds explicit SVG structure" - ) - return 0 + finally: + shutil.rmtree(staging_root, ignore_errors=True) if __name__ == "__main__": diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/converter.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/converter.py index 8e92a8e4..370b0dd4 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/converter.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/converter.py @@ -14,10 +14,15 @@ loads the package and reports basic per-slide structure to verify wiring. from __future__ import annotations import json +import os import re +import shutil +import tempfile from collections.abc import Callable from dataclasses import dataclass, field +from html import unescape from pathlib import Path, PurePosixPath +from urllib.parse import unquote, urlsplit from .color_resolver import ColorPalette from .emu_units import NS @@ -31,6 +36,40 @@ from .ooxml_loader import ( from .slide_to_svg import assemble_part_solo, assemble_slide +_CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"}) +_MANAGED_PRIMARY_SVG_RE = re.compile( + r"(?:slide_\d+|master_\d+_[A-Za-z0-9_-]+|layout_\d+_[A-Za-z0-9_-]+)\.svg" +) +_MANAGED_FLAT_SVG_RE = re.compile(r"slide_\d+\.svg") +_SVG_HREF_RE = re.compile( + r"\b(?:href|xlink:href)\s*=\s*[\"']([^\"']+)[\"']" +) + + +def _validate_media_subdir(value: str) -> None: + """Reject media output paths that can escape the conversion workspace.""" + path = Path(value) + if path.drive or path.anchor or path.is_absolute() or ".." in path.parts: + raise ValueError( + f"media_subdir must stay within the output workspace: {value!r}" + ) + + +def _validate_media_filename(filename: str) -> None: + """Require one media basename so asset maps cannot redirect writes.""" + path = Path(filename) + if ( + not filename + or filename in {".", ".."} + or path.drive + or path.anchor + or path.name != filename + or "/" in filename + or "\\" in filename + ): + raise ValueError(f"Media filename must be a basename: {filename!r}") + + def _extract_theme_info( theme: PartRef, palette: ColorPalette, @@ -77,6 +116,11 @@ def _extract_theme_info( cs = fnt.find("a:cs", NS) if cs is not None and cs.attrib.get("typeface"): fonts[f"{role_prefix}ComplexScript"] = cs.attrib["typeface"] + for supplemental in fnt.findall("a:font", NS): + script = supplemental.attrib.get("script", "") + typeface = supplemental.attrib.get("typeface", "") + if script in _CJK_THEME_SCRIPTS and typeface: + fonts[f"{role_prefix}Script{script}"] = typeface return colors, fonts @@ -233,6 +277,8 @@ def convert_pptx_to_svg( f"inheritance_mode must be 'flat', 'layered', or 'both', " f"got {options.inheritance_mode!r}" ) + if not options.embed_images: + _validate_media_subdir(options.media_subdir) emit_layered = options.inheritance_mode in {"layered", "both"} emit_flat = options.inheritance_mode in {"flat", "both"} result = ConvertResult( @@ -477,9 +523,269 @@ def _render_part( ) -def _write_artifacts(output_dir: Path, result: ConvertResult, - options: ConvertOptions) -> None: - """Write SVG + media files to output_dir. +def _path_lexists(path: Path) -> bool: + """Return whether a path or symlink exists without following the symlink.""" + return path.exists() or path.is_symlink() + + +def _managed_svg_paths(output_dir: Path) -> list[Path]: + """Return converter-owned SVG files without traversing user directories.""" + managed: list[Path] = [] + for dirname, filename_re in ( + ("svg", _MANAGED_PRIMARY_SVG_RE), + ("svg-flat", _MANAGED_FLAT_SVG_RE), + ): + svg_dir = output_dir / dirname + if svg_dir.is_symlink(): + managed.append(svg_dir) + continue + if not svg_dir.is_dir(): + continue + managed.extend( + path + for path in svg_dir.iterdir() + if filename_re.fullmatch(path.name) + and (path.is_file() or path.is_symlink()) + ) + inheritance = svg_dir / "inheritance.json" + if dirname == "svg" and _path_lexists(inheritance): + managed.append(inheritance) + return managed + + +def _referenced_local_paths( + output_dir: Path, + svg_paths: list[Path], +) -> set[Path]: + """Resolve local media referenced by converter-owned SVGs.""" + referenced: set[Path] = set() + output_abs = output_dir.absolute() + for svg_path in svg_paths: + if svg_path.is_symlink() or not svg_path.is_file(): + continue + try: + svg_text = svg_path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + continue + for raw_href in _SVG_HREF_RE.findall(svg_text): + href = unescape(raw_href) + parsed = urlsplit(href) + if parsed.scheme or parsed.netloc or not parsed.path: + continue + href_path = unquote(parsed.path) + if Path(href_path).is_absolute(): + continue + target = Path(os.path.normpath(str(svg_path.parent / href_path))) + try: + relative = target.absolute().relative_to(output_abs) + except ValueError: + continue + if relative.parts: + referenced.add(relative) + return referenced + + +def _validated_relative_paths(paths: set[str | Path]) -> set[Path]: + """Normalize caller-supplied managed paths and reject output escapes.""" + normalized: set[Path] = set() + for value in paths: + path = Path(value) + if ( + path.drive + or path.anchor + or path.is_absolute() + or not path.parts + or ".." in path.parts + ): + raise ValueError(f"Managed artifact path must stay relative: {value}") + normalized.add(path) + return normalized + + +def _reject_symlink_ancestors( + root: Path, + relative_paths: set[Path], +) -> None: + """Reject managed paths that would traverse a preserved user symlink.""" + for relative in relative_paths: + current = root + for component in relative.parts[:-1]: + current /= component + if current.is_symlink(): + raise RuntimeError( + "Managed artifact path crosses an unmanaged symlink: " + f"{relative}" + ) + + +def _remove_managed_paths(candidate_dir: Path, relative_paths: set[Path]) -> None: + """Remove only the previous converter roster from a candidate workspace.""" + _reject_symlink_ancestors(candidate_dir, relative_paths) + parents: set[Path] = set() + for relative in sorted( + relative_paths, + key=lambda item: len(item.parts), + reverse=True, + ): + target = candidate_dir / relative + if target.is_symlink() or target.is_file(): + target.unlink() + elif target.is_dir(): + raise RuntimeError( + "Managed artifact path collides with a preserved directory: " + f"{relative}" + ) + parent = target.parent + while parent != candidate_dir: + parents.add(parent) + parent = parent.parent + + for parent in sorted(parents, key=lambda item: len(item.parts), reverse=True): + if parent.is_symlink() or not parent.is_dir(): + continue + try: + parent.rmdir() + except OSError: + pass + + +def _overlay_staged_tree(staged_dir: Path, candidate_dir: Path) -> None: + """Overlay generated artifacts without overwriting unmanaged user files.""" + for source in sorted(staged_dir.rglob("*")): + relative = source.relative_to(staged_dir) + target = candidate_dir / relative + _reject_symlink_ancestors(candidate_dir, {relative}) + if source.is_symlink(): + raise RuntimeError( + f"Generated artifact must not be a symlink: {relative}" + ) + if source.is_dir(): + if ( + target.is_symlink() + or (_path_lexists(target) and not target.is_dir()) + ): + raise RuntimeError( + f"Generated artifact collides with unmanaged path: {relative}" + ) + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + if _path_lexists(target): + if target.is_dir() or target.is_symlink(): + raise RuntimeError( + f"Generated artifact collides with unmanaged path: {relative}" + ) + if target.read_bytes() != source.read_bytes(): + raise RuntimeError( + f"Generated artifact collides with unmanaged file: {relative}" + ) + shutil.copy2(source, target) + + +def publish_staged_workspace( + output_dir: Path, + staged_dir: Path, + *, + managed_root_files: set[str | Path] | None = None, + managed_relative_paths: set[str | Path] | None = None, +) -> None: + """Atomically publish generated artifacts while preserving user files. + + Converter-owned SVGs, their local media references, and the named managed + artifacts are replaced as one roster. Everything else already present in + the output directory is copied into the candidate unchanged. + """ + output_dir = output_dir.absolute() + staged_dir = staged_dir.absolute() + if ( + output_dir == staged_dir + or output_dir in staged_dir.parents + or staged_dir in output_dir.parents + ): + raise ValueError( + "Staged and output workspaces must not contain one another" + ) + output_resolved = output_dir.resolve(strict=False) + try: + Path.cwd().resolve().relative_to(output_resolved) + except ValueError: + pass + else: + raise RuntimeError( + "Output workspace must not contain the current working directory" + ) + if not staged_dir.is_dir(): + raise ValueError(f"Staged workspace does not exist: {staged_dir}") + if ( + output_dir.is_symlink() + or (_path_lexists(output_dir) and not output_dir.is_dir()) + ): + raise RuntimeError(f"Output path must be a real directory: {output_dir}") + + output_dir.parent.mkdir(parents=True, exist_ok=True) + transaction_dir = Path(tempfile.mkdtemp( + prefix=f".{output_dir.name}.publish-", + dir=output_dir.parent, + )) + candidate_dir = transaction_dir / "candidate" + backup_dir = transaction_dir / "previous" + preserve_backup = False + + try: + if output_dir.is_dir(): + shutil.copytree(output_dir, candidate_dir, symlinks=True) + else: + candidate_dir.mkdir() + + managed_svg = _managed_svg_paths(output_dir) + relative_paths = { + path.relative_to(output_dir) + for path in managed_svg + } + relative_paths.update(_referenced_local_paths(output_dir, managed_svg)) + relative_paths.add(Path("conversion-report.json")) + relative_paths.update(_validated_relative_paths(managed_root_files or set())) + relative_paths.update(_validated_relative_paths(managed_relative_paths or set())) + _remove_managed_paths(candidate_dir, relative_paths) + _overlay_staged_tree(staged_dir, candidate_dir) + + if output_dir.is_dir(): + try: + os.replace(output_dir, backup_dir) + os.replace(candidate_dir, output_dir) + except BaseException as publish_error: + try: + if _path_lexists(backup_dir): + if _path_lexists(output_dir): + failed_output = transaction_dir / "failed-publish" + os.replace(output_dir, failed_output) + os.replace(backup_dir, output_dir) + except BaseException as restore_error: + if ( + not _path_lexists(backup_dir) + and _path_lexists(output_dir) + ): + raise publish_error + preserve_backup = _path_lexists(backup_dir) + raise RuntimeError( + "Failed to publish the new workspace and restore the " + "previous workspace; recovery directory: " + f"{transaction_dir}" + ) from restore_error + raise + else: + os.replace(candidate_dir, output_dir) + finally: + if not preserve_backup: + shutil.rmtree(transaction_dir, ignore_errors=True) + + +def _write_artifact_tree( + output_dir: Path, + result: ConvertResult, + options: ConvertOptions, +) -> None: + """Write a complete converter roster into an empty staging directory. Layout: - ``svg/`` primary view (layered when emitted, otherwise flat) @@ -490,34 +796,32 @@ def _write_artifacts(output_dir: Path, result: ConvertResult, svg_dir = output_dir / "svg" svg_dir.mkdir(exist_ok=True) media_dir = output_dir / options.media_subdir - media_written: set[str] = set() + media_written: dict[str, bytes] = {} - def _write_media(media: dict[str, bytes]) -> None: + def _collect_media(media: dict[str, bytes]) -> None: for filename, blob in media.items(): + _validate_media_filename(filename) if filename in media_written: + if media_written[filename] != blob: + raise RuntimeError( + f"Asset filename collision with different bytes: {filename}" + ) continue - media_dir.mkdir(parents=True, exist_ok=True) - target = media_dir / filename - if target.exists(): - if target.read_bytes() != blob: - raise RuntimeError(f"Asset filename collision with different bytes: {filename}") - else: - target.write_bytes(blob) - media_written.add(filename) + media_written[filename] = blob # Layered mode: write masters and layouts first so they sort ahead of slides. for art in result.masters: (svg_dir / art.filename).write_text(art.svg, encoding="utf-8") - _write_media(art.media_files) + _collect_media(art.media_files) for art in result.layouts: (svg_dir / art.filename).write_text(art.svg, encoding="utf-8") - _write_media(art.media_files) + _collect_media(art.media_files) # Slides (primary view). for art in result.slides: target = svg_dir / f"slide_{art.index:02d}.svg" target.write_text(art.svg, encoding="utf-8") - _write_media(art.media_files) + _collect_media(art.media_files) # Inheritance graph alongside the layered SVGs (only meaningful when we # actually emitted a layered view). @@ -531,9 +835,44 @@ def _write_artifacts(output_dir: Path, result: ConvertResult, for art in result.flat_slides: target = flat_dir / f"slide_{art.index:02d}.svg" target.write_text(art.svg, encoding="utf-8") - _write_media(art.media_files) + _collect_media(art.media_files) _write_conversion_report(output_dir, result) + if media_written: + media_dir.mkdir(parents=True, exist_ok=True) + for filename, blob in media_written.items(): + target = media_dir / filename + if _path_lexists(target): + if ( + target.is_symlink() + or not target.is_file() + or target.read_bytes() != blob + ): + raise RuntimeError( + f"Asset filename collision with different bytes: {filename}" + ) + continue + target.write_bytes(blob) + + +def _write_artifacts( + output_dir: Path, + result: ConvertResult, + options: ConvertOptions, +) -> None: + """Stage a complete conversion, then atomically publish its exact roster.""" + output_dir = output_dir.absolute() + output_dir.parent.mkdir(parents=True, exist_ok=True) + staging_root = Path(tempfile.mkdtemp( + prefix=f".{output_dir.name}.convert-", + dir=output_dir.parent, + )) + staged_dir = staging_root / "generated" + try: + _write_artifact_tree(staged_dir, result, options) + publish_staged_workspace(output_dir, staged_dir) + finally: + shutil.rmtree(staging_root, ignore_errors=True) def _write_conversion_report(output_dir: Path, result: ConvertResult) -> None: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/pic_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/pic_to_svg.py index dc86764d..a1d370f7 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/pic_to_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/pic_to_svg.py @@ -24,7 +24,8 @@ Strategy: preserveAspectRatio="none". - With srcRect, or with a single oversized tile that covers the frame -> wrap the in a nested in the unit rectangle [0,1] x [0,1], - so cropping is expressed as the visible viewBox region. + with overflow hidden so cropping is expressed identically in browsers and + PowerPoint. - Repeating tile fills still use the legacy plain-image fallback; a repeated pattern cannot be represented by the project's native picture-crop subset. - Image bytes are written through the result; the slide assembler decides @@ -160,7 +161,7 @@ def convert_blip_fill( f'width="{fmt_num(xfrm.w)}" height="{fmt_num(xfrm.h)}" ' f'viewBox="{fmt_num(vb_l, 5)} {fmt_num(vb_t, 5)} ' f'{fmt_num(vb_w, 5)} {fmt_num(vb_h, 5)}" ' - f'preserveAspectRatio="none">' + f'preserveAspectRatio="none" overflow="hidden">' f'' f"" diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/slide_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/slide_to_svg.py index 7aad5271..ca462992 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/slide_to_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/slide_to_svg.py @@ -64,7 +64,7 @@ from .ooxml_loader import ( SlideRef, inherited_shape_visibility, ) -from .pic_to_svg import convert_blip_fill, convert_picture +from .pic_to_svg import MediaResolutionError, convert_blip_fill, convert_picture from .prstgeom_to_svg import GeomResult, convert_prst_geom from .preset_svg_markup import serialize_preset_layers from .shape_walker import ( @@ -214,7 +214,7 @@ def assemble_slide( ctx, canvas_w, canvas_h, ) ) - except ValueError as exc: + except (ValueError, MediaResolutionError) as exc: if strict: raise ctx.diagnose( @@ -340,7 +340,7 @@ def assemble_part_solo( ) try: bg_xml = _emit_part_background(fake_slide, ctx, canvas_w, canvas_h) - except ValueError as exc: + except (ValueError, MediaResolutionError) as exc: if strict: raise ctx.diagnose( @@ -458,7 +458,7 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> embed_inline=ctx.embed_images, asset_name_map=ctx.asset_name_map, ) - except ValueError as exc: + except (ValueError, MediaResolutionError) as exc: if ctx.strict: raise ctx.diagnose( @@ -892,6 +892,8 @@ def _clip_blip_image(image_xml: str, geom: GeomResult | None, """Clip image fills to the owning shape geometry when it is not a plain rect.""" if geom is None or geom.tag == "line": return image_xml + if geom.attrs.get("data-pptx-prst") == "rect": + return image_xml if geom.tag == "rect" and not geom.attrs.get("rx") and not geom.attrs.get("ry"): return image_xml @@ -919,22 +921,35 @@ def _inject_clip_path(image_xml: str, clip_id: str) -> str: # --------------------------------------------------------------------------- def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str: - result = convert_picture( - node.xml, node.xfrm, ctx.slide_part, ctx.pkg, - media_subdir=ctx.media_subdir, - embed_inline=ctx.embed_images, - asset_name_map=ctx.asset_name_map, - ) + sp_pr = node.xml.find("p:spPr", NS) + geom = _resolve_geometry(node, sp_pr) + try: + result = convert_picture( + node.xml, node.xfrm, ctx.slide_part, ctx.pkg, + media_subdir=ctx.media_subdir, + embed_inline=ctx.embed_images, + asset_name_map=ctx.asset_name_map, + ) + except MediaResolutionError as exc: + if ctx.strict: + raise + ctx.diagnose( + "object-replaced", + str(exc), + "replace only this picture with a visible placeholder", + ) + return _fallback_node_svg(node, ctx, top_level=top_level) if not result.svg: return "" ctx.media.update(result.media) effect_metadata = unsupported_target_effect_metadata( - node.xml.find("p:spPr", NS), + sp_pr, "picture", ) _diagnose_unsupported_effect(ctx, effect_metadata) + clipped_svg = _clip_blip_image(result.svg, geom, ctx) picture_svg = _inject_root_svg_attrs( - result.svg, + clipped_svg, {**_object_metadata(node, ctx), **effect_metadata}, ) return _wrap_shape_group( @@ -1041,11 +1056,30 @@ def _convert_graphic_fallback(node: ShapeNode, ctx: AssemblyContext, extra_attrs=replacement_attrs, ) + preview_svg = "" + if ctx.render_graphic_previews: + try: + preview_svg = _render_graphic_preview(node, ctx) + except MediaResolutionError as exc: + if ctx.strict: + raise + ctx.diagnose( + "preview-omitted", + str(exc), + "omit the missing baked preview and retain the native, " + "normalized, or placeholder fallback", + ) + chart_replacement_attrs: list[str] = [] chart_payload_metadata = "" if uri in {CHART_URI, CHARTEX_URI}: rendered, chart_replacement_attrs, chart_payload_metadata = ( - _render_graphic_chart(node, ctx, graphic_data) + _render_graphic_chart( + node, + ctx, + graphic_data, + preview_svg, + ) ) if rendered: inner = ( @@ -1061,17 +1095,25 @@ def _convert_graphic_fallback(node: ShapeNode, ctx: AssemblyContext, extra_attrs=chart_replacement_attrs, ) - if uri == "http://schemas.openxmlformats.org/presentationml/2006/ole" and ctx.render_graphic_previews: - rendered = _render_graphic_preview(node, ctx) - if rendered: - labelled = rendered + "\n" + _graphic_preview_label(node, "ole preview") + if uri == "http://schemas.openxmlformats.org/presentationml/2006/ole": + if preview_svg: + labelled = ( + preview_svg + + "\n" + + _graphic_preview_label(node, "ole preview") + ) return _wrap_shape_group(labelled, node, ctx, top_level=top_level) - if ctx.render_graphic_previews: - rendered = _render_graphic_preview(node, ctx) - if rendered: - labelled = rendered + "\n" + _graphic_preview_label(node, f"{uri.rsplit('/', 1)[-1]} preview") - return _wrap_shape_group(labelled, node, ctx, top_level=top_level) + if preview_svg: + labelled = ( + preview_svg + + "\n" + + _graphic_preview_label( + node, + f"{uri.rsplit('/', 1)[-1]} preview", + ) + ) + return _wrap_shape_group(labelled, node, ctx, top_level=top_level) label = uri.rsplit("/", 1)[-1] placeholder = ( @@ -1165,6 +1207,7 @@ def _render_graphic_chart( node: ShapeNode, ctx: AssemblyContext, graphic_data: ET.Element | None, + preview_svg: str, ) -> tuple[str, list[str], str]: """Return a chart fallback plus native Chart replacement metadata.""" result = extract_native_chart_payload( @@ -1187,9 +1230,7 @@ def _render_graphic_chart( f'{_xml_escape(result.native_status)}"' ) - rendered = "" - if ctx.render_graphic_previews: - rendered = _render_graphic_preview(node, ctx) + rendered = preview_svg if rendered: replacement_attrs.append('data-pptx-fallback-kind="source-preview"') elif result.normalized_svg: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/txbody_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/txbody_to_svg.py index fb3ffa41..b3240f1e 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/txbody_to_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/txbody_to_svg.py @@ -25,6 +25,8 @@ from __future__ import annotations from dataclasses import dataclass, field from xml.etree import ElementTree as ET +from svg_to_pptx.drawingml.utils import detect_text_lang, is_cjk_char + from .color_resolver import ColorPalette, find_color_elem, resolve_color from .emu_units import ( NS, Xfrm, fmt_num, emu_to_px, format_ooxml_alpha, @@ -576,11 +578,31 @@ def _build_run( latin_face = _typeface_chain(style_chain, "latin") ea_face = _typeface_chain(style_chain, "ea") cs_face = _typeface_chain(style_chain, "cs") + lang = _attr_chain(style_chain, "lang") + alt_lang = _attr_chain(style_chain, "altLang") # Resolve theme refs (e.g. typeface="+mn-lt" / "+mj-ea") - latin_face = _resolve_theme_typeface(latin_face, theme_fonts) - ea_face = _resolve_theme_typeface(ea_face, theme_fonts) - cs_face = _resolve_theme_typeface(cs_face, theme_fonts) + latin_face = _resolve_theme_typeface( + latin_face, + theme_fonts, + text=text, + lang=lang, + alt_lang=alt_lang, + ) + ea_face = _resolve_theme_typeface( + ea_face, + theme_fonts, + text=text, + lang=lang, + alt_lang=alt_lang, + ) + cs_face = _resolve_theme_typeface( + cs_face, + theme_fonts, + text=text, + lang=lang, + alt_lang=alt_lang, + ) font_family = _build_font_stack(latin_face, ea_face, cs_face) @@ -698,8 +720,63 @@ def _typeface_chain( return None -def _resolve_theme_typeface(face: str | None, theme_fonts: dict[str, str]) -> str | None: - """Theme references look like '+mj-lt' (major latin) / '+mn-ea' (minor EA).""" +def _theme_script_from_lang(lang: str | None) -> str | None: + """Map a DrawingML language tag to one theme supplemental-script key.""" + if not lang: + return None + normalized = lang.strip().replace("_", "-").lower() + if not normalized: + return None + parts = normalized.split("-") + primary = parts[0] + if primary == "ja": + return "Jpan" + if primary == "ko": + return "Hang" + if primary != "zh": + return None + if any(part in {"hant", "cht", "tw", "hk", "mo"} for part in parts[1:]): + return "Hant" + return "Hans" + + +def _theme_script_from_text(text: str) -> str | None: + """Infer a CJK theme script from glyph ranges, defaulting plain Han to Hans.""" + if any( + 0x3100 <= ord(char) <= 0x312F + or 0x31A0 <= ord(char) <= 0x31BF + for char in text + ): + return "Hant" + return { + "ko-KR": "Hang", + "ja-JP": "Jpan", + "zh-CN": "Hans", + }.get(detect_text_lang(text)) + + +def _run_theme_script( + text: str, + lang: str | None, + alt_lang: str | None, +) -> str | None: + """Resolve EA script from run language first, then alternate language/text.""" + return ( + _theme_script_from_lang(lang) + or _theme_script_from_lang(alt_lang) + or _theme_script_from_text(text) + ) + + +def _resolve_theme_typeface( + face: str | None, + theme_fonts: dict[str, str], + *, + text: str = "", + lang: str | None = None, + alt_lang: str | None = None, +) -> str | None: + """Resolve DrawingML major/minor Latin, EA, and complex-script tokens.""" if not face or not face.startswith("+"): return face code = face[1:] @@ -707,10 +784,31 @@ def _resolve_theme_typeface(face: str | None, theme_fonts: dict[str, str]) -> st return theme_fonts.get("majorLatin") or face if code == "mn-lt": return theme_fonts.get("minorLatin") or face - if code == "mj-ea": - return theme_fonts.get("majorEastAsia") or theme_fonts.get("majorLatin") or face - if code == "mn-ea": - return theme_fonts.get("minorEastAsia") or theme_fonts.get("minorLatin") or face + if code in {"mj-ea", "mn-ea"}: + prefix = "major" if code.startswith("mj") else "minor" + script = _run_theme_script(text, lang, alt_lang) + script_face = ( + theme_fonts.get(f"{prefix}Script{script}") + if script is not None else None + ) + return ( + theme_fonts.get(f"{prefix}EastAsia") + or script_face + or theme_fonts.get(f"{prefix}Latin") + or face + ) + if code == "mj-cs": + return ( + theme_fonts.get("majorComplexScript") + or theme_fonts.get("majorLatin") + or face + ) + if code == "mn-cs": + return ( + theme_fonts.get("minorComplexScript") + or theme_fonts.get("minorLatin") + or face + ) return face @@ -848,11 +946,7 @@ def _collect_text_defs(paragraphs: list[TextParagraph]) -> list[str]: def _is_cjk(ch: str) -> bool: """Check if a character is CJK (Chinese/Japanese/Korean) or full-width.""" - cp = ord(ch) - return (0x4E00 <= cp <= 0x9FFF or 0x3400 <= cp <= 0x4DBF or - 0x2E80 <= cp <= 0x2EFF or 0x3000 <= cp <= 0x303F or - 0xFF00 <= cp <= 0xFFEF or 0xF900 <= cp <= 0xFAFF or - 0x20000 <= cp <= 0x2A6DF) + return is_cjk_char(ch) def _char_width(ch: str, font_size: float, bold: bool) -> float: @@ -1197,11 +1291,12 @@ def _run_tspan_attrs(run: TextRun) -> str: """Per-run overrides on a . Only emit attributes that differ from the run that drove the parent (we keep things simple: emit only overrides that can plausibly change run-to-run, never re-emit common - defaults). For v1 we just always emit fill / font-size / weight to be - safe — tspan inherits when omitted, so callers can simplify later. + defaults). For v1 we always emit fill, font family, and font size so each + imported run keeps its resolved typeface even when adjacent runs differ. """ parts = [ f'fill="{run.fill}"', + f'font-family="{run.font_family}"', f'font-size="{fmt_num(run.font_size_px)}"', ] if run.fill_opacity < 1.0: 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 9ceebaf3..d96da2b4 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 @@ -27,7 +27,7 @@ import re import zipfile from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Mapping, MutableMapping +from typing import Any, Iterable, Mapping, MutableMapping from xml.etree import ElementTree as ET from xml.sax.saxutils import quoteattr @@ -926,6 +926,19 @@ class TransitionSummary: effect_options: Mapping[str, object] = field(default_factory=dict) +@dataclass(frozen=True) +class MorphPairExpectation: + """One forced-Morph name expected on two adjacent generated slides.""" + + source_slide_number: int + destination_slide_number: int + key: str + + @property + def shape_name(self) -> str: + return f"!!{self.key}" + + def _qn(namespace: str, tag: str) -> str: return f"{{{namespace}}}{tag}" @@ -2323,6 +2336,172 @@ def validate_pptx_transition_package( return summaries +def _top_level_shape_types_by_name( + slide_xml: bytes, +) -> dict[str, list[str]]: + """Return top-level Selection Pane names and their OOXML container types.""" + root = LET.fromstring(slide_xml) if LET is not None else parse_source_xml(slide_xml) + sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree") + if sp_tree is None: + raise ValueError("slide has no p:cSld/p:spTree") + shapes: dict[str, list[str]] = {} + for child in sp_tree: + c_nv_pr = next(child.iter(_qn(PML_NS, "cNvPr")), None) + name = c_nv_pr.get("name") if c_nv_pr is not None else None + if not name: + continue + shapes.setdefault(name, []).append(_local_name(child.tag)) + return shapes + + +def validate_pptx_morph_pairs( + pptx_path: Path, + expectations: Iterable[MorphPairExpectation], +) -> None: + """Prove that every requested forced-Morph pair survives final packaging.""" + expected_pairs = tuple(expectations) + if not expected_pairs: + return + + errors: list[str] = [] + slide_shapes: dict[int, dict[str, list[str]]] = {} + slide_transitions: dict[int, TransitionSummary] = {} + involved_slides = { + slide_number + for pair in expected_pairs + for slide_number in ( + pair.source_slide_number, + pair.destination_slide_number, + ) + } + try: + with zipfile.ZipFile(pptx_path, "r") as package: + names = set(package.namelist()) + for slide_number in sorted(involved_slides): + part = f"ppt/slides/slide{slide_number}.xml" + if part not in names: + errors.append(f"{part}: Morph slide part is missing") + continue + slide_xml = package.read(part) + try: + slide_shapes[slide_number] = _top_level_shape_types_by_name( + slide_xml + ) + slide_transitions[slide_number] = read_slide_transition_xml( + slide_xml + ) + except Exception as exc: + errors.append(f"{part}: Morph read-back failed: {exc}") + except (OSError, zipfile.BadZipFile, KeyError, ET.ParseError) as exc: + errors.append(f"unable to read PPTX Morph package: {exc}") + + for slide_number, names_to_types in slide_shapes.items(): + duplicate_names = sorted( + name + for name, types in names_to_types.items() + if name.startswith("!!") and len(types) != 1 + ) + if duplicate_names: + errors.append( + f"ppt/slides/slide{slide_number}.xml: duplicate forced-Morph " + f"name(s): {', '.join(duplicate_names)}" + ) + + for pair in expected_pairs: + if pair.destination_slide_number != pair.source_slide_number + 1: + errors.append( + f'Morph pair "{pair.key}" must connect adjacent generated slides' + ) + continue + source_shapes = slide_shapes.get(pair.source_slide_number) + destination_shapes = slide_shapes.get(pair.destination_slide_number) + if source_shapes is None or destination_shapes is None: + continue + + shape_name = pair.shape_name + source_types = source_shapes.get(shape_name, []) + destination_types = destination_shapes.get(shape_name, []) + if len(source_types) != 1: + errors.append( + f'Morph pair "{pair.key}" expected exactly one source object ' + f'named "{shape_name}" on slide {pair.source_slide_number}' + ) + if len(destination_types) != 1: + errors.append( + f'Morph pair "{pair.key}" expected exactly one destination ' + f'object named "{shape_name}" on slide ' + f'{pair.destination_slide_number}' + ) + if ( + len(source_types) == 1 + and len(destination_types) == 1 + and source_types[0] != destination_types[0] + ): + errors.append( + f'Morph pair "{pair.key}" changes OOXML object type from ' + f'{source_types[0]} to {destination_types[0]}' + ) + + transition = slide_transitions.get(pair.destination_slide_number) + if ( + transition is not None + and ( + transition.canonical_effect != "morph" + or transition.effect_options.get("morph_by") != "object" + ) + ): + errors.append( + f'Morph pair "{pair.key}" destination slide ' + f'{pair.destination_slide_number} does not use Morph by object' + ) + + declared_names_by_edge: dict[tuple[int, int], set[str]] = {} + for pair in expected_pairs: + declared_names_by_edge.setdefault( + ( + pair.source_slide_number, + pair.destination_slide_number, + ), + set(), + ).add(pair.shape_name) + for source_slide_number in sorted(slide_shapes): + destination_slide_number = source_slide_number + 1 + if destination_slide_number not in slide_shapes: + continue + transition = slide_transitions.get(destination_slide_number) + if ( + transition is None + or transition.canonical_effect != "morph" + ): + continue + source_names = { + name + for name in slide_shapes[source_slide_number] + if name.startswith("!!") + } + destination_names = { + name + for name in slide_shapes[destination_slide_number] + if name.startswith("!!") + } + declared_names = declared_names_by_edge.get( + (source_slide_number, destination_slide_number), + set(), + ) + unexpected_names = sorted( + (source_names & destination_names) - declared_names + ) + if unexpected_names: + errors.append( + f"Morph edge {source_slide_number}->{destination_slide_number} " + "contains undeclared forced name(s): " + + ", ".join(unexpected_names) + ) + + if errors: + raise ValueError("; ".join(dict.fromkeys(errors))) + + def _validate_package_use_timings( package: zipfile.ZipFile, names: list[str], diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_manager.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_manager.py index 19d2c363..bd042df8 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_manager.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_manager.py @@ -22,6 +22,7 @@ import re import shutil import subprocess import sys +import tempfile from datetime import datetime from pathlib import Path from urllib.parse import urlparse @@ -75,8 +76,10 @@ from _dispatcher import ( # noqa: E402 SOURCE_DIRNAME = "sources" TEXT_SOURCE_SUFFIXES = {".md", ".markdown", ".txt"} TABLE_TEXT_SUFFIXES = {".csv", ".tsv"} -IMAGE_ASSET_SUFFIXES = { +BITMAP_IMAGE_SUFFIXES = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", +} +IMAGE_ASSET_SUFFIXES = BITMAP_IMAGE_SUFFIXES | { ".emf", ".wmf", ".svg", } @@ -84,6 +87,82 @@ IMAGE_ASSET_SUFFIXES = { configure_utf8_stdio() +def _validate_image_manifest( + payload: object, + path: Path, +) -> list[dict]: + """Require a safe, case-insensitively unique image manifest payload.""" + if not isinstance(payload, list): + raise RuntimeError( + f"Image manifest must be a JSON array: {path}" + ) + + seen_filenames: dict[str, str] = {} + for index, item in enumerate(payload): + if not isinstance(item, dict): + raise RuntimeError( + f"Existing image manifest item {index} must be an object: {path}" + ) + filename = item.get("filename") + if ( + not isinstance(filename, str) + or not filename.strip() + or filename in {".", ".."} + or "/" in filename + or "\\" in filename + or ":" in filename + or Path(filename).is_absolute() + or Path(filename).name != filename + ): + raise RuntimeError( + f"Image manifest item {index} has no safe bare filename: {path}" + ) + normalized_filename = filename.casefold() + if normalized_filename in seen_filenames: + raise RuntimeError( + f"Image manifest filename {filename!r} conflicts with " + f"{seen_filenames[normalized_filename]!r} (case-insensitive): {path}" + ) + seen_filenames[normalized_filename] = filename + return payload + + +def _read_existing_image_manifest(path: Path) -> list[dict]: + """Load an existing project image manifest or fail closed on corruption.""" + if not path.exists(): + return [] + if not path.is_file(): + raise RuntimeError(f"Existing image manifest is not a regular file: {path}") + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError( + f"Existing image manifest is unreadable: {path} ({exc}); " + "repair or restore it before importing more assets" + ) from exc + return _validate_image_manifest(payload, path) + + +def _write_json_atomic(path: Path, payload: object) -> None: + """Write JSON through a same-directory temporary file and atomic rename.""" + fd, temp_name = tempfile.mkstemp( + prefix=f"{path.stem}.", + suffix=".tmp", + dir=str(path.parent), + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + os.replace(temp_name, path) + except Exception: + try: + os.unlink(temp_name) + except OSError: + pass + raise + + def is_url(value: str) -> bool: """Return whether a string looks like an HTTP(S) URL.""" parsed = urlparse(value) @@ -139,6 +218,17 @@ class ProjectManager: ) -> str: base_path = Path(base_dir) if base_dir else self.base_dir + if ( + not project_name + or project_name in {".", ".."} + or Path(project_name).is_absolute() + or "/" in project_name + or "\\" in project_name + ): + raise ValueError( + "Project name must be a single, non-absolute path component" + ) + normalized_format = normalize_canvas_format(canvas_format) if normalized_format not in self.CANVAS_FORMATS: available = ", ".join(sorted(self.CANVAS_FORMATS.keys())) @@ -157,6 +247,10 @@ class ProjectManager: project_dir_name = f"{project_name}_{normalized_format}_{date_str}" project_path = base_path / project_dir_name + if not is_within_path(project_path, base_path): + raise ValueError( + f"Project directory must stay within the base directory: {base_path}" + ) if project_path.exists(): raise FileExistsError(f"Project directory already exists: {project_path}") @@ -405,16 +499,8 @@ class ProjectManager: def _merge_image_manifest(self, source_items: list[dict], destination_manifest: Path) -> None: """Merge per-source manifest items into the project-level manifest, keyed by filename.""" - existing_data: list[object] = [] - if destination_manifest.is_file(): - try: - loaded = json.loads(destination_manifest.read_text(encoding="utf-8")) - if isinstance(loaded, list): - existing_data = loaded - else: - print(f"[WARN] Replacing non-list image manifest: {destination_manifest}") - except (OSError, json.JSONDecodeError) as exc: - print(f"[WARN] Replacing unreadable image manifest {destination_manifest}: {exc}") + _validate_image_manifest(source_items, destination_manifest) + existing_data = _read_existing_image_manifest(destination_manifest) new_by_filename: dict[str, dict] = {} new_order: list[str] = [] @@ -422,9 +508,10 @@ class ProjectManager: filename = item.get("filename") if not isinstance(filename, str): continue - if filename not in new_by_filename: - new_order.append(filename) - new_by_filename[filename] = item + normalized_filename = filename.casefold() + if normalized_filename not in new_by_filename: + new_order.append(normalized_filename) + new_by_filename[normalized_filename] = item merged: list[dict] = [] seen: set[str] = set() @@ -434,20 +521,19 @@ class ProjectManager: filename = item.get("filename") if not isinstance(filename, str): continue - if filename in new_by_filename: - merged.append(new_by_filename[filename]) + normalized_filename = filename.casefold() + if normalized_filename in new_by_filename: + merged.append(new_by_filename[normalized_filename]) else: merged.append(item) - seen.add(filename) + seen.add(normalized_filename) - for filename in new_order: - if filename not in seen: - merged.append(new_by_filename[filename]) + for normalized_filename in new_order: + if normalized_filename not in seen: + merged.append(new_by_filename[normalized_filename]) - destination_manifest.write_text( - json.dumps(merged, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) + _validate_image_manifest(merged, destination_manifest) + _write_json_atomic(destination_manifest, merged) @staticmethod def _namespace_from_asset_dir(asset_dir: Path) -> str: @@ -462,10 +548,11 @@ class ProjectManager: source_file: Path, namespace: str, existing_manifest: dict[str, dict], + occupied_names: set[str], ) -> str: """Return a short unique image filename for the runtime image pool.""" candidate = images_dir / source_file.name - if not candidate.exists(): + if candidate.name.casefold() not in occupied_names: return source_file.name try: meta = existing_manifest.get(candidate.name, {}) @@ -483,7 +570,7 @@ class ProjectManager: counter = 2 while True: candidate = images_dir / f"{stem}_{counter}{suffix}" - if not candidate.exists(): + if candidate.name.casefold() not in occupied_names: return candidate.name try: meta = existing_manifest.get(candidate.name, {}) @@ -509,31 +596,31 @@ class ProjectManager: return try: - source_data = json.loads(manifest_path.read_text(encoding="utf-8")) + source_payload = json.loads(manifest_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: print(f"[WARN] Cannot read image manifest {manifest_path}: {exc}") return - if not isinstance(source_data, list): - print(f"[WARN] Ignoring non-list image manifest: {manifest_path}") + try: + source_data = _validate_image_manifest(source_payload, manifest_path) + except RuntimeError as exc: + print(f"[WARN] {exc}") return images_dir = project_dir / "images" + namespace = self._namespace_from_asset_dir(asset_dir) + destination_manifest = images_dir / "image_manifest.json" + existing_data = _read_existing_image_manifest(destination_manifest) images_dir.mkdir(parents=True, exist_ok=True) - namespace = self._namespace_from_asset_dir(asset_dir) - existing_manifest: dict[str, dict] = {} - destination_manifest = images_dir / "image_manifest.json" - if destination_manifest.is_file(): - try: - data = json.loads(destination_manifest.read_text(encoding="utf-8")) - if isinstance(data, list): - existing_manifest = { - item["filename"]: item - for item in data - if isinstance(item, dict) and isinstance(item.get("filename"), str) - } - except (OSError, json.JSONDecodeError): - existing_manifest = {} + existing_manifest = { + item["filename"]: item + for item in existing_data + } + occupied_names = { + path.name.casefold() + for path in images_dir.iterdir() + if path.is_file() + } rename_map: dict[str, str] = {} copied_count = 0 @@ -547,10 +634,12 @@ class ProjectManager: source_file, namespace, existing_manifest, + occupied_names, ) destination = images_dir / new_name if source_file.resolve() != destination.resolve(): shutil.copy2(source_file, destination) + occupied_names.add(new_name.casefold()) rename_map[source_file.name] = new_name copied_count += 1 @@ -640,6 +729,7 @@ class ProjectManager: "archived": [], "markdown": [], "assets": [], + "images": [], "analysis": [], "notes": [], "skipped": [], @@ -760,7 +850,18 @@ class ProjectManager: ) summary["archived"].append(str(archived_path)) - if suffix in PDF_SUFFIXES: + if suffix in BITMAP_IMAGE_SUFFIXES: + images_dir = project_dir / "images" + images_dir.mkdir(parents=True, exist_ok=True) + image_path = self._ensure_unique_path(images_dir / archived_path.name) + shutil.copy2(archived_path, image_path) + summary["images"].append(str(image_path)) + if image_path.name != archived_path.name: + summary["notes"].append( + f"{item}: copied runtime image as {image_path.name} " + "to avoid a filename collision" + ) + elif suffix in PDF_SUFFIXES: canonical_markdown_path = sources_dir / f"{archived_path.stem}.md" if archived_path.stem in explicit_markdown_stems: summary["notes"].append( @@ -1050,6 +1151,10 @@ def main(argv: list[str] | None = None) -> int: print("\nImported asset directories:") for item in summary["assets"]: print(f" - {item}") + if summary["images"]: + print("\nRuntime image copies:") + for item in summary["images"]: + print(f" - {item}") if summary["analysis"]: print("\nAnalysis artifacts:") for item in summary["analysis"]: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_specs.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_specs.py index 3bfa521c..9f6f1d60 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_specs.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_specs.py @@ -71,7 +71,30 @@ _MARKDOWN_DATA_LINE_RE = re.compile( re.MULTILINE, ) _IMAGE_PATH_SUFFIXES = frozenset( - {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff", ".webp"} + { + ".bmp", + ".emf", + ".gif", + ".jpeg", + ".jpg", + ".png", + ".svg", + ".tif", + ".tiff", + ".webp", + ".wmf", + } +) +_IMAGE_ACQUISITION_SOURCES = frozenset( + {"ai", "web", "user", "formula", "placeholder", "slice"} +) +_IMAGE_CROP_POLICIES = frozenset({"adaptive", "no-crop"}) +_LEGACY_IMAGE_METADATA_KEYS = frozenset( + { + "image_rendering", + "image_rendering_behavior", + "image_rendering_references", + } ) _LEGACY_SPEC_LOCK_FORBIDDEN = frozenset({"Mixing icon libraries"}) _SCAFFOLD_TOKEN_RE = re.compile(r"\{\{[A-Z_]+\}\}") @@ -176,6 +199,109 @@ def _looks_like_image_path(raw: str) -> bool: return bool(token) and Path(token).suffix.casefold() in _IMAGE_PATH_SUFFIXES +def parse_spec_lock_image_value(key: str, value: str) -> dict[str, str]: + """Parse one image-lock row while preserving supported legacy rows. + + Current rows use `` | source=... | pattern=... | crop=...``. Legacy + rows remain readable, but any row that starts using named metadata must + provide the complete current contract. + """ + normalized_key = str(key).strip() + normalized_value = str(value).strip() + parts = [part.strip() for part in normalized_value.split("|")] + path_part = parts[0] if parts else "" + + if _looks_like_image_path(normalized_key) and not _looks_like_image_path(path_part): + parts.insert(0, normalized_key) + path_part = normalized_key + elif ( + len(parts) >= 2 + and parts[0].casefold() in _IMAGE_ACQUISITION_SOURCES + and _looks_like_image_path(parts[1]) + ): + path_part = parts[1] + + metadata_parts = [part for part in parts[1:] if "=" in part] + if not metadata_parts: + legacy_crop = ( + "no-crop" + if any( + re.search(r"(?, got {path_part!r}" + ) + + source = metadata["source"].casefold() + if source not in _IMAGE_ACQUISITION_SOURCES: + allowed = ", ".join(sorted(_IMAGE_ACQUISITION_SOURCES)) + raise ValueError(f"source must be one of {allowed}, got {metadata['source']!r}") + if not metadata["pattern"]: + raise ValueError("pattern must be non-empty") + crop = metadata["crop"].casefold() + if crop not in _IMAGE_CROP_POLICIES: + allowed = ", ".join(sorted(_IMAGE_CROP_POLICIES)) + raise ValueError(f"crop must be one of {allowed}, got {metadata['crop']!r}") + + return { + "path": normalized_path, + "source": source, + "pattern": metadata["pattern"], + "crop": crop, + "legacy": "false", + } + + def parse_spec_lock_artifact( lock_path: Path, *, @@ -873,6 +999,16 @@ def _validate_spec_lock_relations( f"unknown catalog id '{reference}'" ) + for key, value in fields("images").items(): + if key.strip().casefold() in _LEGACY_IMAGE_METADATA_KEYS: + continue + try: + parse_spec_lock_image_value(key, value) + except ValueError as exc: + errors.append( + f"{markdown_name} schema: images row {key!r} {exc}" + ) + rhythm = fields("page_rhythm") layouts = fields("pptx_layouts") page_pptx_layouts = fields("page_pptx_layouts") diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit.py index 7ea7f2b0..7febce54 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit.py @@ -991,6 +991,21 @@ def audit_authority_graph( return normalized, cycles, findings +def _parse_numbered_markdown_ids(raw_ids: str) -> list[int]: + """Expand one numbered-Markdown heading expression into its stable ids.""" + ids: list[int] = [] + for part in re.split(r"\s*·\s*", raw_ids): + match = re.fullmatch(r"(\d+)(?:\s*[-–]\s*(\d+))?", part) + if match is None: + raise AuditError(f"Invalid numbered Markdown id expression: {raw_ids!r}") + start = int(match.group(1)) + end = int(match.group(2) or start) + if end < start: + raise AuditError(f"Descending numbered Markdown id range: {raw_ids!r}") + ids.extend(range(start, end + 1)) + return ids + + def _registry_ids(root: Path, config: dict[str, Any]) -> tuple[set[Any], str, list[Any]]: kind = config.get("kind") source = config.get("source") @@ -1005,9 +1020,15 @@ def _registry_ids(root: Path, config: dict[str, Any]) -> tuple[set[Any], str, li regex = re.compile(pattern, re.MULTILINE) except re.error as exc: raise AuditError(f"Invalid registry entry_pattern: {exc}") from exc + id_group = "ids" if "ids" in regex.groupindex else "id" + if id_group not in regex.groupindex: + raise AuditError( + f"Registry {config.get('name')} entry_pattern needs an id or ids group" + ) matched_ids = [ - int(match.group("id")) + item for match in regex.finditer(_read_utf8(root / source)) + for item in _parse_numbered_markdown_ids(match.group(id_group)) ] duplicates = sorted( item for item, count in Counter(matched_ids).items() if count > 1 @@ -1064,6 +1085,20 @@ def _registry_count_claims(paragraph: Paragraph, nouns: list[str]) -> list[int]: return claims +def _is_comprehensive_registry_reference(paragraph: Paragraph, nouns: list[str]) -> bool: + """Return whether a block claims complete registry coverage.""" + scope_nouns = sorted(set(nouns + ["catalog", "registry", "file"])) + scope_pattern = "|".join(re.escape(noun) for noun in scope_nouns) + return re.search( + rf"\b(?:all|every|entire|full)\b(?:\W+\w+){{0,4}}\W+" + rf"(?:{scope_pattern})\b|" + rf"\b(?:{scope_pattern})\b(?:\W+\w+){{0,4}}\W+" + rf"\b(?:all|every|entire|full)\b|" + r"\bfile is split\b", + paragraph.normalized, + ) is not None + + def audit_registries( root: Path, configs: list[dict[str, Any]], @@ -1216,33 +1251,25 @@ def audit_registries( ) ) - ranges = sorted( + ranges = [ tuple(sorted((int(match.group(1)), int(match.group(2))))) for match in range_pattern.finditer(paragraph.text) - ) - comprehensive = re.search( - r"\b(all|every|entire|full|file is split)\b", - paragraph.normalized, - ) + ] + comprehensive = _is_comprehensive_registry_reference(paragraph, nouns) if ranges and comprehensive: - merged: list[list[int]] = [] + declared_ids = { + int(match.group(1)) + for match in id_pattern.finditer(paragraph.text) + } for start, end in ranges: - if not merged or start > merged[-1][1] + 1: - merged.append([start, end]) - else: - merged[-1][1] = max(merged[-1][1], end) - declared_count = sum(end - start + 1 for start, end in merged) - covers_ids = all( - any(start <= item <= end for start, end in merged) - for item in numeric_ids - ) - if declared_count != len(numeric_ids) or not covers_ids: + declared_ids.update(range(start, end + 1)) + if declared_ids != numeric_ids: findings.append( Finding( severity="error", code="REGISTRY_RANGE_MISMATCH", message=( - f"{name} comprehensive ranges cover {declared_count} ids; " + f"{name} comprehensive ranges cover {len(declared_ids)} ids; " f"registry contains {len(numeric_ids)}" ), path=paragraph.path, 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 bba32afd..a5403dc9 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,28 +12,28 @@ "skills/ppt-master/templates/schemas/*.json" ], "exclude": [], - "max_tokens": 365400 + "max_tokens": 388200 }, "file_budgets": { - "AGENTS.md": 2350, + "AGENTS.md": 2400, "skills/ppt-master/SKILL.md": 850, - "skills/ppt-master/references/executor-base.md": 7250, + "skills/ppt-master/references/executor-base.md": 8125, "skills/ppt-master/references/executor-structured.md": 5300, "skills/ppt-master/references/executor-chart.md": 3100, - "skills/ppt-master/references/executor-image.md": 900, + "skills/ppt-master/references/executor-image.md": 1275, "skills/ppt-master/references/executor-web-image.md": 500, "skills/ppt-master/references/executor-notes.md": 900, "skills/ppt-master/references/shared-standards.md": 250, - "skills/ppt-master/references/shared-standards-core.md": 10800, - "skills/ppt-master/references/svg-effects.md": 11600, + "skills/ppt-master/references/shared-standards-core.md": 11100, + "skills/ppt-master/references/svg-effects.md": 11725, "skills/ppt-master/references/native-data-interface.md": 6200, "skills/ppt-master/references/pptx-structure-interface.md": 4300, - "skills/ppt-master/references/strategist.md": 13450, - "skills/ppt-master/references/strategist-image.md": 2250, + "skills/ppt-master/references/strategist.md": 14025, + "skills/ppt-master/references/strategist-image.md": 2500, "skills/ppt-master/references/strategist-template.md": 2100, - "skills/ppt-master/templates/design_spec_reference.md": 2850, - "skills/ppt-master/templates/spec_lock_reference.md": 2350, - "skills/ppt-master/workflows/generate-pptx.md": 12700, + "skills/ppt-master/templates/design_spec_reference.md": 3125, + "skills/ppt-master/templates/spec_lock_reference.md": 2375, + "skills/ppt-master/workflows/generate-pptx.md": 13100, "skills/ppt-master/workflows/stages/apply-template-workspace.md": 1800 }, "load_sets": { @@ -45,7 +45,7 @@ "skills/ppt-master/SKILL.md", "skills/ppt-master/workflows/routing.md" ], - "max_tokens": 5425 + "max_tokens": 5700 }, "support.sponsor-recommendation.en": { "description": "English sponsor context for explicit user requests for model, AI image model, API/provider, or hosted-service recommendations.", @@ -93,7 +93,7 @@ "skills/ppt-master/templates/README.md", "skills/ppt-master/templates/decks/README.md" ], - "max_tokens": 57475 + "max_tokens": 58225 }, "route.create-template.layout": { "description": "Create Layout path through Template_Designer, SVG core, and the structured PPTX interface.", @@ -111,7 +111,7 @@ "skills/ppt-master/templates/README.md", "skills/ppt-master/templates/layouts/README.md" ], - "max_tokens": 57675 + "max_tokens": 58400 }, "route.enhance-native-pptx": { "description": "Finished-PPTX native enhancement route.", @@ -122,7 +122,7 @@ "files": [ "skills/ppt-master/workflows/native-enhance-pptx.md" ], - "max_tokens": 8375 + "max_tokens": 9600 }, "route.fill-native-pptx": { "description": "Raw-PPTX native fill route.", @@ -133,7 +133,7 @@ "files": [ "skills/ppt-master/workflows/template-fill-pptx.md" ], - "max_tokens": 11075 + "max_tokens": 11375 }, "route.generate.planning": { "description": "Generate-PPTX planning through Strategist, including reference-first whole-document authoring and representative multi-source custom mode/style synthesis; schemas and optional scaffolds are tool-consumed.", @@ -172,6 +172,19 @@ ], "max_tokens": 62000 }, + "route.generate.quick-test": { + "description": "Explicit disposable Generate quick-test short circuit with the shared SVG authoring core.", + "scope": "cumulative", + "include": [ + "bootstrap.routing" + ], + "files": [ + "skills/ppt-master/workflows/generate-pptx.md", + "skills/ppt-master/workflows/profiles/quick-test.md", + "skills/ppt-master/references/shared-standards-core.md" + ], + "max_tokens": 30825 + }, "route.generate.planning-image": { "description": "Generate-PPTX planning context after a non-none image source is proposed or confirmed.", "scope": "cumulative", @@ -180,7 +193,7 @@ "stage.generate.strategist.image-layout" ], "files": [], - "max_tokens": 70000 + "max_tokens": 74850 }, "route.generate.planning-formula": { "description": "Formula-only planning context with no non-formula image resource or layout catalog.", @@ -210,7 +223,7 @@ "registry": "image-renderings" } ], - "max_tokens": 76000 + "max_tokens": 79100 }, "stage.generate.apply-template-workspace": { "description": "Conditional Step 3 workspace validation, installation, and fusion framework.", @@ -322,7 +335,7 @@ "stage.generate.executor.notes" ], "files": [], - "max_tokens": 82475 + "max_tokens": 85625 }, "route.generate.flat-ai-two-types": { "description": "Generate-PPTX context with AI images, representative multi-source custom rendering, and two local types.", @@ -335,7 +348,7 @@ "stage.generate.image.ai-two-types" ], "files": [], - "max_tokens": 123000 + "max_tokens": 133200 }, "route.generate.flat-in-hand-image": { "description": "Generate-PPTX context with provided, placeholder, or formula images and no acquisition role.", @@ -347,7 +360,7 @@ "stage.generate.executor.notes" ], "files": [], - "max_tokens": 96300 + "max_tokens": 106525 }, "route.generate.flat-web-image": { "description": "Generate-PPTX context with web image acquisition.", @@ -360,7 +373,7 @@ "stage.generate.image.web" ], "files": [], - "max_tokens": 103575 + "max_tokens": 113800 }, "route.enhance-native-pptx.audio": { "description": "Enhance Native PPTX with the shared narration-audio stage.", @@ -370,7 +383,7 @@ "stage.shared.generate-audio" ], "files": [], - "max_tokens": 11125 + "max_tokens": 14500 }, "route.generate.beautify-flat-no-image": { "description": "Generate-PPTX 1:1 beautify profile on the flat no-image path.", @@ -380,7 +393,7 @@ "profile.generate.beautify-pptx" ], "files": [], - "max_tokens": 89325 + "max_tokens": 92525 }, "route.generate.flat-no-image-chart": { "description": "Default flat Generate-PPTX path with chart authoring, native-data replacement, and verification.", @@ -393,7 +406,7 @@ "stage.generate.verify-charts" ], "files": [], - "max_tokens": 106550 + "max_tokens": 109975 }, "route.generate.brand-flat-no-image": { "description": "Brand-preset flat Generate-PPTX path without image acquisition.", @@ -405,7 +418,7 @@ "stage.generate.template.brand" ], "files": [], - "max_tokens": 89500 + "max_tokens": 92650 }, "route.generate.deck-structured-no-image": { "description": "Deck-preset structured Generate-PPTX path without image acquisition.", @@ -417,7 +430,7 @@ "stage.generate.template.deck" ], "files": [], - "max_tokens": 99425 + "max_tokens": 102600 }, "route.generate.layout-structured-no-image": { "description": "Layout-preset structured Generate-PPTX path without image acquisition.", @@ -429,7 +442,7 @@ "stage.generate.template.layout" ], "files": [], - "max_tokens": 99875 + "max_tokens": 103050 }, "route.generate.topic-only-flat-no-image": { "description": "Topic research followed by the default flat no-image Generate-PPTX path.", @@ -439,7 +452,7 @@ "route.generate.flat-no-image" ], "files": [], - "max_tokens": 83875 + "max_tokens": 87025 }, "stage.generate.executor.flat": { "description": "Incremental flat Executor core with representative multi-source custom mode/style execution.", @@ -549,7 +562,7 @@ "stage.generate.executor.image" ], "files": [], - "max_tokens": 59100 + "max_tokens": 67800 }, "stage.generate.topic-research": { "description": "Gap-targeted factual intake stage.", @@ -565,7 +578,7 @@ "files": [ "skills/ppt-master/workflows/profiles/beautify-pptx.md" ], - "max_tokens": 6900 + "max_tokens": 6950 }, "stage.generate.refine-spec": { "description": "Explicit post-confirmation spec refinement runbook.", @@ -619,7 +632,7 @@ "skills/ppt-master/scripts/docs/pptx-transitions.md", "skills/ppt-master/scripts/docs/svg-pipeline.md" ], - "max_tokens": 21300 + "max_tokens": 28750 }, "stage.generate.video-motion-plan": { "description": "Conditional resolved animation-to-video handoff contract.", @@ -635,7 +648,7 @@ "files": [ "skills/ppt-master/references/animations.md" ], - "max_tokens": 4500 + "max_tokens": 6950 }, "stage.shared.generate-audio": { "description": "Shared narration-audio stage.", @@ -643,7 +656,7 @@ "files": [ "skills/ppt-master/workflows/stages/generate-audio.md" ], - "max_tokens": 2800 + "max_tokens": 4925 }, "governance.failure-recovery": { "description": "Global stop, retry, and resume policy.", @@ -675,7 +688,7 @@ "files": [ "skills/ppt-master/references/native-shape-authoring.md" ], - "max_tokens": 3100 + "max_tokens": 4500 }, "route.generate.planning-template": { "description": "Generate-PPTX planning context after an explicit template workspace is installed.", @@ -685,7 +698,7 @@ "stage.generate.strategist.template" ], "files": [], - "max_tokens": 60250 + "max_tokens": 62275 }, "route.generate.planning-template-ai": { "description": "Explicit template workspace plus confirmed AI-image planning.", @@ -695,7 +708,7 @@ "route.generate.planning-ai" ], "files": [], - "max_tokens": 72700 + "max_tokens": 81175 }, "stage.generate.executor.chart": { "description": "Conditional chart/table page execution rules.", @@ -711,7 +724,7 @@ "files": [ "skills/ppt-master/references/strategist-image.md" ], - "max_tokens": 2250 + "max_tokens": 2500 }, "stage.generate.strategist.image-layout": { "description": "Non-formula image planning plus the image-layout pattern catalog required for Section VIII rows.", @@ -722,7 +735,7 @@ "files": [ "skills/ppt-master/references/image-layout-patterns.md" ], - "max_tokens": 8250 + "max_tokens": 14675 }, "stage.generate.strategist.template": { "description": "Conditional Strategist module for an explicitly installed template workspace.", @@ -749,7 +762,7 @@ "skills/ppt-master/references/image-layout-spec.md", "skills/ppt-master/references/svg-image-embedding.md" ], - "max_tokens": 11625 + "max_tokens": 18425 }, "stage.generate.executor.web-image": { "description": "Conditional sourced-image attribution layered on image execution.", @@ -760,7 +773,7 @@ "files": [ "skills/ppt-master/references/executor-web-image.md" ], - "max_tokens": 12050 + "max_tokens": 18900 }, "stage.generate.executor.notes": { "description": "Post-SVG speaker-notes generation rules.", @@ -776,7 +789,7 @@ "files": [ "skills/ppt-master/references/native-shape-authoring.md" ], - "max_tokens": 3100 + "max_tokens": 4500 }, "stage.shared.svg-effects": { "description": "Conditional advanced SVG paint, effects, transforms, and geometry for Generate Executor or Create Template.", @@ -784,7 +797,7 @@ "files": [ "skills/ppt-master/references/svg-effects.md" ], - "max_tokens": 11600 + "max_tokens": 11725 } }, "duplicates": { @@ -934,7 +947,7 @@ "name": "image-layout-patterns", "kind": "numbered_markdown", "source": "skills/ppt-master/references/image-layout-patterns.md", - "entry_pattern": "^(?P\\d+)\\.\\s+\\*\\*", + "entry_pattern": "^(?P\\d+(?:\\s*(?:[-–]|·)\\s*\\d+)*)\\.\\s+\\*\\*", "reference_terms": [ "image-layout-patterns", "image-text layout patterns" @@ -1120,6 +1133,10 @@ "glob": "skills/ppt-master/scripts/docs/prompt_audit.md", "reason": "Maintainer-only documentation for this audit tool." }, + { + "glob": "skills/ppt-master/scripts/docs/mask-gradient-smoke.md", + "reason": "Maintainer-only executable smoke; never loaded by generation roles." + }, { "glob": "skills/ppt-master/scripts/docs/update_spec.md", "reason": "Maintainer command reference for an optional helper script." diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/resource_paths.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/resource_paths.py index 52061ddd..fa81c898 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/resource_paths.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/resource_paths.py @@ -1,15 +1,16 @@ #!/usr/bin/env python3 """ -PPT Master - Shared SVG Resource Path Helpers +PPT Master - Shared SVG Resource Helpers -Centralizes project-relative SVG resource lookup used by the checker, -finalizer, and SVG-to-PPTX exporter. +Centralizes project-relative resource lookup and nested-SVG closure validation +used by the checker, finalizer, and SVG-to-PPTX exporter. Usage: Imported by scripts; not intended as a standalone CLI. Examples: from resource_paths import resolve_external_image_reference + from resource_paths import svg_image_payload_error Dependencies: None @@ -17,13 +18,45 @@ Dependencies: from __future__ import annotations +import base64 +import binascii +import re from pathlib import Path -from urllib.parse import unquote, urlsplit +from urllib.parse import unquote, unquote_to_bytes, urlsplit +from xml.etree import ElementTree as ET SVG_WORK_DIR_NAMES = frozenset({'svg_output', 'svg_final', 'svg-flat', 'svg_flat'}) TEMPLATE_SOURCE_DIR_NAME = 'templates' TEMPLATE_SPEC_FILENAME = 'design_spec.md' +_SVG_NAMESPACE = 'http://www.w3.org/2000/svg' +_SVG_URL_REFERENCE_RE = re.compile(r'url\(\s*([^)]+?)\s*\)', re.IGNORECASE) +_SVG_CSS_URL_ATTRIBUTES = frozenset({ + 'background', + 'background-image', + 'clip-path', + 'color-profile', + 'cursor', + 'fill', + 'filter', + 'marker', + 'marker-end', + 'marker-mid', + 'marker-start', + 'mask', + 'stroke', + 'style', +}) +_SVG_DIRECT_RESOURCE_ATTRIBUTES = frozenset({'poster', 'src'}) +_SVG_DATA_URI_DEPTH_LIMIT = 8 +_SVG_EXTERNAL_DOCTYPE_RE = re.compile( + br']*\b(?:PUBLIC|SYSTEM)\b', + re.IGNORECASE | re.DOTALL, +) +_XML_STYLESHEET_HREF_RE = re.compile( + r'\bhref\s*=\s*([\'"])(.*?)\1', + re.IGNORECASE | re.DOTALL, +) def project_root_for_svg_path(svg_path: Path) -> Path: @@ -59,6 +92,146 @@ def icon_search_dirs_for_svg(svg_path: Path) -> tuple[Path, Path | None]: return icon_search_dirs_for_project(project_root_for_svg_path(svg_path)) +def _decode_svg_data_uri(raw: str) -> tuple[bytes | None, str | None]: + """Decode an SVG data URI; return ``(None, None)`` for other media.""" + value = raw.strip().strip('\'"') + if not value.lower().startswith('data:'): + return None, None + header, separator, payload = value.partition(',') + media_type = header[5:].split(';', 1)[0].strip().lower() + if media_type != 'image/svg+xml': + return None, None + if not separator: + return None, 'invalid embedded SVG data URI' + is_base64 = any( + token.strip().lower() == 'base64' + for token in header.split(';')[1:] + ) + try: + decoded = ( + base64.b64decode(payload, validate=True) + if is_base64 + else unquote_to_bytes(payload) + ) + except (ValueError, binascii.Error): + return None, 'invalid embedded SVG data URI' + return decoded, None + + +def _svg_reference_error(raw: str, depth: int) -> str | None: + """Return an external or recursively embedded SVG resource error.""" + value = raw.strip().strip('\'"') + if not value: + return 'empty resource reference' + if value.startswith('#'): + return None + if not value.lower().startswith('data:'): + return f'unpackaged external resource {value!r}' + + nested_svg, decode_error = _decode_svg_data_uri(value) + if decode_error is not None: + return decode_error + if nested_svg is None: + return None + if depth >= _SVG_DATA_URI_DEPTH_LIMIT: + return 'embedded SVG resource nesting exceeds the safety limit' + nested_error = _svg_image_payload_error(nested_svg, depth + 1) + if nested_error is None: + return None + return f'embedded SVG resource is not closed: {nested_error}' + + +def _xml_stylesheet_error(raw_bytes: bytes, depth: int) -> str | None: + """Return an external XML stylesheet processing-instruction error.""" + parser = ET.XMLPullParser(events=('pi',)) + try: + parser.feed(raw_bytes) + parser.close() + except ET.ParseError: + return None + for _event, instruction in parser.read_events(): + text = (instruction.text or '').strip() + if not text.lower().startswith('xml-stylesheet'): + continue + match = _XML_STYLESHEET_HREF_RE.search(text) + if match is None: + return 'XML stylesheet processing instruction lacks href' + reference_error = _svg_reference_error(match.group(2), depth) + if reference_error is not None: + return f'XML stylesheet is not closed: {reference_error}' + return None + + +def _svg_image_payload_error(raw_bytes: bytes, depth: int) -> str | None: + """Return why one SVG image payload is not a closed packaged resource.""" + if _SVG_EXTERNAL_DOCTYPE_RE.search(raw_bytes): + return 'unpackaged external XML doctype' + try: + root = ET.fromstring(raw_bytes) + except ET.ParseError as exc: + return f'invalid SVG XML: {exc}' + if root.tag != f'{{{_SVG_NAMESPACE}}}svg': + return 'root must use the SVG namespace' + + stylesheet_error = _xml_stylesheet_error(raw_bytes, depth) + if stylesheet_error is not None: + return stylesheet_error + for elem in root.iter(): + tag = str(elem.tag).rsplit('}', 1)[-1] + for raw_name, raw_value in elem.attrib.items(): + name = raw_name.rsplit('}', 1)[-1] + if name == 'href' and tag != 'a': + reference_error = _svg_reference_error(raw_value, depth) + if reference_error is not None: + return reference_error + elif name in _SVG_DIRECT_RESOURCE_ATTRIBUTES: + reference_error = _svg_reference_error(raw_value, depth) + if reference_error is not None: + return reference_error + elif name == 'data' and tag == 'object': + reference_error = _svg_reference_error(raw_value, depth) + if reference_error is not None: + return reference_error + elif name == 'srcset' and raw_value.strip(): + return 'srcset resource lists are not closed SVG resources' + + if name not in _SVG_CSS_URL_ATTRIBUTES: + continue + for match in _SVG_URL_REFERENCE_RE.finditer(raw_value): + reference_error = _svg_reference_error(match.group(1), depth) + if reference_error is not None: + return f'url() resource is not closed: {reference_error}' + + if tag != 'style': + continue + style_text = ''.join(elem.itertext()) + if re.search(r'@import\b', style_text, flags=re.IGNORECASE): + return 'unpackaged CSS import' + for match in _SVG_URL_REFERENCE_RE.finditer(style_text): + reference_error = _svg_reference_error(match.group(1), depth) + if reference_error is not None: + return f'url() resource is not closed: {reference_error}' + return None + + +def svg_image_payload_error(raw_bytes: bytes) -> str | None: + """Return why a nested SVG image is not a closed packaged resource.""" + return _svg_image_payload_error(raw_bytes, 0) + + +def svg_data_uri_payload_error(raw: str) -> str | None: + """Return why an inline SVG image data URI is not a closed resource.""" + nested_svg, decode_error = _decode_svg_data_uri(raw) + if decode_error is not None: + return decode_error + if nested_svg is None: + return None + nested_error = _svg_image_payload_error(nested_svg, 0) + if nested_error is None: + return None + return f'inline SVG data URI is not closed: {nested_error}' + + def external_image_reference_candidates(svg_dir: Path, href: str) -> list[Path]: """Return candidate paths for a non-data-URI SVG image href.""" parsed = urlsplit(href) @@ -70,20 +243,30 @@ def external_image_reference_candidates(svg_dir: Path, href: str) -> list[Path]: else href.split('?', 1)[0].split('#', 1)[0] ) svg_dir = Path(svg_dir) - project_root = project_root_for_svg_path(svg_dir) - return [ + project_root = project_root_for_svg_path(svg_dir).resolve() + candidates = [ svg_dir / decoded, project_root / decoded, project_root / 'images' / decoded, project_root / 'templates' / decoded, ] + safe_candidates: list[Path] = [] + for candidate in candidates: + resolved = candidate.resolve() + try: + resolved.relative_to(project_root) + except ValueError: + continue + if resolved not in safe_candidates: + safe_candidates.append(resolved) + return safe_candidates def resolve_external_image_reference(svg_dir: Path, href: str) -> Path | None: """Resolve an SVG image href to an existing file, or return None.""" for candidate in external_image_reference_candidates(svg_dir, href): - if candidate.exists(): - return candidate.resolve() + if candidate.is_file(): + return candidate return None 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 index 94e43664..588d7bf1 100644 --- 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 @@ -3,10 +3,10 @@ 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. +to stdout. Result geometry is in SVG-root coordinate space: replace the +operands at their original z-order under the final semantic or structured +parent, never under the old transformed ancestor. The source SVG is read-only; +this tool never rewrites the page. Usage: python3 scripts/shape_boolean_svg.py render SVG_FILE \ @@ -41,9 +41,10 @@ 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." + "path fragments in SVG-root coordinate space. Insert the result at " + "the original z-order under the final semantic or structured " + "parent, never under the old transformed ancestor. The source file " + "is never modified." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -51,7 +52,7 @@ def build_parser() -> argparse.ArgumentParser: render_parser = subparsers.add_parser( "render", - help="Print root-coordinate Boolean-result SVG paths to stdout.", + help="Print SVG-root-coordinate Boolean-result paths to stdout.", ) render_parser.add_argument( "svg_file", diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/slice_images.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/slice_images.py index eed4ff07..2bfe79c7 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/slice_images.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/slice_images.py @@ -175,6 +175,17 @@ def slice_sheet( f"{total_cells} cells; provide exactly one name per cell" ) safe_names = [_safe_basename(n) for n in names] if names else None + if safe_names: + seen_outputs: set[str] = set() + for name in safe_names: + output_name = name if Path(name).suffix else f"{name}.png" + normalized_output = output_name.casefold() + if normalized_output in seen_outputs: + raise ValueError( + f"--names repeats output filename {output_name!r} " + "(case-insensitive)" + ) + seen_outputs.add(normalized_output) if alpha and safe_names: for name in safe_names: suffix = Path(name).suffix.lower() diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/web_to_md.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/web_to_md.py index 4dfbe9ef..b000933c 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/web_to_md.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/web_to_md.py @@ -912,7 +912,7 @@ def _write_emit_result(result_file: str, url: str, markdown_path: str) -> None: print(f" [WARN] Could not write --emit-result: {exc}") -def main() -> None: +def main(argv: list[str] | None = None) -> int: """Run the CLI entry point.""" parser = argparse.ArgumentParser( description="Web to Markdown Converter (Python)") @@ -926,7 +926,7 @@ def main() -> None: help="On success, write the saved output path as JSON to this file " "(single-URL dispatcher use, so a title-named file can be located)") - args = parser.parse_args() + args = parser.parse_args(argv) if args.dir: CONFIG["output_dir"] = args.dir @@ -942,11 +942,16 @@ def main() -> None: and not l.strip().startswith("#")] targets.extend(lines) else: - print(f"Error: File {args.file} not found") + print(f"Error: File {args.file} not found", file=sys.stderr) + return 1 if not targets: - parser.print_help() - sys.exit(0) + parser.print_usage(sys.stderr) + print( + "web_to_md.py: error: at least one URL or --file is required", + file=sys.stderr, + ) + return 2 results = [] for i, url in enumerate(targets): @@ -970,10 +975,12 @@ def main() -> None: for r in results: if not r[0]: print(f" - {r[1]}: {r[2]}") + return 1 + return 0 if __name__ == "__main__": # Disable warnings for verify=False if needed, though often useful to see import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - main() + raise SystemExit(main()) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/server.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/server.py index eeadc3ce..71306e71 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/server.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_editor/server.py @@ -597,8 +597,10 @@ def create_app( logger.warning('slide parse failed: %s: %s', svg_file.name, exc) _cache_put(_LIST_CACHE, _LIST_CACHE_LOCK, path_str, mtime, disk_count) - mem_count = len(annotations.get(svg_file.name, {})) - annotation_count = max(disk_count, mem_count) + if svg_file.name in annotations: + annotation_count = len(annotations[svg_file.name]) + else: + annotation_count = disk_count slides.append({ 'name': svg_file.name, @@ -614,16 +616,43 @@ def create_app( def _safe_svg_path(name: str): """Validate slide name and return safe path. Returns None if invalid. - The early string checks reject obvious bad inputs; the resolve()+startswith() + The early string checks reject obvious bad inputs; the resolve()+relative_to() check is the authoritative path traversal guard. """ if '/' in name or '\\' in name or '..' in name: return None svg_file = (svg_dir / name).resolve() - if not str(svg_file).startswith(str(svg_dir.resolve())): + try: + svg_file.relative_to(svg_dir.resolve()) + except ValueError: return None return svg_file + def _get_annotation_snapshot(name: str): + """Return the page's complete staged annotation state, loading it once.""" + annotations = app.config['ANNOTATIONS'] + if name in annotations: + return annotations[name], None + + svg_file = _safe_svg_path(name) + if svg_file is None: + return None, (jsonify({'error': 'Invalid slide name'}), 400) + if not svg_file.exists(): + return None, (jsonify({'error': 'Slide not found'}), 404) + + try: + root = ET.parse(str(svg_file)).getroot() + except ET.ParseError as exc: + logger.warning('slide parse failed: %s: %s', name, exc) + return None, (jsonify({'error': f'Failed to parse SVG: {exc}'}), 500) + + assign_temp_ids(root) + annotations[name] = { + item['element_id']: item['annotation'] + for item in parse_annotations(root) + } + return annotations[name], None + @app.route('/api/slide/') def get_slide(name: str): svg_file = _safe_svg_path(name) @@ -686,11 +715,13 @@ def create_app( (content, warnings, disk_annotations, id_to_tag), ) - mem_annotations = app.config['ANNOTATIONS'].get(name, {}) - merged: dict[str, str] = {} - for ann in disk_annotations: - merged[ann['element_id']] = ann['annotation'] - merged.update(mem_annotations) + if name in app.config['ANNOTATIONS']: + merged = dict(app.config['ANNOTATIONS'][name]) + else: + merged = { + ann['element_id']: ann['annotation'] + for ann in disk_annotations + } annotations_list = [ { @@ -728,29 +759,28 @@ def create_app( if len(annotation) > 10000: return jsonify({'error': 'Annotation too long (max 10000 chars)'}), 400 - if name not in app.config['ANNOTATIONS']: - app.config['ANNOTATIONS'][name] = {} + annotations, error = _get_annotation_snapshot(name) + if error is not None: + return error - app.config['ANNOTATIONS'][name][element_id] = annotation + annotations[element_id] = annotation return jsonify({ 'status': 'ok', - 'annotations_count': len(app.config['ANNOTATIONS'][name]), + 'annotations_count': len(annotations), }) @app.route('/api/slide//annotate/', methods=['DELETE']) def delete_annotate(name: str, element_id: str): - annotations = app.config['ANNOTATIONS'] - # Ensure the file key exists so save-all knows to rewrite this file - # even if no new annotations were added (pure delete path). - if name not in annotations: - annotations[name] = {} - if element_id in annotations[name]: - del annotations[name][element_id] + annotations, error = _get_annotation_snapshot(name) + if error is not None: + return error + if element_id in annotations: + del annotations[element_id] return jsonify({ 'status': 'ok', - 'annotations_count': len(annotations.get(name, {})), + 'annotations_count': len(annotations), }) @app.route('/api/slide//edit', methods=['POST']) @@ -896,6 +926,7 @@ def create_app( filenames = sorted(set(annotations.keys()) | set(pending_edits.keys())) for filename in filenames: + has_staged_annotations = filename in annotations anns = annotations.get(filename, {}) edits = pending_edits.get(filename, []) # anns may be empty when the user deleted all annotations — still @@ -921,6 +952,8 @@ def create_app( item['element_id']: item['annotation'] for item in parse_annotations(root) } + if not has_staged_annotations: + anns = old_annotations # Clear all existing annotations from the file before writing current state for elem in root.iter(): diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/align_embed_images.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/align_embed_images.py index 7c0f12e0..d30ed99d 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/align_embed_images.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/align_embed_images.py @@ -46,12 +46,12 @@ from __future__ import annotations import base64 import io +import math import os import re import sys from pathlib import Path from typing import TYPE_CHECKING -from urllib.parse import unquote from xml.etree import ElementTree as ET _SCRIPTS_DIR = Path(__file__).resolve().parents[1] @@ -59,6 +59,11 @@ 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 resource_paths import ( # noqa: E402 + resolve_external_image_reference, + svg_data_uri_payload_error, + svg_image_payload_error, +) configure_utf8_stdio() @@ -119,14 +124,7 @@ def _resolve_image_path(href: str, svg_dir: Path) -> Path | None: """ if not href: return None - decoded = unquote(href) - if decoded.startswith(('http://', 'https://', 'file://')): - return None - if os.path.isabs(decoded): - candidate = Path(decoded) - else: - candidate = (svg_dir / decoded).resolve() - return candidate if candidate.exists() else None + return resolve_external_image_reference(svg_dir, href) def _is_svg_image(img_path: Path, raw_bytes: bytes) -> bool: @@ -156,6 +154,29 @@ def _load_pil_image(img_path: Path) -> 'PILImage' | None: return None +def _prepare_raster_for_geometry(img: 'PILImage') -> 'PILImage': + """Apply EXIF orientation and materialize palette/tRNS transparency.""" + from PIL import ImageOps + + prepared = ImageOps.exif_transpose(img) + if prepared.mode == 'P': + prepared = prepared.convert('RGBA' if _has_alpha(prepared) else 'RGB') + elif ( + 'transparency' in getattr(prepared, 'info', {}) + and prepared.mode not in {'RGBA', 'LA'} + ): + prepared = prepared.convert('RGBA') + return prepared + + +def _has_exif_geometry_transform(img: 'PILImage') -> bool: + """Return whether EXIF requires a physical mirror or rotation.""" + try: + return int(img.getexif().get(274, 1)) in range(2, 9) + except (AttributeError, TypeError, ValueError): + return False + + def _normalize_for_save(img: 'PILImage', mime_type: str) -> 'PILImage': """Coerce a PIL image into a mode that the target format can save. @@ -166,15 +187,17 @@ def _normalize_for_save(img: 'PILImage', mime_type: str) -> 'PILImage': if img.mode in ('RGBA', 'LA'): from PIL import Image background = Image.new('RGB', img.size, (255, 255, 255)) - alpha = img.getchannel('A') if img.mode == 'RGBA' else None + alpha = img.getchannel('A') background.paste(img.convert('RGB'), mask=alpha) return background if img.mode != 'RGB': return img.convert('RGB') return img - # PNG / GIF / WEBP — preserve alpha if present + # Lossless output — preserve alpha if present. if img.mode == 'P': - return img.convert('RGBA' if 'A' in img.getbands() else 'RGB') + return img.convert('RGBA' if _has_alpha(img) else 'RGB') + if img.mode not in {'1', 'L', 'LA', 'I', 'I;16', 'RGB', 'RGBA'}: + return img.convert('RGBA' if _has_alpha(img) else 'RGB') return img @@ -182,9 +205,7 @@ def _has_alpha(img: 'PILImage') -> bool: """Return whether a PIL image has transparency.""" if img.mode in ('RGBA', 'LA'): return True - if img.mode == 'P': - return 'transparency' in getattr(img, 'info', {}) - return False + return 'transparency' in getattr(img, 'info', {}) def _target_size( @@ -207,6 +228,8 @@ def _target_size( def _downscale_to_target(img: 'PILImage', target_w: int, target_h: int) -> tuple['PILImage', bool]: """Downscale without upsampling.""" width, height = img.size + if width <= 0 or height <= 0: + return img, False ratio = min(target_w / width, target_h / height, 1.0) if ratio >= 1.0: return img, False @@ -231,9 +254,11 @@ def _encode_pil_to_data_uri( bytes for that path. """ original_mime_type = get_mime_type(src_path.name, fallback_bytes) - mime_type = original_mime_type - if compress and mime_type == 'image/png' and not _has_alpha(img): - mime_type = 'image/jpeg' + # Match native export: only original JPEG assets stay lossy. PNG remains + # PNG, while BMP/TIFF and other static raster formats become lossless PNG. + mime_type = ( + 'image/jpeg' if original_mime_type == 'image/jpeg' else 'image/png' + ) pil_format = _PIL_FORMAT_BY_MIME.get(mime_type, 'PNG') # Encode current PIL image @@ -251,17 +276,20 @@ def _encode_pil_to_data_uri( except (OSError, ValueError): return None - # If caller passed the original bytes and they're smaller (because PIL - # round-tripping an asset that was already well-compressed inflates it), - # fall back to those. - chosen = encoded_bytes - if fallback_bytes and mime_type == original_mime_type and len(fallback_bytes) < len(encoded_bytes): - chosen = fallback_bytes - - chosen = _optimize_image_bytes( - chosen, mime_type, compress=compress, max_dimension=max_dimension, + optimized_bytes = _optimize_image_bytes( + encoded_bytes, mime_type, compress=compress, max_dimension=max_dimension, ) + # If the original represents the same uncropped pixels and is smaller, + # retain it instead of inflating an already efficient PNG/JPEG. + chosen = optimized_bytes + if ( + fallback_bytes + and mime_type == original_mime_type + and len(fallback_bytes) < len(optimized_bytes) + ): + chosen = fallback_bytes + b64 = base64.b64encode(chosen).decode('ascii') return f'data:{mime_type};base64,{b64}', len(chosen) @@ -306,7 +334,10 @@ def _process_one_image( href = _get_href(image) if not href: return False, None - if href.startswith('data:'): + if href.lower().startswith('data:'): + payload_error = svg_data_uri_payload_error(href) + if payload_error is not None: + return False, payload_error return False, None # already inline img_path = _resolve_image_path(href, svg_dir) @@ -325,6 +356,9 @@ def _process_one_image( return False, None if _is_svg_image(img_path, raw_bytes): + payload_error = svg_image_payload_error(raw_bytes) + if payload_error is not None: + return False, f'{img_path.name}: {payload_error}' _embed_raw_image(image, img_path, raw_bytes) if verbose: print(f' [OK] {img_path.name} (svg, embedded as-is)') @@ -352,11 +386,18 @@ def _process_one_image( print(f' [OK] {img_path.name} (animated, embedded as-is)') return True, None + geometry_normalized = _has_exif_geometry_transform(img) + img = _prepare_raster_for_geometry(img) box_x = _parse_float(image.get('x')) box_y = _parse_float(image.get('y')) box_w = _parse_float(image.get('width')) box_h = _parse_float(image.get('height')) - if box_w <= 0 or box_h <= 0: + if ( + not math.isfinite(box_w) + or not math.isfinite(box_h) + or box_w <= 0 + or box_h <= 0 + ): return False, 'zero-sized box' par_attr = image.get('preserveAspectRatio') or '' @@ -367,8 +408,10 @@ def _process_one_image( # ------------------------------------------------------------------ final_img: 'PILImage' = img new_x, new_y, new_w, new_h = box_x, box_y, box_w, box_h - transformed = False # True iff bitmap content changed (crop happened) + transformed = geometry_normalized target_box_w, target_box_h = box_w, box_h + preserve_stretch = False + preserve_slice = False if not par_attr: # No preserveAspectRatio at all. The previous pipeline's fix-aspect @@ -380,13 +423,20 @@ def _process_one_image( align, mode = parse_preserve_aspect_ratio(par_attr) if align == 'none': # Author wants stretch-to-box; preserve geometry, embed bytes. - pass + preserve_stretch = True elif mode == 'slice': x_anchor, y_anchor = get_crop_anchor(align) - cropped = crop_image_to_size(img, int(box_w), int(box_h), - x_anchor, y_anchor) - final_img = cropped + cropped_img = crop_image_to_size( + img, box_w, box_h, x_anchor, y_anchor + ) + final_img = cropped_img transformed = True + preserve_slice = not math.isclose( + cropped_img.size[0] / cropped_img.size[1], + box_w / box_h, + rel_tol=1e-6, + abs_tol=1e-9, + ) else: # meet (or any other mode → treat as meet) new_w_calc, new_h_calc, off_x, off_y = calculate_fitted_dimensions( img.size[0], img.size[1], box_w, box_h, mode='meet', @@ -425,7 +475,11 @@ def _process_one_image( image.set('y', _format_number(new_y)) image.set('width', _format_number(new_w)) image.set('height', _format_number(new_h)) - if 'preserveAspectRatio' in image.attrib: + if preserve_stretch: + image.set('preserveAspectRatio', 'none') + elif preserve_slice: + image.set('preserveAspectRatio', par_attr) + elif 'preserveAspectRatio' in image.attrib: del image.attrib['preserveAspectRatio'] if verbose: @@ -481,8 +535,10 @@ def align_and_embed_images_in_svg( try: tree = ET.parse(svg_path) except ET.ParseError as exc: - if verbose: - print(f' [ERROR] {svg_path.name}: parse failed ({exc})') + print( + f' [ERROR] {svg_path.name}: parse failed ({exc})', + file=sys.stderr, + ) return (0, 1) root = tree.getroot() @@ -511,10 +567,9 @@ def align_and_embed_images_in_svg( processed += 1 elif err: errors += 1 - if verbose: - print(f' [WARN] {svg_path.name}: {err}') + print(f' [ERROR] {svg_path.name}: {err}', file=sys.stderr) - if processed > 0 and not dry_run: + if processed > 0 and errors == 0 and not dry_run: tree.write(svg_path, encoding='utf-8', xml_declaration=False) return (processed, errors) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/crop_images.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/crop_images.py index 09b1f7f3..028bc319 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/crop_images.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_finalize/crop_images.py @@ -86,8 +86,8 @@ def get_crop_anchor(align: str) -> tuple[float, float]: def crop_image_to_size( img: Image.Image, - target_width: int, - target_height: int, + target_width: float, + target_height: float, x_anchor: float = 0.5, y_anchor: float = 0.5, ) -> Image.Image: @@ -108,6 +108,10 @@ def crop_image_to_size( Cropped PIL Image object (preserving original resolution) """ img_width, img_height = img.size + if img_width <= 0 or img_height <= 0: + raise ValueError('source image dimensions must be positive') + if target_width <= 0 or target_height <= 0: + raise ValueError('target image dimensions must be positive') # Calculate target aspect ratio target_ratio = target_width / target_height @@ -117,11 +121,11 @@ def crop_image_to_size( if img_ratio > target_ratio: # Original image is wider; crop left and right sides crop_height = img_height - crop_width = int(img_height * target_ratio) + crop_width = max(1, min(img_width, int(round(img_height * target_ratio)))) else: # Original image is taller; crop top and bottom sides crop_width = img_width - crop_height = int(img_width / target_ratio) + crop_height = max(1, min(img_height, int(round(img_width / target_ratio)))) # Calculate crop position based on anchor point extra_width = img_width - crop_width diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py index 2640eb8e..4cf5d7f6 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality_checker.py @@ -20,6 +20,7 @@ import hashlib from pathlib import Path from typing import List, Dict, Tuple from collections import Counter, defaultdict +from urllib.parse import unquote, urlsplit from xml.etree import ElementTree as ET from console_encoding import configure_utf8_stdio @@ -52,9 +53,13 @@ from svg_to_pptx.canvas_contract import ( ) try: - from project_specs import parse_spec_lock as _parse_spec_lock + from project_specs import ( + parse_spec_lock as _parse_spec_lock, + parse_spec_lock_image_value as _parse_spec_lock_image_value, + ) except ImportError: _parse_spec_lock = None # spec_lock anchor comparison will be skipped + _parse_spec_lock_image_value = None try: from svg_to_pptx.animation_config import ( @@ -101,8 +106,6 @@ try: matrix_multiply as _matrix_multiply, noncanonical_stroke_dash_numbers as _noncanonical_stroke_dash_numbers, noncanonical_transform_numbers as _noncanonical_transform_numbers, - parse_transform_matrix as _parse_transform_matrix, - parse_font_family as _parse_export_font_family, parse_inline_style as _parse_inline_style, parse_project_geometry_length as _parse_project_geometry_length, parse_project_image_aspect_ratio as _parse_project_image_aspect_ratio, @@ -112,10 +115,12 @@ try: parse_project_stroke_enum as _parse_project_stroke_enum, parse_svg_color as _parse_export_color, parse_svg_length as _parse_export_length, + parse_transform_matrix as _parse_transform_matrix, project_definition_errors as _project_definition_errors, project_filter_errors as _project_filter_errors, project_gradient_errors as _project_gradient_errors, project_image_aspect_ratio_errors as _project_image_aspect_ratio_errors, + project_mask_errors as _project_mask_errors, project_marker_errors as _project_marker_errors, project_opacity_errors as _project_opacity_errors, project_paint_errors as _project_paint_errors, @@ -124,6 +129,7 @@ try: project_transform_errors as _project_transform_errors, rect_to_dml_xfrm as _rect_to_dml_xfrm, transform_point as _transform_point, + unsafe_exported_font_faces as _unsafe_exported_font_faces, validate_dml_shape_matrix as _validate_dml_shape_matrix, ) except ImportError: @@ -150,8 +156,6 @@ except ImportError: _matrix_multiply = None _noncanonical_stroke_dash_numbers = None _noncanonical_transform_numbers = None - _parse_transform_matrix = None - _parse_export_font_family = None _parse_inline_style = None _parse_project_geometry_length = None _parse_project_image_aspect_ratio = None @@ -161,10 +165,12 @@ except ImportError: _parse_project_stroke_enum = None _parse_export_color = None _parse_export_length = None + _parse_transform_matrix = None _project_definition_errors = None _project_filter_errors = None _project_gradient_errors = None _project_image_aspect_ratio_errors = None + _project_mask_errors = None _project_marker_errors = None _project_opacity_errors = None _project_paint_errors = None @@ -173,6 +179,7 @@ except ImportError: _project_transform_errors = None _rect_to_dml_xfrm = None _transform_point = None + _unsafe_exported_font_faces = None _validate_dml_shape_matrix = None try: @@ -502,6 +509,9 @@ _BAKE_REQUIRED_VISUAL_PROPERTIES = frozenset({ 'isolation', 'mix-blend-mode', }) +_SHARED_FAIL_CLOSED_STYLE_PROPERTIES = frozenset({'mask'}) + + def _compact_preset_ancestor_paint( root: ET.Element, ) -> list[tuple[str, tuple[str, ...]]]: @@ -870,21 +880,6 @@ def _normalize_hex_rgb(value: str) -> str | None: return color[:6].upper() -# Fonts that survive direct PPTX typeface assignment on a typical Windows / -# macOS viewer without requiring a custom install. Keep this aligned with -# strategist.md §g and drawingml/utils.py FONT_FALLBACK_WIN. -PPT_SAFE_FONTS = { - 'microsoft yahei', 'simhei', 'simsun', 'kaiti', 'fangsong', - 'dengxian', 'microsoft jhenghei', - 'pingfang sc', 'heiti sc', 'songti sc', 'stsong', - 'arial', 'arial black', 'calibri', 'segoe ui', 'verdana', - 'helvetica', 'helvetica neue', 'tahoma', 'trebuchet ms', - 'times new roman', 'times', 'georgia', 'cambria', 'palatino', - 'garamond', 'book antiqua', - 'consolas', 'courier new', 'menlo', 'monaco', - 'impact', -} - # Cheap numeric envelope for font-size role enforcement. Semantic role assignment # is prompt-owned; Checker only verifies that a used value is close to at least # one declared size anchor. @@ -1164,7 +1159,11 @@ class SVGQualityChecker: self._undeclared_size_occurrences: Counter[str] = Counter() self._undeclared_size_counts_ready = False self._lock_seen = False # True once we locate at least one spec_lock.md - self._source_manifest_cache: Dict[Path, Dict] = {} + self._source_manifest_cache: Dict[ + Path, + Tuple[Dict, str | None], + ] = {} + self._source_manifest_errors_reported: set[Path] = set() # Template-mode aggregation (populated by check_directory when # template_mode=True). Each entry is (severity, kind, message) where # severity is 'error' or 'warning'. Printed in print_summary. @@ -1291,6 +1290,7 @@ class SVGQualityChecker: # 2. Check forbidden elements self._check_forbidden_elements(content, root, result) + self._check_mask_contract(root, result) # 2a. Validate direct geometry lengths and stroke widths. self._check_geometry_length_values(root, result) @@ -1388,7 +1388,11 @@ class SVGQualityChecker: # 10. Check web-sourced image attribution. Templates don't carry # image_sources.json; skip in template mode. if not self.template_mode: - self._check_sourced_image_attribution(content, svg_path, result) + self._check_sourced_image_attribution( + root, + svg_path, + result, + ) # Determine pass/fail result['passed'] = len(result['errors']) == 0 @@ -1524,11 +1528,6 @@ class SVGQualityChecker: # Forbidden elements blocklist - PPT incompatible # ============================================================ - # Clipping / masking. The closed image clip-path contract is validated - # separately by _check_clip_path_contract. - if 'mask' in local_names: - result['errors'].append("Detected forbidden element (PPT does not support SVG masks)") - # Style system if 'style' in local_names: result['errors'].append("Detected forbidden
PackyCodeThanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our project users: register using this link and enter the promo code ppt-master during recharge to get 10% off.PackyCodeThanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our project users: register using this link and enter the promo code ppt-master during recharge to get 10% off.
APIKEY.FUN