diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index 89d0e7ba..520abf66 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -24,8 +24,8 @@ "repo": "https://github.com/Yeachan-Heo/oh-my-codex.git", "ref": "main", "adapter": "codex-plugin", - "commit": "57f8e682af899b5d0e28d05b238c903c2fdeb913", - "syncedAt": "2026-07-29T16:00:04Z" + "commit": "bf497e67604f788fe4e85f0f13ec086e50857c5d", + "syncedAt": "2026-07-31T16:00:01Z" }, { "id": "ui-ux-pro-max", @@ -33,8 +33,8 @@ "repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git", "ref": "main", "adapter": "claude-skill", - "commit": "4857a2c5ef989794751a0f66b8545a4a49566286", - "syncedAt": "2026-07-28T15:59:58Z" + "commit": "ec1f2a9027e270c9a4e8e3dbc243136fa9d32505", + "syncedAt": "2026-07-31T16:00:01Z" }, { "id": "caveman", @@ -60,8 +60,8 @@ "repo": "https://github.com/shadcn-ui/ui.git", "ref": "main", "adapter": "claude-skill", - "commit": "5203f537d152844a920caa66e865bc61c6ff4860", - "syncedAt": "2026-07-29T16:00:04Z" + "commit": "cb2bcd88d93b2f9bddb030e9136f1f8773e7eac4", + "syncedAt": "2026-07-31T16:00:01Z" }, { "id": "frontend-slides", @@ -96,8 +96,8 @@ "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", "adapter": "claude-skill", - "commit": "dd6c503df8c247b6544dadf2313c4cceff6b0281", - "syncedAt": "2026-07-29T16:00:04Z" + "commit": "6b42a6a652f9d6e9fc0c81e634c9fdfe771eee10", + "syncedAt": "2026-07-31T16:00:01Z" }, { "id": "next-skills", @@ -105,8 +105,8 @@ "repo": "https://github.com/vercel/next.js.git", "ref": "canary", "adapter": "skill-collection", - "commit": "91c6309c52ab90a6344f9aba059dabf82e82bc0b", - "syncedAt": "2026-07-29T16:00:04Z" + "commit": "7612eaeda1d70daa5abaef01aa255541f8dc3fb8", + "syncedAt": "2026-07-31T16:00:01Z" } ] } diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index 7df158ae..b6f70626 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-29T16:04:09Z" + "syncedAt": "2026-07-31T16:01:47Z" } diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index 245dc7b3..270b3f1b 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": "91c6309c52ab90a6344f9aba059dabf82e82bc0b", + "commit": "7612eaeda1d70daa5abaef01aa255541f8dc3fb8", "adapter": "skill-collection", "sourcePath": "skills", - "syncedAt": "2026-07-29T16:00:04Z" + "syncedAt": "2026-07-31T16:00:01Z" } diff --git a/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json index b6c251ed..47f98b5e 100644 --- a/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "oh-my-codex", "repo": "https://github.com/Yeachan-Heo/oh-my-codex.git", "ref": "main", - "commit": "57f8e682af899b5d0e28d05b238c903c2fdeb913", + "commit": "bf497e67604f788fe4e85f0f13ec086e50857c5d", "adapter": "codex-plugin", "sourcePath": "plugins/oh-my-codex", - "syncedAt": "2026-07-29T16:00:04Z" + "syncedAt": "2026-07-31T16:00:01Z" } diff --git a/plugins/codex/plugins/ppt-master/README.md b/plugins/codex/plugins/ppt-master/README.md index 14a77641..9175f874 100644 --- a/plugins/codex/plugins/ppt-master/README.md +++ b/plugins/codex/plugins/ppt-master/README.md @@ -303,7 +303,8 @@ You: Please create a PPT from projects/q3-report/sources/report.pdf You: Please turn the following into a PPT: [paste your content here...] ``` -Either way, the AI will first confirm the design spec: +By default—unless you explicitly request quick generation—the AI first confirms +the design spec: ``` AI: Sure. Let's confirm the design spec: @@ -315,7 +316,7 @@ AI: Sure. Let's confirm the design spec: The AI handles everything — content analysis, visual design, SVG generation, and PPTX export. -> **Output:** The SVG pipeline has one PPTX converter: it reads `svg_output/` and writes a directly editable native DrawingML deck to `exports/_.pptx`. The normal delivery flow runs `finalize_svg.py`, produces self-contained previews in `svg_final/`, and snapshots `svg_output/` to `backup//svg_output/`; PowerPoint's manual **Convert to Shape** command is outside the supported contract. Explicit disposable few-page tests may instead use [quick-test mode](./skills/ppt-master/workflows/profiles/quick-test.md), which writes only the authored SVGs and one PPTX—no planning, preview, notes, validation, or backup artifacts. By default charts and tables export as individually editable SVG-derived DrawingML shapes, which prioritize cross-app visual consistency. Pass `--native-charts-and-tables` to replace eligible groups with PowerPoint-native Chart/Table objects backed by data, which provide **Edit Data** and object-specific controls but may render differently across apps; this variant is saved as `exports/__native_charts_tables.pptx`. Both routes are editable—the distinction is the PowerPoint object model, not editability itself. +> **Output:** The SVG pipeline has one PPTX converter: it reads `svg_output/` and writes a directly editable native DrawingML deck to `exports/_.pptx`. The default Generate flow runs `finalize_svg.py` and produces self-contained previews in `svg_final/`; PowerPoint's manual **Convert to Shape** command is outside the supported contract. Explicit [quick generation](./skills/ppt-master/workflows/profiles/quick-generate.md) still converts sources, researches factual gaps, and prepares required images, icons, formulas, and resource manifests when needed. The current agent makes the content, page, visual, and resource decisions in context, skips Strategist, confirmation, `design_spec.md`, `spec_lock.md`, and `finalize_svg.py`, then hand-authors the SVG pages, passes the lockless Quick final quality check, and exports the final PPTX. Ordinary export capabilities remain available as needed, including native chart/table replacement, notes, motion, narration, and diagnostics; notes, custom object animation, and narration start off, and the agent may enable them when the request or deck needs them. A default-path Quick export writes the normal postflight report and snapshots `svg_output/` to `backup//svg_output/`; an explicit output path keeps the ordinary no-backup behavior. By default charts and tables export as individually editable SVG-derived DrawingML shapes, which prioritize cross-app visual consistency. Pass `--native-charts-and-tables` to replace eligible groups with PowerPoint-native Chart/Table objects backed by data, which provide **Edit Data** and object-specific controls but may render differently across apps; this variant is saved as `exports/__native_charts_tables.pptx`. Both chart/table export variants are editable—the distinction is the PowerPoint object model, not editability itself. > **Already have a `.pptx` you want to reuse?** Hand the AI that deck plus your material and ask it to "fill this deck with the new content" — it fills text, table, and chart data into your existing design and exports only the pages you pick, staying natively editable. See the [FAQ](./docs/faq.md) and [template-fill workflow](./skills/ppt-master/workflows/template-fill-pptx.md). diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json index c2aaacc7..1e1a511b 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": "dd6c503df8c247b6544dadf2313c4cceff6b0281", + "commit": "6b42a6a652f9d6e9fc0c81e634c9fdfe771eee10", "adapter": "claude-skill", "sourcePath": "skills/ppt-master", - "syncedAt": "2026-07-29T16:00:04Z" + "syncedAt": "2026-07-31T16:00:01Z" } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md index 5708ee8d..475eda60 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/SKILL.md @@ -2,7 +2,7 @@ name: ppt-master description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。" metadata: - version: "4.2.0" + version: "4.3.0" --- # PPT Master Skill 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 a7580c2c..6504dccc 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 @@ -13,7 +13,7 @@ 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) | +| Reveal content in step with the narration | Per-element object animation — `-a auto` for generic entrance reveals, or an `animations.json` sidecar for explicit enter/emphasize/move/exit/static lifecycle choreography | 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, 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 | Consider slow `path_*` motion on a visually subordinate image or atmospheric layer; §4.1 gives one starting recipe | Post-processing; §4.1 | | 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 | @@ -47,7 +47,13 @@ To regenerate a deck with different settings, rerun `svg_to_pptx.py` against the ## 2. Custom Object-Level Animation -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. +Per-element animation is off by default. To enable generic entrance reveals +deck-wide, pass `-a auto` at export (no config needed). When a deck instead +needs a specific object lifecycle—for example enter, move, emphasize, then +exit—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 project already carries `animations.json`, when @@ -57,15 +63,16 @@ reveals, or when the effective Custom Animations outcome in advice and informs an active pass, but never triggers the stage alone. **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. +object animation is in scope, derive motion units and their lifecycle duties +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 python3 skills/ppt-master/scripts/animation_config.py list-groups -# Build an editable scaffold from the post-regroup anchors when useful +# Build a neutral editable scaffold from the post-regroup anchors when useful python3 skills/ppt-master/scripts/animation_config.py scaffold # Validate references before export @@ -75,18 +82,26 @@ python3 skills/ppt-master/scripts/animation_config.py validate python3 skills/ppt-master/scripts/svg_to_pptx.py ``` +The scaffold keeps `defaults.animation.effect: none` and may list untouched +groups as empty `{}` placeholders; creating it does not opt the deck into +object motion. Populate only adopted motion units. + Sparse sidecar excerpt (unlisted slides inherit resolved defaults): ```json { "version": 1, "slides": { - "03_market": { + "03_threshold": { "groups": { - "title": { "effect": "entrance_fade", "order": 1 }, - "chart": { "effect": "entrance_wipe", "effect_options": { "direction": "left" }, "order": 2, "duration": 0.6 }, - "details-button": { "effect": "none" }, - "insight": { "effect": "entrance_fly", "effect_options": { "direction": "up_right" }, "order": 3, "delay": 0.2, "trigger_shape": "details-button" } + "risk-marker": { + "effects": [ + { "effect": "entrance_fade", "order": 1, "duration": 0.25 }, + { "effect": "path_right", "effect_options": { "relative": true }, "order": 2, "duration": 0.7 }, + { "effect": "emphasis_teeter", "order": 3, "duration": 0.45 }, + { "effect": "exit_fade", "order": 4, "duration": 0.3 } + ] + } } } } @@ -97,18 +112,33 @@ Rules: - `slides` keys match SVG stems (`03_market.svg` → `03_market`). - `groups` keys match top-level `` anchors. -- `effect: none` removes that group from the object-animation sequence. -- `order` changes animation order only; it does not change slide layering. -- `delay` is seconds before that group starts in `after-previous` mode. -- `trigger_shape` is a group-only reference to another unique, triggerable +- A populated group block chooses exactly one representation: the + backward-compatible single-effect object, or + `{ "effects": [row, ...] }`. `effects` is non-empty and mutually exclusive + with every legacy single-effect field; each row explicitly names `effect`. + An untouched scaffold `{}` remains a neutral placeholder. +- `effect: none` in the legacy form removes that group from the object-animation + sequence and is useful for overriding inherited generic animation. +- `effects[]` permits the same PowerPoint shape to carry several Animation Pane + rows. `order` sorts ordinary rows across the slide; ties retain SVG group + order and then array order. `trigger_shape` rows keep that relative ordering + in separate interactive sequences rather than interleaving with the main + sequence. Ordering never changes slide layering. +- `delay` is seconds added to that row's resolved Start. +- `trigger` may be set per legacy row or `effects[]` row; otherwise it inherits + the resolved slide Start mode. +- `trigger_shape` is a row-specific reference to another unique, triggerable top-level group. It maps to PowerPoint **Trigger → On Click of**, makes only - that row interactive, and uses `delay` as `TriggerDelayTime`. -- `duration` overrides the per-group schedule duration. `entrance_appear` + that row interactive, and uses `delay` as `TriggerDelayTime`. It implies + `on-click`; an explicit row `trigger` may accompany it only when also + `on-click`. +- `duration` overrides the per-row schedule duration. `entrance_appear` remains a 1ms visibility flip, and instantaneous native emphasis presets retain their PowerPoint-authored duration; the configured value still spaces the next `after-previous` row. -- `effect_options` requires an explicit canonical `effect` in the same block - and accepts only parameters PowerPoint exposes for that effect: +- `effect_options` requires an explicit canonical `effect` in the same legacy + block or `effects[]` row and accepts only parameters PowerPoint exposes for + that effect: | Option | Applies to | |---|---| @@ -118,7 +148,7 @@ Rules: | `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` +- Any animation block or effect row may set `repeat_count` or `repeat_duration` (mutually exclusive), `auto_reverse`, `rewind`, `accelerate`, `decelerate`, `bounce_end`, `restart`, `after_effect`, and `sound`. Ratios are `0..1`; `bounce_end` requires an interpolated behavior and cannot combine with @@ -129,9 +159,10 @@ Rules: - `Speed` and smooth start/end are not duplicate sidecar fields: they are derived from `duration` and `accelerate`/`decelerate`. - This is the complete parameter surface for the generated top-level-group - target model. PowerPoint paragraph/text-range build fields are intentionally - absent because grouped SVG content is not emitted as paragraph builds; media - play/pause/stop commands remain in the audio/video workflows. + target model, including multiple ordered effects on one group. PowerPoint + paragraph/text-range build fields are intentionally absent because grouped + SVG content is not emitted as paragraph builds; media play/pause/stop + commands remain in the audio/video workflows. - Run `python3 skills/ppt-master/scripts/pptx_animations.py --describe ` for that effect's exact option values and full parameter contract. @@ -144,9 +175,10 @@ slides and omitted slide fields inherit `defaults.transition` / `defaults.animation`, then CLI/exporter 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. +slide duration, Start mode, timing modifiers, after-effect, and sound into each +legacy or `effects[]` row. `effect_options` remains coupled to an explicit +effect; `trigger_shape` is never inherited; omitted `order`/`delay` use +exporter defaults. ### 2.1 Deterministic Morph Object Pairing @@ -210,6 +242,33 @@ The generated names follow Microsoft's ## 3. Page Transitions +**Reference — not a constraint**: choose a transition from the relationship +between adjacent pages, not from gallery coverage. Run this playbook before +selecting a canonical key: + +| Pass | Decision | +|---|---| +| Relate | Decide whether the destination continues the same object or space, advances in a meaningful direction, opens a new section, or intentionally breaks continuity. | +| Diagnose | Name the transition's job: neutral continuity, immediate cut, directional progress, object/state continuity, spatial movement, or a deliberate thematic beat. | +| Select | Use the smallest family that performs that job; keep `fade` when no stronger relationship exists. | +| Coordinate | Align direction, duration, and recurrence with reading order, narration, and the deck's established motion language. | +| Stop | Keep `fade` or `none` when another effect adds no meaning; never vary transitions for catalog coverage. | + +| Page relationship | Candidate family | +|---|---| +| Ordinary continuation within one section | `fade` | +| Immediate change with no continuity to preserve | `none` or `cut` | +| Directional steps, timeline, or layer progression | `push` / `wipe`; use `cover` / `uncover` when an overlay relationship is visible | +| The same semantic object or scene changes across adjacent pages | `morph`; use §2.1 pairs when identity must be deterministic | +| Section opening, key reveal, or marked state boundary | Selective `split` / `reveal` / `shape` / `flash` / `random_bars` | +| A repeated collection advances through one spatial frame | `pan` / `conveyor` / `ferris_wheel`; use the §4.2 Morph carousel when individual cards need deterministic identity | +| The viewpoint travels around or through a continuous space | `rotate` / `window` / `orbit` / `fly_through` | +| The narrative or theme supports a stage, paper, or physical-page metaphor | Selective `fall_over` / `drape` / `curtains` / `wind` / `prestige` / `peel_off` / `page_curl` / `airplane` / `origami` / `doors` | +| A disruptive beat represents breakage, collapse, or dispersal | Selective `fracture` / `crush` / `dissolve` / `vortex` / `shred` | +| A marked reveal benefits from a geometric, timed, or textured pattern | Selective `checkerboard` / `blinds` / `clock` / `ripple` / `honeycomb` / `glitter` / `comb` | +| A card, panel, gallery, or viewpoint visibly turns or changes face | Selective `switch` / `flip` / `gallery` / `cube` / `box` / `zoom` | +| Unpredictability is itself the requested behavior | `random`; never use it merely to create variety | + ```bash # Pick a different effect python3 skills/ppt-master/scripts/svg_to_pptx.py -t push --transition-duration 0.6 @@ -263,7 +322,7 @@ Morph tweens objects it can match across consecutive slides. That makes it a gen | Object rotates | Flip, turn, hinge | | Image container scales up | Camera push-in | | Scrim opacity drops, or a cut contour grows | Progressive reveal | -| Same wide image at two `x` offsets | Camera pan (see image-layout-patterns `#87`) | +| Same wide image at two `x` offsets | Camera pan (see image-layout-patterns `#C2-01`) | Chain three or more pages to build a sequence — extend, hold, retract — where each page is still an ordinary editable slide. @@ -286,7 +345,7 @@ that attribute remains importer metadata for mirror/preserve packages **Not supported — Slide Zoom / Summary Zoom.** Click-to-jump navigation built on PowerPoint's Zoom objects (the "click a portrait, zoom into that section" pattern) has no exporter path. Build click-driven navigation with `trigger_shape` on ordinary object animations instead, or with plain hyperlinks. -**No 3D**: perspective rotation, extrusion, and shear are outside the SVG contract — `skewX` / `skewY` and shear matrices fail closed ([`svg-effects.md`](./svg-effects.md) §6.8). Build the same impression with 2D means — offset, scale, overlap, and per-facet lightness (image-layout-patterns `#91`) — rather than attempting a 3D tilt. +**No 3D**: perspective rotation, extrusion, and shear are outside the SVG contract — `skewX` / `skewY` and shear matrices fail closed ([`svg-effects.md`](./svg-effects.md) §6.8). Build the same impression with 2D means — offset, scale, overlap, and per-facet lightness — rather than attempting a 3D tilt. --- @@ -294,18 +353,40 @@ that attribute remains importer metadata for mirror/preserve packages Off by default — enable deck-wide with `-a auto` (or another effect). Once enabled, three Start modes are available — these mirror PowerPoint's animation-pane "Start" dropdown: -- **`on-click`** — entering a slide → first click reveals the first semantic group; each subsequent click reveals the next group in z-order. Suits live presentations where the speaker paces reveals. Forbidden with `--recorded-narration` because video-ready exports need click-free playback. -- **`with-previous`** — all groups start together on slide entry, playing their object animation in parallel. Stagger ignored. -- **`after-previous`** (default) — first group fires on slide entry, subsequent groups cascade after the previous one finishes, with `--animation-stagger` extra spacing. Suits kiosk playback, recorded walkthroughs, or anyone who wants visual flow without clicking. +- **`on-click`** — each click reveals the next group. Use only for a controlled semantic reveal; live delivery alone is insufficient. Forbidden with `--recorded-narration`. +- **`with-previous`** — groups start together as one coordinated beat. Stagger ignored. +- **`after-previous`** (default) — click-free cascade on slide entry with `--animation-stagger` spacing. Use when controlled reveals are unnecessary. + +**Default — coherent Start rhythm (may override when a semantic beat needs +different control)**: Keep one dominant deck rhythm and normally one mode per +slide. Mix only for a distinct simultaneous or presenter-controlled beat. Enable with `-a auto`, select a canonical effect with `--animation entrance_fade`, and choose Start behavior with `--animation-trigger on-click|with-previous|after-previous`. -PowerPoint's separate **Trigger → On Click of** behavior uses group-only +PowerPoint's separate **Trigger → On Click of** behavior uses row-specific `trigger_shape`. It links that row to another top-level group while unlinked rows keep the slide Start mode; it is not a fourth deck-wide Start mode. +**Mandatory — lifecycle before effect selection**: start from `static`, then +classify semantic `initial → action → end` before choosing an effect. Generic +staged reveals normally use `enter`; narrower communication jobs select their +matching lifecycle instead. + +| Duty | State contract | Use when | Effect family | +|---|---|---|---| +| `static` | present → hold as reference → present | Motion adds no clarity or intended feeling | No row; legacy `effect: none` only suppresses inheritance | +| `enter` | absent → introduce → present | Information should be withheld, ordered, or revealed with narration | `entrance_*`; modes only for generic reveal | +| `emphasize` | present → redirect attention → present/altered | An already visible object must regain attention or show a local change; never substitute for its first reveal | Explicit `emphasis_*` | +| `move` | state/position A → progress → state/position B | The trajectory carries spatial or causal meaning, or §4.1 adopts subordinate ambient motion; use Morph for cross-page continuity | Explicit `path_*`, or endpoint pages + Morph | +| `exit` | present → retire → absent | The same slide must remove, replace, or make room for content; an ordinary page change needs no object exit | Explicit `exit_*` | + +**Default — restrained entrance-led choreography (may override for content, +tone, or the request)**: Use entrances for ordinary builds. Add emphasis or +exit sparingly, only for a real duty and fitting effect. Multiple `effects[]` +rows require multiple duties. + The registry exposes two layers: - **203 PowerPoint-native object presets**: 53 `entrance_*` presets, 33 @@ -336,21 +417,24 @@ The native keys mirror the object-capable `MsoAnimEffect` surface. The four media commands—play, pause, stop, and play from bookmark—are not object effects for SVG groups and remain owned by the audio/video workflows. -- `auto` maps semantic ids to canonical entrances: charts/tables/timelines use +- `auto` handles generic `enter` duties only and maps semantic ids to canonical entrances: charts/tables/timelines use `entrance_wipe`; cards/steps use `entrance_fly`; titles/takeaways use `entrance_fade`; image-like ids cycle a richer pool; unmatched ids cycle fade/wipe/fly/zoom. -- `mixed` (legacy mode name) — deterministic. The first animated group on each +- `mixed` (legacy mode name) handles generic `enter` duties only and is + deterministic. The first animated group on each slide uses `entrance_fade`; later groups cycle through a 16-effect canonical PowerPoint entrance pool across the deck. The mode name remains compatible; it no longer selects hand-authored compatibility rows. -- `random` — samples from the same canonical PowerPoint entrance pool. +- `random` handles generic `enter` duties only and samples from the same + canonical PowerPoint entrance pool. Resolution is seeded from the effective deck input, so the same input produces the same choices; `--conversion-trace` records every resolved effect when diagnostics are enabled. `entrance_appear` is excluded from every variation pool because it has no -visible motion. +visible motion. `auto`, `mixed`, and `random` never satisfy an adopted +`emphasize`, `move`, or `exit` duty; those require explicit canonical effects. Flags: `-a/--animation` selects effect/mode; `--animation-trigger` selects Start; `--animation-duration` and `--animation-stagger` control base timing; @@ -372,7 +456,7 @@ context. Keep a full-bleed moving image covering the canvas at both endpoints; exposing the slide beneath it is a visible failure. -It pairs naturally with a fixed foreground: with image-layout-patterns `#90`, the scrim and its cut contour stay locked while the world moves behind the cuts, which reads as looking through windows rather than as a sliding photo. The same logic applies to `#82` and `#12`. +It pairs naturally with a fixed foreground: with image-layout-patterns `#M1-07`, the scrim and its cut contour stay locked while the world moves behind the cuts, which reads as looking through windows rather than as a sliding photo. The same logic applies to `#M1-10` and `#P1-09`. Motion remains subordinate: avoid competing ambient paths or movement that reduces the readability of body copy or data. Multiple coordinated layers are @@ -385,7 +469,7 @@ mechanisms already defined above — none needs a new capability. **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 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. A small stagger, such as `0.1s`, can make digit columns settle in sequence; synchronized motion is also valid when it fits the intended rhythm. +**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) `#M1-08`). 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. A small stagger, such as `0.1s`, can make digit columns settle in sequence; synchronized motion is also valid when it fits the intended rhythm. **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. @@ -395,12 +479,18 @@ mechanisms already defined above — none needs a new capability. ## 5. Anchor Logic — Top-Level `` -Per-element animations are anchored on **top-level `` content groups** in the SVG (e.g. ``, ``). IDs must be unique within the page. One group produces one animation-pane row; whether that row needs a click depends on the selected Start mode. Nested implementation groups may remain anonymous because the sidecar does not target them. +Per-element animations are anchored on **top-level `` content +groups** in the SVG (e.g. ``, ``). IDs must +be unique within the page. A backward-compatible single-effect group produces +one Animation Pane row; `effects[]` may produce several ordered rows targeting +the same PowerPoint shape. Each row inherits the slide Start mode unless it +declares its own `trigger`. Nested implementation groups may remain anonymous +because the sidecar does not target them. **Hard rule — existing groups are not custom-animation intent**: the pre-existing SVG hierarchy is implementation evidence, not an authoritative -reveal plan. During the custom-animation stage, derive one group per logical -page unit from claims, comparisons, sequence, causality, and narration beats; +motion plan. During the custom-animation stage, derive one group per logical +motion unit from claims, comparisons, sequence, causality, and narration beats; split coarse wrappers and merge fragmented atoms when needed, then use `list-groups` only after that rewrite. This is also the granularity PowerPoint uses for group-select / group-move. Do not split or merge units to hit a target @@ -429,8 +519,9 @@ reference; a self-trigger; or any attempt to animate or trigger from a structural layer. These errors never downgrade or silently omit a target. Generated export reads each slide's timing tree back and checks row count/order, -trigger, trigger shape, shape target, preset class, resolved effect tuple, native behavior -signature, duration, and timeline offset. Package validation then checks root +including repeated rows on one shape, 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 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 730346cd..99d3470a 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 @@ -1,20 +1,28 @@ # Artifact Ownership Specification -Global artifact ownership rules for PPT Master projects. +Global artifact ownership rules for PPT Master projects. A selected route or +profile may explicitly omit an artifact without erasing its facts. **Hard rule**: Read each fact from its owning artifact. Do not merge multiple channels into a second source of truth. +**Quick Generate projection**: Quick omits confirmation, Design Spec, and lock. +Its current main agent reads source/analysis facts, keeps routine decisions in +active context, and prepares images/icons/formulas plus required manifests +before SVG authoring. Those artifacts retain their factual/provenance roles. +Quick writes the same final SVG quality provenance and package postflight as +the default profile, but it does not create `svg_final/`. + --- ## 1. Ownership Matrix | 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 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/` content-type files | Content contract | Main pipeline factual/text origin for tables, chart data values, SmartArt node wording, and presentation content | Default Strategist reads content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`), judges by content, and resolves approved semantics plus preferred on-slide wording into §IX; Quick's current agent resolves them in active context. Default Executor opens source passages only for explicit verification/resolution. Never replace values with PPTX geometry JSON. | +| `sources/*.facts.json` | Fact provenance contract | Stable external `fact_id` → claim/source mapping created by topic research | Default Strategist cites IDs in §IX and Executor resolves them for attribution; Quick's current agent carries the same IDs into visible 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 | -| `analysis/source_profile.json` | Machine fact index | Compact Strategist-facing PPTX intake digest | Main pipeline reads as factual context and recommendation candidates | +| `analysis/source_profile.json` | Machine fact index | Compact PPTX intake digest | Default Strategist or Quick's current agent reads it as factual context and recommendation candidates | | `analysis/.identity.json` | Native deck identity facts | Canvas, theme palette/fonts, observed usage | Read selectively when detailed identity facts are needed | | `analysis/.slide_library.json` | Native PPTX structure facts | Text slots, geometry, native tables, native chart caches, SmartArt nodes/connections | Direct PPTX workflows use as native fill/structure contract | | `analysis/image_analysis.csv` | Regenerated image fact view | Measured facts about the current `images/` folder | Re-run `analyze_images.py` before reading image facts after changes | @@ -22,8 +30,9 @@ Global artifact ownership rules for PPT Master projects. | `spec_lock.md` | Execution anchor and routing contract | Machine-readable stable color/type roles, icons, images, page rhythm, charts, `template_reuse_scope`, and the route's PowerPoint structure mode; mirror/layout template routes additionally own input prototypes, the Master roster, and the complete page-to-Master/Layout mapping | Strategist authors the route-specific anchors from the audited Design Spec plus current project/page/template context. Executor retains the complete lock once per valid execution context; local uncertainty consults that retained copy before the owning Design Spec fragment. Sparse page-local color/font garnish needs no lock row; a recurring semantic role or new adaptive Layout identity requires Strategist repair before reuse. | | `project_manager.py page-context` stdout | Derived on-demand page context | Read-only model-facing anchor set + current-page delta + fingerprints for large references | Use only for explicit diagnostics/telemetry or an unresolved page/template/chart path-SHA projection. Never edit or persist it as a replacement source of truth, and never run it as a routine pre-page gate. `global` is a bounded anchor set, not a whitelist. `reference_set` carries path/SHA/load policy but never appends reference payloads. | | `analysis/page-context/P.usage.json` | Derived optional context telemetry | Measured on-demand page-context size plus hashes of owning inputs/references | `page-context --record-usage` deterministically replaces only the invoked page's snapshot; `page-context-report` summarizes existing snapshots. Telemetry may be partial. Use token data to evaluate context cost, never as content or an execution contract. | -| `images/` | Runtime image pool | User, extracted, AI, web, formula, slice, EMF/WMF assets | Step 5 writes here; `analysis/image_analysis.csv` derives from current contents | -| `icons/` | Prepared project icon pool | Bundled icons copied by `icon_sync.py` plus user-provided, template, imported, or custom icon SVGs | Executor may use any icon in this project-local pool; `spec_lock.icons.inventory` records planned bundled choices rather than an exhaustive whitelist. Exporter global fallback is legacy compatibility only. | +| `images/` | Runtime image pool | User, extracted, AI, web, formula, slice, EMF/WMF assets | Default Step 5 or Quick Generate resource preparation writes here; `analysis/image_analysis.csv` derives from current contents | +| `images/image_prompts.json`, `image_queries.json`, `image_sources.json`, `formula_manifest.json` | Conditional resource contracts | AI/web/formula execution status and provenance | Create only for a triggered path, including Quick. They guide preparation/attribution, never page design. | +| `icons/` | Prepared project icon pool | Bundled icons copied by `icon_sync.py` plus user-provided, template, imported, or custom icon SVGs | SVG authoring may use any icon in this project-local pool; `spec_lock.icons.inventory` records the default plan's bundled choices rather than an exhaustive whitelist. Exporter global fallback is legacy compatibility only. | | `templates/` | Project template reference | Step 3 imported specs, template SVGs, and non-image assets | Strategist reads the template Design Spec and actual SVG roster during planning. Continuous Executor reuses that context; fresh Executor reads the Design Spec once and each selected complete SVG only before first use or after its SHA changes. | | `templates/template_execution_manifest.json` (`v1`) + `templates/template_execution/*.text-slots.json` (`v2-min`) | Derived template index | Compact prototype/source-import summary plus per-prototype text-slot diagnostics; the sidecar integrity hash is tool-only | Materialization may publish these deterministic records, but page-context does not inject or require them and models do not read them during page authoring. The complete prototype SVG is the sole visual/template authority; never author from either JSON artifact. | | `/svg/` | Imported native-payload backing | Complete PPTX-derived metadata, hidden carriers, fallback evidence, and source structure | Keep immutable; create-template materialization may resolve a validated source ref against these files, but models do not edit or bulk-read them | @@ -38,11 +47,11 @@ Global artifact ownership rules for PPT Master projects. | `svg_output/` | Page-design author source | Main-agent handwritten SVG pages containing the complete visible design | Quality checker and native PPTX export read this as the canonical visual/page-layout source; templates and locks do not add missing visible objects at export | | `notes/total.md` | Conditional speaker-note source | Complete notes before splitting | Step 6 writes only when the effective Speaker Notes outcome is enabled; Step 7.1 splits | | `notes/slide_*.md` | Conditional split notes | Per-slide notes generated from `total.md` | Derived by `total_md_split.py` only when speaker notes are enabled | -| `svg_final/` | Derived visual preview | Self-contained post-processed SVGs that may be opened directly or inserted as SVG pictures | Rebuild from `svg_output/` with `finalize_svg.py`; do not use as a supported PPTX source | -| `validation/svg_quality_report.json` | Quality provenance | Final SVG gate split into blocking / introduced / inherited / source-import categories, bound to the checked SVG bytes by SHA-256 | `svg_quality_checker.py --stage final --json` writes before export; the exporter reads it programmatically and links it only when the export-source fingerprint matches. Agents use successful command output and do not load the full JSON except for targeted failure/audit reads. | -| `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 | +| `svg_final/` | Default-only derived visual preview | Self-contained post-processed SVGs that may be opened directly or inserted as SVG pictures | Default rebuilds it from `svg_output/` with `finalize_svg.py`; Quick omits it. Never use it as a supported PPTX source. | +| `validation/svg_quality_report.json` | Final SVG quality provenance | Final SVG gate split into blocking / introduced / inherited / source-import categories, bound to the checked SVG bytes by SHA-256 | Default runs `svg_quality_checker.py --stage final --json`; Quick adds `--quick-generate` so the checker ignores Design Spec/lock and validates the lockless flat roster. Export links the report only when fingerprints match; Quick requires that link to pass before PPTX creation. | +| `validation/.report.json` | Published-package audit | PPTX package/resource postflight status, part counts, and quality-gate linkage | Both Generate profiles write it and emit `[POSTFLIGHT]` after package validation. | +| `exports/` | Delivery artifacts | Native DrawingML PPTX and explicit native-object/narration variants | Default Step 7.3 or Quick direct export writes final deliverables from `svg_output/`. | +| `backup//svg_output/` | Default-path frozen author-source archive | Re-export source without re-running LLM | Both Generate profiles write a snapshot for default-path exports; explicit `-o/--output` skips it. | | `animations.json` | Optional animation config | Page-transition and object-animation sidecar | Existing files activate intent resolution: Stage 3 `false` preserves, explicit objects-off exports `-a none`, and all-motion-off bypasses with `--no-animations`. Creation requires explicit instruction or enabled outcome; §IX advice never activates it | --- @@ -51,7 +60,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 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. | +| Content authority | Content-type files in `sources/` own the factual/text origin for content, tables, chart values, and SmartArt wording. Default Strategist resolves them into §IX and Executor realizes that contract without drafting a second outline. Quick's current agent resolves them once in active context before SVG authoring. `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. | @@ -64,7 +73,7 @@ Global artifact ownership rules for PPT Master projects. | SVG source | `svg_output/` is the only author source for generated pages. | | Page-design closure | On SVG-authoring routes, every visible exported-slide object exists in the corresponding page SVG or an explicitly referenced visual asset. | | Package-behavior separation | Speaker notes, animations, transitions, narration, and direct native-PPTX workflows keep their owning artifacts; do not force them into SVG metadata. | -| Post-processed SVG | `svg_final/` is disposable, must be rebuilt in Step 7.2, and serves only as a self-contained visual preview / manually insertable SVG picture. | +| Post-processed SVG | In Default Generate, `svg_final/` is disposable, must be rebuilt in Step 7.2, and serves only as a self-contained visual preview / manually insertable SVG picture. Quick omits it. | | Export source | The only supported generated-PPTX route reads `svg_output/` through the project SVG-to-DrawingML converter. A diagnostic `-s final` override does not change ownership or create a supported release route. | | Shape-conversion boundary | PowerPoint's manual Convert-to-Shape operation on `svg_final/` is outside the project compatibility contract. | | Confirmation | Final UI/chat confirmation overrides recommendations and is consumed once into `design_spec.md`. Enabled refinement applies arbitrary revisions there and requires approval; only then may active-decision fidelity release lock authoring. | @@ -82,7 +91,8 @@ Global artifact ownership rules for PPT Master projects. | `/authoring-svg/authoring_summary.json` | Current authoring SVGs plus tool-only manifest roster | `python3 ${SKILL_DIR}/scripts/svg_authoring_view.py /authoring-svg --refresh-summary`; in-place vector/picture extraction refreshes it automatically | | `notes/slide_*.md` | `notes/total.md`, when speaker notes are enabled | `python3 ${SKILL_DIR}/scripts/total_md_split.py ` | | `svg_final/` | `svg_output/` plus project assets | `python3 ${SKILL_DIR}/scripts/finalize_svg.py ` | -| `validation/svg_quality_report.json` | `svg_output/`, locks, template provenance | `python3 ${SKILL_DIR}/scripts/svg_quality_checker.py --stage final --json` | +| `validation/svg_quality_report.json` | `svg_output/`, plus locks/template provenance in Default Generate | Default: `python3 ${SKILL_DIR}/scripts/svg_quality_checker.py --stage final --json`; Quick: append `--quick-generate` | | Native PPTX + `validation/.report.json` | `svg_output/` plus notes/assets and final quality report | `python3 ${SKILL_DIR}/scripts/svg_to_pptx.py ` | +| Quick native PPTX | `svg_output/`, prepared resources, passing Quick final report | `python3 ${SKILL_DIR}/scripts/svg_to_pptx.py --quick-generate` | **Default - regenerate derived views**: When a source artifact changes, regenerate the derived artifact at the owning step instead of patching the derived file directly. 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 f193da07..d41beb11 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 @@ -9,7 +9,7 @@ Always-loaded Executor authority for flat SVG page authoring and behavior shared | `pptx_structure.mode: structured` | [`executor-structured.md`](./executor-structured.md) | | Any data chart, chart catalog selection, or text-grid table | [`executor-chart.md`](./executor-chart.md) | | A page will use a preset pattern fill or evaluate native chart/table replacement | [`native-data-interface.md`](./native-data-interface.md) before deciding eligibility or emitting metadata | -| Any image/formula; a cited `#` or optional composition recall also triggers the library | [`executor-image.md`](./executor-image.md); conditionally [`image-layout-patterns.md`](./image-layout-patterns.md) | +| Any image/formula | [`executor-image.md`](./executor-image.md) + [`image-layout-spec.md`](./image-layout-spec.md) + [`image-layout-patterns.md`](./image-layout-patterns.md) + [`svg-image-embedding.md`](./svg-image-embedding.md) | | Any `Status: Sourced` web image | [`executor-web-image.md`](./executor-web-image.md), after `executor-image.md` | | Effective Speaker Notes outcome is enabled after all SVG pages pass | [`executor-notes.md`](./executor-notes.md) | @@ -27,18 +27,17 @@ Always-loaded Executor authority for flat SVG page authoring and behavior shared ## 1. Effect Capability Discovery -**Reference — not a constraint**: Scan this menu for treatments that support the locked style and hierarchy. After selecting one, load [`svg-effects.md`](./svg-effects.md) before authoring it — except the cross-page motion row, which loads [`animations.md`](./animations.md) §3.1. +**Mandatory — select by visual job**: establish each page's semantic skeleton, +then run the already-loaded [`svg-effects.md`](./svg-effects.md) §6.1 procedure +and Visual Job Router before finalizing; use §6.13 for a coordinated page +recipe when useful. The catalog expands construction vocabulary; it creates no +effect quota. Active cross-page continuous action additionally loads +[`animations.md`](./animations.md) §3.1 before authoring both endpoints. -| Visual need | Available construction | -|---|---| -| Color / material | alpha paint, gradients, translucent overlays | -| 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, 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. +**Hard rule — discovery does not expand compatibility**: Follow +`svg-effects.md` syntax and fallbacks; unsupported source/backdrop blur, blend +mode, SVG `` / per-pixel masking, dense texture, or skew remains +baked/alternative-only. **Default — resolve active 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. Apply this preparation only when an explicit user motion instruction, an enabled effective Custom Animations outcome, or an existing `animations.json` activates motion; a §IX Motion suggestion alone remains non-operative advice. An active 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. @@ -144,6 +143,7 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P` - **Generation rhythm**: P01 → first-page gate → uninterrupted remaining pages → final gate, in one context without batches or mid-run checker calls. - **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; when speaker notes are enabled, state the attribution naturally there too. When §IX says `Data class: scenario`, place a visible localized `Scenario data` / `情景数据` label adjacent to the affected KPI/chart and, when notes are enabled, state naturally there 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. +- **Default — vary a planned deck motif instead of cloning it (may omit where it has no page job)**: when §III `Theme` names a cross-page motif, use the current §IX `Layout` to preserve its recognizable contour, direction, material, or relationship while varying scale, crop, density, position, and content interaction by page role. Apply it only where it supports hierarchy or continuity; do not paste identical ornament or invent a second recurring identity. - **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, 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. @@ -178,7 +178,7 @@ content. | 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. | +| Supported closed-shape / resolvable-text operands need union, cutout, overlap, symmetric difference, or fragmentation | Use `shape_boolean_svg.py` 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 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. | @@ -223,9 +223,7 @@ redirect, loop, or batch helper output into `svg_output/`. ### SVG File Naming Convention -Format: `_.svg` (two-digit number from 01; name matches the deck's language and the page title in the Design Spec). - -Examples: `01_封面.svg` / `02_目录.svg` / `03_核心优势.svg`; `01_cover.svg` / `02_agenda.svg` / `03_key_benefits.svg`. +Format: `_.svg`. Use one roster-wide zero-padded index width sized for the Design Spec §IX roster, such as `01_cover.svg` through `12_end.svg` or `001_cover.svg` through `120_end.svg`; match the deck language and page title. --- 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 381526a3..7cefe9bf 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 @@ -1,14 +1,18 @@ -> See [`executor-base.md`](./executor-base.md) for the always-loaded Executor core. +> Default Generate also loads [`executor-base.md`](./executor-base.md). Quick +> Generate deliberately does not; this branch supplies its conditional image +> realization rules directly. # Executor Image Branch Conditional Executor authority for image status handling, placement, crop behavior, formula images, and template-bundled images. -**Trigger**: load when `design_spec.md §VIII` or `spec_lock.md images` contains any image/formula row, or when a selected template carries bitmap assets. +**Trigger**: load for any image/formula in §VIII, the lock, a Quick Generate roster, or a selected template. ## 1. Image Handling -Handle images by their status in the Design Spec's Image Resource List. Status enum and lifecycle: [`svg-image-embedding.md`](svg-image-embedding.md). +Handle images by status; enum and lifecycle: [`svg-image-embedding.md`](svg-image-embedding.md). + +**Mode boundary**: Default keeps Strategist → Executor with no downstream acquisition/reselection. Quick substitutes the main agent's prepared transient roster for §VIII/lock below; the same boundary starts at SVG authoring. | Status | Source | Handling | |--------|--------|----------| @@ -16,14 +20,30 @@ Handle images by their status in the Design Spec's Image Resource List. Status e | **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 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 | +| **Needs-Manual** | Acquisition or suitability remains unresolved | Default uses a placeholder until Step 7. Quick blocks every required row in this status; file presence alone does not bypass it. | | **Placeholder** | Not yet prepared | Use dashed border placeholder | **Reference syntax**: see [`svg-image-embedding.md`](svg-image-embedding.md). **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/`. -**Reference — layout catalog is optional recall**: Load [`image-layout-patterns.md`](./image-layout-patterns.md) only when an active suggestion cites `#` or inspiration is useful; resolve only cited ids. Free-form suggestions need no catalog lookup. Adapt or decline the suggestion when the page communicates better, while preserving resource role/source, must-use, crop/content, and explicit user/template constraints. Expression-only changes need no upstream rewrite. +**Default — active image integration (may override when plain placement is +stronger)**: Treat loaded [`image-layout-patterns.md`](./image-layout-patterns.md) +as vocabulary and [`image-layout-spec.md`](./image-layout-spec.md) as math, not +a quota or lock. Run the catalog's §7 playbook before drawing; its combinations +are recall aids, not coverage targets. A `#P...` suggestion completes only the +page skeleton; omitted `M`, effect, Boolean, or native overlay leaves +realization open and never means “keep plain.” For every image-bearing page, +derive the image/content or image/shape relationship from its communication job, +hierarchy, copy, asset ratio/focus, and deck rhythm. Before drawing, form one +relevant treatment candidate, compare it with `P`-only, implement the stronger +legal composition, and realize any adopted treatment through the already-loaded +[`svg-effects.md`](./svg-effects.md) and +[`native-shape-authoring.md`](./native-shape-authoring.md). This is an opportunity +pass, not an effect quota. Deepen, simplify, replace, or combine suggestions; +plain placement remains valid when it communicates better. Preserve role/source, +must-use, crop/content, and explicit user/template constraints; expression-only +changes need no upstream rewrite. **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. @@ -36,9 +56,11 @@ Handle images by their status in the Design Spec's Image Resource List. Status e **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, only when adopted**: A layout -suggestion, including pattern `#100`, never activates this transport. Apply it -only when the chosen composition uses independent same-source crops or an -explicit editable/Morph requirement needs them. Once active, reuse one exact +suggestion, including pattern `#M1-11`, never activates this transport. Pattern +`#M1-09` is a separate deliberate-offset treatment and never claims registered +or Morph continuity. Apply this transport only when independent crops must +preserve one exact scene map or an explicit editable/Morph requirement needs +them. Once active, 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; @@ -49,7 +71,7 @@ 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 +rescaling the scene. A compound clip on one `` is pattern `#M1-10`, 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. +**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 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-notes.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-notes.md index 02eadda7..d99752b4 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-notes.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-notes.md @@ -4,11 +4,12 @@ Conditional late-stage authority for generating the complete speaker-notes document. -**Trigger**: load only after all SVG pages pass the final quality check and the -effective Speaker Notes outcome in `design_spec.md §I` is enabled. A missing -legacy outcome uses compatibility default `enabled`; effective Narration Audio -enabled also requires Speaker Notes enabled. When notes are disabled, do not -load this branch or create `notes/total.md`. +**Trigger**: Default Generate loads this after the final quality check when the +effective Speaker Notes outcome in `design_spec.md §I` is enabled. Quick +Generate loads it after its final check when the current agent selected notes +or narration in active context. A missing legacy outcome defaults to enabled. +Narration requires notes; when notes are disabled, do not load this branch or +create `notes/total.md`. ## 1. Complete Speaker-notes Document @@ -16,11 +17,11 @@ Write the complete deck to `notes/total.md` in one batch for coherent transition **Pure spoken narration**: `notes_to_audio.py` reads the body verbatim. Write prose only; never add Markdown list/bullet markup, stage markers, key-point labels, duration lines, or other metadata. -**Length follows content**: size natural sentences to semantic burden. Two to five is typical, not a cap; anchor pages may use less and dense pages more. Honor `design_spec.md` style, detail, and source rules. Duration is pacing guidance only: never pad, repeat, compress, or omit meaning to hit it. +**Length follows content**: size natural sentences to semantic burden. Two to five is typical, not a cap; anchor pages may use less and dense pages more. Honor the active Design Spec or Quick context plus source rules. Duration is pacing guidance only: never pad, repeat, compress, or omit meaning to hit it. ## 2. Final-SVG Grounding and Coverage -**Hard rule — the final SVG is the visible page authority**: read every finalized `svg_output/.svg` in slide order. Use the locked plan and approved sources for context; never write from the outline or core message alone. +**Hard rule — the final SVG is the visible page authority**: read every finalized `svg_output/.svg` in slide order. Use the active plan/context and approved sources; never write from the outline or core message alone. Before drafting, internally inventory the visible title/subtitle and every information-bearing direct-root ``; structured placeholder content still counts. Coverage requires its unique claim, evidence, example, relationship, qualifier, or implication—not merely its label—to enter the narration. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-structured.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-structured.md index 88f82840..312b6748 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-structured.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/executor-structured.md @@ -56,7 +56,7 @@ When `spec_lock.md` records the AI-derived `template_reuse_scope: mirror`, Execu 4. **What you must not touch** — element positions, sizes, fonts, colors, fills, strokes, gradients, **which image each `` points at**, `` grouping, sprite-sheet `` wrappers, decorative `` / `` / `` / `` shapes, `` markers, embedded chart data structures. Mirror's value is preserving the source deck's visual identity — any geometric / decorative drift defeats the purpose. **The `href` path is not the image**: normalizing a bare `href="cover_bg.png"` to `href="../images/"` (when Step 3 relocated the asset to `images/`) points at the *same* image and changes nothing visual — that is an allowed path fix, not a fidelity edit. Leaving the bare href as-is is also fine; the exporter and live preview resolve bare hrefs against `images/` either way. 5. **Content fit** — if the replacement needs a different number of text segments/items, do not merge/split nodes, drop sourced content, or restructure the grid. Report `warning: P content does not fit mirror reference ; choose another prototype or change template_reuse_scope to layout/style`, then return to Strategist to select the prototype or scope and update the planning mappings. 6. **Visible text editing** — mirror SVGs may keep literal source text rather than `{{...}}` authoring markers. Edit values in place while retaining imported semantic `data-pptx-placeholder` identity and exact text topology. -7. **Output filename** — follow the standard project SVG naming convention (`_.svg` where `` matches the project page index, not the mirror source index). The mirror filename is the *reference*, not the *output*. +7. **Output filename** — follow the standard project SVG naming convention (`_.svg` where `` matches the project page index, not the mirror source index). The mirror filename is the *reference*, not the *output*. **Detecting mirror mode**: read `template_reuse_scope` from the retained lock. `replication_mode: mirror` in the installed template only determines whether that scope is legal; it must never force mirror behavior when the lock records `layout` or `style`. 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 38650626..49fdf497 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 @@ -4,7 +4,7 @@ Conditional Executor authority for inline attribution on web-sourced images. -**Trigger**: load when at least one placed image has `Status: Sourced`. +**Trigger**: load for any placed `Status: Sourced` image. Quick Generate uses the same `image_sources.json` contract without interaction. ## 1. Inline Attribution for Sourced Images @@ -20,6 +20,6 @@ The credit is **not** rendered by post-processing or export — it must be prese Use `attribution_text` from the manifest entry as the **starting point**. You may omit the filename and full URL when the visible source/provider remains clear, 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 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. +`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 Default Generate post-processing or Quick Generate direct export. **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 b73cb49c..d48474a7 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 @@ -8,25 +8,28 @@ Shared baseline for both acquisition paths. Path-specific behavior lives in the ## 1. Trigger Condition -Active when at least one resource list row has `Acquire Via: ai` / `web` / `slice`. Rows with `user` / `formula` / `placeholder` are skipped. +Active when at least one resource row has `Acquire Via: ai` / `web` / `slice`. Rows with `user` / `formula` / `placeholder` are tracked but skipped by these acquisition roles. | Mode | Trigger | |---|---| -| In-pipeline | `generate-ppt` workflow, image rows present | +| Default Generate | `generate-ppt` workflow, `design_spec.md §VIII` image rows present | +| Quick Generate | [`quick-generate`](../workflows/profiles/quick-generate.md) is active and its transient resource roster contains image rows | | Standalone | Direct request against an existing project | --- ## 2. Image Resource List Format -Defined in `design_spec.md §VIII`. Status enum: see [`svg-image-embedding.md`](svg-image-embedding.md). +Default Generate uses Strategist-owned `design_spec.md §VIII` plus its lock projection. Quick Generate substitutes a transient active-context roster; it creates neither planning artifact. Status enum: [`svg-image-embedding.md`](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. +**Quick Generate ownership**: explicit user assets, URLs, and path instructions win. Otherwise the main agent chooses required `user` / `ai` / `web` / `slice` / `formula` rows and AI path `auto`, without interaction. + --- ## 3. Path Dispatch @@ -50,9 +53,10 @@ For each row with `Status: Pending`: Before processing any row: -1. `read_file /design_spec.md` — extract color scheme, canvas format, target audience +1. Read the Default Design Spec/lock, or reuse Quick's transient roster and active visual/page decisions 2. Group resource list rows by `Acquire Via` 3. Confirm `project/images/` exists +4. Materialize explicit user assets, render declared formulas, and finish triggered ai/web/slice acquisition before SVG authoring begins --- @@ -66,18 +70,28 @@ After all rows reach terminal status: - `image_prompts.json` exists when ≥1 ai row processed; every entry has `status ∈ {Generated, Needs-Manual}` (no `Pending` or `Failed` remaining) - `image_sources.json` exists when ≥1 web row processed; every entry has `license_tier ∈ {no-attribution, attribution-required, manual}` (`manual` = a user-supplied `--from-url` replacement) -> `Needs-Manual` is a legitimate terminal state for ai rows — Step 7 entry waits for the user to place the file. See [`image-generator.md`](./image-generator.md) §7 Offline Manual Mode. +> `Needs-Manual` is terminal for acquisition, not export readiness. A later +> supplied/replaced file must be validated and its row reconciled to +> `Generated`, `Sourced`, or `Rendered` with the matching manifest evidence. +> Quick blocks every required row that still says `Needs-Manual`, regardless of +> whether an unverified candidate file happens to exist. See +> [`image-generator.md`](./image-generator.md) §7. --- ## 6. Failure Handling -**Hard rule**: acquisition failures MUST NOT halt the pipeline. +**Hard rule — automatic exhaustion before blocking**: acquisition failures MUST NOT open an interactive choice or stop while an untried permitted strategy remains. 1. Run the selected path's initial strategy 2. On recoverable failure (network, no candidates, license rejection, rate limit), continue through materially different strategies that remain inside that path's confirmed permissions; never loop an already exhausted strategy 3. When the path-specific query/provider/license-stage or backend/retry strategy is exhausted, set `Status: Needs-Manual`, log the reason in conversation, and continue -4. After the phase completes, summarize all `Needs-Manual` rows for the user — list filenames, where prompts live (`images/image_prompts.md` paste-ready blocks for ai rows; refresh via `image_gen.py --render-md` if stale), and where to place generated files (`project/images/`). For `slice` rows, list the parent sheet filename and target element names; the user places the sheet, then the agent reruns `slice_images.py`. +4. After the phase completes, summarize all `Needs-Manual` rows for the user — list filenames, where prompts live (`images/image_prompts.md` paste-ready blocks for ai rows; refresh via `image_gen.py --render-md` if stale), and where to place generated files (`project/images/`). After supply/replacement, validate the file and reconcile the owning row plus manifest to its usable status. For `slice` rows, list the parent sheet filename and target element names; the user places the sheet, then the agent reruns `slice_images.py`. + +**Quick Generate export gate**: exhaust allowed automation without asking; stop +before `--quick-generate` when a required row is not both backed by its +validated file/provenance and in a usable status. File presence alone never +bypasses `Needs-Manual`. `Needs-Manual` is also the entry status for **Offline Manual Mode** (no `IMAGE_BACKEND` configured, no host-native image tool in use). Affected ai rows are marked `Needs-Manual` from the start without a failed attempt — see [`image-generator.md`](./image-generator.md) §7 Offline Manual Mode. @@ -100,9 +114,9 @@ Executor reads the manifest per slide and renders inline credits when needed — --- -## 8. Handoff with Strategist +## 8. Intent Ownership -The `Reference` field is **intent**, not a query. Strategist writes free-form intent; the receiving role translates. +The `Reference` field is **intent**, not a query. Strategist owns it by default; Quick's main agent owns it in the transient roster. The receiving role translates without reopening it. | ✅ Intent | ❌ Pre-processed | |---|---| @@ -111,19 +125,24 @@ The `Reference` field is **intent**, not a query. Strategist writes free-form in --- -## 9. Handoff with Executor +## 9. Handoff with SVG Authoring -Executor consumes the resource list plus: +SVG authoring consumes the resource roster plus: | Artifact | Path | Purpose | |---|---|---| | Image files | `project/images/*.{jpg,png,webp}` | `` references | | Manifest | `project/images/image_sources.json` | `license_tier` per Sourced image | -Executor does NOT invoke `image_gen.py` / `image_search.py` / `slice_images.py`. +**Default Generate boundary**: Executor does NOT invoke `image_gen.py` / `image_search.py` / `slice_images.py`; missing material returns to Strategist-owned preparation. + +**Quick Generate boundary**: the main agent finishes acquisition before SVG authoring, then neither acquires nor reselects while drawing. --- ## 10. Task Completion Checkpoint -Verify internally that every row was processed, all triggered manifests/sidecars were written, and each result is `Generated`, `Sourced`, or `Needs-Manual`. Do not print a checklist. On success, auto-proceed to Executor and emit at most one compact status line when useful; on failure, report only the blocking rows and required recovery. +Verify every row, file, triggered manifest/sidecar, and provenance record. +Default proceeds to Executor. Quick proceeds without interaction after +preparation and exports only when every required row has validated evidence and +a usable status. Report only blocking recovery. 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 6762c30f..4d98a63f 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 @@ -2,9 +2,9 @@ # Image_Generator Reference Manual -Role definition for the **AI image generation path**: convert each `Acquire Via: ai` row into an optimized prompt, generate the image, and save it to `project/images/`; also defines the `slice` derivation path for AI-generated illustration sheets. +Role definition for the **AI image generation path**: convert each active `Acquire Via: ai` row into an optimized prompt, generate the image, and save it to `project/images/`; also defines the `slice` derivation path for AI-generated illustration sheets. -**Trigger**: resource list rows with `Acquire Via: ai` or `slice`. The role is loaded only when at least one such row exists. +**Trigger**: the Default Generate resource list or Quick Generate transient roster contains `Acquire Via: ai` or `slice`. The role is loaded only when at least one such row exists. --- @@ -16,7 +16,7 @@ AI images exist to serve the deck's communication goal. Pick whatever combinatio | `page_role` | Use | |---|---| -| `local` | Image occupies a region of an SVG page (left half, right column, hero band, accent corner). Composition is the AI's call — fill the region as the page design wants | +| `local` | Image occupies a prepared SVG region. The AI composes inside that bitmap/container; it does not choose the page region or final SVG geometry | | `hero_page` | Image is the page's main voice — cover, chapter divider, mood transition, single-number hero, closing quote. SVG above may be minimal or empty | **Two text policies** (orthogonal to page_role): @@ -34,18 +34,18 @@ AI images exist to serve the deck's communication goal. Pick whatever combinatio - **In-image text is only for words that will not need editing later** — visual keywords, decorative lettering, mood words. Editable text (titles that may be reworded, subtitles, dates, authors, captions, body) belongs in SVG. Changing one in-image word costs an image regeneration; one SVG word costs a keystroke. - Prompts are one coherent prose paragraph, not tag soup (a model-output reality, not an aesthetic choice) -Everything else is the AI's judgment per page. No mandated padding, no type-locked text_policy, no scenario whitelists for hero_page. +Everything else inside the prepared bitmap is the AI's judgment per page. No mandated padding, no type-locked text_policy, no scenario whitelists for hero_page. --- ## 2. Style and Composition Inputs -Every AI image uses one deck-wide rendering, the deck's stable color anchors/semantic behavior, and a per-image type / composition. Only rendering is a separate image-direction decision. +Every AI image uses one deck-wide rendering, the deck's stable color anchors/semantic behavior, and a per-image type / internal composition. Only rendering is a separate image-direction decision. | Dimension | Decides | When fixed | |---|---|---| | **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 | +| **Deck colors** | Core background / primary / accent / secondary-accent / text anchors from `spec_lock.md colors` in Default Generate, or from the active-context visual decisions in Quick Generate | Default: anchored after Stage 2; Quick: resolved before acquisition | | **Type** | Optional recall for a local structural infographic's internal skeleton (infographic / flowchart / framework / matrix / cycle / funnel / pyramid / comparison / timeline / map / scene). Use it when one template fits; otherwise omit type and write the composition directly in §4.1 E prose. Local single-subject/portrait and `hero_page` images also omit 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. @@ -71,7 +71,7 @@ Every AI image uses one deck-wide rendering, the deck's stable color anchors/sem ### Step 1 — Load the dimension indices -Read the two index files that own user-visible image direction and per-image composition. +Read the two index files that own user-visible image direction and per-image internal composition. ``` read_file references/image-renderings/_index.md @@ -80,7 +80,7 @@ read_file references/image-type-templates/_index.md ### Step 2 — Resolve deck-wide rendering + deck colors -**Primary path — Strategist already recorded rendering and core deck color anchors in `spec_lock.md colors`**: +**Default Generate path — Strategist already recorded rendering and core deck color anchors in `spec_lock.md colors`**: ``` image_rendering: vector-illustration @@ -91,11 +91,13 @@ accent: #D4AF37 Use them as identity anchors. Do not create another user-facing image-color choice. The rendering and image subject may derive coherent tonal transitions, material colors, lighting, and atmospheric hues when the context requires them, while the core roles keep their established meaning. +**Quick Generate path**: the main agent resolves one active-context rendering/color set, honoring explicit user values and deciding the rest without interaction. Write it to `image_prompts.json`; create no planning artifacts. + **Hard rule — `custom` catalog basis**: when `image_rendering` is `custom`, first inspect the optional `image_rendering_references` row. If present, read every exact `image-renderings/.md` it lists and synthesize their line, texture, depth, material, and mood guidance under `image_rendering_behavior` before assembling prompts. If absent, the custom is genuinely novel: read no preset file and use `image_rendering_behavior` directly. Never infer or add adjacent references during execution. The deck color-role rows remain authoritative. **Declared-inference fallback — when an existing `spec_lock.md` omits the `image_rendering` key** (see [`failure-recovery.md`](../workflows/governance/failure-recovery.md) §2): -This fallback covers a missing key only. An empty or invalid value stops for lock repair. If `spec_lock.md` itself is absent, stop at [`generate-pptx.md`](../workflows/generate-pptx.md) Step 5 before prompt assembly or image generation; do not use `design_spec.md` as a substitute. +This fallback covers a missing key only. An empty or invalid value stops for lock repair. Outside the active [`quick-generate`](../workflows/profiles/quick-generate.md) profile, if `spec_lock.md` itself is absent, stop at [`generate-pptx.md`](../workflows/generate-pptx.md) Step 5 before prompt assembly or image generation; do not use `design_spec.md` as a substitute. | Signal | Maps to | |---|---| @@ -118,25 +120,27 @@ Derive color behavior from the available roles and image context: background / s ### Step 3 — Per-image type + assembly -For each `Acquire Via: ai` row in `design_spec.md §VIII`: +For each `Acquire Via: ai` row, use Strategist-owned §VIII/lock by default or the main agent's transient Quick roster. Explicit values remain binding; Quick resolves omissions automatically. -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. +`Layout pattern` is a page-realization preference and is not copied wholesale into the bitmap prompt. Any generation-time subject direction, focal placement, quiet region, or overlay-safety requirement must therefore be present in the row's `Reference`, the matching §IX block, or the Quick roster's visual intent. + +1. **Determine `page_role`** — the owning row's explicit value wins; a blank or omitted value resolves to `local`. In Default Generate, `hero_page` must be Strategist-explicit; in Quick Generate, the main agent may resolve it while building the transient roster. +2. **Determine `text_policy`** — the owning row'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 or free composition** — an Illustration Sheet omits manifest `type` and follows §4.3's grid composition. For another local structural infographic, use one of the 11 types only when the `_index.md` offers a real match; otherwise omit type and author the intended structure directly with §4.1 E. A local single-subject/portrait image omits type and uses §4.1 A/B inside its actual region. A `hero_page` omits type and uses §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 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 image's specific `Reference` intent (from `design_spec.md §VIII` or the Quick Generate transient roster) - Container sizing from the selected type file, or the row's Dimensions for no-type prose - The hard rules from §5 below (HEX-not-as-text, rendering-aligned human depiction and likeness authorization, text policy) The assembled prompt is **one cohesive paragraph**, not a bulleted list of tags. See §4 for the assembly template. -### Step 4 — Write the manifest and execute the confirmed path +### Step 4 — Write the manifest and execute the selected path -Write `project/images/image_prompts.json` per §6. Then follow §7 Path Selection. `image_gen.py --manifest` is Path A only; confirmed `host-native` runs the host image tool directly, and confirmed `manual` renders the Markdown sidecar and hands off without API generation. +Write `project/images/image_prompts.json` per §6, then follow §7. Default uses its confirmed path; Quick uses an explicit active-context path or `auto` without asking. --- @@ -250,7 +254,7 @@ An illustration sheet can produce several small **spot illustrations** in one ge **Default — one sheet for a compatible spot family (may override when separate generation serves the assets better)**: Prefer a sheet when several elements share similar proportions, detail, quality, and semantic precision. Generate elements separately when those needs differ materially; quantity alone neither requires nor forbids a sheet. A single hero/local image stays with the normal one-row-per-image flow (§4.1). -**Hard rule**: a spot sheet is a generation source, not a slide asset. The sheet row is never listed in `spec_lock.md images` and never referenced from SVG. Only the sliced element rows are placed. +**Hard rule**: a spot sheet is a generation source, not a slide asset. In Default Generate, keep the sheet row out of `spec_lock.md images`; in Quick Generate, mark it generation-only in the transient roster. The sheet is never referenced from SVG. Only sliced element rows are placed. **Sheet prompt convention** (one manifest item, `page_role: local`, `text_policy: none`, `image_size` chosen from final placement size): @@ -276,10 +280,10 @@ Use that deliberately. On a wide sheet (`16:9`, `21:9`, `4:1`, `8:1`), `1xN` mak If one deck needs mixed shapes, create separate sheets per shape family unless one carefully designed grid gives every element enough room. Keep the visual family consistent through the same `deck_rendering` and `color_scheme`, not by forcing all cells into one square sheet. -**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)): +**Resource contract — the sheet and its elements are different row kinds.** A sliced element can only be placed if it exists in the active placeable-resource authority: `spec_lock.md images` in Default Generate or the transient roster in Quick Generate. Default Generate keeps both row kinds in §VIII under [`strategist-image.md`](./strategist-image.md); Quick Generate resolves the same distinction in active context without creating planning artifacts: - **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 carries a Strategist layout recommendation; Executor may realize it as a direct cutout or inside an appropriate container while preserving the resource and crop/content constraints. +- **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 the active placeable-resource authority, normally with `crop=no-crop` (a tight-trimmed transparent spot should be fit, not cover-cropped). Their dimensions are filled in after slicing (the preparation pass re-runs `analyze_images.py`). Each row carries an owner-resolved layout recommendation; SVG authoring may realize it as a direct cutout or inside an appropriate container while preserving the resource and crop/content constraints. 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. @@ -296,7 +300,7 @@ python3 scripts/slice_images.py /images/illus_sheet.png --grid 2x3 \ 2. **Clean grid, or it cuts ugly.** State the exact row/column structure and cell shape so the model does not invent a square matrix; `--trim` absorbs smaller placement variance. Do not generate several sheets or read them back merely to choose a favorite; re-roll only when user/live-preview feedback exposes an unusable slice. 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. -**Reference — sliced-asset placement is not a constraint**: A transparent slice may remain an unboxed cutout or enter a card, evidence frame, label, panel, or other suitable container. Strategist's layout text is an expression recommendation; Executor owns the actual geometry and treatment while preserving the resource role and crop/content constraints. +**Reference — sliced-asset placement is not a constraint**: A transparent slice may remain an unboxed cutout or enter a card, evidence frame, label, panel, or other suitable container. The owner-resolved layout text is an expression recommendation; SVG authoring owns the actual geometry and treatment while preserving the resource role and crop/content constraints. **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. @@ -360,7 +364,7 @@ The font for in-image text is a free natural-language description, not an enum. 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 | +| Active typography source contains | Optional descriptor if you want to echo the SVG body | |---|---| | `KaiTi` / `FangSong` / `Georgia` / serif families | "elegant serif lettering, refined letterforms" | | `Microsoft YaHei` / `PingFang SC` / `Arial` / sans-serif families | "clean geometric sans-serif, modern letterforms" | @@ -446,9 +450,9 @@ Write `project/images/image_prompts.json` with this shape: | Field | Required | Source | Description | |---|---|---|---| -| `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 | +| `deck_rendering` | yes | Step 2 active authority | Single rendering name shared by all items in this deck | +| `color_scheme` | yes | Step 2 active authority | Core deck color anchors shared by every item; prompts may add contextual tonal behavior, but no separate image palette | +| `items[].filename` | yes | Active resource authority | Output filename with extension | | `items[].type` | no | Step 3 per-image | Optional one-of-11 internal-composition type for a local structural infographic when a template genuinely fits. Omit it for custom §4.1 E prose, `hero_page`, an Illustration Sheet, and local single-subject/portrait 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 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. | @@ -486,7 +490,9 @@ 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, 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: +**Quick Generate selection**: an explicit user instruction for `api`, `host-native`, or `manual` retained in active context wins. When the user did not specify a path, select `auto` and run the A → B → C chain without asking or creating a planning artifact. + +**Default Generate selection — 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`). @@ -497,7 +503,7 @@ C (AI-generated) supports three implementation modes sharing one `image_prompts. 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. -**Hard rule**: Step 4 is execution, not re-decision. Never present an interactive choice between paths here — image strategy was locked in Strategist Step 4 h item. +**Hard rule**: this step is execution, not re-decision. Default Generate uses the path locked in Strategist Step 4 h. Quick Generate uses the explicit active-context instruction or `auto`. Never present an interactive choice here. > All three modes share one output contract: file at `project/images/`. Step 6 SVG references are mode-agnostic. @@ -580,16 +586,21 @@ Triggered automatically when `IMAGE_BACKEND` is not configured (or Path A fails) 1. Verify `images/image_prompts.json` was written 2. Set `status: "Needs-Manual"` on every affected item per [`image-base.md`](./image-base.md) §6 -3. Continue to Step 6 — Executor draws a dashed placeholder for each `Needs-Manual` row; the Step 7 image readiness gate verifies the supplied files and swaps them in +3. Apply the mode boundary: + - Default Generate: continue to Step 6; Executor draws a dashed placeholder and Step 7 verifies the supplied file + - Quick Generate: retain the prompt and `Needs-Manual` status, and block direct export until every required supplied file is validated and its row is reconciled to `Generated` 4. Print one consolidated handoff to the user: - Filenames awaiting manual generation - Pointer to `images/image_prompts.md` (paste-ready `### Image N:` block per item) or `image_prompts.json` (`items[].prompt`) - Target placement: `project/images/` matching the resource list exactly - - Resume command: re-run Step 7 once all expected files exist + - Resume: Default Generate re-runs Step 7; Quick Generate re-runs its resource gate, final checker, then `--quick-generate` -**User-initiated**: When Strategist Step 4 captured "user wants manual generation" up front, Path A is skipped from the start; the workflow above runs as a planned mode. +**User-initiated**: When Strategist Step 4 captured `manual` in Default Generate, or the user explicitly requested `manual` in the Quick Generate active context, Path A is skipped from the start. -> The pipeline tolerates `Needs-Manual` rows end-to-end. The user can leave the project, generate offline at their own pace, then resume Step 7. +> Default Generate tolerates `Needs-Manual` rows through authoring and resumes +> at Step 7. Quick Generate preserves the same manifest and handoff but does not +> run `--quick-generate` while a required row still says `Needs-Manual`; validate +> a later supplied file and update it to `Generated` first. #### AI-specific Failure Handling (extends image-base.md §6) @@ -650,7 +661,7 @@ Diagnose the failure category, adjust the **one specific dimension** responsible **Variant workflow**: 1. Set the unsatisfactory item's `status` back to `Pending` and update its `prompt` in place -2. Re-run the same confirmed path used for the original item: Path A may re-run `image_gen.py --manifest` (only that item is re-processed); Path B uses the host-native tool again for that item; Offline Manual re-renders the sidecar and hands off +2. Re-run the same resolved path used for the original item: Path A may re-run `image_gen.py --manifest` (only that item is re-processed); Path B uses the host-native tool again for that item; Offline Manual re-renders the sidecar and hands off 3. To try multiple stylistic approaches, append additional items with distinct filenames (e.g. `cover_bg_v2.png`) rather than overwriting --- @@ -662,6 +673,6 @@ Diagnose the failure category, adjust the **one specific dimension** responsible - Mixing renderings or introducing an unrelated image-only palette across images in the same deck - Tag-soup prompts (keyword lists separated by commas without a coherent visual scene) - Globbing `image-renderings/*.md` or any subdirectory — read only the chosen preset or exact custom-reference files -- Placing an image without updating its `image_prompts.json` `status` and the resource list status +- Placing an image without updating its `image_prompts.json` `status` and the active resource authority's status - Switching rendering or core deck-color semantics for a single image—`hero_page` is not an exception to deck-wide coherence - Embedding body copy, data points, bullet lists, or long quotes inside an image — those route to SVG 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 ea72dd62..b959c238 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-patterns.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-layout-patterns.md @@ -1,436 +1,249 @@ -# Image-Text Layout Pattern Library +# Image and Formula Layout Pattern Catalog -An optional vocabulary library for ways images can be placed on a slide. Open it when a page would benefit from more composition ideas; ordinary natural-language layout suggestions remain valid without consulting or citing the library. When using it, start at **High-Yield Patterns** below. - -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 an inspiration library, not a legality boundary or teaching document; it sets no usage, id, family, or coverage quota. - -> **Numbers are stable optional 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** is a router over those same numbers; use it when entering by page situation. +Compact composition vocabulary for prepared images, illustrations, and rendered formula assets. Use the patterns as options, not as a checklist. --- -## Core Principle — Two Layers +## 1. Catalog Boundary -Almost every pattern below is an instance of one underlying split: - -> **The image carries atmosphere, world-building, emotional weight. Native SVG shapes carry information, data, editable text.** - -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 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. - ---- - -## High-Yield Patterns — Optional Starting Points - -The patterns below are efficient ways to expand a page beyond familiar splits. Nearly all of them are **one `` plus geometry** — no extra asset, no generation cost, no second render — and they are the SVG equivalents of what PowerPoint users reach for under Merge Shapes. - -**Reference — not a constraint**: use this router when it adds a useful composition option. A plain split, equal grid, bare whitespace, or an unlisted free-form construction remains valid when it serves the content. - -| Page situation | Reach for | Produces | -|---|---|---| -| One ordinary photo must carry a cover or a chapter divider | `#90` scrim with shapes cut out + `#86` contour echo | Three elements turn a stock image into a designed page; the cut contour is where the page's character comes from | -| The supplied image does not fit the canvas | `#89` same image twice — sharp cutout over a receded copy | Subject at full fidelity in any aspect ratio; no stretching, no letterbox bars, no second asset | -| Several peer images belong to one frame | `#92` split tiling — one parent cut into interlocking cells | Edges interlock exactly; the group still reads as one object | -| One image should 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 | -| A busy photo has no clear focus | `#99` selective desaturation, or `#96` cutout subject re-laid over its own photo | Focus without cropping; the subject can then overlap a title, a panel, or a grid line | -| Text needs legibility but a solid scrim would kill the photo | `#97` frosted-glass panel | The photo's colour and composition stay visible through the panel | -| An image grid looks like a stock template | `#88` non-rectangular tessellation with 1–3 cells left empty | The empty cells are where the title and body copy live | -| A subject should escape its container | `#85` subject breaking out + `#96` | Depth with no shadow at all | -| One place should be recognized across consecutive pages | `#87` one image panned across pages | The deck reads as one continuous scene; `-t morph` uses heuristic matching, while explicit `morph.pairs` makes the camera pan deterministic | - -**Reference — not a constraint**: when citing a modifier-only result, also name the content-appropriate Primary that supplies the page bones. Free-form suggestions may describe the complete relationship without catalog ids. - -**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. - -**Reference — not a constraint**: repeated plain splits may be a reason to consult this library, but are not evidence of failure. Neither §VIII nor the final deck must cite an id or cover a pattern family. - -Each entry above is specified in full at its own number below; the table routes, it does not restate. - ---- - -# Part 1 — Primary Structures - -Pick one or more of these as the page's bones. Cross-primary combinations are encouraged (see Composition Guidance). - -## Container Layouts (where the image sits) - -1. **Full-bleed background with floating title** — `` + scrim `` for legibility + overlay ``. - -2. **Left-third image + right text body** — `` on the left; text area in the remaining width; optional right-edge gradient fade for smooth transition. - -3. **Right-third image + left text body** — mirror of #2. - -4. **Right image bleeding off the canvas edge** — `` width extended past viewBox; text on left with a rightward gradient fade so the image emerges from the text area without a visible boundary. - -5. **Top-band image + bottom multi-column text** — `` at the top + bottom-fade gradient + 2–3 evenly spaced text columns below. - -6. **Bottom-band image + top title + middle text** — mirror of #5 with the image at the bottom and a top-fade gradient. - -7. **Top-and-bottom symmetric split** — image occupies 50% (top or bottom) with a divider line or thin gradient band separating the halves. - -8. **Z-pattern serpentine** — three rows, image on the left in rows 1 and 3, on the right in row 2 (or alternating). Each row roughly 1/3 canvas height; visual flow zigzags down the page. - -9. **3×3 grid with central image** — nine cells; center cell holds the image, the other 8 hold text blocks, color swatches, or small data widgets. - -93. **Containers arrayed along a curve (fan, arc, ring)** — N image containers distributed along an arc or wave, each rotated to sit square to the curve at its own position. Reads as motion and hierarchy at once, and it is the backbone of fan spreads, ring layouts, dial/roulette pages, and arched photo rows. - - **Geometry** — place container `i` of `n` on a circle of radius `r` about `(cx, cy)`: - ``` - θᵢ = θ_start + i × (θ_span / (n − 1)) - xᵢ = cx + r·cos(θᵢ) yᵢ = cy + r·sin(θᵢ) - rotationᵢ = θᵢ + 90° (tangent-aligned; drop this for upright containers) - ``` - Use `transform="rotate(rotationᵢ xᵢ yᵢ)"` on each container group. A `θ_span` of 60–120° reads as a fan; 360° with `θ_start = -90°` gives an evenly spaced ring. For a wave instead of an arc, sample the wave's own path and use its local tangent as the rotation. - - **Two things to get right**: keep radius and angular step *constant* — an eyeballed fan reads as a mistake, not a flourish; and when containers are tangent-aligned, images inside must not inherit the rotation blindly (a sideways face is the failure mode). Counter-rotate the image inside its container, or keep the containers upright and let only their positions follow the curve. - -10. **Centered image with radial callouts pointing outward** — image (often circular via `clipPath`) at canvas center; multiple `` leader lines + small `` endpoints + offset text labels in surrounding space. - -11. **Diagonal split with directional gradient (not hard polygon cut)** — full-bleed `` + overlay `` whose gradient axis runs along the diagonal, plus a `` to make the divider read. Do NOT hard-clip: polygon cuts give stair-stepped edges on text panels. - -12. **Faded image as backdrop with oversized overlay text** — `` + heavy semi-transparent `` over it + huge `` (80–120px) on top. Image becomes texture; text is the subject. - -13. **Narrow vertical image strip + giant horizontal title** — `` + thick divider `` + large `` (60–90px) in the remaining width. - -14. **Horizontal banner strip cutting through mid-section** — `` with edge fades; text blocks above and below the band. - -15. **Multi-image montage with bold text spanning across** — `` tiled with 2–4px gaps + large `` (60–100px) in a `` band spanning the montage, so the text stays legible across every tile beneath it. - -16. **Negative-space dominant — small image, mostly whitespace** — image and text together occupy less than 40% of the canvas; rest is empty. - -17. **Picture-in-picture inset** — large `` background + small `` overlaid inside it with a `` frame. - -18. **Image as full-height sidebar column** — narrow ``; rest of canvas is content area. - -19. **Image floating in whitespace with thin frame and caption** — `` + thin `` frame around it + `` caption below. - -## Image-as-Canvas + Native Overlay (the most underused family) - -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 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, 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; 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. - -44. **Background image + native network/architecture diagram** — same logic as #43 but for structural diagrams. Image provides atmosphere or visual anchor; the actual nodes, connections, and labels are SVG circles, lines, icons, and text — all editable. - -45. **Background image + numbered hotspots with sidebar legend** — small numbered `` markers placed on the image at points of interest. A sidebar (left or right) lists "1. … 2. … 3. …" with corresponding descriptions. - -46. **Background image + bordered "lens" rectangle highlighting a sub-region** — full-bleed image + a bordered `` framing a sub-region + caption nearby. Frame draws the eye to one detail without occluding the surrounding context. - -## Multi-Image Compositions - -94. **Embracing arc row (2D substitute for a 3D perspective wall)** — a row of images or cards where the centre element is largest and each step outward shrinks and drops, so the tops trace an arc and the row appears to curve toward the viewer. This is what PowerPoint decks build with 3D rotation (perspective left / right, X-axis 330° / 30°) for logo walls, certificate rows, and photo shelves — and it is reproducible in 2D, which matters because 3D transforms are outside the SVG contract ([`svg-effects.md`](./svg-effects.md) §6.8). - - **Construction**: for element `k` steps from the centre, apply `scale = 0.88ᵏ` and offset `y` downward so every element's *top* edge lands on one shallow arc; keep the horizontal step constant. Mirror the sequence left and right of the centre. Add a soft ground shadow or a reflection fading downward to seat the row. Bottom-aligning instead of top-arcing gives the flatter "shelf" variant. - - The depth cue is entirely **scale + vertical offset + consistent light**; do not reach for skew or a fake 3D tilt, which fail closed on export. Three to seven elements is the working range — beyond that the outermost ones shrink into illegibility. - -47. **Small multiples — 3–6 same-kind images in an evenly spaced row** — identical containers, identical caption blocks (title + one line). Not a generic grid: the identical framing *is* the message, because readers compare across panels only when the structure is constant. - -48. **Side-by-side comparison (before/after, A/B, then/now)** — two `` of equal size in 50/50 split with thin divider `` and "before" / "after" labels. - -49. **Asymmetric collage** — one large `` + 2–3 smaller `` arranged around it; sizes vary, gaps consistent. - -50. **Tiled grid (2×2, 2×3, 3×3) with equal cells** — `cell_size = (canvas - total_gap) / cols`; consistent `gap=2–20px`. - -51. **Mosaic** — irregular tile sizes packed together with or without thin gaps; each image clipped to its tile's rect. - -92. **Split tiling — one parent shape cut into interlocking cells** — the most-used construction in real image-heavy decks, and the counterpart to #82. Take one parent shape (circle, annulus, rounded rect, trapezoid, wave band), lay cutting lines across it (long bars, evenly distributed or fanned at different angles), and split it into cells. Each cell then holds a *different* image. Because every cell comes from one parent, the edges interlock exactly — no gaps, no overlaps, and the group still reads as one object. - - | Parent + cutters | Result | - |---|---| - | Circle + 2 crossed bars | Quadrant wheel | - | Annulus + radial bars | Ring segments | - | Wave band + vertical bars | Rhythmic strip | - | Trapezoid + slanted bars | Perspective row | - - **Authoring**: compute each cell's contour and write it as its own `` clip — the geometry is deterministic, so derive the cells rather than eyeballing them. `shape_boolean_svg.py 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 / #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**). - -54. **Overlapping image stack** — `` elements with overlapping `x/y` positions; each subsequent one in front (z-order by document order); often combined with slight rotation for layered photo-print look. - -55–56. **Diptych / triptych** — two images abutting 50/50, vertical or horizontal (**#55**), or three side-by-side at equal or 2:1:2 widths (**#56**), with an optional thin divider ``. Distinct from #26, where the panels live inside one image file, and from #48, where the pairing carries a before/after argument. - -88. **Non-rectangular tessellation (honeycomb, diamond, chevron array)** — a tiled field of hexagons, diamonds, or slanted parallelograms, each cell holding its own image via `clipPath` (#23) and separated by a consistent 2–3px stroke in the background color, which reads as the grid's mortar. The non-rectangular counterpart to #50 / #51. - - **Geometry**: a flat-top hexagon of width `w` and height `h` is `M x+0.25w,y L x+0.75w,y L x+w,y+0.5h L x+0.75w,y+h L x+0.25w,y+h L x,y+0.5h Z`. Tile it by stepping `0.75w` horizontally and offsetting alternate columns by `0.5h` vertically. - - **Leave cells deliberately empty**: fill 1–3 tiles with a flat or gradient deck color instead of a photo. A fully-populated honeycomb reads as a stock template, and the empty cells are where the title and body copy live. Keep the identical stroke on the empty cells so they read as designed rather than as a missing image. - -## Imported Deck Patterns (image-led promotional pages) - -These patterns come from polished image-text decks where photos define the slide skeleton instead of sitting inside generic cards. Treat them as layout vocabulary for travel, product, venue, hospitality, real-estate, event, and brochure-style decks. - -73. **Full-bleed poster image + side title stack** — title stack on the left or lower-left third, no title card; scrim only where the image is busy. - -74. **TOC image-navigation cards** — 3–5 vertical image cards, each with a translucent overlay, chapter number, title, one-line summary. A visual preview of the deck, not a text list. - -75. **Asymmetric dual-image chapter banner** — one small + one wide image across the upper half; chapter title below, anchored by an oversized section number. - -76. **Mid-page image belt with native text inset** — wide image strip through the middle 45–60%, key text inside its calm region, heading above. - -77. **Photo mosaic with a text cell** — irregular grid with one cell reserved for copy. The missing photo is the hierarchy; do not fill every slot just because a grid exists. - -78. **Ambient banner + evidence photo + text panel** — atmospheric image above, concrete evidence photo below, copy on a tinted side panel. One image sets mood, the other proves it. - -79. **Ribbon-header image cards** — 3 columns, colored ribbon or chevron title above each image, prose below. - -80. **Side hero image + staggered evidence cards** — full-height image in a side column; 2–4 smaller cards staggered vertically opposite it rather than gridded. - -81. **Illustration-as-layout field** — a large vector or cutout illustration acts as the image region and sets spatial rhythm, with text in its calm areas. For when a photo would be too literal but the page still needs image-scale mass. - ---- - -# Part 2 — Modifier Layers - -Stack any of these freely on top of a Primary structure. Multiple Modifiers per page is the expected case, not the exception. - -## Non-rectangular Image Shapes - -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, 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)** — 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). - -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. - - **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`. 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. 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. - - Let the *subject* be what escapes, not a corner of background, and break out only once per page — a page where everything escapes has no frame left to break. - -26. **Triptych baked into a single wide image** — one wide `` whose internal composition already contains 2–3 scenes. Generate the triptych as one image (not three separate calls) when scene-to-scene consistency matters — the model preserves character identity, lighting continuity, and color grading far more reliably when panels are produced together. - -## Overlay, 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(...)"`. - -> **Default — focal-safe text contrast (may override when the image and treatment demonstrably remain legible).** `preserveAspectRatio="xMidYMid slice"` center-crops whatever the source aspect ratio does not cover, so estimate the crop before placing text. Keep copy clear of the focal subject and maintain readable contrast across its full area. A gradient transition is valid when those conditions hold; use an opaque plateau or solid panel only when the image and softer treatment cannot guarantee them. When subject position is unresolved, prefer the opaque treatment rather than guessing. - -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 `#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. - -98. **Grid scrim with per-cell opacity** — instead of one flat or gradient scrim, cover the image with a grid of adjacent rectangles and give each cell a *slightly different* opacity (say 10–40 %, varied irregularly). The photo shows through unevenly, so the overlay reads as texture — panelled glass, a pixel field, a contact sheet — rather than as a sheet of paint. Text sits on the denser cells. - - Keep the variation small and non-repeating: a regular light/dark alternation reads as a checkerboard, and a wide spread reads as broken rendering. Butt the cells exactly (no gaps, no strokes) so the grid is felt rather than drawn. Distinct from #50 / #88, where every cell holds its own image; here one image lies beneath one grid of glass. - -99. **Selective desaturation — colour only where it matters** — the whole image is muted while one subject stays in full colour, which fixes the focus of a busy photo without cropping it. Two registered copies: a desaturated (and usually darkened) version filling the frame, and the colour original clipped to just the subject region, sitting exactly on top. - - **Both copies are baked assets** — there is no runtime colour filter on the native route ([`svg-effects.md`](./svg-effects.md) §6.12), so produce the desaturated file with a one-line Pillow `ImageEnhance.Color(img).enhance(0)` pass rather than reaching for `feColorMatrix`. Clip the colour copy along a real edge in the picture (the subject's own contour, per #96) — a rectangular colour patch over a desaturated field reads as an accident. - -32. **Multi-stop scrim with hue shift** — three-or-more-stop `` where stops are different colors (e.g. dark navy → transparent → warm orange). This re-grades the image's color world without regenerating — particularly useful when an AI image came back with the right composition but wrong color temperature. - -90. **Full-canvas scrim with shapes cut out of it (the cover / divider formula)** — the single highest-yield formula in this catalog, and the one real decks reuse most: a full-slide `` whose outer contour is the canvas and whose inner subpath(s) are cut out using the opposite-winding rule from #83, laid over a full-bleed image. The scrim mutes the photo everywhere except through the cuts, so one ordinary image becomes a designed page. Three elements total: image, scrim, title. - - **The cut contour** — any of these, all authored as reversed inner subpaths in the same ``: - - | Contour | Reads as | - |---|---| - | Wave, arc, ribbon (one soft curve across the page) | Editorial banner / horizon | - | Freehand closed curve (irregular, hand-drawn) | Organic torn-paper window | - | An array of hexagons / trapezoids / circles | Rhythmic screen, a window wall | - | Oversized numeral or letterform | Chapter marker (see caveat) | - - An array of cuts is just several reversed subpaths in the same `d` — the same construction as #82, except here the image shows *through* the holes rather than being clipped *into* the shapes. - - **Paint the scrim** with either a flat light fill at 0.15–0.25 opacity (white over a photo is the reliable default) or, for a directional reveal, a gradient that varies `stop-opacity` rather than color (`1 → 0.8 → 0`), so the image emerges progressively instead of through one hard boundary. Add a 1–2px stroke in the same light color on the cut edge to keep it crisp. - - **Edge thickness**: to make the cut read as a physical opening, apply `feDropShadow` with `dx="0" dy="0"` and a small `stdDeviation` to the scrim path. Per [`svg-effects.md`](./svg-effects.md) §6.4 a zero-offset shadow is classified and exported as a **glow**, not a shadow — so use an accent or light color; black will read as diffuse haze rather than an edge. Never apply it to the `` itself (#36). - - **Numeral / lettering caveat**: cutting *text* out of the scrim needs the glyph as a `` outline, which is not something to author by hand — least of all for CJK. Set the numeral as ordinary `` over the scrim (nearly as strong, fully editable), or pre-render a knocked-out numeral as an RGBA PNG (#68). Do not approximate glyph outlines. - - **Motion pairing**: the scrim stays fixed while the image beneath drifts slowly (a 4–10s linear path, left, starting with the previous animation) — the cuts then behave like windows onto a moving world. That is an animation-stage decision, not page design; see [`animations.md`](./animations.md). It pairs with this pattern more often than with any other. - -97. **Frosted-glass panel over the photo** — a legibility panel that is neither a flat scrim (#30) nor a full blur: a region of the image itself, blurred and lightened, sitting under the text while the rest of the photo stays sharp. It keeps the photo's color and composition visible through the panel, which a solid scrim destroys. - - **Build it from a baked asset** — runtime blur does not survive native export ([#34](#), [`svg-effects.md`](./svg-effects.md) §6.12). Produce a blurred copy of the source (a Pillow `GaussianBlur` at a large radius, plus a brightness lift), then place that copy clipped to the panel contour and registered to the same position as the base image, so the blur lines up exactly with what is behind it. Add a thin light stroke and, if the style wants it, a slight lightening overlay. - - The panel must stay in register with the base photo; a frosted panel showing a *different* part of the scene is the classic tell. Pair with #95 when the panel should also carry a floating edge. - -33. **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. - -35. **Duotone treatment** — two-color mapping of a photograph (e.g. deep navy shadows + warm cream highlights). Bake it into the source image; the native PPT route does not support a runtime duotone filter chain. - -36. **Drop shadow under image panel** — `` applied to the image panel's backing ``. Standard depth lift; filters do not apply directly to `` under the project contract. - -37. **Inner / outer glow on overlay shape** — `` on a shape, or simply a slightly larger blurred `` underneath the target. - -## Image as Texture / Atmosphere - -57 · 60 · 61. **Image pushed into the background** — the same move at three intensities: a full-bleed texture wash under the page (**#57**, overlay ``), low-contrast ambient atmosphere that is seen but never read (**#60**), or a watermark sitting behind body copy (**#61**). Suppress it with an overlay `` or a pre-dimmed asset — never a runtime filter. - -58. **Image fragment as decorative corner element** — small `` (often with `clipPath`) placed in one corner; not the focus, just visual seasoning. - -59. **Image as horizontal divider band** — narrow `` placed between two text sections instead of a `` divider. - -## 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 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, 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. - -66. **Image fading into the solid background** — soften the image's edge into the deck's background color via a `` overlay whose end-stop matches the background hex exactly. The image's rectangular boundary disappears, producing seamless integration. - -67. **Image with knock-out / cut-out shape** — overlay a shape filled with the background color or another image, creating the impression of a hole punched through the underlying image. - -68. **Text-as-mask over image** — letterforms revealing image through them. Under the canonical SVG compatibility boundary in [`shared-standards-core.md`](./shared-standards-core.md), realize this pattern as a pre-rendered image rather than a runtime effect. Prompt for "large lettering revealing the underlying scene through letterforms" and treat the result as a fixed artistic choice. - -69. **Image rotated at a slight angle for editorial feel** — `transform="rotate(angle cx cy)"` on the `` or its container ``; 2–6 degrees typical. Adds dynamism without breaking layout. - -70–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. **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 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. - - **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. - -96. **Cutout subject re-laid over its own photo** — the mechanism behind every "subject escapes the frame" page (#85), and worth stating on its own because it is a two-asset technique: keep the original photo as the background layer, and place a background-removed PNG of its subject on top, in perfect register. - - Once the subject exists as a free-floating layer, it can overlap anything drawn between the two copies: a title the subject stands in front of, a color panel it steps out of, a shape frame it breaks through, a grid line it crosses. The base photo can be tinted, desaturated, blurred (baked), or scrimmed as hard as the layout needs, because the sharp subject on top is what the eye reads. - - Register is everything — the cutout must sit exactly where the subject sits in the base image; a few px of drift reads as a printing error. Keep the cutout's own edge clean rather than adding a stroke, unless the design calls for the sticker look of #63. - -89. **Same image twice — sharp cutout over a receded full-bleed copy** — the single best answer to "the photo is too narrow / too short for this canvas, and stretching distorts the subject". Reference the same file twice: the bottom copy fills the whole canvas (or panel) and is pushed back; the top copy is clipped to a shape (#82, #24, a slanted band, a folded contour) at native proportions and stays sharp. The subject reads at full fidelity while the background extends the frame to any aspect ratio — no stretching, no letterbox bars, no second asset. - - **Recede the bottom copy with what survives export**: a color-tinted or darkened overlay `` (#30 / #31) at 0.5–0.8, or a desaturated / lowered-brightness variant of the file. **Blur does not survive** — per #34 the native route does not preserve a blurred-image backdrop, so if the design depends on blur it must be baked into a second image file (a one-line Pillow `GaussianBlur` pass over the original is enough); never rely on a filter at export time. Keep both copies in register — same center, same crop logic — or the trick reads as two unrelated photos. - -86. **Contour echo — the clip path reused as a stroke** — after clipping an image (#20–#25, #82, #83), reuse the *same* `d` as a ``, drawn slightly larger or offset a few px. The outline repeats the cut geometry instead of boxing it in a rectangle, which is what #70 / #71 do. One extra element, no new asset. Offset it in a single consistent direction across the page; an echo on every side reads as a border, not an echo. - -91. **Faceted gradients for folded / dimensional form (origami, ribbon, folded band)** — build a folded or faceted object from several adjacent `` facets, then give each facet its own `` whose direction and lightness differ from its neighbours — one face catching light, the next in shade. The fold is created by the *lightness break between adjacent facets*, not by any shadow effect, so it survives export intact as ordinary shapes. - - Keep every facet on one hue and vary only lightness (a white → light-grey → white ramp across three facets already reads as a crease), remove all strokes so the facets meet seamlessly, and keep the light direction consistent across the whole object. Combine with #82 by using the assembled facet outline as the clip contour, which puts a photo inside the folded form. Do not reach for `` shadows to fake depth here — [`svg-effects.md`](./svg-effects.md) owns effect limits, and the gradient break is both cheaper and more reliable. - -87. **One image panned across consecutive pages** — a single wide image referenced by 2–4 consecutive slides, each showing a different horizontal segment (same `` file and container geometry per page, only `x` shifts). Static on its own, it makes the deck read as one continuous scene; the audience recognizes the place before reading a word. - - **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. - ---- - -## Composition Guidance - -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 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: - -- one Primary from Part 1 (e.g. #48 side-by-side comparison) -- `#21` rounded-rectangle clipPath on the image (rx=6 or circle) -- `#27` top-edge linearGradient in the deck's accent color, opacity 0.55 → 0 -- `#66` bottom-edge linearGradient fading to background color, opacity 0 → 0.95 -- small color-block badge + reversed-out label replacing any opaque color bar that would otherwise sit over the image - -Combine freely. The "AI-default" failure mode is the opposite: defaulting to bare #2 / #3 (left/right split) with no Modifier at all. - -**Reference — image-led promotional deck moves (not a constraint)**: - -| Page intent | Pattern candidates | +| Boundary | Rule | |---|---| -| Cover / ending with strong atmosphere | `#73` + `#27` / `#30` only if contrast needs it | -| Visual table of contents | `#74` + `#30` / `#31` | -| Chapter divider | `#75` | -| Venue / destination overview | `#76` or `#78` | -| Many product/place photos | `#77` or `#50` when equality is the message | -| Service / feature comparison | `#79` | -| Benefits with one dominant proof image | `#80` | -| Light promotional page without photos | `#81` | +| Selection | **Reference — not a constraint**: use any pattern, combine compatible ones, or author a clearer free-form composition; no ID, family, or coverage quota applies | +| Canonical IDs | Two-level prompt handles such as `#P1-01` and `#M2-01`; the letters expose composition responsibility, the first digit selects a family, and the final number follows current browse order. No legacy aliases or exporter mapping | +| Composition grammar | Select one or more compatible `P` structures, then add only useful `M`, prepared `A`, or cross-page `C` patterns | +| Effect options | Direction, side, position, proportion, contour, and intensity are options stated after the ID; they do not create another pattern | +| Asset ownership | Consume prepared project-local assets; no acquisition or processing during SVG realization | +| Exact information | Keep exact or editable text, data, labels, and annotations native | -**Reference — not a constraint**: before adding another photo, consider whether one prepared image plus #82–#100 can express the idea more clearly. Registration and prepared-asset boundaries remain mandatory when the chosen technique depends on them. +| Group | Responsibility | Families | Entries | +|---|---|---|---:| +| `P` · Primary Structures | Define the page skeleton | `P1` Single Visual · `P2` Image as Canvas · `P3` Multi-Visual | 46 | +| `M` · Modifier Layers | Add crop/reveal, tone/focus, or framing/placement/depth treatment to an existing skeleton | `M1` Reveal/Crop/Registration · `M2` Tone/Focus/Contrast · `M3` Framing/Placement/Depth | 27 | +| `A` · Asset-Dependent Treatments | Require a prepared composite, cutout, or registered derivative | `A1` Composite/Appearance · `A2` Subject Layers · `A3` Registered Derivatives | 10 | +| `C` · Cross-Page Continuity | Sustain a visual relationship across slides | `C1` Persistent State · `C2` Camera Continuity · `C3` Matched Framing | 4 | -**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 - -- 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 - scenario-specific rendering choice. +| Mechanism, not generic “mask” | Owner | +|---|---| +| Layout geometry | [`image-layout-spec.md`](./image-layout-spec.md) | +| Image-treatment implementation map | [`svg-effects.md`](./svg-effects.md) §6.1 Image-Treatment Implementation Map | +| Crop: policy / legality / wrapper | [`svg-image-embedding.md`](./svg-image-embedding.md) / [`shared-standards-core.md`](./shared-standards-core.md) / [`svg-effects.md`](./svg-effects.md) | +| Scrim / gradient / wash | [`svg-effects.md`](./svg-effects.md) | +| Shadow / glow / overlay-boundary elevation | [`svg-effects.md`](./svg-effects.md) §6.4 | +| Boolean hole / text subtraction | [`native-shape-authoring.md`](./native-shape-authoring.md) | +| Faceted or folded native form | [`native-shape-authoring.md`](./native-shape-authoring.md) §7.1 / [`svg-effects.md`](./svg-effects.md) §6.11 | +| Per-pixel mask / blend | Prepared / baked asset; [`svg-effects.md`](./svg-effects.md) boundary | +| Chart overlay / motion | [`executor-chart.md`](./executor-chart.md) / [`animations.md`](./animations.md) | --- -For sizing math (calculating container dimensions from image aspect ratio when using side-by-side intent), see [`image-layout-spec.md`](image-layout-spec.md). This file is the design vocabulary; that file is the dimension calculator. +## 2. Situation Router + +| Page need | Pattern options | +|---|---| +| Quiet, direct evidence | `#P1-11` negative space, `#P1-12` framed figure, `#P3-04` small multiples, `#P3-03` comparison | +| One visual should become the page canvas | `#P2-01`–`#P2-10` native overlays | +| One source should span unusual geometry | `#M1-10` one picture, `#M1-11` addressable pictures, `#A3-01` sharp subject over receded copy | +| Several visuals should read as one system | `#P3-05` grid, `#P3-14` mosaic with text cell, `#P3-20` tessellation, `#P3-21` split tiling, `#P3-22` curve array, `#P3-23` depth row | +| A foreground needs an opening or reveal | `#M1-06` true hole, `#M1-07` cut scrim, `#M1-08` background-registered fill, `#M1-05` text subtraction | +| Text needs contrast without discarding the visual | `#M2-01` directional scrim, `#M2-05` spotlight, `#A3-02` prepared frosted panel, `#M2-09` grid scrim | +| A subject should cross or re-layer around native content | `#A2-02` frame breakout or `#A2-03` registered subject/base pair | +| A cover, divider, or promotional page needs image-led structure | `#P1-01`, `#P1-04`, `#P1-13`, or `#P3-15`–`#P3-19` | +| Consecutive pages should share one visual world | `#C1-01` persistent state, `#C2-01` pan, `#C2-02` push/pull, or `#C3-01` matched framing | + +--- + +## 3. Primary Structures + +### 3.1 P1 · Single-Visual Structures + +- **#P1-01 · Full-bleed title field** — float a native title over one canvas-filling image; optionally use a poster-scale side or lower-corner stack directly on the image without a title card. +- **#P1-02 · Side image with content field** — place one visual beside native copy; let reading direction choose left/right and hierarchy choose partial- or full-height. +- **#P1-03 · Edge-bleed image** — extend the visual beyond one canvas edge so it enters or exits the page instead of sitting in a box. +- **#P1-04 · Image band or belt** — use a top band with content columns below, a middle band with content above and below, or a lower band beneath the title/content field; native copy may also occupy a verified calm zone while the heading stays outside. +- **#P1-05 · Balanced horizontal split** — give image and content balanced top/bottom fields with a deliberate seam. +- **#P1-06 · Central image in a 3×3 field** — put the visual at the center and use surrounding cells for labels, evidence, or small data. +- **#P1-07 · Centered image with radial callouts** — place one focal visual centrally and route native callouts outward. +- **#P1-08 · Diagonal visual/content transition** — use a diagonal image/content boundary whose contour supports the page's reading direction. +- **#P1-09 · Receded image with oversized type** — push the image into the background and make typography the dominant foreground. +- **#P1-10 · Slim image strip with large type** — place a narrow image strip beside oversized horizontal type. +- **#P1-11 · Negative-space dominant** — keep the visual and copy compact so whitespace carries hierarchy. +- **#P1-12 · Framed figure with caption** — float one image in whitespace with a restrained frame and native caption. +- **#P1-13 · Illustration as layout field** — let a large illustration or cutout set the page rhythm; place copy in its calm regions. + +### 3.2 P2 · Image as Canvas with Native Overlay + +**Reference — not a constraint**: use `P2` when native annotations, data, or process nodes bind to locations inside the prepared visual; an ordinary side image or inset remains `P1` / `P3`. + +- **#P2-01 · Annotated evidence** — place compact annotation cards with routed leaders over the visual. +- **#P2-02 · Hotspots with sidebar legend** — pair numbered points on the visual with a matching native legend. +- **#P2-03 · Detail lens** — outline one sub-region on the existing picture and place a native caption nearby; keep one picture object and do not add a rescaled image inset. +- **#P2-04 · Overview with zoom callout** — keep the full overview visible, add a second independently cropped picture from the exact same source, and link the selected region to that detail with native annotation; preserve source-region correspondence, not page-space registration. +- **#P2-05 · Contextual metrics** — place native KPI tiles in calm regions of the visual. +- **#P2-06 · Process through a scene** — connect numbered flow nodes along meaningful geometry in a real or illustrated scene. +- **#P2-07 · Engineering overlay** — add measurement lines, end ticks, module tags, and exact labels. +- **#P2-08 · Architecture or network overlay** — draw native nodes, connections, icons, and labels over the scene. +- **#P2-09 · Interface overlay** — add translucent UI panels, progress indicators, badges, and native arcs. +- **#P2-10 · Accurate chart over visual context** — draw the chart natively, treat the image as context only, and follow [`executor-chart.md`](./executor-chart.md). + +`#P2-03` and `#P2-04` are not interchangeable: the former annotates one picture; the latter exports an overview plus a second same-source picture object with an independent crop. + +### 3.3 P3 · Multi-Visual Structures + +- **#P3-01 · Diptych** — pair two adjacent images around one shared visual argument. +- **#P3-02 · Triptych** — align three distinct sources, unlike a baked multi-scene asset. +- **#P3-03 · Before/after or A/B comparison** — place two equally sized image containers side by side and label both states explicitly. +- **#P3-04 · Small multiples** — arrange same-kind images in identical containers and caption structures so peers can be compared. +- **#P3-05 · Equal-cell tiled grid** — use equal containers when equality and scanability are the message. +- **#P3-06 · Linear image sequence** — align a horizontal sequence by height with content-driven widths, or a vertical sequence by width with annotations and captions on one shared side. +- **#P3-07 · Z-pattern serpentine** — alternate image and text positions down successive bands to create a zigzag reading path. +- **#P3-08 · Ascending or descending picture process** — step image containers progressively upward or downward and use native numbering or connectors to preserve sequence. +- **#P3-09 · Picture-in-picture inset** — overlay one framed image over a larger source; use `#P2-04` when the inset magnifies a selected region from that exact source. +- **#P3-10 · Overlapping image stack** — use z-order and restrained offsets to create a layered print or archive feel. +- **#P3-11 · Asymmetric collage** — balance one dominant visual with smaller supporting visuals using consistent gaps. +- **#P3-12 · Irregular mosaic** — pack different-sized tiles into one coherent field. +- **#P3-13 · Montage with spanning type** — tile several visuals and run one legible native title treatment across the assembled field. +- **#P3-14 · Photo mosaic with a text cell** — reserve one mosaic cell for copy so absence of a photo creates hierarchy. +- **#P3-15 · Image-navigation table of contents** — turn sections into visual navigation cards with native numbering and summaries. +- **#P3-16 · Asymmetric dual-image chapter banner** — pair a compact image with a wider image and anchor them with a native section marker. +- **#P3-17 · Ambient image, evidence image, and text panel** — let one visual establish mood and another provide concrete proof. +- **#P3-18 · Ribbon-header image cards** — give peer image columns distinct native ribbon or chevron headings. +- **#P3-19 · Side hero with staggered evidence cards** — pair a full-height hero field with supporting cards that step through the opposite side. +- **#P3-20 · Non-rectangular tessellation** — tile clipped geometric cells and reserve selected cells for native copy or color. +- **#P3-21 · Split tiling** — fragment one parent contour into interlocking cells, each holding a different image as an independent object. +- **#P3-22 · Containers arrayed along a curve** — distribute containers consistently along an arc, wave, or ring; keep image orientation intentional. +- **#P3-23 · Embracing arc row** — create depth with a center-weighted scale and vertical-offset rhythm while keeping the objects two-dimensional. + +--- + +## 4. Modifier Layers + +### 4.1 M1 · Reveal, Crop, and Registration + +- **#M1-01 · Geometric crop** — clip the visual to a circle, ellipse, rounded rectangle, or bounded polygon; the contour is an effect option. +- **#M1-02 · Custom-path crop** — use one authored organic or silhouette contour when a basic geometric crop cannot express it. +- **#M1-03 · Layered paper-cut stack** — clip image layers independently and draw vector layers in their final geometry. +- **#M1-04 · Faux painted knock-out** — cover part of an image with the matching background or another prepared visual only when the surrounding field makes the imitation credible. +- **#M1-05 · Text-as-subtraction** — reveal an image or field through glyph-shaped holes; materialize supported text Boolean geometry through [`native-shape-authoring.md`](./native-shape-authoring.md). +- **#M1-06 · Panel with a true hole** — subtract an opening from a foreground panel so changing content behind it remains valid; follow [`native-shape-authoring.md`](./native-shape-authoring.md) §6. +- **#M1-07 · Scrim with true cutouts** — subtract image-reveal openings from a full-canvas scrim; lettering and complex cuts follow [`native-shape-authoring.md`](./native-shape-authoring.md) §6. +- **#M1-08 · Background-registered shape fill** — fill a stationary shape with the page background sampled in root coordinates so it impersonates a hole while remaining an object. +- **#M1-09 · Deliberately misregistered fragments** — separate same-source fragments and break their alignment intentionally for torn, misprint, or glitch language. +- **#M1-10 · One image across detached shapes** — export one native picture with disjoint clip subpaths so one continuous scene spans every shape. +- **#M1-11 · Same-source addressable crops** — export several independent native pictures that share an exact source coordinate system; follow [`executor-image.md`](./executor-image.md) §1. + +The following three patterns are topologically different and are not interchangeable: + +| ID | Sources | Exported picture topology | Visual relationship | +|---|---|---|---| +| One-picture compound crop (`#M1-10`) | One source | One native picture with disjoint clip subpaths | One continuous scene spans detached shapes; fragments are not independent picture objects | +| Addressable same-source crops (`#M1-11`) | One exact source reference | Several independently addressable native pictures | Crops share one source coordinate system and remain in exact registration; follow [`executor-image.md`](./executor-image.md) §1 | +| Different-source split tiling (`#P3-21`) | Different sources | Several independent picture objects in interlocking cells | The parent contour unifies peers; scene continuity across cells is not implied | + +### 4.2 M2 · Tone, Focus, and Contrast + +- **#M2-01 · Directional gradient scrim** — add directional contrast while retaining image detail; when copy overlays the image, protect its side and keep the focal side clear. Direction, protected side, opacity curve, and stops are options. +- **#M2-02 · Radial vignette** — darken the periphery to emphasize the central field. +- **#M2-03 · Flat wash** — uniformly darken, lighten, or palette-tint an image to integrate it with the page. +- **#M2-04 · Multi-hue gradient scrim** — shift color temperature or bridge image regions with a multi-stop field. +- **#M2-05 · Radial spotlight** — keep a selected region clear while surrounding content recedes. +- **#M2-06 · Texture or atmospheric wash** — turn an image into a low-contrast supporting texture or atmosphere field rather than presenting it as primary evidence. +- **#M2-07 · Watermark image field** — place a strongly receded image behind body copy. +- **#M2-08 · Fade into a solid background** — match the fade endpoint to the page background so the image edge disappears. +- **#M2-09 · Grid scrim with varied opacity** — modulate one underlying image through a seamless grid of translucent cells. + +### 4.3 M3 · Framing, Placement, and Depth Accents + +- **#M3-01 · Restrained image frame** — trace the image with one restrained outline. +- **#M3-02 · Repeated photo-print frames** — repeat nearby outlines for a layered photo-print treatment. +- **#M3-03 · Editorial rotation** — rotate an image or its container slightly when the style benefits from an informal print gesture. +- **#M3-04 · Lifted image panel** — separate a standalone image panel from the background with one restrained depth cue; [`svg-effects.md`](./svg-effects.md) owns the legal effect. +- **#M3-05 · Contour echo** — reuse a non-rectangular clip contour as an offset stroke instead of boxing it in a rectangle. +- **#M3-06 · Decorative corner fragment** — use a cropped image fragment as a secondary corner accent. +- **#M3-07 · Image divider band** — replace a line between content regions with a narrow visual strip. + +--- + +## 5. Asset-Dependent Treatments + +**Prepared-asset gate**: every treatment below consumes its named project-local asset; it does not authorize creation during SVG realization. Embedded lettering belongs to the artwork only when deliberately fixed; authoritative or editable labels remain native SVG. If a required asset is absent, return to the active workflow's preparation owner or choose a native treatment. + +### 5.1 A1 · Prepared Composites and Appearance + +- **#A1-01 · Baked multi-scene composite** — use one prepared source containing coordinated internal scenes; distinct from a `P3` structure built from separate images. +- **#A1-02 · Prepared blurred backdrop** — use a prepared blurred asset; runtime image blur is not the backdrop mechanism. +- **#A1-03 · Prepared duotone photograph** — use a prepared two-color image treatment. +- **#A1-04 · Prepared soft image-to-image blend** — use a precomposited or baked-alpha asset when arbitrary images must blend per pixel. + +### 5.2 A2 · Subject and Cutout Layers + +- **#A2-01 · Transparent sticker or cutout** — use a prepared RGBA asset and preserve its open silhouette. +- **#A2-02 · Subject breaking out of a container** — register a prepared foreground subject across its frame boundary. +- **#A2-03 · Registered subject/base pair** — align a base photo with its prepared transparent subject cutout in one coordinate system; optionally place a native title, panel, or shape between them so the subject crosses that middle layer. + +### 5.3 A3 · Registered Derivatives + +- **#A3-01 · Sharp subject over receded full-frame derivative** — register a sharp focal crop or prepared cutout subject over a blurred, tinted, or desaturated full-frame derivative; never cover it with an opaque full-frame copy. +- **#A3-02 · Registered frosted-glass panel** — place a prepared registered blurred crop beneath the native text panel. +- **#A3-03 · Selective desaturation** — register a prepared color subject layer over a desaturated base. + +--- + +## 6. Cross-Page Continuity + +### 6.1 C1 · Persistent Visual State + +- **#C1-01 · Persistent visual with progressive overlays** — keep one source, crop, and placement stable while native annotations or claims change, replace, or accumulate across consecutive pages. + +### 6.2 C2 · Camera Continuity + +- **#C2-01 · Cross-page image pan** — show different regions of one wide image across consecutive pages so the audience recognizes one continuous place. +- **#C2-02 · Cross-page push-in or pull-out** — reuse one source while the crop or scale moves from overview to detail, or detail to overview, across consecutive pages. + +If motion is enabled, [`animations.md`](./animations.md) owns its implementation; these patterns only define the static framing relationship. + +### 6.3 C3 · Matched Framing + +- **#C3-01 · Matched framing across sources** — keep the subject anchor, visual scale, horizon, or dominant contour aligned while consecutive pages replace one source with another. + +--- + +## 7. Composition Playbook + +**Reference — not a constraint**: build from the page's communication job, not catalog coverage. Choose the smallest combination that resolves the page and any intentional cross-page relationship. + +### 7.1 Combination Procedure + +| Pass | Decision | +|---|---| +| Skeleton | Select the `P` relationship: one visual field, comparison, sequence, evidence view, or multi-image system. Compatible Primaries may share one page | +| Job | Name the concrete integration need or stylistic role: contrast, aspect fit, focus, reveal/opening, peer cohesion, exact native information, or a recurring depth/print gesture | +| Apply | Add the smallest `M` that serves each chosen job; add no technique without a job | +| Prepared asset | Use `A` only when the named project-local composite, cutout, or derivative already exists | +| Continuity | Add `C` only when adjacent pages deliberately share a persistent state, camera relationship, or matched framing | +| Integrate | Reuse contours, baselines, gap rhythm, palette, and required registration so the layers read as one composition | +| Stop | Omit or simplify the next layer when it repeats a job, competes with the message, requires an unavailable asset, or weakens legibility/editability | + +### 7.2 High-Yield Combinations + +| Page job | Composition candidates | +|---|---| +| Atmospheric cover or divider | `#P1-01` + `#M2-01`; use `#M1-07` + optional `#M3-05` when an opening should supply the page character | +| One source does not fit the canvas | `#A3-01` + `#M1-02` or `#M1-10`, with every copy kept in exact registration | +| Comparison with evidence on both sides | `#P3-03` + `#P2-01`; keep labels, leaders, and exact claims native | +| Scene-backed evidence or metrics | `#P2-01` / `#P2-05` + `#M2-01` or `#M2-03`; let the image carry context and native SVG carry information | +| One selected region needs explanation | Use `#P2-03` for an outline and caption on one picture; use `#P2-04` when a second same-source picture must magnify the region | +| Several sources should read as one object | `#P3-21` + restrained `#M3-01`, or `#P3-20` + a native text/color cell | +| One continuous scene should span detached shapes | `#M1-10` + optional `#M3-05`; keep one-picture topology | +| Same-source windows must remain independent | `#M1-11`; add `#C2-01` or `#C2-02` only when consecutive pages use the relationship | +| A prepared subject should re-layer over its source | `#A2-03`; keep the base and cutout registered, and insert a native middle layer only when it has a distinct job | +| A busy visual needs one focal region | `#M2-05`, or prepared `#A3-01` / `#A3-03` when a native contrast treatment is insufficient | +| A visual argument should build across pages | `#C1-01` + `#P2-01` or `#P2-05`; keep the underlying source and frame stable | +| Formula or technical figure needs explanation | `#P1-12` + `#P2-07` / `#P2-03`; use `#P2-04` only when a second cropped detail is useful, and keep explanatory labels native | + +**Registration boundary**: registration-dependent effects succeed only when their declared coordinate relationship remains exact. Preserve registration for `#M1-10`, `#A2-02`, `#A3-01`, `#M1-08`, `#A2-03`, `#A3-02`, `#A3-03`, and `#M1-11`; `#M1-09` is the intentional exception. + +**Source-correspondence boundary**: `#P2-04` reuses one exact source but intentionally changes the detail crop, scale, and placement; preserve the selected-region correspondence instead of forcing page-space registration. + +**Formula placement**: treat a rendered formula as a prepared visual asset. Use whitespace patterns such as `#P1-11` or `#P1-12` for isolated derivations, `#P1-07`, `#P2-07`, `#P2-03`, or `#P2-04` for annotated formulas, and `#P3-04` or `#P3-03` for comparisons; keep editable explanatory text native. + +All compatibility details remain owned by [`shared-standards-core.md`](./shared-standards-core.md) and its routed references. 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 46366fe7..526e5116 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 @@ -1,237 +1,184 @@ -> See [`shared-standards-core.md`](./shared-standards-core.md) for common technical constraints. +> See [`svg-image-embedding.md`](./svg-image-embedding.md) for SVG image syntax and crop-policy enforcement. # Image Layout Specification -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. +Neutral geometry and review rules for every image or rendered-formula placement. This file calculates the selected composition; it never chooses a resource, pattern, or automatic left/right or top/bottom layout. -**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). +**When to run**: whenever an image or rendered formula will be placed. Use the current page composition to select its region first, then apply the relevant single-item, adjacent, overlay, or multi-item calculation below. --- -## Layout Decision Flow +## 1. Ownership and Inputs -``` -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 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. -``` +| Role | Owns | +|---|---| +| Default Strategist | Resource choice, semantic role, crop boundary, and preferred image/content or image/shape relationship | +| Image_Generator | Composition inside each generated bitmap for its planned container | +| Default Executor | Final SVG regions and geometry; may adapt the preferred relationship while preserving binding resource, content, and crop constraints | +| Quick Generate main agent | The planning and realization decisions above in one active context | -**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. +This specification and [`image-layout-patterns.md`](./image-layout-patterns.md) are the always-read geometry and composition vocabulary; [`svg-image-embedding.md`](./svg-image-embedding.md) owns embedding. Default and Quick SVG authoring also load [`svg-effects.md`](./svg-effects.md) and [`native-shape-authoring.md`](./native-shape-authoring.md) before realization, so apply their contracts directly when a selected construction needs effects, preset geometry, or Boolean geometry. Other routes follow their own documented load triggers. + +### 1.1 Geometry notation + +| Symbol | Meaning | +|---|---| +| `(x0, y0, W, H)` | Current selected page region | +| `(ws, hs)` | Measured source width and height | +| `R = ws / hs` | Source aspect ratio | +| `Q = W / H` | Selected-region aspect ratio | +| `g`, `gx`, `gy` | Gap between adjacent regions, columns, or rows | +| `ax`, `ay` | Horizontal and vertical anchor fractions in `[0,1]` | + +All dimensions must be finite and positive. Derive `R` from current measured source data rather than a requested or previously planned size. --- -## Layout Starting Points (side-by-side intent) +## 2. Aspect-Ratio Placement -| Image Ratio | Useful Starting Structure | Image Position | Description | -|-------------|-------------|----------------|-------------| -| > 2.0 (ultra-wide) | Top-bottom split | Top full-width | Image spans canvas width, height proportional | -| 1.5-2.0 (wide) | Top-bottom split | Top | Image width = content area width, height proportional | -| 1.2-1.5 (standard) | Left-right split | Left | Image height-first fit, width proportional | -| 0.8-1.2 (square) | Left-right split | Left | Image takes content area height, width proportional | -| < 0.8 (portrait) | Left-right split | Left | Image height = content area height, width proportional | +### 2.1 Contain -> Boundary ratios are orientation cues, not thresholds. Let text volume, focal content, page hierarchy, and crop safety decide. +Contain keeps the complete source visible inside `(W,H)`: + +```text +if R >= Q: + w = W + h = W / R +else: + h = H + w = H × R + +x = x0 + ax × (W - w) +y = y0 + ay × (H - h) +``` + +Centered contain uses `ax = ay = 0.5`. SVG realization normally maps this to a legal `meet` anchor. + +### 2.2 Fill + +Fill covers `(W,H)` without distortion and crops overflow: + +```text +if R >= Q: + h = H + w = H × R +else: + w = W + h = W / R + +overflow_x = w - W +overflow_y = h - H +x = x0 - ax × overflow_x +y = y0 - ay × overflow_y +``` + +Centered fill uses `ax = ay = 0.5`. SVG realization normally maps this to a legal `slice` anchor. Use fill only when the active crop boundary permits the computed loss and the anchor protects the declared focal content. + +### 2.3 Mode selection + +| Need | Geometry | +|---|---| +| Complete source, formula, evidence, or edge content | Contain | +| Region coverage with a focal-safe crop | Fill | +| Complete source plus a detail view | One contain placement plus a separately justified crop | +| Irregular or repeated source windows | Apply the selected region math first, then load the owning crop/shape reference | --- -## Dimension Calculation Formulas +## 3. Single Image or Formula -### Canvas Parameters (All Formats) +Place a standalone item by applying §2 to its selected region. The region itself comes from the page hierarchy; source ratio determines the item geometry inside it, not the page structure. -| Format | Canvas | Margins (L/R, T/B) | Content Area (W x H) | Title Height | Content Start Y | -|--------|--------|--------------------|-----------------------|-------------|----------------| -| PPT 16:9 | 1280x720 | 60, 60 | 1160 x 600 | 60px | 80px | -| PPT 4:3 | 1024x768 | 50, 50 | 924 x 608 | 60px | 70px | -| Xiaohongshu | 1242x1660 | 60, 80 | 1122 x 1500 | 80px | 100px | -| WeChat Moments | 1080x1080 | 60, 60 | 960 x 960 | 60px | 80px | -| Story | 1080x1920 | 60, 120/180 | 960 x 1620 | 80px | 140px | -| WeChat Article | 900x383 | 40, 40 | 820 x 303 | 40px | 50px | +For an item adjacent to another region, divide only the available selected region. Let `q_item` and `q_other` be positive visual weights for the image/formula and the other content. -> Below, **W** = content area width, **H** = content area height (excludes title). PPT 16:9 example: W=1160, H=600. +### 3.1 Horizontal adjacency -### Top-Bottom Layout Calculation - -``` -Image width = W = 1160 px -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 or choose another composition while preserving binding -resource/content/crop constraints. +```text +available = W - g +item_width = available × q_item / (q_item + q_other) +other_width = available - item_width ``` -### Left-Right Layout Calculation +Both regions use height `H`. Place either region first according to the selected composition; no fixed share is implied. -**Method 1 (height-first, suitable for portrait images)**: -``` -Image height = H = 600 px -Image width = H x R = 600 x R px -Text area width = W - image width - gap(20px) +### 3.2 Vertical adjacency + +```text +available = H - g +item_height = available × q_item / (q_item + q_other) +other_height = available - item_height ``` -**Method 2 (width-constrained, for wide images converted to left-right)**: -``` -Image width = W x 0.7 = 812 px -Image height = image width / R -Text area width = W - image width - gap(20px) -``` +Both regions use width `W`. Place either region first according to the selected composition. -**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. +### 3.3 Overlay and inset + +An overlay keeps the image region and overlay region independently measurable. An inset selects a child region `(xi, yi, Wi, Hi)` inside the current region, then reapplies §2 using the same source ratio. Do not derive either region from an assumed percentage; size it from the actual hierarchy, copy, focal content, and required separation. --- -## Layout Examples +## 4. Multiple Images -### Ultra-wide Image (ratio 2.45) +### 4.1 Equal grid -``` -Original: 1960x800, R=2.45 → Top-bottom split -Image: 1160x473, Text area: 1160x147 → 7:3 top-bottom +For `c` columns and `r` rows: + +```text +cell_width = (W - (c - 1) × gx) / c +cell_height = (H - (r - 1) × gy) / r + +cell_x(col) = x0 + col × (cell_width + gx) +cell_y(row) = y0 + row × (cell_height + gy) ``` -### Standard Landscape (ratio 1.38) +Use equal cells when peer comparison is the message. Apply contain or fill independently to each source within its cell. -``` -Original: 1614x1171, R=1.38 → Left-right split -Image: 773x560 (left), Text area: 367x560 (right) → 7:3 left-right +### 4.2 Weighted tracks + +For column weights `u[1]…u[c]` and row weights `v[1]…v[r]`: + +```text +available_width = W - (c - 1) × gx +available_height = H - (r - 1) × gy + +column_width[j] = available_width × u[j] / sum(u) +row_height[k] = available_height × v[k] / sum(v) ``` -### Wide Image Edge Case (ratio 1.75) +Use weighted tracks when one item is primary. A spanning item receives the sum of its tracks plus the internal gaps it crosses. -``` -Original: 1820x1040, R=1.75 -Strategist compares top-bottom: image height=663, text area=-43 ❌ -Strategist recommends left-right: image 780x446 (left), text area 360x600 (right) → 7:3 left-right -``` +### 4.3 Free multi-item composition + +For montage, arc, overlap, or another non-grid arrangement, assign one explicit region to every item and verify the union against `(W,H)`. Reuse one gap/rhythm system where separation is intended; overlap is explicit geometry, not a negative-gap accident. --- -## Portrait Canvas Override +## 5. Rendered Formula Geometry -Default selection table assumes **landscape or square canvas**. For portrait canvases (height > width), left-right splits leave both columns too narrow — use the override below. +Treat a rendered formula as an aspect-ratio source and apply contain within its selected mathematical region. Centering is the default geometric anchor; align to a nearby baseline or relation only when the page composition defines that relationship. -| Canvas Orientation | Image Ratio | Useful Starting Structure | Reason | -|-------------------|-------------|-------------------|--------| -| Portrait (Xiaohongshu, Story) | > 1.5 (wide) | Top-bottom | Same as landscape canvas | -| Portrait (Xiaohongshu, Story) | 1.2-1.5 (standard) | Top-bottom | Left-right too narrow on tall canvas | -| Portrait (Xiaohongshu, Story) | 0.8-1.2 (square) | Top-bottom | Image fits well in top half | -| Portrait (Xiaohongshu, Story) | 0.5-0.8 (portrait) | Left-right | Portrait image on tall canvas works | -| Portrait (Xiaohongshu, Story) | < 0.5 (extreme portrait) | Left-right | Image takes one side, text the other | +For `n` vertically stacked formula regions with equal lanes: -> Square canvases (WeChat Moments 1:1): use the standard landscape rules. +```text +lane_height = (H - (n - 1) × g) / n +lane_y[i] = y0 + i × (lane_height + g) +``` + +Contain each formula independently in its lane. When formulas are visual peers, a common effective scale may improve comparison; otherwise let their selected regions reflect their semantic weight and source ratios. --- -## Multi-Image Layout +## 6. Composition Checks -For slides with multiple images, divide the content area evenly using the formulas below. +| Check | Required response | +|---|---| +| Computed width or height is non-positive | Re-select the page regions or reduce gaps | +| Contain leaves unusable residual space | Recompose the surrounding regions; do not stretch the source | +| Fill removes focal or required content | Change anchor, enlarge the region, or use contain | +| Adjacent text/content region cannot carry its material | Reweight or change the selected relationship | +| Equal cells imply equality that the content does not have | Use weighted tracks or a free composition | +| Peer images use inconsistent visual scale without meaning | Normalize their regions or make the hierarchy explicit | +| Formula symbols become unreadable at the intended viewing size | Enlarge its region or restructure the page | +| Gaps, alignments, or overlaps drift without purpose | Recalculate from the shared region and gap values | -### Grid Formulas - -``` -columns = number of columns -rows = number of rows -gap = 20px (PPT formats) or 30px (social formats) - -cell_width = (W - (columns - 1) * gap) / columns -cell_height = (H - (rows - 1) * gap) / rows -``` - -### Common Patterns - -| Image Count | Layout | Grid | Description | -|-------------|--------|------|-------------| -| 2 (both landscape) | Side-by-side | 2x1 | Two equal columns | -| 2 (both portrait) | Stacked | 1x2 | Two equal rows | -| 2 (mixed) | 1 large + 1 small | Custom | Landscape top (full-width), portrait right-bottom | -| 3 | 1 large + 2 small | 1+2 | Left large (50% width), right column with 2 stacked | -| 4 | Grid | 2x2 | Equal-sized cells | - -### Example: 2x2 Grid on PPT 16:9 - -``` -W=1160, H=600, gap=20 -cell_width = (1160 - 20) / 2 = 570 -cell_height = (600 - 20) / 2 = 290 - -Image positions: - (60, 80) 570x290 (650, 80) 570x290 - (60, 390) 570x290 (650, 390) 570x290 -``` - -> 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. - ---- - -## Composition Checks - -| Check | Action | -|-----------|-----------------| -| 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 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 | - ---- - -## Handoff Fields - -This spec only defines layout calculation. Write computed fields into the Image Resource List defined in [`svg-image-embedding.md`](svg-image-embedding.md): - -| Field | Meaning | -|-------|---------| -| `Ratio` | Original image width / height | -| `Layout pattern` | Non-empty Strategist layout suggestion in free-form prose, optionally citing catalog ids; 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=`; source/crop exactly project §VIII, while pattern preserves the normalized free-form suggestion and any optional catalog ids 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). - -### SVG Image Embedding Examples - -Complete display (`no-crop` assets such as data charts): - -```xml - -``` - -**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 - -``` - ---- - -## Automation Tool - -```bash -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` 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. - ---- - -## Role Responsibilities - -| Role | Responsibility | -|------|---------------| -| **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 | +The final geometry must express the active page hierarchy, preserve the selected resource relationships, and remain valid under the conditionally loaded technical contracts. 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 ee439265..6be8cbaf 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 @@ -2,9 +2,9 @@ # Image_Searcher Reference Manual -Role definition for the **web image acquisition path**: translate Strategist intent into keyword queries, search openly-licensed providers, download a license-cleared image into `project/images/`, and record provenance + license metadata into `image_sources.json`. +Role definition for the **web image acquisition path**: translate the active resource owner's intent into keyword queries, search openly-licensed providers, download a license-cleared image into `project/images/`, and record provenance + license metadata into `image_sources.json`. -**Trigger**: resource list rows with `Acquire Via: web`. The role is loaded only when at least one such row exists. +**Trigger**: the Default Generate resource list or Quick Generate transient roster contains `Acquire Via: web`. The role is loaded only when at least one such row exists. --- @@ -69,12 +69,13 @@ Keep two layers distinct: | Layer | Owner and grammar | |---|---| -| 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. | +| Default Generate `design_spec.md §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. | +| Quick Generate transient `Reference` | Current main agent's active-context intent after honoring explicit user assets, URLs, subjects, and constraints; unspecified choices are resolved automatically without confirmation. | | `image_queries.json.items[].query` / positional query | Image_Searcher's concrete entity/identity keyword string. Start with the shortest phrase that preserves identity; keep exact multi-word names and necessary disambiguators even when they exceed four words. Omit mood, quality, composition, HEX, and negative wording. | Web APIs match metadata, not semantic intent. Providers try the original query first, then progressively simplified four/three/two/one-word variants. A pipeline manifest should therefore use a concise query without pre-truncating exact names. For Chinese landmarks, use the precise Chinese name with Wikimedia; for stock providers, use compact English identity terms when they retain the subject. -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 tries materially different query/provider/permitted-license strategies until no untried strategy remains, then marks `Needs-Manual`. Never loosen `required_terms`, the license policy, or the locked intent to manufacture a match. +Image_Searcher consumes the active Reference and never rewrites its owner. In Default Generate, that means no rewrite of `design_spec.md` or `spec_lock.md`; in Quick Generate, the transient Reference remains fixed for the run. A candidate either satisfies that existing subject/focal/crop intent, or the role tries materially different query/provider/permitted-license strategies until no untried strategy remains, then marks `Needs-Manual`. Never loosen `required_terms`, the license policy, or the active intent to manufacture a match. 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. @@ -172,29 +173,36 @@ 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. Review it against the locked §VIII Reference and Crop Policy before it is trusted: +A metadata-ranked top hit is *downloadable and token-relevant*, not necessarily *visually suitable* — `score_candidate` never sees pixels. Review it against the active 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). 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. +- **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 active crop policy remains safe. +- **Non-multimodal model (no vision)**: do **not** pretend to confirm. Default Generate hands off via each `source_page_url`. Quick Generate does not open an interaction; mark a required image `Needs-Manual` when visual suitability cannot be established, preserve provenance, and let the quick export gate block. -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. +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 active 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`. **Replacement ladder when a best match is not right** (any reviewer): 1. refine the query and re-run that row while each revision tests a materially different identity phrase or disambiguator; do not repeat a semantically exhausted query; -2. **manual URL replace (universal, model-agnostic)** — the user finds a better image anywhere and gives its URL; download and swap it in: +2. **manual URL replace (universal, model-agnostic)** — use a user-supplied URL and swap it in: ```bash python3 scripts/image_search.py --from-url --filename .jpg -o /images ``` - Recorded with `license_tier: manual` — verifying usage rights is the user's call. Human replacement is a legitimate outcome, not a failure. It updates the image and `image_sources.json` but does **not** rewrite `image_queries.json`, so a row fixed this way may still read `Needs-Manual` in the batch manifest — harmless: the file is present, so export proceeds ([`executor-web-image.md`](./executor-web-image.md) §1); + Recorded with `license_tier: manual` — verifying usage rights is the user's + call. In Quick Generate, use this step only when the URL was already + supplied; never pause to request one. The command updates the image and + `image_sources.json` but does **not** rewrite `image_queries.json`. Validate + the downloaded file and matching manual-provenance entry, then reconcile + that query row and the active roster to `Sourced` before export; a stale + `Needs-Manual` status remains blocking + ([`executor-web-image.md`](./executor-web-image.md) §1); 3. (opt-in) `--save-candidates` to pull auto-alternatives with their own `source_page_url`s, then `--promote` the best (below); 4. when the query variants, configured provider chain, and permitted license stages are exhausted and no user-confirmed manual URL is available, mark the row `Needs-Manual`. Web search is far cheaper than AI generation, so this review pass is well worth it. -**This review never halts the pipeline** (image-base §6 hard rule). It runs inside Step 5 image acquisition: an image that cannot be verified or replaced right now becomes `Needs-Manual` and the deck still builds (placeholder), so generation flows straight into Step 6. Manual `--from-url` replacement is an improvement step, not a blocking gate — do it now, or later from live preview, without stopping the run. +**This review never opens an acquisition-time interaction** ([`image-base.md`](./image-base.md) §6). Default Generate may build a placeholder and continue to Step 6. Quick Generate finishes all permitted automated strategies, records `Needs-Manual`, and blocks direct export when the unresolved image is required. ### Manual review candidates (escalation, opt-in) @@ -306,11 +314,11 @@ CLI exit: `0` when all attempted rows resolve; `1` while any row remains `Failed --- -## 9. Handoff with Strategist +## 9. Handoff with the Intent Owner Reference field is **intent description**, not a query. See [`image-base.md`](./image-base.md) §8 for the rule. -Keep it intact as the acceptance contract. Derive a separate concise provider query that preserves exact names and necessary disambiguation; do not pass the Reference verbatim or rewrite it after search. +Keep it intact as the acceptance contract. In Default Generate the owner is Strategist; in Quick Generate it is the current main agent's transient roster. Derive a separate concise provider query that preserves exact names and necessary disambiguation; do not pass the Reference verbatim or rewrite it after search. --- @@ -335,7 +343,7 @@ 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 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` web image was reviewed against the active Reference/Crop Policy — a multimodal model via `images/.review/.jpg`; without vision, Default Generate hands off via `source_page_url` while Quick Generate records `Needs-Manual` without interaction. A mismatch was re-queried, replaced, escalated, or marked `Needs-Manual`, never repaired by rewriting the active 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 author + license credit in every SVG that references it - [ ] `metadata_dimensions` warnings surfaced when downloaded preview is much smaller than upstream-claimed size diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/modes/showcase.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/modes/showcase.md index 138e5107..132cddf7 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/modes/showcase.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/modes/showcase.md @@ -24,7 +24,7 @@ Titles are short and evocative — a phrase, not a sentence. - Generous negative space around the primary visual relationship. - Bold use of the deck's theme color for atmosphere (cover / chapter pages). -> Hero / full-bleed / breathing-page geometry lives in [`executor-base.md`](../executor-base.md) and the optional [`image-layout-patterns.md`](../image-layout-patterns.md) library; this mode decides *what each page makes primary*. +> Hero / full-bleed / breathing-page geometry lives in [`executor-base.md`](../executor-base.md) and the compact [`image-layout-patterns.md`](../image-layout-patterns.md) vocabulary loaded by the image branch; this mode decides *what each page makes primary*. --- 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 10a7f706..e61d9461 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 @@ -4,9 +4,10 @@ Use this reference during Executor SVG construction or project-owned canonical template maintenance when basic primitives, one standard PowerPoint shape, or -multiple closed shapes can express the intended object. Prefer, in order: +supported shape/text operands 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 +Boolean result from closed shapes and/or resolvable text. 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. @@ -24,7 +25,7 @@ Apply this decision order before drawing any new geometric contour. | 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. | +| Two or more supported closed-shape / resolvable-text operands 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. | | 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. | @@ -201,7 +202,7 @@ freshness contract. ## 6. Shape Boolean Materialization -**Trigger**: Current page construction has two or more closed vector operands +**Trigger**: Current page construction has two or more supported shape/text 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 @@ -217,7 +218,7 @@ python3 ${SKILL_DIR}/scripts/shape_boolean_svg.py render \ | Concern | Contract | |---|---| -| Sources | Closed `path`, `polygon`, `rect`, `circle`, `ellipse`, or one validated compact authored shape preset. Open ordinary geometry, connectors, ordinary groups, text, images, definitions, and nested SVG viewports fail closed. | +| Sources | Closed `path`, `polygon`, `rect`, `circle`, `ellipse`, one validated compact authored shape preset, or supported horizontal implicit-LTR direct `` with a resolvable exact OpenType weight/style (`--font-dir` adds search roots). Text becomes glyph geometry and is no longer editable text. Open geometry, groups, nested 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 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. | @@ -258,9 +259,8 @@ reads flat no matter how strong the contrast. Keep every stop on one hue and vary only lightness, hold one light direction for the whole page, and remove strokes so adjacent facets meet cleanly. For a cylinder, apply the alternating ramp across the body and cap it with an ellipse -carrying its own shallower ramp. This is the shape-level twin of -[`image-layout-patterns.md`](./image-layout-patterns.md) `#91`, which applies the -same idea across separate facets of a folded form. +carrying its own shallower ramp. The same light logic applies across separate +facets of any folded form. ### 7.2 Reflection without a reflection effect @@ -301,7 +301,7 @@ but the four jobs they normally do are all reachable with gradients: | Contact shadow under an object | Ellipse filled with a `radialGradient` from dark-transparent at the centre to fully transparent at the rim | | Spotlight / stage pool | Cone or ellipse filled with a gradient fading to transparent at its far end, at low opacity over the scene | | Object dissolving into the page | Overlay a rectangle whose gradient runs from transparent to the exact page background hex | -| Hiding an object while keeping it live | Full transparency, or a background-registered fill ([`image-layout-patterns.md`](./image-layout-patterns.md) `#95`) | +| Hiding an object while keeping it live | Full transparency, or a background-registered fill ([`image-layout-patterns.md`](./image-layout-patterns.md) `#M1-08`) | A radial or linear alpha ramp reads the same as a feathered edge at slide scale and, unlike a filter, exports intact. Never approximate a soft edge with a stack 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 712377f6..c7d0df34 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 @@ -426,8 +426,8 @@ helper cannot write a project, select layout, or generate a page. stroke, optional fill/stroke opacity, stroke width, line cap, and line join. Normal generated pages use `spec_lock.md` for stable semantic color anchors and choose page-local paint from the retained Design Spec, style, and composition context. -The test-only [`quick-test`](../workflows/profiles/quick-test.md) profile has no -lock: keep every chosen paint value explicit in the SVG. +The lockless [`quick-generate`](../workflows/profiles/quick-generate.md) profile +keeps every chosen paint value explicit in the SVG. `create-template` authored templates take their values from the confirmed brief and template `design_spec.md`. Use ordinary SVG for gradients, patterns, filters, or other treatments outside @@ -530,7 +530,7 @@ continue without modification. ## 3. Canvas Format Quick Reference -Use the already locked canvas id and exact viewBox. [`canvas-formats.md`](canvas-formats.md) owns format selection; this core owns only SVG conformance on that canvas. The test-only [`quick-test`](../workflows/profiles/quick-test.md) profile has no lock; its first SVG establishes the canvas and every remaining page must use the identical viewBox. +Use the already locked canvas id and exact viewBox. [`canvas-formats.md`](canvas-formats.md) owns format selection; this core owns only SVG conformance on that canvas. The lockless [`quick-generate`](../workflows/profiles/quick-generate.md) profile uses its first SVG to establish the canvas; every remaining page must use the identical viewBox. --- @@ -553,12 +553,12 @@ Semantic markers are minimal compiler hints. Flat pages declare one root `data-p - **Canvas authority**: New authoring writes `viewBox="0 0 W H"` with positive integer pixels from the lock, or from the first SVG when the explicit - `quick-test` profile is active. Numerically equivalent spellings and positive + `quick-generate` profile is active. Numerically equivalent spellings and positive fractional imported dimensions remain compatible; export quantizes once at `1 SVG px = 9,525 EMU`. Invalid/non-finite values, non-zero origin, non-positive size, or unsupported PowerPoint dimensions are errors. All pages and Layout prototypes in one normal build share the numeric canvas and match - `spec_lock.md canvas.viewBox`; quick-test pages match the first SVG; + `spec_lock.md canvas.viewBox`; quick-generate pages match the first SVG; standalone templates match `design_spec.md canvas_viewbox`. Optional root `width`/`height` do not override `viewBox`. Root `` transform is forbidden; nested crop and `` keep @@ -580,7 +580,7 @@ These forms are needed only when the stated PPT behavior matters: | One editable PPT text frame with mixed formatting or multiline prose | Use one `` per logical paragraph and non-positional `` children for inline runs. Keep the first authored line as direct text; later lines use direct positioned `` children that repeat parent `x` with positive relative `dy`; an all-`` form may start at `dy="0"`. Default retains these breaks without PowerPoint wrapping; `--reflow-text` may join eligible lines. A font-size change, list marker, or larger accepted gap starts another paragraph. Sibling `` elements are forbidden as one paragraph's line breaks; they remain valid for independent frames. | | Stable object grouping or object-level animation anchor | Wrap the intended object in ``. Content grouping is **mandatory** per §4.3 — a top-level `` is also the animation anchor; it is not an optional convenience. | | Native PowerPoint background promotion | Outside structured mode, the first eligible visual layer may be a direct full-canvas `` or one inside a simple single-child group. Its fill must have a registered native mapping (solid, linear/radial gradient, or preset pattern), and it must have no transform, filter, clip, rounding, or visible stroke. Export writes the fill as Slide `p:bg`; image elements remain pictures. Structured routes use the narrower explicit solid-background ownership contract in [`pptx-structure-interface.md`](./pptx-structure-interface.md). | -| Free-design / brand-only PowerPoint structure | Use `pptx_structure.mode: flat`. Keep every represented object Slide-local; export materializes one clean project-owned Master plus one Blank Layout from the current lock, removes stock content placeholders/Layout inventory, and retains only the standard date/footer/slide-number capability hooks. Do not author Master/Layout identities, layers, or placeholder slots. Quick-test uses the same flat object ownership but converter-default theme scaffolding because no lock exists. | +| Free-design / brand-only PowerPoint structure | Use `pptx_structure.mode: flat`. Keep every represented object Slide-local; export materializes one clean project-owned Master plus one Blank Layout from the current lock, removes stock content placeholders/Layout inventory, and retains only the standard date/footer/slide-number capability hooks. Do not author Master/Layout identities, layers, or placeholder slots. Quick-generate uses the same flat object ownership but converter-default theme scaffolding because no lock exists. | | Reusable template-based PowerPoint Layout | Select one complete authoring SVG per page in `page_layouts`, declare each unique Master/Layout definition once, and assign pages through `page_pptx_layouts`. Strict preserves the prototype contract; adaptive retains its Master and uses a current or new Layout key already declared and assigned by Strategist. Construction cannot extend or mutate that mapping downstream. Non-mirror skin follows `spec_lock`. | **Hard rule — supported shape conversion**: Every PPT editability claim in this specification refers to the project converter reading `svg_output/` and emitting native DrawingML. `svg_final/` is a self-contained visual preview that may be inserted into PowerPoint as an SVG picture. PowerPoint's manual Convert-to-Shape operation is unsupported; do not narrow the authoring contract to its undocumented SVG subset. @@ -589,7 +589,7 @@ These forms are needed only when the stated PPT behavior matters: **Hard rule — root groups protect body-text layout**: Every visible direct root `` declares positive root-coordinate `data-pptx-bounds="x y width height"`. Keep it when frame/native coordinates size one PowerPoint object; placeholder bounds also supply the slot frame. On flat pages, make each module zone as generous as the canvas and sibling layout allow without overlapping another module zone. Checker validates this subcanvas against the root `viewBox`, then recursively validates only estimable `` descendants against it using the shared SVG-to-PPTX per-run width estimate and safety headroom. Nested groups and all shapes, images, paths, `` instances, effects, and object frames are not content-boundary inputs. Per side, Checker ignores text/bounds overflow through `1px`, warns through `5%` of the containing boundary dimension, and fails above `5%`. Bounds do not clip or reflow. -Wrap each logical Slide-local body unit in one descriptive top-level ``; group count follows the page's semantic units, and each group becomes one animation step when animation is enabled. Nested implementation groups may remain anonymous and need no bounds; any nested bounds are ignored. Flat pages use ordinary groups; structured slots already qualify, while titles, direct atomic Master/Layout elements, and canvas-level static framing—including background images and full-canvas scrim/decoration rectangles—may remain root primitives. On flat pages, give such static framing a stable `id` plus `data-pptx-role="background"` / `"decoration"`; never add a `` solely to silence an ungrouped-element advisory. +Wrap each logical Slide-local body unit in one descriptive top-level ``; group count follows the page's semantic units, and each group becomes one stable animation target when animation is enabled. Generic deck-wide animation gives that target one step; an explicit animation sidecar may assign it several ordered effects. Nested implementation groups may remain anonymous and need no bounds; any nested bounds are ignored. Flat pages use ordinary groups; structured slots already qualify, while titles, direct atomic Master/Layout elements, and canvas-level static framing—including background images and full-canvas scrim/decoration rectangles—may remain root primitives. On flat pages, give such static framing a stable `id` plus `data-pptx-role="background"` / `"decoration"`; never add a `` solely to silence an ungrouped-element advisory. **Reference — not a constraint**: A top-level semantic group may contain descriptive nested `` edit groups when its internal elements form useful @@ -646,8 +646,8 @@ separate parent content group; never put them inside the preset group itself. The normal serial post-processing and export workflow belongs to [`generate-pptx.md`](../workflows/generate-pptx.md) Step 7. The explicit -test-only exception belongs to -[`quick-test.md`](../workflows/profiles/quick-test.md). This file defines SVG +direct-generation exception belongs to +[`quick-generate.md`](../workflows/profiles/quick-generate.md). This file defines SVG authoring boundaries and intentionally does not mirror commands, flags, or output behavior. 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 8a7de821..38d6b555 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 @@ -4,7 +4,7 @@ Conditional extension for formula assets, proposed / confirmed image elaboration, AI rendering selection, and `design_spec.md §VIII` resource planning. -**Trigger**: Core first derives proposed `recommend.image_usage`. Load this module before Stage-2 direction construction when that proposal contains any non-`none` source, when the user supplied an explicit non-`none` image constraint, or when formula handling is triggered. After confirmation, the confirmed sources bound production: confirmed `none` with no formula trigger stops before resource authoring. On a formula-only path, read §3 and the formula-row rules in §4; skip non-formula planning. [`strategist.md`](./strategist.md) owns source recommendation; this module owns image-dependent candidates, production detail, and §VIII rows. +**Trigger**: Load before Stage-2 directions for a proposed non-`none` source, an explicit non-`none` constraint, or formulas. If confirmed `none` becomes non-`none`, load after Stage 2 without backfilling candidates. Confirmed `none` without formulas stops before resources. A formula-only path reads §3 and §4 formula rows only. [`strategist.md`](./strategist.md) owns source recommendation; this module owns image-dependent candidates, production detail, and §VIII rows. --- @@ -18,12 +18,14 @@ For illustration, apply this precedence: confirmed `none` → explicit user inte **Default — one coherent sheet for compatible same-family spots (may override when aspect, detail, quality, or semantic needs differ)**: prefer one Illustration Sheet when several AI-generated spots can share a useful cell shape and production treatment; generate them independently when forcing one sheet would weaken a planned element. When a sheet is chosen, plan one unplaced `ai` Illustration Sheet row plus one placed `slice` row per used element; only slice rows enter `spec_lock.md images`. State the intended placement shape family in the sheet reference and use separate sheets for incompatible shapes. [`image-generator.md`](./image-generator.md) §4.3 owns grid, ratio, slicing, and execution details. Stage 3 chooses the AI execution path under `image-generator.md` §7; do not pre-empt or re-pick it here. -## 2. AI Image Strategy — propose before Stage 2; lock only for confirmed `ai` +## 2. AI Image Strategy — propose only for recommended `ai`; lock any confirmed `ai` When proposed sources include `ai`, read [`image-renderings/_index.md`](./image-renderings/_index.md) before constructing Stage 2. Unless the user or active template already names a rendering, place at least three credible, distinct preset renderings across the coordinated safe/shifted/bold directions; a genuine compatibility shortfall may return fewer with a reason. Each preset `image_strategy` carries localized `rendering`, `visual`, and `mood` only. Mood includes a recognizable real-world analogy. Image colors always inherit that direction's deck HEX roles; never add an image palette or alter deck colors to rescue a rendering. 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`. +Post-confirmation AI activation creates no candidates: read the selected preset, or consume custom behavior/references. + 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,14 +51,16 @@ 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, preferred layout suggestion, 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 the non-empty free-form suggestion, including any optional catalog ids, while remaining preferred expression rather than 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 short, specific provider query without rewriting this locked intent, while complete entity names or necessary disambiguation may use more words; formula preserves 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 suggestion, 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 the non-empty free-form suggestion, including any optional hierarchical catalog ids, while remaining preferred expression rather than 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 short, specific provider query without rewriting this locked intent, while complete entity names or necessary disambiguation may use more words; formula preserves source LaTeX and placement intent. Any subject direction, focal placement, quiet region, or overlay-safety requirement that must affect acquisition/generation belongs in `Reference` or the matching §IX block, not only in `Layout pattern`. **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`. -**Mandatory**: write one concise, non-empty, executable `Layout pattern` value per non-formula row in ordinary language. It may cite stable ids from [`image-layout-patterns.md`](./image-layout-patterns.md), but reading the library or using ids is not required. Preserve any cited id accurately; otherwise describe the composition without inventing one. +**Mandatory**: each placed row, including formulas, gets one executable `Layout pattern`. It is preferred expression, not locked geometry; optional hierarchical ids from the already-read [`image-layout-patterns.md`](./image-layout-patterns.md) must be exact. They are prompt lookup handles for Executor, not exporter effect codes. Executor may adapt the suggestion while preserving resource identity/source, must-use status, crop/content, and explicit user/template constraints; layout-only changes need no upstream rewrite. -**Reference — not a constraint**: open [`image-layout-patterns.md`](./image-layout-patterns.md) only when its vocabulary would expand the current options. Techniques needing a cutout, blurred crop, or desaturated copy require that prepared asset. Executor may adapt, replace, or decline the suggestion while preserving resource role, file/source, must-use status, crop boundary, content, and explicit user/template constraints; layout-only changes need no upstream rewrite. +**Default — action-bearing image plan (may override when restraint better serves the page)**: For a `hero_page` or other image-led row, name an image/content or image/shape action—not position, size, crop, or legibility scrim alone. Plain split and full bleed remain valid when clearest. -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`. +Choose narrative intent before dimensions, then apply the already-read [`image-layout-spec.md`](./image-layout-spec.md) to the actual page region. Techniques needing a cutout, blurred crop, or desaturated copy require that prepared asset. 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`. Judge `text_policy` per AI row using [`image-generator.md`](./image-generator.md) §5.3; paper figures, academic schematics, panel comparisons, and data-axis graphics are positive triggers for reconsidering an all-`none` plan. Step 5 dispatches pending `ai` / `slice` rows to Image_Generator and pending `web` rows to Image_Searcher; formula rows bypass both. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-template.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-template.md index cd2b431e..f67eefcb 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-template.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/strategist-template.md @@ -64,7 +64,7 @@ When the communication contract conflicts with the workspace, choose and state t > Internal note: `content_divergence` controls source reorganization; the AI-derived `template_reuse_scope` records the reused layer; `template_adherence` records whether a structured plan keeps or extends existing Layout identities. -**Template design precedence**: User overrides win. Otherwise template colors and title/body stacks are fixed anchors, not industry defaults. Each of ≥3 Stage-2 directions carries all six palette roles and complete fonts: repeat fixed values with `typography.fixed: true`; vary only template-open roles. Keep declared icon and image constraints. +**Template design precedence**: User overrides win. Otherwise template colors and title/body stacks are fixed anchors, not industry defaults. Each of ≥3 directions carries six palette roles and complete fonts: repeat fixed values with `typography.fixed: true`; vary only template-open roles. Bundles differ overall; fonts may repeat. Keep declared icon and image constraints. --- 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 34fff016..fbf41c8f 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 @@ -20,7 +20,7 @@ As a top-tier AI presentation strategist, receive source documents, perform cont ## 1. Strategist Confirmation Stage -🚧 **GATE — whole-document authoring**: Generate Step 4 reads `templates/design_spec_reference.md`, writes the complete Design Spec from scratch, passes Gate 1, then reads `templates/spec_lock_reference.md` and writes the complete lock projection. For a new project, create each finished artifact once; do not instantiate or patch a placeholder scaffold. Run `project_manager.py validate`; the machine schemas, not remembered headings, own grammar validation. +🚧 **GATE — whole-document authoring**: Generate Step 4 reads `${SKILL_DIR}/templates/design_spec_reference.md`, authors the complete Design Spec once, passes Gate 1, then reads `${SKILL_DIR}/templates/spec_lock_reference.md` and authors the complete lock once. Do not scaffold or patch placeholders. Run `project_manager.py validate`; machine schemas, not remembered headings, own grammar validation. ⛔ **BLOCKING**: After the read, present professional recommendations for the confirmation fields below and wait for explicit user confirmation. @@ -38,7 +38,7 @@ Do not force communication intent into one catalog label; Stage 1 records compos > > **One opt-in exception**: present the refinement line with the split-mode note ([`generate-pptx.md`](../workflows/generate-pptx.md) Step 4). Only explicit opt-in runs [`refine-spec`](../workflows/stages/refine-spec.md): write the Design Spec once, pass Gate 1, then stop before the lock for unrestricted chat revision. Never enter it unprompted. -> **Default presentation surface — Confirm UI.** Use `/confirm_ui/recommendations.stage1.json`, `.stage2.json`, and `.stage3.json`; launch per Generate Step 4. Stage 1 writes canonical BCP-47 `primary_language` apart from UI `lang`; the server normalizes legacy English/Chinese/Japanese/Korean names, rejecting `und` and Chinese without script/region; Strategist projects it through Design Spec §I to lock communication. Replace only the active unconfirmed stage; preserve confirmed files. Stage 2 carries ≥3 safe / shifted / bold `design_directions`; each bundles visual style, a six-role HEX palette, primary-language heading/body typography plus an English companion only for non-English decks, icons, and conditional image rendering. Print the URL, Stage-1 summary, and `confirm_ui.md` chat fallback; this is not confirmation. Skip launch only for explicit chat-only use; chat-question tools are no substitute. Step 4 reads final confirmed `result.json` once for Design Spec authoring. [`confirm_ui.md`](../scripts/docs/confirm_ui.md) owns schema and lifecycle. +> **Default presentation surface — Confirm UI.** Before launch, apply [`confirm_ui.md`](../scripts/docs/confirm_ui.md)'s sticky per-run surface decision; its explicit chat branch skips every UI command, and a chat selection after UI launch follows its in-run switch procedure. Chat-question tools alone do not select a branch. In the UI branch, use `/confirm_ui/recommendations.stage1.json`, `.stage2.json`, and `.stage3.json`; replace only the active unconfirmed stage, preserve confirmed files, and print the URL plus Stage-1 summary/fallback without treating that handoff as confirmation. Stage 1 writes canonical BCP-47 `primary_language` apart from UI `lang`; Strategist projects it through Design Spec §I to lock communication. Stage 2 carries ≥3 safe / shifted / bold `design_directions`, each bundling visual style, a six-role HEX palette, primary-language heading/body typography plus an English companion only for non-English decks, icons, and conditional image rendering. Step 4 retains final confirmation from the selected channel for Design Spec authoring. `confirm_ui.md` owns schema and lifecycle. **Confirmed-value semantics**: confirmation preserves both the value and the owning field's semantic type. Apply the type to the affected property, not automatically to the whole object: @@ -212,7 +212,7 @@ See [`../templates/icons/README.md`](../templates/icons/README.md) for the curre **Family selection**: -- User/template typography is authoritative. When it fixes the stacks, repeat them and set `typography.fixed: true` on every Stage-2 direction. Otherwise ≥3 directions use different concrete heading/body combinations spanning concord and contrast; no extra font round. +- User/template typography is authoritative. Repeat fixed stacks with `typography.fixed: true` in every direction; never vary them for diversity. Keep ≥3 directions distinct as full bundles; reasonable font repetition is non-blocking, with no extra font round. - Every Stage-2 direction carries `heading` / `body` `primary`, `css`, and positive `body_size`; add `english` only when the deck's main language is not English. - Use concrete, target-installed PowerPoint faces. The Confirm UI font catalog supplies additional manual dropdown choices, not a recommendation whitelist. - 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. @@ -266,7 +266,7 @@ Formula policy and formula-asset planning are conditional. If the source contain **Conditional module — two-stage trigger**: 1. First derive the proposed `recommend.image_usage` in core. If it contains any non-`none` source—especially `ai`—read [`strategist-image.md`](./strategist-image.md) **before authoring the Stage-2 design directions** so rendering and other image-dependent candidate details are real, not backfilled after confirmation. An explicit non-`none` image constraint or the formula trigger from §g activates the module at the same point. -2. After confirmation, the confirmed value is the production boundary. A confirmed non-`none` set continues into resource planning; confirmed `none` with no formula trigger skips all downstream image rows even if the proposed recommendation had loaded the module. +2. Confirmed sources bound production. Non-`none` loads or retains [`strategist-image.md`](./strategist-image.md) for resource planning without backfilling candidates; `none` without formulas writes no image rows. The module owns formula policy, AI rendering alternatives, acquisition paths, resource rows, prompt depth, page roles, and placement intent. @@ -283,13 +283,25 @@ user/template requirements bind. | 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 a concise §VIII `Layout pattern` suggestion, 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 | +| 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 initial state → action → end state; leave effect, ids, pairing names, and timing to Executor | +| Object animation | Progressive reveal, emphasis, movement, removal, or deliberate stillness clarifies sequence, causality, comparison, hierarchy, narration order, full-view → detail, atmosphere → evidence, or hotspot/annotation order | Add an optional §IX `Motion suggestion` naming each relevant semantic unit's lifecycle duty and initial state → communication action → end state, plus any meaningful order/relationship; leave group ids, effects, options, and timing to Executor | -Write useful motion advice regardless of the effective Custom Animations outcome. -The suggestion remains non-binding and never activates custom-animation -execution by itself; only an explicit motion requirement or an enabled outcome -may require visible endpoint/reveal-state preparation. +**Reference — not a constraint: motion lifecycle vocabulary.** + +| Duty | Semantic lifecycle | +|---|---| +| `enter` | absent → introduce → present | +| `emphasize` | present → redirect attention → present/altered | +| `move` | state/position A → progress → state/position B | +| `exit` | present → retire → absent | +| `static` | present → hold as reference → present | + +Use only relevant duties—no category quota. For every unit mentioned in a +`Motion suggestion`, state its duty, lifecycle, and meaningful order; never +name an effect, target id, option, or timing. Write useful advice regardless of +the effective outcome. Suggestions remain non-binding and never activate the +custom stage; only an explicit motion requirement or an enabled outcome may +require visible lifecycle-state preparation. Review planned pages through two lenses: @@ -396,6 +408,16 @@ Lock the stable role set the deck needs, including recurring neutrals such as `s | Core + surrounding forces | center-radiating or hub-spoke | | Wide visual + explanation | top-bottom split | +**Default — define one cross-page visual motif when it can carry identity or +meaning (may omit when restraint serves the deck better)**: after the complete +§IX roster and planned visual resources are known, choose or inherit one reusable +page-scale geometry or material gesture—such as a directional contour, opening, +line lattice, or oversized numeral. Fold its recognizable invariant and allowed +variation (scale, crop, density, position, content interaction) into the +existing §III `Theme`, and mention it only in §IX `Layout` blocks that use it. +Vary it by page role instead of copying one ornament; create no motif field or +lock row. This is a continuity Reference, not a decoration quota. + On PPT 16:9, start from a 1200×640 safe area with 40px outer margins, then adapt to content. Template workspaces may supply different geometry; when active, [`strategist-template.md`](./strategist-template.md) owns precedence. --- @@ -440,11 +462,11 @@ Generate's notes/audio dependency gate. Record animation provenance as Stage 3 `false`, explicit objects-off, or explicit all-motion-off; only the last includes transitions. -1. Use the retained complete final-confirmation state already read once by Generate Step 4, then read `templates/design_spec_reference.md`. +1. With Generate Step 4's retained complete final-confirmation state, read `${SKILL_DIR}/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, including one effective outcome plus provenance for Speaker Notes, Custom Animations, and Narration Audio. Resolve them from latest explicit user instruction → matching Stage 3 proactive value → compatibility default `enabled` / `disabled` / `disabled`; Narration Audio enabled requires Speaker Notes enabled without rewriting the raw proactive evidence, and a dependency-driven notes outcome records that provenance. 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. +5. Read `${SKILL_DIR}/templates/spec_lock_reference.md`; create the lock once or resynchronize stale derived state from the approved Design Spec and context. Retain identity/refinements and stable roles/routing; omit unnamed page-local values, do not reopen evidence, and make no new recommendation. **Final confirmation → Design Spec consumption map**: 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 c9c095c3..2141b2c4 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 @@ -2,28 +2,84 @@ # SVG Effects and Geometry Specification -Conditional reference for advanced paint, effects, transforms, freeform/radial geometry, and constructed visual styles. Load only when the page uses one of these capabilities. +Authority for advanced paint, effects, transforms, freeform/radial geometry, and constructed visual styles. Default and Quick Generate load it before SVG authoring; other SVG-authoring routes follow their workflow trigger. **Cross-reference map**: unqualified §1, §2, and §4 references point to [`shared-standards-core.md`](./shared-standards-core.md); §6 references are local to this file. ## 6. Advanced SVG Effects and Authoring Techniques -**Reference — not a constraint**: “Advanced” means capability depth, not rarity. -Use any compatible technique when it serves the locked visual style and content. +**Mandatory**: Default and Quick Generate read this file completely before SVG +authoring and keep its compatible techniques in active construction vocabulary. +Before finalizing each page, run the §6.1 selection procedure and Visual Job +Router. Use §6.13 when diagnosed jobs benefit from one coordinated page recipe. + +**Default — situational use (may override when plain construction is stronger)**: +“Advanced” means capability depth, not an effect quota. During page authoring, +recall relevant techniques from content, hierarchy, legibility, semantics, +rhythm, and style; apply those that materially help. ### 6.1 Availability, Precedence, and Fidelity | Decision layer | Authority | |---|---| | Technical validity | Required / Forbidden / Conditional contracts in this file | -| Project values | `/spec_lock.md` stable anchors plus the retained Design Spec and current page context | -| Aesthetic fit | Locked `visual_style` / `visual_style_behavior` | +| Project values | Default: `/spec_lock.md` anchors plus retained Design Spec/page context; Quick: anchors resolved in the current context | +| Aesthetic fit | Locked or Quick-resolved `visual_style` / `visual_style_behavior` | | Per-page choice | Content purpose, hierarchy, legibility, semantics, and rhythm | +**Mandatory — job-first effect selection**: establish the editable semantic +skeleton first, then diagnose effect jobs before treating the page as complete. +Plain construction remains valid only when that diagnostic finds no unresolved +visual job. + +| Pass | Decision | +|---|---| +| Skeleton / diagnose | Establish native information, relationships, and hierarchy. Before completion, check image/text integration, plane separation, focus, state/direction, material/style, and the recurring motif; keep plain construction when none needs treatment. | +| Surface / select | Name the target, confirm its owning subsection and fidelity, then use the Router. Choose a compatible technique that fully performs the job; prefer simpler/native-stable alternatives only when communication is equal. `Approximate` requires review, not automatic rejection. | +| Integrate / stop | Align paint, contour, light, hierarchy, and z-order; combine only techniques with different jobs. Check legibility, editability, density, fidelity, and style; simplify failures, use legal alternatives, and bake only the smallest pixel-dependent layer. Keep authoritative text/data native. | + +#### Visual Job Router + +**Reference — not a quota**: route diagnosed problems through this table. A +page may use no listed technique, one technique, or several techniques with +different jobs. + +| Diagnosed visual problem | Candidate technique | Authority / stop | +|---|---|---| +| Meaningful direction, continuous value, or center focus is missing | Linear/radial gradient or channel alpha | §6.2 / §6.3; otherwise keep solid paint | +| Picture/card/overlay elevation or boundary is unclear | Object or picture/carrier shadow, restrained glow, or hairline | §6.4; equal peers stay flat; one light direction | +| Native copy and image do not integrate | Scrim, fade, wash, vignette, off-center spotlight, or faux glass | §6.5 and the Image-Treatment Implementation Map; verify contrast; no backdrop blur | +| Relationship state, direction, continuity, or boundary is unclear | Draft/optional/future → dash; direction → marker; undirected → solid; continuous flow → gradient stroke; repeated boundary → frame/contour/crop edge; exact grid → multi-subpath | §6.6 / §6.3; every line needs a job | +| Short display text needs notation or silhouette | Removed/former → strike; eyebrow distinction → tracking; display silhouette → outline/gradient; luminous metric → glow; semantic list → native bullet | §6.7 / §6.4; no decorative body-copy treatment | +| Tilt, repetition, or reversible asset direction helps composition | Rotate, translate/mirror, or local `` | §6.8; never mirror text, logos, or directional evidence | +| Resolved style needs hand, print, pixel, facets, layers, ribbon, or line-plus-area | Matching constructed recipe | §6.11; no generic decorative freeform | +| Meaning needs an unmatched silhouette, radial hierarchy, gauge, or custom route | Freeform, explicit arc/sector, or calculated arrowhead | §6.9 / §6.10; prefer an equal stock shape/marker | +| Look depends on dense texture, source blur, per-pixel composite, reflection, or skew | Native-safe alternative or prepared/baked asset | §6.12; text/data stay editable | + +#### Image-Treatment Implementation Map + +**Reference — not a constraint**: when image composition names one of these +modifier or prepared-asset treatments, resolve its implementation here. +`Effect-only` keeps a visible capability here without restoring a layout ID. + +| Image handles / treatment | Construction / boundary | +|---|---| +| `M2 · 01/03/04/08/09` · scrim, wash, fade, grid | Explicit solid/linear/radial layers over one picture; §6.2 / §6.3 / §6.5 | +| `M2 · 06/07` · atmospheric wash, watermark/receded field | Reduced picture alpha + optional wash; subordinate to native content; §6.2 / §6.5 | +| `M2 · 02/05` · vignette or spotlight | Radial layer with movable `fx/fy` or `cx/cy`; outer geometry `Approximate`; §6.3 / §6.5 | +| `M3 · 04` · lifted picture panel / visible overlay edge | Picture/carrier shadow, glow, or hairline; shadow one support shape for a framed/captioned panel; §6.4 | +| `M3 · 01/02/05` · frame, print frame, contour/cut edge | Registered native stroke/path; §6.6 | +| `M3 · 03; M1 · 09` · rotation, misregistration, Riso offset | Transform + explicit duplicate layers; §6.8 / §6.11 | +| `M1 · 03` + effect-only forms · paper cut, facets/folds, ribbon, staging | Ordered paths/facets + consistent paint/light; §6.11 / [`native-shape-authoring.md`](./native-shape-authoring.md) §7 | +| `M1 · 01/02/04–08` · crop, opening, subtraction, reveal | Direct clip or materialized Boolean; no ``; [`shared-standards-core.md`](./shared-standards-core.md) §1.2 / [`native-shape-authoring.md`](./native-shape-authoring.md) §6 | +| Effect-only · faux glass | Visible field + translucent panel + highlight; no blur or frosted-crop substitution; §6.5 | +| `A1 · 02–04; A3 · 02/03` · blur, duotone, blend, frost, desaturation | Prepared local bitmap/composite/derivative; registered frost is a blurred derivative; §6.12 | + **Reference — illustrative colors**: colors below demonstrate syntax only; -generated pages choose paint from the locked identity anchors, visual style, -content semantics, and current composition. A contextual tint, gradient stop, -shadow/glow paint, or one-off display color need not already be a lock row; +generated pages choose paint from the Default locked or Quick-resolved identity +anchors, visual style, content semantics, and current composition. A contextual +tint, gradient stop, shadow/glow paint, or one-off display color need not +already be a persistent identity role; promote it only when it becomes a recurring named role. Fidelity labels are defined in [`shared-standards-core.md`](./shared-standards-core.md). Review an `Approximate` result in native PPTX when the effect carries material meaning. @@ -54,7 +110,7 @@ contract, such as SVG's default fill or §6.3's required gradient-stop color. | Fill/text alpha | Opaque `fill` + `fill-opacity="0..1"` | Fill/run alpha; `Native-stable` | | Stroke alpha | Opaque `stroke` + `stroke-opacity="0..1"` | Line/outline alpha; `Native-stable` | | Gradient-stop alpha | Opaque `stop-color` + `stop-opacity="0..1"` | Per-stop alpha; `Native-stable` | -| Shadow/glow alpha | Opaque `flood-color` + `flood-opacity="0..1"` | Effect alpha; `Native-stable` within §6.4 | +| Shadow/glow alpha | Opaque `flood-color` + `flood-opacity="0..1"` | Glow is `Native-stable`; outer shadow is visually calibrated `Approximate` within §6.4 | | Picture fade | `` | Picture ``; `Native-stable` | | One atomic whole-object fade | Non-group element `opacity="0..1"` | Alpha compiled into its supported paint/effect channels; `Native-normalized` | | Pattern alpha | Opaque pattern child paint + child fill/stroke opacity | Conditional; [`native-data-interface.md`](./native-data-interface.md) | @@ -104,7 +160,7 @@ closed parser checks. See | Definition | Direct `` / `` child of `` with unique `id` | | Reference | Exact local `url(#id)` | | 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` | +| 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`, and their effective focus must lie inside the circle centered at `(0.5,0.5)` with radius `0.5` | | Forbidden | External/quoted refs, `href` inheritance, `gradientTransform`, `spreadMethod`, CSS gradients | | Target | Contract and fidelity | @@ -115,8 +171,13 @@ closed parser checks. See | `` | No gradient paint; use §6.5 overlays | 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; +coincident endpoints are invalid. Radial export preserves the effective focus +(`fx/fy`, otherwise `cx/cy`) as a point-focused circle; its outer center and +radius normalize to `0.5`, so distinct outer `cx/cy` and `r` are dropped. A +focus outside that canonical circle is invalid because SVG renderers clamp it +to the circumference while DrawingML retains the rectangle coordinates; +reverse import centers such a source focus and records a diagnostic. +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 @@ -124,7 +185,7 @@ PPTX import normalizes gradients and reports degradation; 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. +Default `spec_lock.colors` literals or Quick-resolved anchors. **Hard rule — non-degenerate gradient geometry**: an `objectBoundingBox` gradient stroke requires non-zero intrinsic width and height. SVG stroke width @@ -155,8 +216,9 @@ Filters are native-effect metadata, not a general pixel-filter surface. | Concern | Contract | |---|---| | Definition/reference | Direct `` child with unique id; direct `filter="url(#id)"` attribute, never inline style | -| Public targets | ``, ``, ``, `` | +| Public targets | ``, ``, ``, ``, ``; an exact outer `` is also registered when its sole visual child is one clipped `` | | Required primitive | `feDropShadow` or `feGaussianBlur` | +| Generated glow form | Zero-offset `feDropShadow` with flood paint, or the complete blur + flood + composite + merge graph below; never bare blur | | Required parameters | Explicit `stdDeviation` on either effect primitive; explicit `dx`, `dy`, and `flood-opacity` on `feDropShadow`; explicit `flood-opacity` on `feFlood`; explicit `slope` on linear `feFuncA` | | Accepted helpers | `feOffset`, `feFlood`, `feComposite`, `feMerge`, `feMergeNode`, `feComponentTransfer`, linear `feFuncA` | | Alpha transfer | Linear `feFuncA` maps multiplicative `slope` only; `intercept` is unsupported | @@ -171,16 +233,19 @@ converter-only historical path may also multiply flood-color alpha and ancestor group opacity. Native export does not preserve filter-region, `in/in2/result`, merge order, or composite topology. Other primitives, multiple independent effects, filters on -`` / `` / `` / unsupported targets are forbidden; apply the +`` / ordinary `` / unsupported targets are forbidden; apply the effect to supported objects or use explicit layers. -The sole `` exception is the hash-locked -`data-pptx-part="geometry-preview"` transport in §1.4: it must be a direct child -of an imported preset object and reference the same filter as that object's one -hidden geometry carrier. The preview is render-only and never becomes a second -PowerPoint object; this exception does not authorize filters on ordinary groups. -PPTX import preserves one registered shape/connector shadow or glow and records -unsupported object/run effects as import diagnostics instead of exposing a new -authoring surface. See +Special `` carriers are limited to the exact single clipped-image +form in §6.5, the hash-locked +`data-pptx-part="geometry-preview"` transport in §1.4—a direct child of an +imported preset object referencing the hidden geometry carrier's filter—and the +exact imported picture-crop carrier in §6.5, which keeps the effect outside its +viewport. Neither authorizes ordinary group filters or creates a second +PowerPoint object. +PPTX import maps one classifiable shape/connector/picture outer shadow or glow +to this contract. Unsupported effects and outer-shadow variants whose scale, +skew, alignment, or rotation semantics cannot be retained become import +diagnostics instead of a silently simplified authoring surface. See [`conversion.md`](../scripts/docs/conversion.md#import-compatibility-and-recovery-boundary) for tolerant, strict, and release-handling behavior. The quality checker and exporter preflight enforce the same definition, @@ -211,7 +276,8 @@ native export. ``` Even `feDropShadow` with `dx="0" dy="0"` becomes glow. Use an existing accent -color for glow; black reads as diffuse shadow. +color; black reads as diffuse shadow. Bare `feGaussianBlur` remains compatible +input but is never generated: preview blurs the object while export emits glow. | Elevation | Use | `dy` | `stdDeviation` | Alpha | |---|---|---:|---:|---:| @@ -220,22 +286,21 @@ color for glow; black reads as diffuse shadow. | Raised | Primary CTA, focused card, overlay | 6–10 | 10–16 | 0.12–0.20 | | Glow | Short display text, metric, focus accent | 0 offset | 4–8 | 0.35–0.55 | -**Strong default — single light source per page**: every `feOffset` shadow on -one slide shares the same `dx`/`dy` direction (default `dx="0"`, `dy="4"`–`dy="8"`, -light from upper front). Contradictory shadow directions read as multiple light -sources — a clear low-quality tell. The one sanctioned exception is a deliberate -upward paper-layer light, where every affected layer flips direction together; -never mix directions on the same plane. This is a strong default, not a -checker-enforced hard rule. +**Default — one light source per page (may override when every affected layer +uses one deliberate alternative direction)**: every `feOffset` shadow on one +slide shares the same `dx`/`dy` direction (default `dx="0"`, +`dy="4"`–`dy="8"`, light from upper front). Contradictory shadow directions +make one plane read as several incompatible surfaces. A deliberate upward +paper-layer treatment flips every affected layer together; never mix +directions on the same plane. -**Reference — not a constraint**: keep at most two -non-floor tiers; two or three shadowed objects usually suffice. Do not lift -every peer card or stack strong shadow, border, gradient, and tint on one -container. Same-family colored shadow is reserved for a focal accent. On dark -backgrounds, prefer a light hairline or restrained glow; never glow body copy. -Negative `dy` is valid for an intentional upward paper-layer light source when -every affected layer uses the same direction. For older/strict renderers, -replace a filter with two or three offset translucent shapes behind the object: +**Reference — not a constraint**: use no more elevation categories than the +hierarchy needs; a page may reuse one category across several related objects. +Do not lift every peer card or stack strong shadow, border, gradient, and tint +on one container. Same-family colored shadow is reserved for a focal accent. +On dark backgrounds, prefer a light hairline or restrained glow; never glow body copy. +For older/strict renderers, replace a filter with two or three offset +translucent shapes behind the object: alpha `0.03–0.05`, increasing offset/radius, and optional same-family tint near `0.04` (`Native-stable`). @@ -243,6 +308,8 @@ alpha `0.03–0.05`, increasing offset/radius, and optional same-family tint nea ### 6.5 Image Treatments, Overlays, and Glass-like Surfaces +#### Image Carrier and Crop Contracts + | Need | Authoring contract | Fidelity | |---|---|---| | Cover/crop | Readable raster dimensions + aligned `slice` | Native `srcRect`; `Native-stable`; otherwise native crop cannot be guaranteed | @@ -263,8 +330,16 @@ modes, `none` with a mode, and extra tokens are errors; the converter never guesses a fallback. **Hard rule — fit/clip interaction**: a non-trivial clip disables `meet` -frame-fit. Match the image box to the source ratio or use `slice`. Do not apply -filters directly to ``. +frame-fit. Match the image box to the source ratio or use `slice`. Put one §6.4 +filter directly on an unclipped ``. For a clipped picture, keep +`clip-path` on the `` and put the filter on an exact outer `` whose +sole visual child is that image. Never combine `filter` and `clip-path` on the +same ``: SVG would clip the preview effect while PowerPoint would not. +The carrier may keep object-local id, role, transform, and +`data-pptx-carrier`. It may own `data-pptx-layer="master|layout"` only when +the carrier itself is the direct fixed atom. It must not own +`data-pptx-placeholder`, `data-pptx-binding`, or chart/table replacement +metadata; keep slot ownership on the outer placeholder boundary. **Hard rule — picture frames and sources are explicit and decodable**: every SVG `` has explicit positive `width`/`height` and exactly one non-empty @@ -285,7 +360,7 @@ every non-root `` is the exact wrapper accepted by the shared crop parser: |---|---| | 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` | +| 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`; an exact imported picture carrier may hold its one §6.4 filter outside this viewport | | 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 @@ -296,12 +371,15 @@ plain ``. Extra, indirect, or character content; unknown attributes; malformed or unrepresentable crops; and general nested viewports fail. Checker and converter share this parser. +#### Image Overlay and Material Techniques + | Overlay | Construction | Typical stops / alpha | |---|---|---| | Directional scrim | Linear rect, darkest beside text | `0%: 0.88; 55%: 0.30; 100%: 0` | | Bottom title fade | Vertical rect over lower image | black `0 → 0.72` | -| Vignette/spotlight | Centered radial rect (`cx=50%`, `cy=50%`, `r=70%`); native center only | black `0 → 0.58` | +| Vignette/spotlight | Radial rect; place the hotspot with `fx/fy` or `cx/cy` inside the canonical focus circle; outer center/radius remain approximate | black `0 → 0.58` | | Brand wash | Directional existing brand-color gradient | `0.80 → 0.10` | +| Grid scrim | Seamless no-stroke rect cells over one image; vary neighboring alpha narrowly and irregularly | Keep the field subordinate; a regular alternation reads as a checkerboard | | Faux glass | Visible fields + diagonal linear panel (`0,0 → 1,1`) + highlight stroke; optional §6.4 elevation | white `0.38 → 0.12`; stroke about `0.55` | Layer in document order: image → scrim/wash → text. True source/backdrop blur is @@ -556,8 +634,8 @@ 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 +path only for a required data curve, custom route, or locked or Quick-resolved +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 @@ -645,27 +723,28 @@ filled `Native-normalized` arrowhead. Example: --- -### 6.11 Constructed Visual Styles +### 6.11 Constructed Technique Recipes **Hard rule — explicit construction**: these are supported-layer recipes, not 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. +**Reference — not a constraint**: use them only when they match the locked or +Quick-resolved 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 | -|---|---|---| -| Faux glass | §6.5 translucent panel + highlight stroke + visible fields | No backdrop blur; `Native-normalized` | -| Hand-drawn mark | Rotated translucent bar + irregular `Q/C` paths + round caps | No roughness filter; `Native-normalized` | -| Ink wash | Few same-family translucent closed curves/strokes | No feather/wet edge; `Native-normalized` | -| Riso offset | Duplicate text/shape with small offset, second ink, lower alpha | No blend mode; `Native-normalized` | -| Pixel grid | Integer-aligned rects on one cell grid | `shape-rendering` preview-only; `Native-stable` | -| Halftone | Sparse calculated circles | `Native-stable`; bake dense screens / use suitable [`native-data-interface.md`](./native-data-interface.md) preset | -| Isometric facets | Shared-vertex top/front/side polygons, one light direction | 2D only; `Native-normalized` | -| Paper cut | Ordered organic paths + consistent §6.4 shadow per layer | Filter each layer, not group; `Approximate` | -| Gradient ribbon | Non-degenerate cubic path + §6.3 gradient stroke; closed gradient-filled shape for horizontal/vertical ribbons | `Native-normalized`; no mesh gradient; re-import may flatten color | -| Line-plus-area data | Low-alpha closed area first, crisp line above | Keep area subordinate; `Native-normalized` | +| Family | Technique | Use when | Construction / boundary | +|---|---|---|---| +| Material / depth | Faux glass | Visible field must remain present behind a panel | §6.5 translucent panel + highlight; no backdrop blur; `Native-normalized` | +| Material / depth | Paper cut | Ordered layers/openings carry the material language | Organic paths + one §6.4 shadow per layer, never the group; `Approximate` | +| Hand / print | Hand-drawn mark | Annotation, underline, or highlighter gesture | Rotated translucent bar + restrained `Q/C` paths + round caps; no roughness filter; `Native-normalized` | +| Hand / print | Ink wash | Brush mass or atmosphere | Same-family translucent curves/strokes; no feather/wet edge; `Native-normalized` | +| Hand / print | Riso offset | Deliberate print misregistration | Offset duplicate, second ink, lower alpha; no blend mode; `Native-normalized` | +| Hand / print | Pixel grid | Sparse hard-cell digital accent | Integer-aligned rect grid; `shape-rendering` preview-only; `Native-stable` | +| Hand / print | Halftone | Sparse screen modulation | Calculated circles; `Native-stable`; bake dense screens or use [`native-data-interface.md`](./native-data-interface.md) | +| Form / geometry | Faceted or folded form | Isometric object, folded ribbon, dimensional numeral/band | Shared vertices, one light direction, same-hue alternating paint per [`native-shape-authoring.md`](./native-shape-authoring.md) §7.1; no 3D; `Native-normalized` | +| Form / geometry | Gradient ribbon | Continuous directional energy, not faceted depth | Cubic gradient stroke or closed gradient-filled band; no mesh gradient; `Native-normalized`, re-import may flatten color | +| Data expression | Line plus area | Magnitude context beneath an exact reading edge | Subordinate low-alpha area first, crisp line above; `Native-normalized` | **Minimal construction anchors**: @@ -725,24 +804,22 @@ import diagnostics. Resolve those diagnostics before release export; see --- -### 6.13 Scenario Quick Reference +### 6.13 Page-Level Composition Recipes -**Reference — not a constraint**: fidelity remains authoritative in the owning -subsection; this table only routes scenarios. +**Reference — not a quota**: use the planned page skeleton; when images are +active, select it through +[`image-layout-patterns.md`](./image-layout-patterns.md). Read each recipe +back-to-front and omit every layer without a distinct job. -| Decision family | Scenario routing | Authority / boundary | +| Page / deck job | Back-to-front stack | Stop | |---|---|---| -| Elevation | Floating card → resting shadow; one CTA → colored shadow; equal peers/background → flat; maximum predictability → layered shapes; title/metric → glow | §6.4; never body-copy glow | -| Image/material | Text over image → directional scrim; bottom title → bottom fade; centered hero → vignette; brand wash → brand overlay; glass card → faux glass | §6.5; no backdrop blur | -| Lines | Draft/optional → dash; process direction → marker; flow/series → gradient stroke; exact grid → multi-subpath path | §6.6 / §6.3 | -| Text | Removed/former value → line-through; eyebrow → tracking; watermark/outline heading → text outline; list → native bullet | §6.7 | -| Composition | Move/rotate/mirror → §6.8 transform; repeated static mark → local `` | §6.8; preserve z-order | -| 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; 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 | 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 | +| Cover | Hero field → optional scrim/wash → purposeful opening/contour → native title | Stop when copy is safe and title/field read together | +| Divider | Image band or quiet field → restrained wash → recurring geometry → number/title | Reuse deck language; add no effect family | +| Text-led explanation | Quiet field → recurring material/contour → native hierarchy → optional local emphasis | Emphasis clarifies the argument, never decorates body copy | +| Process / system | Context field → native relation lines → nodes/labels → optional state/direction focus | Every connector stays semantic; atmosphere must not obscure flow | +| Evidence / metric | Context field → local contrast → native leaders/labels/metric → optional focus/elevation | Claims stay native; atmosphere must not weaken evidence | +| Comparison | Matched planes → optional shared wash/divider → matched labels → one difference marker | Keep crop, elevation, and paint symmetric unless asymmetry is the claim | +| Closing / CTA | Receded field → echoed contour/gradient → native action → optional raised accent | Add no effect family or competing image | +| Cross-page motif | Reuse contour, gradient direction, line language, texture, or light logic; vary scale, crop, or position by page job | Preserve recognition without copying the page or adding novelty effects | --- 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 7261f14a..50c5bbf5 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 @@ -8,12 +8,20 @@ Technical spec and workflow for adding images to SVG files. ## Image Resource List Format -Defined in the Design Specification & Content Outline; each image carries an `Acquire Via` field plus a status annotation. This file is authoritative for status names and SVG embedding behavior. If image approach includes "B) User-provided": run `analyze_images.py` right after the Strategist confirmation stage and complete the list before outputting the design spec. +Each image carries an `Acquire Via` field plus a status annotation. This file +owns status names, resource lifecycle, and embedding workflow; +[`svg-effects.md`](./svg-effects.md) §6.5 owns native carrier, crop transport, +and filter/clip contracts. + +| Mode | Resource authority and preparation timing | +|---|---| +| Default Generate | `design_spec.md §VIII` plus its lock projection; when user-provided images are selected, run `analyze_images.py` after Strategist confirmation and complete the list before Executor | +| Quick Generate | Current main agent's transient active-context roster; materialize explicit user paths first, resolve unspecified acquisition decisions automatically, and finish user/ai/web/slice/formula preparation before SVG authoring without confirmation | ```markdown | Filename | Dimensions | Purpose | Type | Layout pattern | Crop Policy | Acquire Via | Status | Reference | |----------|------------|---------|------|----------------|-------------|-------------|--------|-----------| -| team.jpg | 800x600 | Team photo | Photography | `#2 left-third` | adaptive | web | Pending | Diverse engineering team in modern office | +| team.jpg | 800x600 | Team photo | Photography | `#P1-02 image left, copy right` | adaptive | web | Pending | Diverse engineering team in modern office | | formula_001.png | 736x168 | Page 3 block equation | Latex Formula | formula | no-crop | formula | Rendered | `E = mc^2` | ``` @@ -26,7 +34,7 @@ Defined in the Design Specification & Content Outline; each image carries an `Ac | **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 attribution contract) | | **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 | +| **Needs-Manual** | Automatic acquisition is unavailable/exhausted or the selected path requires manual fulfillment; for `slice`, the parent sheet is unavailable | Default Generate may use a dashed placeholder until its readiness gate. Quick Generate blocks every required row still in this status, even if an unverified candidate file exists; validate a supplied replacement and reconcile it to `Generated`, `Sourced`, or `Rendered` first. For `slice`, 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 | @@ -35,24 +43,29 @@ Defined in the Design Specification & Content Outline; each image carries an `Ac ## Workflow ``` -1. Strategist defines image needs → Add image resource list with Acquire Via + Status per row -2. Image Acquisition (Step 5): +1. Resolve image needs: + - Default Generate → Strategist-owned resource list + lock projection + - Quick Generate → current main agent builds a transient roster in active context; explicit user paths/URLs/choices win, unspecified choices use automatic resolution, no interaction +2. Prepare project-local resources before SVG authoring: + - user → materialize the explicit source under project/images/ → Existing + - formula → write formula_manifest.json and run latex_render.py → Rendered - 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/) +3. SVG authoring consumes only prepared resources (Executor in Default Generate; current main agent in Quick Generate) ├── Existing / Generated → ├── Sourced + license_tier=no-attribution → only ├── Sourced + license_tier=attribution-required → + small credit element on the slide ├── Sourced + license_tier=manual → only (user-supplied --from-url; rights/credit are user responsibility) ├── Rendered formula → - └── Placeholder / Needs-Manual without file → Dashed border + description text + └── Placeholder / Needs-Manual → Dashed border + description text until a supplied file is validated and status is reconciled 4. Preview: python3 -m http.server -d 8000 → /svg_output/.svg -5. Post-processing & Export → follow [`generate-pptx.md`](../workflows/generate-pptx.md) Step 7 +5. Export: + - Default Generate → follow [`generate-pptx.md`](../workflows/generate-pptx.md) Step 7 + - Quick Generate → after every required resource has a validated expected file/provenance and usable status, run the profile's final checker, then its `--quick-generate` export ``` -> Keep external references in `svg_output/` during generation. `finalize_svg.py` auto-embeds images into the mandatory `svg_final/` visual preview; native PPTX export independently reads `svg_output/`. +> Keep external references in `svg_output/` during generation. Default Generate uses `finalize_svg.py` to embed images into the mandatory `svg_final/` visual preview. Quick Generate omits that preview artifact. Both native PPTX exports independently read image references from `svg_output/`. **Hard rule — export boundary**: `svg_final/` is a self-contained SVG preview for embeddable raster/SVG assets and may be manually inserted into PowerPoint as an SVG picture. EMF/WMF assets retain the documented external-reference exception for lossless native passthrough. The only supported generated-PPTX route is `svg_output/` through the project SVG-to-DrawingML converter. PowerPoint's manual Convert-to-Shape operation is unsupported. @@ -126,11 +139,11 @@ python3 -m http.server -d 8000 ## Conversion Process -Follow [`generate-pptx.md`](../workflows/generate-pptx.md) Step 7; it owns the -serial post-processing and export commands. Its mandatory finalization step -embeds image references into the self-contained `svg_final/` preview, while the -supported native PPTX release still reads `svg_output/` and maps it directly to -DrawingML. +Default Generate follows [`generate-pptx.md`](../workflows/generate-pptx.md) +Step 7; it owns the serial post-processing and export commands. Quick Generate +follows [`quick-generate.md`](../workflows/profiles/quick-generate.md) after its +required-resource gate. The native PPTX converter reads `svg_output/` and maps +its project-local image references directly to DrawingML in both modes. ### Standalone: align_embed_images.py (advanced) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/requirements.txt b/plugins/codex/plugins/ppt-master/skills/ppt-master/requirements.txt index 3f46a2a6..cd6ad82f 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/requirements.txt +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/requirements.txt @@ -18,6 +18,8 @@ python-pptx>=0.6.21 XlsxWriter>=3.0.0 # PowerPoint-style Merge Shapes materialization / PowerPoint 风格合并形状物化 skia-pathops>=0.9.2 +# OpenType shaping + glyph outlines for text operands / 文字 operand 的排版与轮廓 +uharfbuzz>=0.50.0 # Recorded narration / 录制计时和旁白 # notes_to_audio.py generates per-slide narration audio on macOS/Linux/Windows. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/README.md index 212b80c4..b7abbcea 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 @@ -5,6 +5,7 @@ This directory contains user-facing scripts for conversion, project setup, direc ## Directory Layout - Top-level `scripts/`: runnable entry scripts +- `scripts/project_management/`: internals behind `project_manager.py` - `scripts/source_to_md.py`: unified source-document → Markdown dispatcher - `scripts/source_to_md/`: source-document → Markdown routing/batch helpers and backend converters (`_dispatcher.py`, `_batch.py`, `pdf_to_md.py`, `doc_to_md.py`, `excel_to_md.py`, `ppt_to_md.py`, `web_to_md.py`) - `scripts/image_backends/`: internal provider implementations used by `image_gen.py` @@ -45,7 +46,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`, `pptx_delivery_check.py` | [docs/project.md](./docs/project.md) | +| Project management | `project_manager.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) | @@ -223,7 +224,11 @@ python3 scripts/shape_boolean_svg.py render slide.svg \ The first source owns result paint and is the primary geometry for `subtract`. Local and ancestor transforms are baked into SVG-root coordinates. Replace the operands with every returned path at the root in the primary operand's z-order; -`fragment` returns multiple stable sibling paths. See +`fragment` returns multiple stable sibling paths. Operands may be supported +closed geometry or supported horizontal implicit-LTR direct `` whose exact +OpenType weight/style can be resolved; repeat `--font-dir PATH` for additional +font roots. Text is shaped to glyph outlines before the operation, so the +result remains editable freeform geometry but is no longer editable text. See [`references/native-shape-authoring.md`](../references/native-shape-authoring.md) §6 for the closed operand and failure contract. 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 9a98c194..42aa4baf 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 @@ -2,20 +2,12 @@ """ Image Size Analysis Tool ======================== -Reports objective parameters (width, height, aspect ratio, category) for all -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. - -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. +Reports objective parameters for all images in a folder. It does not resolve a +canvas, prescribe a layout, or generate Strategist recommendations. Usage: python scripts/analyze_images.py python scripts/analyze_images.py projects/xxx/images - python scripts/analyze_images.py projects/xxx/images --canvas ppt43 Output: - Analysis report displayed in console @@ -41,37 +33,11 @@ except ImportError: print("Error: PIL/Pillow not installed. Run: pip install Pillow") sys.exit(1) -try: - from config import CANVAS_FORMATS, LAYOUT_MARGINS -except ImportError: - CANVAS_FORMATS = { - 'ppt169': { - 'name': 'PPT 16:9', - 'width': 1280, - 'height': 720, - }, - } - LAYOUT_MARGINS = { - 'ppt169': { - 'top': 60, 'right': 60, 'bottom': 60, 'left': 60, - 'content_width': 1160, 'content_height': 600 - }, - } - -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 CATEGORY_WIDTH = 50 -# Title area height and gap between image/text areas (px) -TITLE_HEIGHT = 60 -LAYOUT_GAP = 20 -# Minimum text area dimensions (px) -MIN_TEXT_HEIGHT = 150 -MIN_TEXT_WIDTH = 280 - ImageAnalysis = dict[str, object] @@ -219,7 +185,7 @@ def _result_from_manifest( 'ratio_source': 'manifest', 'format': Path(filename).suffix.lstrip('.').upper(), 'has_transparent_pixels': None, - 'layout_hint': classify_ratio(ratio), + 'category': classify_ratio(ratio), 'filesize_kb': os.path.getsize(filepath) / 1024, } _apply_manifest_metadata(result, meta) @@ -238,11 +204,10 @@ def _result_from_manifest( def classify_ratio(aspect_ratio: float) -> str: - """Classify image aspect ratio into layout category. + """Classify an image by its objective aspect-ratio range. - Thresholds aligned with image-layout-spec.md: - >2.0 ultra-wide, 1.5-2.0 wide, 1.2-1.5 standard landscape, - 0.8-1.2 square, <0.8 portrait. + Ranges: >2.0 ultra-wide, 1.5-2.0 wide, 1.2-1.5 standard + landscape, 0.8-1.2 near square, and <0.8 portrait. """ if aspect_ratio > 2.0: return "Ultra-wide" @@ -256,77 +221,6 @@ def classify_ratio(aspect_ratio: float) -> str: return "Portrait" -def compute_layout_dimensions( - ratio: float, - content_w: int, - content_h: int, - gap: int = LAYOUT_GAP, -) -> dict: - """Compute image and text area dimensions following image-layout-spec.md. - - Returns dict with layout_type, image_w, image_h, text_w, text_h. - """ - # Effective content height (below title) - H = content_h - W = content_w - - def _try_top_bottom() -> dict | None: - img_w = W - img_h = int(round(W / ratio)) - text_h = H - img_h - gap - if text_h >= MIN_TEXT_HEIGHT: - return { - 'layout_type': 'top-bottom', - 'image_w': img_w, - 'image_h': img_h, - 'text_w': W, - 'text_h': text_h, - } - return None - - def _try_left_right_height_first() -> dict | None: - img_h = H - img_w = int(round(H * ratio)) - text_w = W - img_w - gap - if text_w >= MIN_TEXT_WIDTH: - return { - 'layout_type': 'left-right', - 'image_w': img_w, - 'image_h': img_h, - 'text_w': text_w, - 'text_h': H, - } - return None - - def _try_left_right_width_constrained() -> dict: - img_w = int(round(W * 0.7)) - img_h = int(round(img_w / ratio)) - text_w = W - img_w - gap - return { - 'layout_type': 'left-right', - 'image_w': img_w, - 'image_h': min(img_h, H), - 'text_w': max(text_w, MIN_TEXT_WIDTH), - 'text_h': H, - } - - # Decision tree per image-layout-spec.md - if ratio > 1.5: - # Ultra-wide or wide → try top-bottom first - result = _try_top_bottom() - if result: - return result - # Fallback to left-right (wide-constrained) - return _try_left_right_width_constrained() - else: - # Standard landscape, square, portrait → try left-right (height-first) - result = _try_left_right_height_first() - if result: - return result - # Fallback to left-right (width-constrained) - return _try_left_right_width_constrained() - - def _analyze_images(images_dir: str) -> tuple[list[ImageAnalysis], list[str]]: """Analyze all image files in a directory. @@ -373,7 +267,7 @@ def _analyze_images(images_dir: str) -> tuple[list[ImageAnalysis], list[str]]: 'ratio_source': 'native', 'format': image_format, 'has_transparent_pixels': has_transparent_pixels, - 'layout_hint': classify_ratio(aspect_ratio), + 'category': classify_ratio(aspect_ratio), 'filesize_kb': os.path.getsize(filepath) / 1024 } _apply_manifest_metadata(result, meta) @@ -411,25 +305,6 @@ def analyze_images(images_dir: str) -> list[ImageAnalysis]: return results -def enrich_with_layout( - results: list[ImageAnalysis], - canvas_key: str, -) -> None: - """Add computed layout dimensions to each result in-place.""" - margins = LAYOUT_MARGINS.get(canvas_key) - - if not margins: - print(f"[WARN] No layout margins for canvas '{canvas_key}', skipping dimension calculation") - return - - content_w = margins['content_width'] - content_h = margins['content_height'] - - for img in results: - dims = compute_layout_dimensions(img['aspect_ratio'], content_w, content_h) - img.update(dims) - - def print_results(results: list[ImageAnalysis]) -> None: """Print the analysis report to stdout.""" @@ -437,31 +312,26 @@ def print_results(results: list[ImageAnalysis]) -> None: print("Image Size Analysis Report") print("=" * REPORT_WIDTH) - has_layout = 'layout_type' in results[0] if results else False - - if has_layout: - print("\nNote: 'Img (SxS)' shows the image area *if* the Strategist chooses the") - print("side-by-side intent for this image. Decide narrative intent first — see") - print("references/strategist-image.md. Hero / atmosphere / accent intents ignore it.\n") - print(f"{'No.':<4} {'Width':<7} {'Height':<7} {'Ratio':<7} {'Source':<8} {'Refs':<5} {'Size':<10} {'Category':<20} {'Img (SxS)':<14} {'Filename'}") - else: - print(f"\n{'No.':<4} {'Width':<7} {'Height':<7} {'Ratio':<7} {'Source':<8} {'Refs':<5} {'Size':<10} {'Category':<20} {'Filename'}") + print( + f"\n{'No.':<4} {'Width':<7} {'Height':<7} {'Ratio':<7} " + f"{'Source':<8} {'Refs':<5} {'Size':<10} {'Category':<20} {'Filename'}" + ) print("-" * REPORT_WIDTH) for i, img in enumerate(results, 1): 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: - img_area = f"{img['image_w']}x{img['image_h']}" - print(f"{base} {img_area:<14} {img['filename'][:35]}") - else: - print(f"{base} {img['filename'][:40]}") + base = ( + f"{i:<4} {img['width']:<7} {img['height']:<7} " + f"{img['aspect_ratio']:<7.2f} {ratio_source:<8} {usage_count:<5} " + f"{img['filesize_kb']:<10.1f}KB {img['category']:<20}" + ) + print(f"{base} {img['filename'][:40]}") print("-" * REPORT_WIDTH) print(f"Total: {len(results)} images\n") - # Group statistics by aspect ratio (aligned with image-layout-spec.md thresholds) + # Group statistics by objective aspect-ratio ranges. print("\nGroup by Aspect Ratio:") print("-" * CATEGORY_WIDTH) @@ -508,49 +378,6 @@ def print_results(results: list[ImageAnalysis]) -> None: print(f" ... and {len(native_only) - 10} more") -def generate_markdown(results: list[ImageAnalysis], canvas_key: str) -> None: - """Print a Markdown-ready image inventory section.""" - print("\n" + "=" * REPORT_WIDTH) - print("Markdown Snippet for Strategist (Copy & Paste)") - print("=" * REPORT_WIDTH) - - has_layout = 'layout_type' in results[0] if results else False - fmt_name = CANVAS_FORMATS.get(canvas_key, {}).get('name', canvas_key) - - print(f"\n## Image Resource Inventory (Auto-scan Results — {fmt_name})\n") - - print("> Decide narrative intent per image (hero / atmosphere / side-by-side /") - print("> accent) per `references/strategist-image.md` before filling the table. The") - print("> `Img Area (SxS)` / `Text Area (SxS)` columns only apply if the chosen") - print("> intent is side-by-side; ignore them for hero / atmosphere / accent intents.\n") - - if has_layout: - print("| Filename | Size | Ratio | Category | Img Area (SxS) | Text Area (SxS) | Intent | Usage | Type | Status | Generation Description |") - print("|----------|------|-------|----------|----------------|-----------------|--------|-------|------|--------|-----------------------|") - else: - print("| Filename | Size | Ratio | Category | Intent | Usage | Type | Status | Generation Description |") - print("|----------|------|-------|----------|--------|-------|------|--------|-----------------------|") - - for img in results: - ratio_str = f"{img['aspect_ratio']:.2f}" - asset_kind = str(img.get('asset_kind', 'bitmap')) - image_type = "Office Vector" if asset_kind == "office_vector" else "" - status = ( - "PPTX Native Only" - if asset_kind == "office_vector" and not img.get('svg_renderable', True) - else "Existing" - ) - - if has_layout: - img_area = f"{img['image_w']}x{img['image_h']}" - text_area = f"{img['text_w']}x{img['text_h']}" - print(f"| {img['filename']} | {img['width']}x{img['height']} | {ratio_str} | {img['layout_hint']} | {img_area} | {text_area} | (to be filled) | {img.get('usage_count', 1)} refs | {image_type} | {status} | - |") - else: - print(f"| {img['filename']} | {img['width']}x{img['height']} | {ratio_str} | {img['layout_hint']} | (to be filled) | {img.get('usage_count', 1)} refs | {image_type} | {status} | - |") - - print("\n" + "=" * REPORT_WIDTH + "\n") - - 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)): @@ -558,16 +385,10 @@ def _format_optional_number(value: object, digits: int = 2) -> str: return f"{float(value):.{digits}f}" -def save_csv( - results: list[ImageAnalysis], - csv_path: str | Path, - include_layout: bool | None = None, -) -> None: +def save_csv(results: list[ImageAnalysis], csv_path: str | Path) -> 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", @@ -587,8 +408,6 @@ def save_csv( "SizeKB", "Category", ] - if include_layout: - header.extend(["ImageArea_SxS", "TextArea_SxS"]) temporary_path: Path | None = None try: @@ -622,20 +441,8 @@ def save_csv( image.get("svg_renderable", True), image.get("pptx_native_supported", True), _format_optional_number(image["filesize_kb"], digits=1), - image["layout_hint"], + image["category"], ] - 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 @@ -646,37 +453,15 @@ def save_csv( 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" + description="Analyze objective image-file facts" ) parser.add_argument( "images_dir", help="Path to the images directory" ) - parser.add_argument( - "--canvas", - 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(argv) images_dir = Path(args.images_dir).resolve() @@ -688,31 +473,18 @@ def main(argv: list[str] | None = None) -> int: print(f"Error: Not a directory: {images_dir}") return 1 - canvas_key, canvas_source = _resolve_canvas_key(images_dir, args.canvas) - if canvas_key not in CANVAS_FORMATS: - 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)} " - f"({fmt.get('width', '?')}x{fmt.get('height', '?')}; {canvas_source})" - ) results, errors = _analyze_images(str(images_dir)) - enrich_with_layout(results, canvas_key) if results: print_results(results) - generate_markdown(results, canvas_key) else: 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) + save_csv(results, csv_path) if errors: print( diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/check_annotations.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/check_annotations.py index c9599457..79d509d6 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/check_annotations.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/check_annotations.py @@ -24,6 +24,7 @@ from pathlib import Path from typing import Optional from console_encoding import configure_utf8_stdio +from slide_roster import discover_slide_svgs configure_utf8_stdio() @@ -65,7 +66,7 @@ def scan_directory(dir_path: Path) -> dict[str, list[dict]]: return {} results = {} - for svg_file in sorted(svg_dir.glob('*.svg')): + for svg_file in discover_slide_svgs(svg_dir): annotations = scan_svg_file(svg_file) if annotations: results[svg_file.name] = annotations diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/server.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/server.py index 20a8d73d..1394cf6c 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/server.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/server.py @@ -69,6 +69,7 @@ from server_common import ( # noqa: E402 process_alive as _process_alive, read_lock as _read_lock, release_lock as _release_lock, + validate_port as _validate_port, ) configure_utf8_stdio() @@ -113,10 +114,9 @@ _ICON_PREVIEW_SAMPLES = { 'phosphor-duotone': ('house', 'chart-line', 'users', 'target'), } -# Shares port 5050 with the live preview server (svg_editor/server.py). The two -# never run at once: confirm is Step 4 and shuts down on confirm (or idle), -# freeing the port before live preview starts at Step 6. One port = one forward -# rule for the whole pipeline. They still keep separate processes and locks. +# Prefer the same memorable entry port as live preview. Normal single-project +# execution releases it between Step 4 and Step 6; concurrent projects advance +# from this base while explicit ``--port`` remains exact. DEFAULT_PORT = 5050 PUBLIC_HOST = '127.0.0.1' STARTUP_TIMEOUT = 10 @@ -172,9 +172,10 @@ def _server_url(port: int, path: str = '') -> str: def _wait_for_server_ready( port: int, proc: subprocess.Popen, + project_path: Path, timeout: int = STARTUP_TIMEOUT, ) -> bool: - """Wait until the detached child is accepting HTTP requests.""" + """Wait until this project's detached confirm server is accepting requests.""" deadline = time.time() + timeout last_error = '' health_url = _server_url(port, '/api/health') @@ -185,9 +186,17 @@ def _wait_for_server_ready( return False try: with urllib.request.urlopen(health_url, timeout=1) as resp: - if 200 <= resp.status < 500: + data = json.load(resp) + if ( + resp.status == 200 + and isinstance(data, dict) + and data.get('service') == 'confirm_ui' + and data.get('project') == str(project_path) + and data.get('pid') == proc.pid + ): return True - except (OSError, urllib.error.URLError) as exc: + last_error = 'health response belongs to another service or project' + except (OSError, ValueError, urllib.error.URLError) as exc: last_error = str(exc) time.sleep(0.2) logger.error( @@ -203,6 +212,7 @@ def _launch_background_server( project_path: Path, *, preferred_port: int, + exact_port: bool, idle_timeout: int, open_browser: bool, ) -> tuple[subprocess.Popen, int, Path]: @@ -210,7 +220,7 @@ def _launch_background_server( confirm_dir = project_path / CONFIRM_DIR_NAME confirm_dir.mkdir(parents=True, exist_ok=True) log_path = confirm_dir / 'server.log' - port = _find_free_port(preferred_port) + port = preferred_port if exact_port else _find_free_port(preferred_port) cmd = [ sys.executable, str(Path(__file__).resolve()), @@ -230,7 +240,9 @@ def _launch_background_server( logger=logger, ) logger.info('log: %s', log_path) - if not _wait_for_server_ready(port, proc): + if not _wait_for_server_ready(port, proc, project_path): + if proc.poll() is None: + proc.terminate() raise RuntimeError(f'confirm UI failed to become reachable: {_server_url(port)}') _sync_session_state(confirm_dir, server_port=port, event='server-ready') url = _server_url(port) @@ -255,9 +267,9 @@ def _preferred_recovery_port(lock_file: Path, fallback: int) -> int: existing = _read_lock(lock_file) try: port = int((existing or {}).get('port', 0) or 0) + return _validate_port(port) if port else fallback except (TypeError, ValueError): - port = 0 - return port or fallback + return fallback def _open_browser_async(url: str, delay: float = 0.4) -> None: @@ -322,6 +334,8 @@ def _wait_for_result( def _result_stage(result_file: Path) -> Optional[str]: """Return the canonical ``stage`` field of result.json, or None.""" + if not result_file.is_file(): + return None try: data = _read_json_object(result_file) except (OSError, json.JSONDecodeError, ValueError): @@ -684,13 +698,12 @@ def _typography_signature( return tuple(values) -def _typography_candidates_distinct_error( +def _typography_candidates_fixed_error( candidates: list, - labels: list[str], *, main_language: object, ) -> Optional[str]: - """Require every candidate to offer a different relevant font combination.""" + """Reject contradictions in an explicitly fixed typography contract.""" fixed = [ isinstance(candidate, dict) and candidate.get('fixed') is True for candidate in candidates @@ -708,24 +721,6 @@ def _typography_candidates_distinct_error( if any(signature != signatures[0] for signature in signatures[1:]): return 'fixed typography candidates must repeat the same font combination' return None - seen = {} - for index, candidate in enumerate(candidates): - signature = _typography_signature( - candidate, - main_language=main_language, - ) - if signature in seen: - previous = seen[signature] - combination = ( - 'heading/body primary' - if _is_english_language(main_language) - else 'heading/body primary+english' - ) - return ( - f'{labels[index]} repeats {labels[previous]}; ' - f'{combination} combinations must differ' - ) - seen[signature] = index return None @@ -779,12 +774,8 @@ def _stage2_design_directions_error( image_strategy.get('rendering') or '' ).strip(): return f'{label}.image_strategy.rendering must be non-empty' - return _typography_candidates_distinct_error( + return _typography_candidates_fixed_error( typography_candidates, - [ - f'design_directions.candidates[{index}].typography' - for index in range(len(typography_candidates)) - ], main_language=main_language, ) @@ -812,12 +803,8 @@ def _stage2_design_directions_error( if error: return error if main_language: - return _typography_candidates_distinct_error( + return _typography_candidates_fixed_error( typography, - [ - f'typography.candidates[{index}]' - for index in range(len(typography)) - ], main_language=main_language, ) return None @@ -993,6 +980,16 @@ def _stage2_solution_error( ) if typography_error: return typography_error + + if _uses_ai_images(result): + image_strategy = result.get('image_strategy') + if not isinstance(image_strategy, dict) or not str( + image_strategy.get('rendering') or '' + ).strip(): + return 'image_usage includes ai, so image_strategy.rendering must be non-empty' + rendering = image_strategy['rendering'].strip() + if rendering != 'custom' and rendering not in _ai_rendering_ids(): + return f'image_strategy.rendering is not a known preset: {rendering}' return None @@ -1404,11 +1401,9 @@ def _wait_result_status( def _shutdown_existing(lock_file: Path) -> int: """Stop a confirm server left running for this project (idempotent). - Step 4 always calls this on exit so the page never lingers on the shared - port 5050 — whether the user clicked **Confirm** (the page already shut the - server down) or replied in chat instead (the server is still up). Tries a - graceful ``/api/shutdown`` first, falls back to killing the recorded pid, - then clears the lock. A no-op when nothing is running. + Step 4 always calls this on exit so the page never lingers on its selected + port. Tries a graceful ``/api/shutdown`` first, falls back to killing the + recorded pid, then clears the lock. A no-op when nothing is running. """ existing = _read_lock(lock_file) if not existing: @@ -1528,6 +1523,11 @@ def _ai_comparison_items(kind: str) -> list[dict[str, str]]: return items +def _ai_rendering_ids() -> set[str]: + """Return the rendering presets exposed by the confirmation UI.""" + return {item['id'] for item in _ai_comparison_items('rendering')} + + def _build_ai_image_comparison() -> dict: return { 'rendering': _ai_comparison_items('rendering'), @@ -1610,6 +1610,8 @@ def create_app( rec_ok = False resp = jsonify({ 'status': 'ok', + 'service': 'confirm_ui', + 'pid': os.getpid(), 'project': str(project_path), 'recommendations': rec_ok, 'stage': stage, @@ -1874,8 +1876,8 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument('project_dir', help='Path to project directory') parser.add_argument( - '--port', type=int, default=DEFAULT_PORT, - help=f'Port to listen on (default: {DEFAULT_PORT})', + '--port', type=int, default=None, + help=f'Exact port to listen on (default: first free port from {DEFAULT_PORT})', ) parser.add_argument('--no-browser', action='store_true', help='Do not auto-open browser') parser.add_argument( @@ -1912,7 +1914,7 @@ def build_parser() -> argparse.ArgumentParser: '--shutdown', action='store_true', help='Stop a confirm server left running for this project, then exit ' '(idempotent). Run at the end of Step 4 so the page never lingers ' - 'on the shared port before live preview starts.', + 'on its selected port before live preview starts.', ) return parser @@ -1927,6 +1929,13 @@ def main(argv: Optional[list[str]] = None) -> int: datefmt='%H:%M:%S', ) + if args.port is not None: + try: + args.port = _validate_port(args.port) + except ValueError as exc: + logger.error('%s', exc) + return 2 + project_path = Path(args.project_dir).resolve() if not project_path.is_dir(): logger.error('%s is not a directory', project_path) @@ -1958,11 +1967,17 @@ def main(argv: Optional[list[str]] = None) -> int: rec_file, ) return 1 - recovery_port = _preferred_recovery_port(lock_file, args.port) + exact_port = args.port is not None + recovery_port = ( + args.port + if exact_port + else _preferred_recovery_port(lock_file, DEFAULT_PORT) + ) try: _, actual_port, _ = _launch_background_server( project_path, preferred_port=recovery_port, + exact_port=exact_port, idle_timeout=args.timeout, open_browser=False, ) @@ -2011,7 +2026,8 @@ def main(argv: Optional[list[str]] = None) -> int: try: proc, port, _ = _launch_background_server( project_path, - preferred_port=args.port, + preferred_port=args.port if args.port is not None else DEFAULT_PORT, + exact_port=args.port is not None, idle_timeout=args.timeout, open_browser=not args.no_browser, ) @@ -2028,10 +2044,16 @@ def main(argv: Optional[list[str]] = None) -> int: ) return 0 + try: + port = args.port if args.port is not None else _find_free_port(DEFAULT_PORT) + except RuntimeError as exc: + logger.error('%s', exc) + return 1 + # Per-project mutual exclusion: refuse duplicate launches. Stale locks # (dead pid) are overwritten by _claim_lock. lock_file = project_path / LOCK_FILE_NAME - existing = _claim_lock(lock_file, args.port) + existing = _claim_lock(lock_file, port) if existing: existing_pid = existing.get('pid', '?') existing_port = existing.get('port', '?') @@ -2055,17 +2077,17 @@ def main(argv: Optional[list[str]] = None) -> int: str(project_path), idle_timeout=args.timeout, lock_file=lock_file, - server_port=args.port, + server_port=port, ) - url = _server_url(args.port) + url = _server_url(port) if not args.no_browser: _open_browser_async(url) logger.info('running at %s', url) logger.info('project: %s', project_path) logger.info('idle timeout: %ds (0 = disabled)', args.timeout) - app.run(host=PUBLIC_HOST, port=args.port, debug=False) + app.run(host=PUBLIC_HOST, port=port, debug=False) return 0 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/static/app.js b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/static/app.js index 2309ed05..148fe047 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/static/app.js +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/static/app.js @@ -80,7 +80,12 @@ formula_policy: "Formula rendering policy", image_ai_path: "AI image source", image_strategy: "Generated image style", - image_strategy_empty: "No generated-image style candidates were provided.", + image_strategy_empty: "No preset style references are available. You can still use a custom style.", + image_strategy_required: "Choose a generated-image preset or describe a custom style.", + image_strategy_invalid: "The selected generated-image preset is not available.", + image_strategy_select_placeholder: "Choose a generated-image preset…", + image_strategy_recommended_group: "Recommended for this deck", + image_strategy_all_group: "All preset styles", image_strategy_rendering: "Rendering", image_strategy_visual: "Visual", image_strategy_mood: "Mood", @@ -234,7 +239,12 @@ formula_policy: "数式レンダリング方針", image_ai_path: "AI画像の生成元", image_strategy: "生成画像のスタイル", - image_strategy_empty: "生成画像スタイルの候補がまだありません。", + image_strategy_empty: "プリセットのスタイル見本を利用できません。カスタムスタイルは引き続き使用できます。", + image_strategy_required: "生成画像のプリセットを選ぶか、カスタムスタイルを記述してください。", + image_strategy_invalid: "選択した生成画像プリセットは利用できません。", + image_strategy_select_placeholder: "生成画像のプリセットを選択…", + image_strategy_recommended_group: "この資料へのおすすめ", + image_strategy_all_group: "すべてのプリセットスタイル", image_strategy_rendering: "レンダリング", image_strategy_visual: "ビジュアル", image_strategy_mood: "ムード", @@ -388,7 +398,12 @@ formula_policy: "公式渲染策略", image_ai_path: "生成配图来源", image_strategy: "生成图风格", - image_strategy_empty: "还没有提供生成图风格候选。", + image_strategy_empty: "当前没有可用的预设风格参考,仍可使用自定义风格。", + image_strategy_required: "请选择一种生成图预设,或填写自定义风格。", + image_strategy_invalid: "所选生成图预设当前不可用。", + image_strategy_select_placeholder: "选择生成图预设…", + image_strategy_recommended_group: "本项目推荐", + image_strategy_all_group: "全部预设风格", image_strategy_rendering: "渲染风格", image_strategy_visual: "视觉", image_strategy_mood: "情绪", @@ -598,6 +613,7 @@ var CAT = null; // catalogs.json — finite option universe var REC = null; // current recommendation stage — AI picks + candidates var ICON_PREVIEWS = {}; // /api/icon-previews — real SVG samples from templates/icons + var AI_IMAGE_COMPARISON = {}; // /api/ai-image-comparison — preset rendering catalog var STATE = {}; var REC_ALIASES = { icons: { @@ -1066,11 +1082,6 @@ return /^en(?:-|$)/i.test(recommendationLanguage()); } - function setProjectLanguageAttributes(node) { - node.lang = recommendationLanguage(); - node.dir = "auto"; - } - function setEnglishLanguageAttributes(node) { node.lang = "en"; node.dir = "ltr"; @@ -1348,6 +1359,23 @@ }).slice(0, 3); } + function imageStrategyCatalogCandidates() { + var items = AI_IMAGE_COMPARISON && AI_IMAGE_COMPARISON.rendering; + if (!Array.isArray(items)) return []; + return items.map(function (item) { + return item && item.id ? { rendering: item.id } : null; + }).filter(Boolean); + } + + function imageStrategySelectableCandidates() { + var recommended = imageStrategyRecommendationCandidates(); + var seen = {}; + recommended.forEach(function (candidate) { seen[candidate.rendering] = true; }); + return recommended.concat(imageStrategyCatalogCandidates().filter(function (candidate) { + return !seen[candidate.rendering]; + })); + } + function imageStrategyCustomCandidate() { var candidate = customCandidateSpec("image_strategy"); if (!customCandidateBehavior("image_strategy")) { @@ -1355,10 +1383,9 @@ return item && item.rendering === "custom"; })[0] || {}; } - if (!candidate || typeof candidate !== "object") return null; + if (!candidate || typeof candidate !== "object") candidate = {}; candidate = Object.assign({}, candidate, { rendering: "custom" }); - var normalized = normalizedImageStrategy(candidate); - return String(normalized.behavior || "").trim() ? normalized : null; + return normalizedImageStrategy(candidate); } function normalizedImageStrategy(candidate) { @@ -2018,39 +2045,8 @@ !typographyFamiliesComplete(STATE.typography)); } - function previewText(value, maxCharacters) { - var text = String(value || "").replace(/\s+/g, " ").trim(); - var characters = Array.from(text); - if (characters.length <= maxCharacters) return text; - return characters.slice(0, Math.max(1, maxCharacters - 1)).join("") + "…"; - } - - function projectLanguageProse() { - var fields = [ - "core_message", - "communication_intent", - "audience_outcome", - "delivery_context", - "artifact_afterlife", - "content_divergence", - "audience" - ]; - var values = []; - fields.forEach(function (field) { - var value = String((STATE && STATE[field]) || "").replace(/\s+/g, " ").trim(); - if (value && values.indexOf(value) < 0) values.push(value); - }); - return values; - } - - function projectLanguageSample(role) { - var prose = projectLanguageProse(); - if (!prose.length) return ""; - if (role === "heading") return previewText(prose[0], 56); - return previewText(prose[1] || prose[0], 96); - } - function sampleText(role, field) { + // Keep comparison copy stable: choices change visual treatment, not content. var useEnglish = field === "english" || isEnglishProject(); if (role === "heading") { return t(useEnglish ? "preview_latin_title" : "preview_big_title"); @@ -2060,19 +2056,14 @@ function fontSample(box, slot, css, role) { var line = el("div", "font-sample-line"); - var projectSample = projectLanguageSample(role); - var slotSample = String(slot.sample_primary || "").trim(); - var primary = el("span", "fs-primary", - projectSample || slotSample || sampleText(role, "primary")); - if (projectSample || slotSample) setProjectLanguageAttributes(primary); - else setUiLanguageAttributes(primary); + var primary = el("span", "fs-primary", sampleText(role, "primary")); + setUiLanguageAttributes(primary); var primaryStack = previewFontStack(slot.primary, css); if (primaryStack) primary.style.fontFamily = primaryStack; if (primaryStack) primary.title = primaryStack; line.appendChild(primary); if (!isEnglishProject()) { - var english = el("span", "fs-english", - slot.sample_english || sampleText(role, "english")); + var english = el("span", "fs-english", sampleText(role, "english")); setEnglishLanguageAttributes(english); var englishStack = previewFontStack(slot.english, css); if (englishStack) english.style.fontFamily = englishStack; @@ -2415,12 +2406,23 @@ host.appendChild(sec); - var selIdx = -1; + var nameMatch = -1; + var signatureMatch = -1; var stateSignature = typographySignature(STATE.typography || {}); if (STATE.typography && STATE.typography.name !== "custom") cands.forEach(function (c, i) { - var sameName = (localized(c, "name") || c.name) === STATE.typography.name; - if (sameName || typographySignature(c) === stateSignature) selIdx = i; + var sameName = [localized(c, "name"), c.name_zh, c.name_en, c.name_ja] + .some(function (name) { return name === STATE.typography.name; }); + if (!sameName && c.name && typeof c.name === "object") { + sameName = Object.keys(c.name).some(function (key) { + return c.name[key] === STATE.typography.name; + }); + } + if (sameName && nameMatch < 0) nameMatch = i; + if (typographySignature(c) === stateSignature && signatureMatch < 0) signatureMatch = i; }); + // Names preserve the selected candidate when several recommendations share + // one font stack. Signature matching is only a first-match legacy fallback. + var selIdx = nameMatch >= 0 ? nameMatch : signatureMatch; if (selIdx >= 0) selectFont(selIdx); else if (STATE.typography && STATE.typography.name === "custom") { ["heading", "body"].forEach(function (role) { @@ -2499,30 +2501,18 @@ var headEnglishStack = previewFontStack(head.english, head.css); var bodyPrimaryStack = previewFontStack(body.primary, body.css); var bodyEnglishStack = previewFontStack(body.english, body.css); - var projectTitle = projectLanguageSample("heading"); - var projectBody = projectLanguageSample("body"); - var headingSample = String(head.sample_primary || "").trim(); - var bodySample = String(body.sample_primary || "").trim(); card.style.background = bg; - titlePrimary.textContent = projectTitle || - headingSample || - sampleText("heading", "primary"); - if (projectTitle || headingSample) setProjectLanguageAttributes(titlePrimary); - else setUiLanguageAttributes(titlePrimary); - titleEnglish.textContent = head.sample_english || - sampleText("heading", "english"); + titlePrimary.textContent = sampleText("heading", "primary"); + setUiLanguageAttributes(titlePrimary); + titleEnglish.textContent = sampleText("heading", "english"); title.style.color = pri; title.style.fontSize = Math.round(bodyPx * 1.7) + "px"; titlePrimary.style.fontFamily = headPrimaryStack || ""; titleEnglish.style.fontFamily = headEnglishStack || ""; - bodyPrimary.textContent = projectBody || - bodySample || - sampleText("body", "primary"); - if (projectBody || bodySample) setProjectLanguageAttributes(bodyPrimary); - else setUiLanguageAttributes(bodyPrimary); - bodyEnglish.textContent = body.sample_english || - sampleText("body", "english"); + bodyPrimary.textContent = sampleText("body", "primary"); + setUiLanguageAttributes(bodyPrimary); + bodyEnglish.textContent = sampleText("body", "english"); bodyWrap.style.color = txt; bodyWrap.style.fontSize = bodyPx + "px"; bodyPrimary.style.fontFamily = bodyPrimaryStack || ""; @@ -2533,8 +2523,7 @@ content.style.color = txt; content.style.fontFamily = bodyPrimaryStack || ""; content.innerHTML = stylePreviewContentMarkup(STATE.icons); - if (projectLanguageProse().length) setProjectLanguageAttributes(content); - else setUiLanguageAttributes(content); + setUiLanguageAttributes(content); chip.style.background = sbg; chipDot.style.background = sacc; chipLabel.textContent = t("role_secondary_bg"); @@ -2570,8 +2559,13 @@ visual.innerHTML = ""; var row = appendImageStrategyPreviews(visual, strategy); visual.classList.toggle("image-strategy-preview-empty", !row); - if (!row) visual.appendChild(el("div", "toggle-desc", t("image_strategy_no_reference"))); - title.textContent = strategy.name || t("image_strategy_ai_custom"); + if (!row) visual.appendChild(el("div", "toggle-desc", + strategy.rendering === "custom" ? t("image_strategy_no_reference") : + t("image_strategy_select_placeholder"))); + title.textContent = strategy.name || + (strategy.rendering === "custom" ? t("image_strategy_ai_custom") : + (strategy.rendering ? comparisonValueLabel("rendering", strategy.rendering) : + t("image_strategy_select_placeholder"))); var parts = []; if (strategy.rendering) { parts.push(t("image_strategy_rendering") + ": " + @@ -2599,17 +2593,6 @@ } function stylePreviewRows() { - var prose = projectLanguageProse(); - if (prose.length) { - var projectRows = []; - for (var i = 0; i < Math.min(3, Math.ceil(prose.length / 2)); i += 1) { - projectRows.push([ - previewText(prose[i * 2], 36), - previewText(prose[i * 2 + 1] || "", 72) - ]); - } - return projectRows; - } return [ [t("preview_point_1_title"), t("preview_point_1_text")], [t("preview_point_2_title"), t("preview_point_2_text")], @@ -2657,22 +2640,21 @@ var strategySub = el("div", "subfield image-strategy-subfield"); strategySub.appendChild(el("div", "subfield-label", t("image_strategy"))); strategySub.appendChild(el("div", "toggle-desc", t("image_strategy_reference_hint"))); - var strategyGrid = el("div", "font-grid image-strategy-grid"); - var strategyCands = imageStrategyRecommendationCandidates(); + var recommendedStrategies = imageStrategyRecommendationCandidates(); + var strategyCands = imageStrategySelectableCandidates(); + var hasRecommendedStrategies = recommendedStrategies.length > 0; var customStrategy = STATE.image_strategy_custom || imageStrategyCustomCandidate(); + var presetPicker = el("div", "image-strategy-picker"); + var presetSelect = el("select", "font-select image-strategy-select"); var customCard = null; var syncCustomStrategy = function () {}; var selectCustomImageStrategy = function () {}; - function markStrategyCard(selectedCard) { - strategyGrid.querySelectorAll(".font-card").forEach(function (card) { - card.classList.toggle("selected", card === selectedCard); - }); - } - - function selectImageStrategy(idx, selectedCard) { + function selectImageStrategy(idx) { + if (!strategyCands[idx]) return; STATE.image_strategy = normalizedImageStrategy(strategyCands[idx]); - markStrategyCard(selectedCard || strategyGrid.querySelector('[data-strategy-index="' + idx + '"]')); + presetSelect.value = String(idx); + if (customCard) customCard.classList.remove("selected"); syncCustomStrategy(false); refreshImageStrategyPreview(); } @@ -2685,28 +2667,44 @@ return -1; } - strategyCands.forEach(function (candidate, idx) { - var card = el("div", "font-card"); - card.setAttribute("data-strategy-index", String(idx)); - var top = el("div", "font-card-head"); - top.appendChild(el("span", "font-card-name", - localized(candidate, "name") || (t("option_prefix") + " " + (idx + 1)))); - if (candidate.rendering) { - top.appendChild(el("span", "font-card-meta", - t("image_strategy_rendering") + ": " + comparisonValueLabel("rendering", candidate.rendering))); + function strategyOptionLabel(candidate, idx) { + var renderingLabel = comparisonValueLabel("rendering", candidate.rendering); + var candidateName = localized(candidate, "name") || renderingLabel || + (t("option_prefix") + " " + (idx + 1)); + return candidateName !== renderingLabel ? + candidateName + " · " + renderingLabel : candidateName; + } + + function appendStrategyOptions(label, start, end) { + if (start >= end) return; + var group = document.createElement("optgroup"); + group.label = label; + for (var idx = start; idx < end; idx += 1) { + var option = document.createElement("option"); + option.value = String(idx); + option.textContent = strategyOptionLabel(strategyCands[idx], idx); + group.appendChild(option); } - card.appendChild(top); - appendImageStrategyPreviews(card, candidate); - [ - ["image_strategy_visual", localized(candidate, "visual")], - ["image_strategy_mood", localized(candidate, "mood")] - ].forEach(function (row) { - if (row[1]) card.appendChild(el("div", "color-note", t(row[0]) + ":" + row[1])); - }); - card.addEventListener("click", function () { selectImageStrategy(idx, card); }); - strategyGrid.appendChild(card); + presetSelect.appendChild(group); + } + + var placeholderOption = document.createElement("option"); + placeholderOption.value = ""; + placeholderOption.textContent = t("image_strategy_select_placeholder"); + placeholderOption.disabled = true; + placeholderOption.selected = true; + presetSelect.appendChild(placeholderOption); + appendStrategyOptions(t("image_strategy_recommended_group"), 0, recommendedStrategies.length); + appendStrategyOptions(t("image_strategy_all_group"), recommendedStrategies.length, strategyCands.length); + presetSelect.disabled = !strategyCands.length; + presetSelect.addEventListener("change", function () { + selectImageStrategy(parseInt(presetSelect.value, 10)); }); - if (!strategyCands.length) strategyGrid.appendChild(el("div", "toggle-desc", t("image_strategy_empty"))); + presetPicker.appendChild(presetSelect); + if (!strategyCands.length) { + presetPicker.appendChild(el("div", "toggle-desc", t("image_strategy_empty"))); + } + strategySub.appendChild(presetPicker); if (customStrategy) { customStrategy = normalizedImageStrategy(customStrategy); @@ -2723,7 +2721,8 @@ ].forEach(function (row) { if (row[1]) customCard.appendChild(el("div", "color-note", t(row[0]) + ":" + row[1])); }); - var customCopy = el("div", "ai-custom-candidate-copy", customStrategy.behavior); + var customCopy = el("div", "ai-custom-candidate-copy", + customStrategy.behavior || t("image_strategy_custom_placeholder")); customCard.appendChild(customCopy); var customInput = el("textarea", "text-input image-strategy-custom-input"); setNaturalInputDirection(customInput); @@ -2734,7 +2733,7 @@ customCard.appendChild(customInput); syncCustomStrategy = function (selected) { - customCopy.textContent = customStrategy.behavior || ""; + customCopy.textContent = customStrategy.behavior || t("image_strategy_custom_placeholder"); customCopy.style.display = selected ? "none" : "block"; customInput.style.display = selected ? "block" : "none"; if (selected && customInput.value !== customStrategy.behavior) { @@ -2744,7 +2743,8 @@ selectCustomImageStrategy = function () { STATE.image_strategy = normalizedImageStrategy(customStrategy); - markStrategyCard(customCard); + presetSelect.value = ""; + customCard.classList.add("selected"); syncCustomStrategy(true); refreshImageStrategyPreview(); }; @@ -2760,9 +2760,8 @@ customInput.focus(); }); syncCustomStrategy(false); - strategyGrid.appendChild(customCard); + strategySub.appendChild(customCard); } - strategySub.appendChild(strategyGrid); var recommendedIds = selectedImageUsageIds(recValue("image_usage")); if (!recommendedIds.length) recommendedIds = [defaultImageUsageId()]; @@ -2813,10 +2812,9 @@ selectCustomImageStrategy(); } else if (STATE.image_strategy && imageStrategyCandidateIndex(STATE.image_strategy) >= 0) { selectImageStrategy(imageStrategyCandidateIndex(STATE.image_strategy)); - } else if (strategyCands.length) { + } else if (needsGeneratedImagesForUsage(STATE.image_usage) && + hasRecommendedStrategies && strategyCands.length) { selectImageStrategy(imageStrategySelectedIndex()); - } else if (customCard) { - selectCustomImageStrategy(); } refreshUsageChips(); host.appendChild(sec); @@ -3147,8 +3145,6 @@ ); } else if (directionStrategy) { STATE.image_strategy = normalizedImageStrategy(directionStrategy); - } else if (STATE.image_strategy_custom) { - STATE.image_strategy = normalizedImageStrategy(STATE.image_strategy_custom); } } @@ -3231,6 +3227,24 @@ return !!valid; } + function imageStrategyValid(payload) { + if (!needsGeneratedImagesForUsage(payload.image_usage)) return true; + var imageStrategy = payload.image_strategy || {}; + var rendering = String(imageStrategy.rendering || "").trim(); + if (!rendering) { + document.getElementById("confirm-status").textContent = t("image_strategy_required"); + return false; + } + var presetIds = imageStrategyCatalogCandidates().map(function (candidate) { + return candidate.rendering; + }); + if (rendering !== "custom" && presetIds.length && presetIds.indexOf(rendering) < 0) { + document.getElementById("confirm-status").textContent = t("image_strategy_invalid"); + return false; + } + return true; + } + function positiveNumber(value) { var number = parseFloat(value); return isFinite(number) && number > 0; @@ -3321,6 +3335,7 @@ function submitStage2() { var payload = stage2Payload(); if (!imageUsageValid(payload.image_usage)) return; + if (!imageStrategyValid(payload)) return; if (!designSystemValid(payload)) return; if (!customSelectionsValid(payload)) return; submitStage(payload, 3); @@ -3387,6 +3402,7 @@ payload.image_strategy = normalizedImageStrategy(payload.image_strategy); } normalizeCreativePayload(payload); + if (!imageStrategyValid(payload)) return; if (!designSystemValid(payload)) return; if (!customSelectionsValid(payload)) return; btn.disabled = true; @@ -3446,6 +3462,11 @@ .catch(function () { return {}; }); } + function loadAiImageComparison() { + return fetchJson("/api/ai-image-comparison", "AI image comparison") + .catch(function () { return {}; }); + } + function boot() { applyStaticTranslations(); var toggleBtn = document.getElementById("btn-lang-toggle"); @@ -3531,11 +3552,13 @@ Promise.all([ loadCatalogs(), fetchJson("/api/recommendations", "recommendations"), - loadIconPreviews() + loadIconPreviews(), + loadAiImageComparison() ]).then(function (res) { CAT = res[0]; REC = res[1]; ICON_PREVIEWS = res[2] || {}; + AI_IMAGE_COMPARISON = res[3] || {}; if (REC.lang === "zh" || REC.lang === "en" || REC.lang === "ja") { var hasStored = false; try { hasStored = !!window.localStorage.getItem("ppt_lang"); } catch (e) { /* ignore */ } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/static/style.css b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/static/style.css index 70fcf027..b708f733 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/static/style.css +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/confirm_ui/static/style.css @@ -497,19 +497,7 @@ textarea.text-input { resize: vertical; line-height: 1.5; } gap: 8px; margin: 8px 0 10px; } -.image-strategy-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - align-items: stretch; -} -.image-strategy-grid > .font-card:not(.image-strategy-custom-card) { - min-width: 0; - padding: 10px; -} -.image-strategy-grid > .image-strategy-custom-card, -.image-strategy-grid > .toggle-desc { - grid-column: 1 / -1; -} +.image-strategy-picker { margin-top: 10px; } .image-strategy-preview { position: relative; aspect-ratio: 16 / 9; @@ -554,6 +542,7 @@ textarea.text-input { resize: vertical; line-height: 1.5; } } .image-strategy-custom-card { background: var(--card); + margin-top: 10px; } .font-sample-heading { font-size: 24px; font-weight: 700; line-height: 1.3; } .font-sample-body { font-size: 14px; color: #333; margin-top: 4px; } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/advanced-image-motion-smoke.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/advanced-image-motion-smoke.md index e2a7cac3..7884115d 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/advanced-image-motion-smoke.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/advanced-image-motion-smoke.md @@ -12,7 +12,7 @@ The fixture deliberately closes the full planning and execution chain: - `design_spec.md` carries `Motion suggestion`, `Native shape suggestion`, one current §VIII image row, and `Crop Policy`; -- `spec_lock.md` projects that row with optional layout pattern `#100`; +- `spec_lock.md` projects that row with optional layout pattern `#M1-11`; - both pages reuse one raster through ordinary, ellipse-preset, and custom-path independent nested crops; - `animations.json` pairs the main crop across adjacent Morph pages; @@ -130,7 +130,7 @@ preset = ( | Filename | Dimensions | Ratio | Purpose | Type | Layout pattern | Crop Policy | Acquire Via | Status | Reference | text_policy | page_role | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| scene.png | 1280x720 | 16:9 | Morph crop continuity | Photo | #100 same-source independent crops with a shaped detail | adaptive | user | Existing | Synthetic three-band scene for crop and Morph verification | none | local | +| scene.png | 1280x720 | 16:9 | Morph crop continuity | Photo | #M1-11 same-source independent crops with a shaped detail | adaptive | user | Existing | Synthetic three-band scene for crop and Morph verification | none | local | ## IX. Content Outline @@ -194,7 +194,7 @@ preset = ( - library: none - inventory: none ## images -- scene: images/scene.png | source=user | pattern=#100 same-source independent crops with a shaped detail | crop=adaptive +- scene: images/scene.png | source=user | pattern=#M1-11 same-source independent crops with a shaped detail | crop=adaptive ## page_rhythm - P01: dense - P02: dense diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/confirm_ui.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/confirm_ui.md index 42f6654d..35062c40 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/confirm_ui.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/confirm_ui.md @@ -16,7 +16,46 @@ **Hard rule**: Keep detailed Confirm UI behavior here. The Generate route may summarize orchestration, but it should not duplicate the full JSON schema, catalog behavior, or launcher lifecycle. -**Fallback rule**: The page is default. Use chat on an explicit chat-only request, when the user answers the always-on handoff in chat, or after launch failure/timeout and one `result.json` re-check; a chat-question tool alone is not a launch failure. Preserve all three stages and keep Stage-1 prompts open-ended. +**Mandatory surface decision — before any UI command**: Resolve the most recent +explicit confirmation-surface instruction for this run before running +`--daemon` or `--wait-only`. Unrelated later messages do not reset the selected +branch. A new explicit selection may change it before launch; once confirmation +starts in chat or UI switches to chat, keep chat for the rest of this run. + +| Most recent explicit surface instruction | Branch | +|---|---| +| The user explicitly delegates confirmation | Present one complete delegated three-stage summary in chat. Do not launch the page or fabricate `result.json`. | +| Otherwise, the user asks for or agrees to personally confirm in chat, or declines the confirmation page | Use chat for all three stages. Do not launch the page, run `--wait-only`, or require a UI-authored `result.json`. | +| No explicit confirmation-surface instruction exists for this run | Use the page as the default. | + +Interpret the instruction semantically: “confirm here”, “use the chat window”, or +“do not open the confirmation page” are sufficient; no literal `chat-only` +keyword is required. Invoking a chat-question tool by itself does not select the +chat branch—the user's instruction does. Both branches preserve the same three +stages and confirmed-value semantics. + +**Fallback rule**: When no surface was selected before launch, the page is the +default. Use chat when the user answers the always-on handoff in chat, or after +launch failure/timeout and one `result.json` re-check. A chat-question tool +alone is not a launch failure. Preserve all three stages and keep Stage-1 +prompts open-ended. + +**In-run UI → chat switch — any stage**: If the user explicitly selects chat +after the UI server has launched—while `--wait-only` is active, before that wait +starts, or after it times out while the server remains live: + +1. If a wait is active, interrupt it and confirm that its process has exited. + Only the return code from this deliberate wait interruption is expected to + be non-zero. +2. Run `server.py --shutdown` and require that cleanup to + succeed. The browser tab may remain open, but its stopped server makes it + inactive. +3. Re-check `result.json` once for the current stage. Retain only values whose + confirmation was persisted before shutdown; an unsubmitted browser draft is + not confirmed. +4. Continue the unresolved current stage and every remaining stage in chat. Do + not call `--wait-only` again, recover the server, or relaunch the page during + this run. **Always-on Stage-1 chat handoff**: Launch the healthy daemon without `--wait`. After it returns, immediately post its actual URL plus one compact, localized @@ -27,13 +66,15 @@ changing its value. End with an explicit localized line saying that, if the page did not open or cannot be reached, the user may reply “continue with these recommendations” or revise the same items directly in chat; the same three-stage flow will continue. Only then run `--wait-only --wait-stage stage1`. A chat -reply selects the chat path without waiting for timeout. The handoff is context, -not confirmation, and silence confirms nothing. After launch failure/timeout -and the required result re-check, present the same items as open Stage-1 chat -questions and wait for an explicit response. +reply to that handoff applies the in-run switch above without waiting for +timeout. The handoff is context, not confirmation, and silence confirms +nothing. After launch failure/timeout and the required result re-check, present +the same items as open Stage-1 chat questions and wait for an explicit response. ## `confirm_ui/server.py` +The following launch and wait commands belong to the **UI branch only**: + ```bash python3 scripts/confirm_ui/server.py --daemon # healthy launch; return for chat handoff python3 scripts/confirm_ui/server.py --wait-only --wait-stage stage1 # Stage 1 @@ -45,12 +86,12 @@ python3 scripts/confirm_ui/server.py --timeout 0 # disable idle python3 scripts/confirm_ui/server.py --shutdown # Step 4 cleanup (idempotent) ``` -- Binds `127.0.0.1:5050` by default — or the next free port if another project already holds it (the launch log prints the actual URL) — and auto-opens the browser (suppress with `--no-browser`). `--port ` forces a specific port. -- In `--daemon` mode the launcher starts the child server with browser opening suppressed, waits for `GET /api/health` to prove the server is accepting requests, then opens the printed `http://127.0.0.1:` URL. If health never becomes reachable, the command fails before presenting a dead page. -- **Shares port 5050 with the live preview server** (`svg_editor/server.py`). The two never run at once: confirm is Step 4, live preview is Step 6, and Step 4 always shuts this server down on exit (see `--shutdown`) so the port is free. One port = one forward rule for the whole pipeline. They still keep **separate processes and locks** (`.confirm_ui.lock` vs `.live_preview.lock`). +- Without `--port`, binds the first free port from `127.0.0.1:5050`; the launch log prints the actual URL. `--port N` is exact and fails when unavailable. Auto-open is suppressed by `--no-browser`. +- In `--daemon` mode the launcher starts the child with browser opening suppressed, then accepts readiness only when `GET /api/health` identifies this confirm service, project, and child process. It opens the printed `http://127.0.0.1:` URL only after that check. +- Confirm UI and live preview prefer the same memorable base port but keep separate processes and project-local locks (`.confirm_ui.lock` vs `live_preview/lock.json`). Normal Step 4 cleanup releases the confirm port before Step 6; concurrent projects may use different ports. - `--daemon` starts the Flask process in the background and returns after the health check. Generate uses it without `--wait` so the Stage-1 chat handoff appears before blocking. `--daemon --wait` remains a combined compatibility form. The wait budget defaults to **590 s** (`--wait-timeout`); on timeout the detached server remains live, and the caller re-checks `result.json` once before chat fallback. - `--wait-only` attaches to the page opened by `--daemon` and blocks until the requested stage. If that stage is already persisted, it returns before recovery, so a fast submit between launch, chat handoff, and wait is not lost. Otherwise, if the recorded server died, it restarts on the recorded/default port. Use `stage1` only for a fresh Stage-1 launch, `stage2` for the complete-solution handoff, and `final` for Stage 3. It keys on stage alone; resume chooses the target from the persisted result instead of restarting Stage 1. -- `--shutdown` stops a confirm server left running for this project and exits — **idempotent** (a no-op when nothing is running). Tries a graceful `/api/shutdown`, falls back to killing the recorded pid, then clears the lock. Generate Step 4 runs this on every path (page-confirm or chat-fallback) so the page never lingers on the shared port before live preview starts. +- `--shutdown` stops a confirm server left running for this project and exits — **idempotent** (a no-op when nothing is running). Tries a graceful `/api/shutdown`, falls back to killing the recorded pid, then clears the lock. Generate Step 4 runs this on every path so the selected port is released before live preview starts. - Refuses to start unless the recommendation file expected from `result.json` exists (initially `/confirm_ui/recommendations.stage1.json`; `--shutdown` needs no recommendations). - Per-project lock at `/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten. - Idle auto-shutdown after 900 s by default; `/api/shutdown` exits gracefully and releases the lock. @@ -200,7 +241,7 @@ After Stage 1 is confirmed, create `recommendations.stage2.json` with the comple } ``` -The example abbreviates the required ≥3 directions. Custom mode/style candidates remain mandatory; AI usage also requires the custom image candidate. Stage 2 rejects fewer than three bundles, incomplete six-role palettes, and incomplete heading/body stacks. Legacy grids remain readable only with three complete palettes and complete typography. +The example abbreviates the required ≥3 directions. Custom mode/style candidates remain mandatory; only a recommendation containing AI requires the custom image candidate. Stage 2 rejects fewer than three bundles, incomplete six-role palettes, and incomplete heading/body stacks. Legacy grids remain readable only with three complete palettes and complete typography. After Stage 2 is confirmed, create `recommendations.stage3.json` with production recommendations only; leave both earlier files unchanged: @@ -231,12 +272,12 @@ After Stage 2 is confirmed, create `recommendations.stage3.json` with production - When confirmed Stage-2 `image_usage` includes `ai`, Stage 3 sets `recommend.image_ai_path` to one of `auto` / `api` / `host-native` / `manual`. Stage 2 never asks for the acquisition mechanism while the user is still deciding the image role. - **Color candidates carry the user-facing core `palette`**: `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, and `body_text`. The page renders every role as a labelled swatch with its HEX value visible, and offers per-role override inputs for precise single-role edits, plus a **Custom color card with a free-text box** — the user can describe the palette in words or paste HEX values instead of filling each role; this writes `color: { "name": "custom", "custom": "" }` to `result.json` for the AI to interpret. Legacy `text` is accepted as an alias for `body_text`, but new files should write `body_text`. Strategist derives secondary text, borders, state colors, and visual-style neutral tiers while writing `design_spec.md`, then projects the machine values to `spec_lock.md`; those are not user-facing confirmation choices. - **Candidate display text may be multilingual**: color / typography candidates can provide `name_zh` / `name_en` / `name_ja` and `note_zh` / `note_en` / `note_ja`; the page falls back to legacy `name` / `note`. Labels resolve in the page language first, then fall back across the others (a `ja` page: ja → en → zh; zh/en pages keep their zh↔en fallback and try `_ja` last), so when `lang` is `ja` always include the `_ja` variants — otherwise the candidate labels render in English. -- **Typography candidates** use concrete heading/body `primary`; non-English decks also use `english`, while English-primary decks omit it. `cjk` / `latin` remain legacy aliases. Localized `name` labels the pair and `css` only previews. Three generated pairs differ; user/template-fixed pairs repeat only with `fixed: true`. Catalog `fonts` supplies language-filtered dropdowns plus Other without limiting recommendations; edits mark Custom and refresh the preview. Include topic samples. PPT baselines are `text` 20 · `balanced` 24 · `presentation` 32 px; cards preserve sizes and submit px. +- **Typography candidates** use concrete heading/body `primary`; non-English decks also use `english`, while English-primary decks omit it. `cjk` / `latin` remain legacy aliases. Localized `name` labels the pair and `css` only previews. Bundles differ overall; font pairs may repeat without blocking. Fixed pairs require `fixed: true`. Catalog `fonts` supplies language-filtered dropdowns plus Other without limiting recommendations; edits mark Custom and refresh the preview. Include topic samples. PPT baselines are `text` 20 · `balanced` 24 · `presentation` 32 px; cards preserve sizes and submit px. - **Per-role size override** (parallel to color's per-role HEX override): besides `body_size`, the page exposes editable inputs for `title` / `subtitle` / `annotation`. The browser applies one documented deterministic dependency chain: `reading mode → body baseline → unpinned role sizes` (role ramp: `body ×` the §g ratios). Changing reading mode updates the body and all unpinned roles locally; changing body updates unpinned roles locally. Editing body or a role pins that value, so later reading-mode changes do not overwrite it. Font / direction-card selection preserves all current sizes. This is a browser-only state update: it performs no fetch, asks the backend to author no new recommendations, and a re-render preserves exactly what the user sees. Each role input is labelled as px and shows an approximate pt equivalent (`1px = 0.75pt`) for orientation. The final values are written to `result.json` as `typography.sizes: { "title", "subtitle", "annotation" }` in **px** — every canvas, no pt and no `sizes_pt` provenance. These confirmed values are Strategist input anchors: the completed page plan may add recurring roles, and downstream execution owns bounded per-occurrence treatment. Candidate `sizes` remain accepted for compatibility, but the fresh Stage-2 baseline is normalized through the same local ramp before first render. - **`delivery_purpose` compatibility key / Reading mode** (enumerable, PPT only) decides where meaning is carried, not merely how large type is: `text` makes pages self-contained with complete sentences, short prose, captions, tables, and necessary detail; `balanced` shares explanation between page and presenter; `presentation` uses one idea, concise claims, and visual evidence while speech / notes carry the detail. It therefore governs page grammar, granularity, density / rhythm, and note burden. Reading-mode cards intentionally show **no px value**; the typography section owns the separately visible body / role sizes and applies any local default. It is surfaced in Stage 2 beside the visual system, separate from communication intent. `recommend.delivery_purpose` pre-selects one; `result.json` retains the key, while `spec_lock.md` uses canonical `consumption_mode`. Non-PPT canvases omit it. - **Combined style preview** — a compact live "overall impression" strip sits just above the color section and is **sticky**: it pins under the topbar so it stays visible while the user scrolls through the color / icon / typography sections, keeping the picking controls and their combined effect on screen together. It applies the currently selected color palette **and** typography (heading sample in `primary` over `background`, body sample in `body_text`, an `accent` bar, a `secondary_bg` chip) and repaints on every color / HEX-override / font / `body_size` change. It does not replace the per-candidate swatches or font samples (those stay for picking); it is deliberately an abstract style chip, **not** a slide-layout preview — page layout preview remains the live-preview server's job (Step 6). No schema field; it derives entirely from the existing color + typography selections. -- **Generated-image direction** appears only for `image_usage: ai`: up to three preset cards plus one full-width AI custom proposal. Custom has no preset dropdown; selection makes it editable and submits `rendering: "custom"` + `behavior`. If that behavior uses catalog renderings, it visibly names their exact ids; Strategist retains that confirmed basis as optional `image_rendering_references` in `spec_lock.md`. A genuinely novel behavior produces no reference row. The live preview follows the selection. No image palette is written; deck colors remain authoritative, and legacy `image_strategy.palette` is ignored. -- **`design_directions`** is the canonical Stage-2 spectrum: ≥3 safe / shifted / bold bundles with localized copy, style, icons, conditional image strategy, complete language-aware typography, and HEX `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, `body_text`. Selection applies the bundle; component controls override it. `result.json` stores components, not a direction id. +- **Generated-image direction** appears only for `image_usage: ai`. One preset dropdown contains project recommendations when present plus the 20 system styles; Custom remains a separate card and is blank when AI was added manually. A preset submits its id; Custom submits `rendering: "custom"` + non-empty `behavior`; closing AI omits `image_strategy`. Catalog-based custom behavior names exact ids for optional `image_rendering_references`; a novel behavior has none. The left preview follows selection. No image palette is written; deck colors remain authoritative, and legacy `image_strategy.palette` is ignored. +- **`design_directions`** is the canonical Stage-2 spectrum: ≥3 meaningfully different safe / shifted / bold bundles with localized copy, style, icons, conditional image strategy, complete language-aware typography, and HEX `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, `body_text`. Selection applies the bundle; component controls override it. `result.json` stores components, not a direction id. - `recommend.generation_mode` and `refine_spec` mirror [`generate-pptx`](../../workflows/generate-pptx.md) Step 4. `split` / `true` are explicit opt-ins. Refinement adds no UI stage: after Gate 1 it stops before the lock for unrestricted chat revision until approval. - `content_divergence` is a **free-text** Stage-1 source-treatment field. Blank means a balanced default; facts stay sourced at every level. Strategist consumes it while authoring §IX and records it in `design_spec.md §I`; it is not written to `spec_lock.md`. Beautify sends `{ "value": "keep source wording and page structure verbatim", "locked": true }`, so the UI displays it read-only and the server restores it on every staged submit. Template-fill does not use this confirmation flow and does not surface it. - `lang` is the soft UI-language default (`zh` / `en` / `ja`); the persisted user choice wins. It never sets `primary_language`. @@ -284,7 +325,7 @@ The shape above is final. The proactive-execution values are independent flat bo - Bespoke mode / style prose lives only in the required behavior sibling; image custom prose lives in `image_strategy.behavior`. Canvas / icons retain free-text edge cases, color / typography retain `name: "custom"`, and image usage remains a source-id array plus `image_notes`. - `image_ai_path` and `image_strategy` appear only with `image_usage: ai` and remain confirmed downstream. The page is default; explicit/failure chat fallback keeps identical fields. `image_ai_path` selects the Step 5 path, and [`strategist-image.md`](../../references/strategist-image.md) §2 retains the selected rendering or custom behavior as the deck-level image identity anchor; individual prompts still adapt subject, composition, and atmosphere within it. -- After the user clicks the **final Confirm** (Stage 3, or single-pass), the page saves `result.json` and shuts the server down (auto-close). Stage-1 **Confirm contract & continue** and Stage-2 **Confirm solution & continue** keep the page open while it polls for the downstream stage file. The default flow is `--daemon` → Stage-1 chat handoff → `--wait-only --wait-stage stage1` → Stage-2 wait → final wait; the AI reads each result immediately. Chat fallback shows the same initially-unselected custom proposals. Either way, Step 4 ends with `--shutdown` so a never-confirmed page cannot hold port 5050 ahead of Step 6 live preview. +- After the user clicks the **final Confirm** (Stage 3, or single-pass), the page saves `result.json` and shuts the server down (auto-close). Stage-1 **Confirm contract & continue** and Stage-2 **Confirm solution & continue** keep the page open while it polls for the downstream stage file. The default flow is `--daemon` → Stage-1 chat handoff → `--wait-only --wait-stage stage1` → Stage-2 wait → final wait; the AI reads each result immediately. Chat fallback mirrors the same preset/custom choices. Either way, Step 4 ends with `--shutdown` so a never-confirmed page cannot retain its selected port ahead of Step 6 live preview. ## Scope 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 ae6020a8..9e3c6aaa 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 @@ -9,7 +9,11 @@ Image tools cover formula rendering, prompt-based AI generation, web image searc ## `latex_render.py` -Manifest-driven LaTeX formula renderer. Strategist writes `images/formula_manifest.json` after the Typography confirmation; this script renders only those declared formulas to transparent PNGs and writes dimensions back into the manifest. +Manifest-driven LaTeX formula renderer. Default Generate has Strategist write +`images/formula_manifest.json` after Typography confirmation; Quick Generate +has the current agent write the same resource manifest without confirmation. +This script renders only those declared formulas to transparent PNGs and writes +dimensions back into the manifest. ```bash python3 scripts/latex_render.py @@ -37,18 +41,21 @@ Manifest shape: } ``` -Output files land directly under `project/images/`. Formula filenames should use a shared `formula_` prefix, e.g. `formula_001.png`. The default provider chain is `codecogs,quicklatex,mathpad,wikimedia`; each provider is tried automatically until one succeeds, and the winning provider is recorded back into the manifest. `--providers` or manifest-level `providers` may override the order, but all four are available as no-key fallbacks. Formula PNGs are transparent by default. `background` is the temporary render matte and local background-removal reference; set `transparent: false` only when an opaque final formula asset is intentional. The script does not scan `spec_lock.md` or source documents for `$...$`; formula selection is a Strategist decision. +Output files land directly under `project/images/`. Formula filenames should use a shared `formula_` prefix, e.g. `formula_001.png`. The default provider chain is `codecogs,quicklatex,mathpad,wikimedia`; each provider is tried automatically until one succeeds, and the winning provider is recorded back into the manifest. `--providers` or manifest-level `providers` may override the order, but all four are available as no-key fallbacks. Formula PNGs are transparent by default. `background` is the temporary render matte and local background-removal reference; set `transparent: false` only when an opaque final formula asset is intentional. The script does not scan `spec_lock.md` or source documents for `$...$`; formula selection belongs to the active resource owner. ## `image_gen.py` Unified image generation entry point. -This script is the **Path A** API/proxy executor for generated images. In the -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. +This script is the **Path A** API/proxy executor for generated images. Default +Generate checks `design_spec.md §I / AI Image Acquisition Path` before manifest +mode: only `api` / `auto` permits Path A; a missing or unknown value fails +closed and returns to Step 4 recovery. Quick Generate has no Design Spec: use +the explicit active-context path when supplied, otherwise `auto` selects the +A → B → C chain defined in +[`image-generator.md`](../../references/image-generator.md) §7 without asking. +In either profile, `host-native` uses the host image tool directly and `manual` +uses the read-only Markdown sidecar. ```bash python3 scripts/image_gen.py "A modern futuristic workspace" @@ -158,16 +165,29 @@ MINIMAX_API_KEY=your-api-key ## `analyze_images.py` -Analyze images in a project directory before writing the design spec or composing slide layouts. +Analyze objective image-file facts in a project directory before writing the +design spec or authoring SVG. ```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. +The tool does not resolve a canvas or recommend a left/right, top/bottom, or +other slide layout. Its atomic CSV records EXIF-corrected native dimensions and +`AspectRatio`, the objective aspect-ratio category, optional source +`SourceDisplayRatio`, format, actual transparent-pixel presence, usage count, +and bitmap/vector capability facts. 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 an already-selected provided/web asset whose focal-safe crop, overlay contrast, or quiet region remains materially ambiguous may be inspected for that placement; this never reopens selection or provenance, never bulk-opens the image folder, and never restores routine readback of AI-generated images. +Use this as the default factual inventory; it does not perform semantic image +understanding or choose composition. 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 an already-selected +provided/web asset whose focal-safe crop, overlay contrast, or quiet region +remains materially ambiguous may be inspected for that placement; this never +reopens selection or provenance, never bulk-opens the image folder, and never +restores routine readback of AI-generated images. ## `image_search.py` 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 index 4f125065..aca5cc26 100644 --- 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 @@ -17,7 +17,8 @@ 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 pptx_to_svg.color_resolver import ColorPalette +from pptx_to_svg.fill_to_svg import _angle_to_unit_endpoints, resolve_fill from svg_quality_checker import SVGQualityChecker from svg_to_pptx.drawingml.converter import ( SvgNativeConversionError, @@ -46,7 +47,7 @@ valid = svg( - + @@ -61,6 +62,66 @@ radial_xml = build_gradient_fill(radial) assert '' in linear_xml assert '' in linear_xml assert '' in radial_xml +assert ( + '' + in radial_xml +) +radial.set("fx", "0.8") +radial.set("fy", "0.2") +focused_xml = build_gradient_fill(radial) +assert ( + '' + in focused_xml +) +native_gradient = ET.fromstring( + f'' + f'{focused_xml}' +)[0] +restored = resolve_fill(native_gradient, None) +assert 'cx="0.5" cy="0.5" r="0.5" fx="0.8" fy="0.2"' in restored.defs[0] +assert '' in ( + build_gradient_fill(ET.fromstring(restored.defs[0])) +) +radial.set("fx", "0") +radial.set("fy", "0") +outside_focus_errors = project_gradient_errors(valid) +assert any( + "must lie within the canonical circle" in error + for error in outside_focus_errors +), outside_focus_errors +try: + build_gradient_fill(radial) +except ValueError as exc: + assert "must lie within the canonical circle" in str(exc) +else: + raise AssertionError("outside radial focus reached DrawingML") +radial.set("fx", "0.8") +radial.set("fy", "0.2") + +diagnostics = [] +palette = ColorPalette( + None, + None, + strict=False, + diagnostic_sink=lambda code, message, fallback: diagnostics.append( + (code, message, fallback) + ), +) +outside_native_xml = focused_xml.replace( + 'l="80000" t="20000" r="20000" b="80000"', + 'l="0" t="0" r="100000" b="100000"', +) +outside_native_gradient = ET.fromstring( + f'' + f"{outside_native_xml}" +)[0] +normalized = resolve_fill(outside_native_gradient, palette) +assert " fx=" not in normalized.defs[0] +assert " fy=" not in normalized.defs[0] +assert any( + code == "path-gradient-focus-normalized" + for code, _message, _fallback in diagnostics +) with tempfile.TemporaryDirectory(prefix="ppt-master-gradient-smoke-") as tmp: source = Path(tmp) / "gradient.svg" @@ -202,9 +263,11 @@ print("Mask and gradient smoke: passed") PY ``` -The three invalid-gradient cases, all three direct mask forms, and a mask -hidden inside a `data-icon` asset must produce the named shared-validator -errors in both Checker and direct export. The legal cases must retain stop -alpha, promote the full-canvas gradient to one native `p:bg`, default an -unpositioned linear gradient to horizontal, and recover approximately 30 -degrees from the importer's out-of-unit-box endpoint form. +The three invalid-gradient cases, the outside-circle radial focus, all three +direct mask forms, and a mask hidden inside a `data-icon` asset must produce +the named shared-validator errors in both Checker and direct export. The legal +cases must retain stop alpha, round-trip an in-circle focus, center an imported +outside-circle focus with a diagnostic, promote the full-canvas gradient to +one native `p:bg`, default an unpositioned linear gradient to horizontal, and +recover approximately 30 degrees from the importer's out-of-unit-box endpoint +form. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-animations.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-animations.md index 73b758e0..8a396400 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-animations.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/pptx-animations.md @@ -25,17 +25,23 @@ package validation; they do not resolve or author animation effects. ## 2. Domain Model -One resolved animation-pane row contains these fields: +`groups.` accepts either one backward-compatible effect object or one +non-empty `effects[]` array; the forms are exclusive and every array row names +`effect`. Both expand into the same row model, so repeated shape targets are +valid. Legacy rows also accept `trigger`; omitted row settings inherit the +resolved slide animation. + +One resolved row contains these fields: | Field | Meaning | |---|---| | Target | Positive PowerPoint shape id written to `p:spTgt@spid` | | Effect | One canonical PowerPoint-authored preset class / id / subtype / behavior-tree signature | -| Trigger | `on-click`, `with-previous`, or `after-previous` | +| Trigger | Row-specific `on-click`, `with-previous`, or `after-previous`; omitted values inherit the resolved slide Start mode | | Trigger shape | Optional different top-level group; maps to PowerPoint `On Click of` | | Duration | Finite positive schedule duration; scalable native behavior trees preserve their internal timing ratios | -| Delay | Finite non-negative offset used by `after-previous` or as trigger-shape `TriggerDelayTime` | -| Order | Positive integer sidecar order; ties retain stable SVG order | +| Delay | Finite non-negative row offset; shape-trigger rows use it as `TriggerDelayTime` | +| Order | Positive integer sidecar order; ties retain stable SVG group order, then `effects[]` index | | Effect options | Effect-specific `direction`, `amount`, `color`, `font_name` (one installed PowerPoint face, required for Change Font; not a CSS list), `relative`, or `size` values from PowerPoint `EffectParameters` | | Timing options | Repeat count/span, auto-reverse, rewind, accelerate/decelerate, bounce-end ratio, and restart policy | | Completion | Optional dim/hide behavior and packaged `.m4a`/`.mp3`/`.wav` sound | @@ -44,15 +50,18 @@ Modes resolve before XML writing: | Mode | Resolution | |---|---| -| `auto` | Deterministic semantic mapping from the SVG group id | -| `mixed` | Deterministic cycle over canonical PowerPoint entrance presets | -| `random` | Stable seeded choice from the same canonical preset pool | +| `auto` | Generic entrance only: deterministic semantic mapping from the SVG group id | +| `mixed` | Generic entrance only: deterministic cycle over canonical PowerPoint entrance presets | +| `random` | Generic entrance only: stable seeded choice from the same canonical entrance pool | | `none` | No object-animation sequence | The same effective input produces the same `random` choices. When enabled, `--conversion-trace` records each resolved row and effect, so a generated deck can be audited without replaying the resolver. +`animation_config.py scaffold` is neutral: object defaults are `none`, and +empty `{}` group placeholders inherit no motion until populated. + --- ## 3. Canonical Registry and Compatibility Inputs @@ -128,7 +137,7 @@ for marker-free legacy SVGs. | Target state | Behavior | |---|---| -| Ordinary content group | Animatable | +| Ordinary content group | Animatable; a legacy block resolves one row and `effects[]` may resolve several rows against the same final shape | | Legacy chrome-like id | Skipped unless explicitly named in `animations.json` | | Explicit sidecar group override | May override only the legacy chrome-name heuristic | | `data-pptx-layer` or explicit static role/placeholder | Structural and never animatable | @@ -157,11 +166,17 @@ Trigger mapping: | `with-previous` | `withEffect` | | `after-previous` | `afterEffect` | -A group-level `trigger_shape` resolves to a different shape id and writes +A row-level `trigger_shape` resolves to a different shape id and writes PowerPoint's native `interactiveSeq` with `onClick` shape conditions. Its row -remains `clickEffect`; group `delay` becomes `TriggerDelayTime`. Ordinary rows +remains `clickEffect`; row `delay` becomes `TriggerDelayTime`. Ordinary rows remain in `mainSeq` and keep the slide Start mode. +Row `trigger` overrides slide Start in both forms. `trigger_shape` implies +`on-click` and conflicts with an explicit non-`on-click` Start. Repeated +`p:spTgt@spid` values are valid distinct Animation Pane rows. Ordinary rows +retain page-wide `order`; trigger-shape rows retain their relative order in +separate `interactiveSeq` branches and do not interleave with `mainSeq`. + The writer does not emit `p:bldP` for grouped content or pictures. Microsoft defines `p:bldP@spid` for a text-bearing `p:sp`; using it for `p:grpSp` or `p:pic` creates an invalid build reference. Package validation still accepts a @@ -198,7 +213,7 @@ project-level preflight; field-only validation remains filesystem-independent. Generated export reads every slide back before packaging and compares each requested row with the serialized result: -- row count and row order; +- row count and row order, including stable repeated-target rows; - trigger, optional trigger shape, and shape target; - resolved effect key, preset class, filter, `presetID`, and `presetSubtype`; - exact effect options, repeat/reverse/rewind/acceleration/bounce/restart 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 a3c3ab63..4bad07fd 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 @@ -22,6 +22,7 @@ python3 scripts/project_manager.py page-context-report ``` Notes: +- `init --quick-generate`: only `svg_output/`; no README - 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 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 6c826de8..29f57bee 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 @@ -153,6 +153,12 @@ source.write_text( fill="#2563EB" stroke="#0F172A" stroke-width="5" stroke-dasharray="10 4"/> + 01 + X + X @@ -174,17 +180,18 @@ source.write_text( ) operations = [ - ("union", "preset-source", "preset-cut"), - ("combine", "body", "cutout"), - ("fragment", "body", "cutout"), - ("intersect", "body", "cutout"), - ("subtract", "body", "cutout"), + ("union", "union", "preset-source", "preset-cut"), + ("combine", "combine", "body", "cutout"), + ("fragment", "fragment", "body", "cutout"), + ("intersect", "intersect", "body", "cutout"), + ("subtract", "subtract", "body", "cutout"), + ("text-subtract", "subtract", "body", "text-cutout"), ] expected_custom_shapes = 0 -for index, (operation, first, second) in enumerate(operations, start=1): +for index, (name, 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}", + "--source", first, "--source", second, "--id", f"result-{name}", ) paths = list( ET.fromstring( @@ -199,12 +206,12 @@ for index, (operation, first, second) in enumerate(operations, start=1): if operation == "fragment": assert len(paths) > 1 assert [path.get("id") for path in paths] == [ - f"result-fragment-{piece}" + f"result-{name}-{piece}" for piece in range(1, len(paths) + 1) ] else: assert len(paths) == 1 - assert paths[0].get("id") == f"result-{operation}" + assert paths[0].get("id") == f"result-{name}" 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) @@ -212,7 +219,7 @@ for index, (operation, first, second) in enumerate(operations, start=1): assert (paths[0].get("d") or "").count("M ") >= 2 expected_custom_shapes += len(paths) - (svg_output / f"{index:02d}_{operation}.svg").write_text( + (svg_output / f"{index:02d}_{name}.svg").write_text( '' '' @@ -237,6 +244,8 @@ rejections = [ ("union", "body", "imported", "PPTX import/round-trip metadata"), ("union", "body", "dashoffset", "stroke-dashoffset"), ("intersect", "body", "far", "produced no filled area"), + ("subtract", "body", "missing-font", "cannot resolve an installed font"), + ("subtract", "body", "nested-text", "child content is unsupported"), ] for operation, first, second, expected_error in rejections: rejected = subprocess.run( @@ -252,9 +261,13 @@ for operation, first, second, expected_error in rejections: assert rejected.returncode != 0 assert expected_error in rejected.stderr, rejected.stderr -run_tool("svg_quality_checker.py", svg_output, "--format", "ppt169") +run_tool( + "svg_quality_checker.py", project, + "--quick-generate", "--format", "ppt169", + "--stage", "final", "--json", +) pptx = project / "boolean-smoke.pptx" -run_tool("svg_to_pptx.py", project, "--quick-test", "-o", pptx) +run_tool("svg_to_pptx.py", project, "--quick-generate", "-o", pptx) with zipfile.ZipFile(pptx) as archive: slides = [ name @@ -265,7 +278,7 @@ with zipfile.ZipFile(pptx) as archive: archive.read(name).count(b"") for name in slides ) -assert len(slides) == 5, slides +assert len(slides) == len(operations), slides assert custom_shapes == expected_custom_shapes readback = project / "readback" @@ -278,7 +291,7 @@ readback_custom_shapes = sum( slide.read_text(encoding="utf-8").count('data-pptx-custgeom="') for slide in slides ) -assert len(slides) == 5, slides +assert len(slides) == len(operations), slides assert readback_custom_shapes == expected_custom_shapes print( f"Shape Boolean smoke: passed " @@ -287,10 +300,11 @@ print( PY ``` -The five inline negative cases must return nonzero and match their expected +The seven 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. +`boolean-smoke.pptx` path in PowerPoint: both shape-cut and text-cut Subtract +results must have real holes, and every Fragment sibling must remain separately +selectable. ## `compact_svg_coordinates.py` @@ -546,33 +560,45 @@ warning and uses `flat`; no SVG regeneration is required. A missing `spec_lock.m an explicit legacy/unknown mode, or a requested `structured` export without an explicit current structured contract remains blocking. -Disposable few-page converter/layout tests may use the explicit -[`quick-test`](../../workflows/profiles/quick-test.md) profile: +Explicit direct generation may use the +[`quick-generate`](../../workflows/profiles/quick-generate.md) profile after the +current agent has converted/read sources, researched identified factual gaps, +and prepared the required images, icons, formulas, and resource manifests as +needed. That profile skips Strategist, Confirm UI, `design_spec.md`, and +`spec_lock.md`; it does not skip the resources required by the authored pages. +After the complete SVG roster exists, run its lockless final checker, then +export: ```bash -python3 scripts/svg_to_pptx.py --quick-test +python3 scripts/svg_quality_checker.py \ + --quick-generate --stage final --json +python3 scripts/svg_to_pptx.py --quick-generate ``` -This test-only flag reads `svg_output/` directly, infers one consistent canvas, -uses flat converter-default package scaffolding, disables notes and motion, and -does not read or require `spec_lock.md`. It writes the PPTX only: no -`backup/`, conversion trace, or `validation/` report. ZIP integrity and Slide -count are checked in memory and reported through -`[QUICK-TEST] status=passed`. The flag rejects options that would add native -data objects, motion, narration, alternate SVG sources, or diagnostic sidecars. +This direct-export flag takes `svg_output/` as its authored page source, resolves +valid project-local resources referenced by those pages, infers one consistent +canvas, uses flat converter-default package scaffolding, and does not read or +require `spec_lock.md`. Notes, motion, narration, native objects, conversion +trace, and other ordinary exporter capabilities remain available; notes, +custom object animation, and narration start off in Quick and may be enabled +when needed. The exporter refuses a missing, blocking, non-final, or stale +Quick final report before PPTX creation. Default-path output retains the normal +postflight report and `backup/` snapshot; explicit `-o` retains the ordinary +no-backup behavior. Existing source, analysis, image/icon/formula, and +resource-manifest artifacts remain untouched. For generated-project narration, follow the [`generate-audio`](../../workflows/stages/generate-audio.md) stage. It owns voice selection, audio generation, and the narrated re-export workflow. Behavior: -- Default output (normal flow, no `-o`): +- Default output (either Generate profile, no `-o`): - `exports/_.pptx` — native editable pptx (canonical output) - `validation/_.report.json` — package postflight, quality-gate linkage, unresolved resource audit, and published part counts - - `backup//svg_output/` — copy of Executor SVG source, always written so the pptx can be rebuilt via `finalize_svg → svg_to_pptx` without re-running the LLM + - `backup//svg_output/` — copy of authored SVG source for re-export without re-running the LLM - `exports/` contains only final PPTX deliverables; machine-readable quality and postflight reports belong in `validation/`. -- Normal flow always runs `finalize_svg.py` before export. This directory is the self-contained SVG visual preview; it is not packaged as a second PPTX. Quick-test deliberately skips it. -- In normal flow, explicit `-o/--output` changes the native PPTX destination and skips `backup/`; its postflight report still uses the output stem under the project `validation/` directory. Quick-test writes no report. +- The default Generate flow always runs `finalize_svg.py` before export. This directory is the self-contained SVG visual preview; it is not packaged as a second PPTX. Quick-generate deliberately skips it. +- In both Generate profiles, explicit `-o/--output` changes the native PPTX destination and skips `backup/`; the postflight report still uses the output stem under the project `validation/` directory. - Postflight reruns ZIP integrity and published Slide count. Internal relationships, structured-package validation, transitions, and animations are enforced before the builder publishes the PPTX and are reported as `enforced-at-build`, not as repeated @@ -608,14 +634,14 @@ Behavior: - `[Content_Types].xml` is generated from the actual media extensions written into the PPTX. Unknown media extensions fail unless Python's `mimetypes` can identify them. - Native export writes to a temporary file first and publishes the requested PPTX only after conversion succeeds. A failed conversion does not replace the main output file. - `--conversion-trace` without a path writes `validation/.trace.json`. `--conversion-trace ` respects the explicit destination; relative paths are resolved from the project root, so `exports/.trace.json` remains available when intentionally requested. -- After 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. +- After publication, native export writes `validation/.report.json`. The report distinguishes authored Slides from internal Layout definitions, reruns ZIP integrity and published Slide-count checks, records slide/layout/master/notes part counts, labels relationship/structured/transition/animation validation as enforced at build time, links the final SVG quality report only when its SHA-256 source fingerprint matches the exact export inputs, and surfaces stale/unverified gates, unresolved template tokens, generic-only font stacks, and external image references. A matching final quality report with introduced warnings yields `passed-with-warnings` and a `quality_introduced_warnings=` receipt instead of a clean `passed` claim. - 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. - 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 +- The default Generate flow embeds speaker notes automatically unless `--no-notes` is used; Quick Generate defaults them off and enables them with `--with-notes` - Recorded narration is opt-in: - `notes_to_audio.py` uses `edge-tts` by default, or a configured cloud TTS provider (`elevenlabs`, `minimax`, `qwen`, `cosyvoice`), and generates one audio file per slide into `audio/` - Narration text is read strictly from the matching `notes/*.md` file; the script only skips Markdown heading lines (`# ...`) and does not summarize, rewrite, or filter delivery notes @@ -631,26 +657,27 @@ Behavior: - Long-audio import and automatic long-audio splitting are not supported; keep narration assets page-level - Voice choices can be listed with `python3 scripts/notes_to_audio.py --list-common-voices`, `python3 scripts/notes_to_audio.py --list-voices --locale zh-CN`, or provider-specific `--provider --list-voices` - Page transitions are controlled by `-t/--transition`; per-element object animations are controlled by `-a/--animation` -- Per-element animation applies to ordinary top-level SVG `` groups in z-order; use one group per logical Slide-local content unit rather than targeting a group count. Master/Layout atoms and slot groups are structural and excluded; exact id tokens remain a fallback only when explicit structural roles are absent +- Per-element animation applies to ordinary top-level SVG `` groups; each group is a PowerPoint shape-target anchor, not necessarily one Animation Pane row. Use one group per logical Slide-local content unit rather than targeting a group count. Master/Layout atoms and slot groups are structural and excluded; exact id tokens remain a fallback only when explicit structural roles are absent - An explicit `animations.json` group entry may override the marker-free legacy chrome-name heuristic. It cannot override `data-pptx-layer` or an explicit static role/placeholder marker -- Start mode is set by `--animation-trigger`, mirroring PowerPoint's Start dropdown: `after-previous` (default, cascade with `--animation-stagger` spacing on slide entry), `on-click` (presenter-paced), `with-previous` (all together on slide entry) -- `on-click` is for live presentations only; recorded narration rejects it because the tool does not generate object-level click timings +- Start mode is set globally by `--animation-trigger`, mirroring PowerPoint's Start dropdown: `after-previous` (default, cascade with `--animation-stagger` spacing on slide entry), `on-click` (presenter-paced), or `with-previous` (all together on slide entry). A sidecar row may override it with `trigger`; the slide value is only the inherited Start mode +- `on-click` is for live presentations only; recorded narration rejects every row that resolves to it, including a row with `trigger_shape`, because the tool does not generate object-level click timings - Flat SVG roots without top-level groups fall back to at most 8 visible primitives; beyond that, animation is skipped on the slide - Per-element animation defaults to `none`. `auto` is opt-in (`-a auto`) and maps - effects from the group's SVG id: information-dense elements get a stable - effect (chart→wipe, card-/step-/pillar-→fly, title/takeaway→fade); image-like - ids (hero/figure-/image/img-/kpi) cycle through a richer pool - (zoom/dissolve/circle/box/diamond/wheel), while unmatched ids cycle through - fade/wipe/fly/zoom. -- `mixed` (legacy) is deterministic: the first animated group on each slide uses `fade`, then later groups cycle through a larger 16-effect pool across the whole deck; `random` uses a stable seed from the effective deck input, and `--conversion-trace` records each resolved effect when enabled -- `--animation-duration` controls the per-element schedule length (default + generic entrance effects from the group's SVG id: information-dense elements + get a stable entrance (chart→wipe, card-/step-/pillar-→fly, + title/takeaway→fade); image-like and unmatched ids rotate through bounded + entrance pools. +- `mixed` (legacy) deterministically rotates through the canonical entrance pool; `random` selects from the same entrance pool with a stable seed from the effective deck input. `auto`, `mixed`, and `random` never choose emphasis, motion-path, or exit effects; select an explicit canonical `entrance_*`, `emphasis_*`, `path_*`, or `exit_*` key for those authored duties. `--conversion-trace` records each resolved effect when enabled +- `--animation-duration` controls the inherited per-row schedule length (default `0.4`); scalable native effects preserve internal timing ratios, while instantaneous presets keep their authored duration. `--animation-stagger` - adds gap between elements in `after-previous` mode (default `0.5`) -- Optional object-level overrides live in `/animations.json` or a path passed via `--animation-config`; build and validate them with `animation_config.py scaffold|validate` + supplies the default gap between successive non-trigger-shape rows in + `after-previous` mode (default `0.5`) +- Optional object-level overrides live in `/animations.json` or a path passed via `--animation-config`; build and validate them with `animation_config.py scaffold|validate`. The scaffold is neutral (`defaults.animation.effect: none`, untouched groups `{}`). A populated group uses either the fully compatible legacy single-effect fields or a non-empty `effects[]`, never both; every `effects[]` row names an explicit effect +- One `effects[]` row becomes one Animation Pane record on the group's shape target. Each row may independently set sequence `order`, `delay`, `duration`, `trigger`, and `trigger_shape`; ordinary rows use page-wide order, while `trigger_shape` rows keep relative order in separate interactive sequences and imply `on-click` - Animation configuration is strict: unknown effects/modes/triggers, invalid finite/range/order values, missing slides/groups, and structural-layer targets fail export without fallback or silent omission -- Generated export reads every slide back and verifies animation row order, trigger, shape target, resolved effect tuple, duration, and offset. Package validation then checks timing placement, `p:cTn` ids, and `p:spTgt` references before publication -- The animation writer does not emit `p:bldP` for groups or pictures. Direct-PPTX routes preserve source object animation and perform structural package validation only; they do not author effects +- Generated export reads every slide back and verifies animation row order, including repeated rows on one shape target, trigger, shape target, resolved effect tuple and native behavior signature, duration, and offset. Package validation then checks timing placement, `p:cTn` ids, and `p:spTgt` references before publication +- The animation writer does not emit paragraph/text-range builds (`p:bldP`), custom freeform motion paths, native Chart/SmartArt build sequences, or media playback commands for grouped SVG content. Direct-PPTX routes preserve source object animation and perform structural package validation only; they do not author effects - The full registry, OOXML rules, and compatibility boundary are documented in [`pptx-animations.md`](./pptx-animations.md) Dependency: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/icon_sync.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/icon_sync.py index 25d572f0..4b97ad0b 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/icon_sync.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/icon_sync.py @@ -6,8 +6,8 @@ Copy chosen library icons into `/icons//` when selected. Missing names exit non-zero before export. Known basenames need no separate existence check; search the chosen library only for unresolved concepts. -Project-local custom icons count as satisfied. In one Strategist selection -batch, `simple-icons` may accompany one of the four stylistic libraries for +Project-local custom icons count as satisfied. In one resource-selection batch, +`simple-icons` may accompany one of the four stylistic libraries for real brand marks. Usage: 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 50bba666..56355666 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 @@ -19,8 +19,10 @@ if __name__ == "__main__": print("This is an internal helper module used by image_gen.py backends.") raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1) +import base64 import io import os +import re import time import requests @@ -97,6 +99,62 @@ def detect_image_extension(image_bytes: bytes, content_type: str = None) -> str return None +DATA_URI_HEADER = re.compile( + r"data:(?Pimage/[A-Za-z0-9.+-]+)(?P;[^,]*)?,", + re.IGNORECASE, +) + + +def decode_data_uri(value: str) -> tuple[bytes, str | None]: + """ + Decode a base64 image data URI into raw bytes plus its declared content type. + + The declared type is returned so callers can hand it to `save_image_bytes` instead of + assuming the payload matches the output extension. + """ + header = DATA_URI_HEADER.match(value.strip()) + if not header: + raise ValueError("Expected a base64 image data URI (data:image/...;base64,...).") + + params = (header.group("params") or "").lower() + if "base64" not in params: + raise ValueError("Only base64-encoded image data URIs are supported.") + + payload = "".join(value.strip()[header.end():].split()) + payload += "=" * (-len(payload) % 4) + return base64.urlsafe_b64decode(payload), header.group("mime").lower() + + +def find_data_uri(content) -> str | None: + """ + Return the first base64 image data URI inside a chat completion `content` value. + + OpenAI-compatible gateways differ here: some return a dedicated image field, others + inline the image in the message text (often as `![image](data:image/png;base64,...)`) + or in a content-part list. + """ + if isinstance(content, str): + header = DATA_URI_HEADER.search(content) + if not header: + return None + payload = re.match(r"[A-Za-z0-9+/=_-]*", content[header.end():]).group(0) + return content[header.start():header.end()] + payload + + if isinstance(content, list): + for part in content: + if isinstance(part, dict): + nested = part.get("image_url") + if isinstance(nested, dict): + nested = nested.get("url") + found = find_data_uri(nested if nested else part.get("text")) + else: + found = find_data_uri(part) + if found: + return found + + return None + + def _normalize_extension(ext: str) -> str: """Normalize equivalent image extensions to a canonical form.""" ext = ext.lower() diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_backends/backend_openrouter.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_backends/backend_openrouter.py index ce685f95..c0851b45 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_backends/backend_openrouter.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/image_backends/backend_openrouter.py @@ -24,7 +24,6 @@ if __name__ == "__main__": print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend openrouter") raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1) -import base64 import os import time import threading @@ -32,6 +31,8 @@ import requests from image_backends.backend_common import ( MAX_RETRIES, + decode_data_uri, + find_data_uri, is_rate_limit_error, normalize_image_size, resolve_output_path, @@ -63,6 +64,24 @@ def _resolve_url(base_url: str) -> str: """Resolve the OpenRouter generation endpoint.""" return base_url.rstrip("/") + "/chat/completions" +def _message_image_uri(message: dict) -> str | None: + """ + Locate the generated image in a chat completion message. + + OpenRouter returns it in a dedicated `images` array; other OpenAI-compatible endpoints + reachable through OPENROUTER_BASE_URL inline it in the message content instead. + """ + images = message.get("images") + if images: + url = images[0].get("image_url") + if isinstance(url, dict): + url = url.get("url") + if url: + return url + + return find_data_uri(message.get("content")) + + def _generate_image(api_key: str, prompt: str, aspect_ratio: str = "1:1", image_size: str = "1K", output_dir: str = None, filename: str = None, @@ -127,11 +146,11 @@ def _generate_image(api_key: str, prompt: str, if result.get("choices"): message = result["choices"][0]["message"] - if message.get("images"): + image_uri = _message_image_uri(message) + if image_uri: + image_data, content_type = decode_data_uri(image_uri) path = resolve_output_path(prompt, output_dir, filename, ".png") - # strip "data:image/png;base64," - image_data = base64.urlsafe_b64decode(message["images"][0]["image_url"]["url"][22:]) - return save_image_bytes(image_data, path) + return save_image_bytes(image_data, path, content_type) raise RuntimeError("No image was generated. The server may have refused the request.") diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/latex_render.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/latex_render.py index 150d4974..1ae4f9ac 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/latex_render.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/latex_render.py @@ -2,7 +2,7 @@ """ PPT Master - LaTeX Formula Renderer -Render Strategist-declared LaTeX formulas to transparent PNG assets. +Render project-declared LaTeX formulas to transparent PNG assets. The script reads an explicit manifest; it never scans spec_lock.md or source content for dollar-delimited math. @@ -553,7 +553,7 @@ def render_manifest( def build_parser() -> argparse.ArgumentParser: """Build the CLI parser.""" parser = argparse.ArgumentParser( - description="Render Strategist-declared LaTeX formulas to PNG assets.", + description="Render project-declared LaTeX formulas to PNG assets.", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("project_path", type=Path, help="Project directory.") diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py index 175e214d..9313b556 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/narration_sync.py @@ -55,9 +55,11 @@ from pptx_animations import ( # noqa: E402 ANIMATION_TIMING_OPTION_FIELDS, animation_seconds_to_milliseconds, normalize_animation_effect, + normalize_animation_trigger, ) from pptx_transitions import read_slide_transition_xml # noqa: E402 from svg_to_pptx.animation_config import ( # noqa: E402 + animation_group_effect_entries, scan_project_targets, scan_svg_targets, validate_animation_config_errors, @@ -112,6 +114,7 @@ class AnimationGroupState: """One effective canonical animation row before narration timing.""" group_id: str + effect_index: int | None order: int source_index: int duration_ms: int @@ -531,12 +534,12 @@ def _effective_slide_animation( "canonical animation stagger", allow_zero=True, ) - trigger = slide_animation.get( - "trigger", - default_animation.get("trigger", "after-previous"), + trigger = normalize_animation_trigger( + slide_animation.get( + "trigger", + default_animation.get("trigger", "after-previous"), + ) ) - if not isinstance(trigger, str): - raise ValueError(f"Canonical animation trigger must be a string: {trigger!r}") timing_options = { field: ( slide_animation[field] @@ -582,16 +585,81 @@ def _animation_playback_duration_ms( return max(1, round(one_play * float(repeat_count))) +def _active_group_effect_entries( + slide_name: str, + group_id: str, + group_cfg: dict[str, Any], + slide_effect: str | None, +) -> tuple[tuple[int | None, str, dict[str, Any]], ...]: + """Return active legacy or multi-effect rows with stable write locations.""" + group_path = ( + f'slides[{json.dumps(slide_name, ensure_ascii=False)}]' + f'.groups[{json.dumps(group_id, ensure_ascii=False)}]' + ) + effect_entries = animation_group_effect_entries( + group_cfg, + path=group_path, + ) + is_multi_effect = 'effects' in group_cfg + active: list[tuple[int | None, str, dict[str, Any]]] = [] + for index, (effect_path, effect_cfg) in enumerate(effect_entries): + effect = normalize_animation_effect( + effect_cfg.get("effect", slide_effect) + ) + if effect is None: + continue + active.append(( + index if is_multi_effect else None, + effect_path, + effect_cfg, + )) + return tuple(active) + + def _group_is_animated( + slide_name: str, + group_id: str, group_cfg: dict[str, Any], slide_effect: str | None, ) -> bool: - if "effect" not in group_cfg: - return slide_effect is not None - return normalize_animation_effect(group_cfg["effect"]) is not None + return bool( + _active_group_effect_entries( + slide_name, + group_id, + group_cfg, + slide_effect, + ) + ) + + +def _active_group_order_requires_svg( + slide_name: str, + groups_cfg: dict[str, Any], + group_ids: list[str], + slide_effect: str | None, +) -> bool: + """Return whether SVG group order is needed to break sequence ambiguity.""" + if len(group_ids) <= 1: + return False + order_owners: dict[int, str] = {} + for group_id in group_ids: + for _effect_index, _effect_path, effect_cfg in _active_group_effect_entries( + slide_name, + group_id, + groups_cfg[group_id], + slide_effect, + ): + order = effect_cfg.get("order") + if order is None: + return True + previous_group = order_owners.setdefault(order, group_id) + if previous_group != group_id: + return True + return False def _needs_svg_group_resolution( + slide_name: str, settings: SlideAnimationSettings, groups_cfg: dict[str, Any], plan_entries: list[TimingPlanEntry] | None, @@ -601,14 +669,21 @@ def _needs_svg_group_resolution( group_id for group_id, group_cfg in groups_cfg.items() if isinstance(group_cfg, dict) - and _group_is_animated(group_cfg, settings.effect) + and _group_is_animated( + slide_name, + group_id, + group_cfg, + settings.effect, + ) ] if plan_entries is None: if settings.effect is not None: return True - return ( - len(active_explicit) > 1 - and any("order" not in groups_cfg[group_id] for group_id in active_explicit) + return _active_group_order_requires_svg( + slide_name, + groups_cfg, + active_explicit, + settings.effect, ) candidate_ids = list( @@ -624,11 +699,19 @@ def _needs_svg_group_resolution( for group_id in candidate_ids if group_id in groups_cfg and isinstance(groups_cfg[group_id], dict) - and _group_is_animated(groups_cfg[group_id], settings.effect) + and _group_is_animated( + slide_name, + group_id, + groups_cfg[group_id], + settings.effect, + ) ] - if len(active_candidates) <= 1: - return False - return any("order" not in groups_cfg[group_id] for group_id in active_candidates) + return _active_group_order_requires_svg( + slide_name, + groups_cfg, + active_candidates, + settings.effect, + ) def _resolve_animation_groups( @@ -652,19 +735,12 @@ def _resolve_animation_groups( "must be an object" ) groups_cfg[group_id] = group_cfg - interactive_ids = sorted( - group_id - for group_id, group_cfg in groups_cfg.items() - if group_cfg.get("trigger_shape") is not None - and _group_is_animated(group_cfg, settings.effect) + use_svg = _needs_svg_group_resolution( + slide_name, + settings, + groups_cfg, + plan_entries, ) - if interactive_ids: - raise ValueError( - f'Recorded narration cannot synchronize trigger-shape animations ' - f'on slide "{slide_name}": {", ".join(interactive_ids)}' - ) - - use_svg = _needs_svg_group_resolution(settings, groups_cfg, plan_entries) candidate_ids: list[str] if use_svg: svg_path = project_path / "svg_output" / f"{slide_name}.svg" @@ -706,7 +782,12 @@ def _resolve_animation_groups( if targets_by_id[group_id].structurally_static and ( group_id not in groups_cfg - or _group_is_animated(groups_cfg[group_id], settings.effect) + or _group_is_animated( + slide_name, + group_id, + groups_cfg[group_id], + settings.effect, + ) ) ) if structural_ids: @@ -722,7 +803,12 @@ def _resolve_animation_groups( group_cfg = groups_cfg.get(target.group_id, {}) explicitly_animated = ( target.group_id in groups_cfg - and _group_is_animated(group_cfg, settings.effect) + and _group_is_animated( + slide_name, + target.group_id, + group_cfg, + settings.effect, + ) ) if target.chrome and not explicitly_animated: continue @@ -739,53 +825,105 @@ def _resolve_animation_groups( else: candidate_ids = list(groups_cfg) - preliminaries: list[tuple[int, int, str, dict[str, Any]]] = [] + preliminaries: list[ + tuple[ + int, + int, + int, + str, + int | None, + str, + dict[str, Any], + str, + ] + ] = [] for source_index, group_id in enumerate(candidate_ids): group_cfg = groups_cfg.get(group_id, {}) - if not _group_is_animated(group_cfg, settings.effect): - continue - order = group_cfg.get("order", source_index + 1) - if isinstance(order, bool) or not isinstance(order, int) or order <= 0: - raise ValueError( - f'Canonical animation order for "{slide_name}/{group_id}" ' - f"must be a positive integer: {order!r}" + effect_entries = _active_group_effect_entries( + slide_name, + group_id, + group_cfg, + settings.effect, + ) + for effect_position, ( + effect_index, + effect_path, + effect_cfg, + ) in enumerate(effect_entries): + order = effect_cfg.get("order", source_index + 1) + if isinstance(order, bool) or not isinstance(order, int) or order <= 0: + raise ValueError( + f'Canonical animation order for "{effect_path}" ' + f"must be a positive integer: {order!r}" + ) + if effect_cfg.get("trigger_shape") is not None: + raise ValueError( + f'Recorded narration cannot synchronize trigger-shape ' + f'animation "{effect_path}" on slide "{slide_name}"' + ) + effect_trigger = normalize_animation_trigger( + effect_cfg.get("trigger", settings.trigger) ) - preliminaries.append((order, source_index, group_id, group_cfg)) - preliminaries.sort(key=lambda item: (item[0], item[1])) + if effect_trigger == "on-click": + raise ValueError( + f'Recorded narration cannot synchronize on-click animation ' + f'"{effect_path}" on slide "{slide_name}"' + ) + preliminaries.append(( + order, + source_index, + effect_position, + group_id, + effect_index, + effect_path, + effect_cfg, + effect_trigger, + )) + preliminaries.sort(key=lambda item: (item[0], item[1], item[2])) states: list[AnimationGroupState] = [] - for sequence_index, (order, source_index, group_id, group_cfg) in enumerate( - preliminaries - ): + for sequence_index, ( + order, + source_index, + _effect_position, + group_id, + effect_index, + effect_path, + effect_cfg, + effect_trigger, + ) in enumerate(preliminaries): duration_ms = animation_seconds_to_milliseconds( - group_cfg.get("duration", settings.duration_ms / 1000), - f'canonical animation duration for "{slide_name}/{group_id}"', + effect_cfg.get("duration", settings.duration_ms / 1000), + f'canonical animation duration for "{effect_path}"', allow_zero=False, ) timing_options = dict(settings.timing_options) timing_options.update( { - field: group_cfg[field] + field: effect_cfg[field] for field in ANIMATION_TIMING_OPTION_FIELDS - if field in group_cfg + if field in effect_cfg } ) playback_duration_ms = _animation_playback_duration_ms( duration_ms, timing_options, - label=f'canonical animation for "{slide_name}/{group_id}"', + label=f'canonical animation for "{effect_path}"', + ) + default_delay = ( + settings.stagger_ms / 1000 + if effect_trigger == "after-previous" and sequence_index > 0 + else 0 ) original_delay_ms = animation_seconds_to_milliseconds( - group_cfg.get( - "delay", - 0 if sequence_index == 0 else settings.stagger_ms / 1000, - ), - f'canonical animation delay for "{slide_name}/{group_id}"', + effect_cfg.get("delay", default_delay), + f'canonical animation delay for "{effect_path}"', allow_zero=True, ) states.append( AnimationGroupState( group_id=group_id, + effect_index=effect_index, order=order, source_index=source_index, duration_ms=playback_duration_ms, @@ -922,7 +1060,11 @@ def rebuild_animations( if used_svg: svg_fallback_slide_count += 1 if timing_plan is None and states: - positional_slides.append((slide_name, len(states), len(cues))) + positional_slides.append(( + slide_name, + len({state.group_id for state in states}), + len(cues), + )) state_ids = {state.group_id for state in states} cue_by_group: dict[str, int | None] = {} @@ -941,9 +1083,12 @@ def rebuild_animations( ) cue_by_group[entry.group_id] = entry.cue_number else: + ordered_group_ids = list( + dict.fromkeys(state.group_id for state in states) + ) cue_by_group = { - state.group_id: index + 1 if index < len(cues) else None - for index, state in enumerate(states) + group_id: index + 1 if index < len(cues) else None + for index, group_id in enumerate(ordered_group_ids) } animation_value = derived_slide.setdefault("animation", {}) @@ -960,10 +1105,18 @@ def rebuild_animations( previous_end_ms = 0 referenced_cues: set[int] = set() + seen_groups: set[str] = set() for state in states: - cue_number = cue_by_group.get(state.group_id) + first_group_effect = state.group_id not in seen_groups + seen_groups.add(state.group_id) + cue_number = ( + cue_by_group.get(state.group_id) + if first_group_effect + else None + ) if cue_number is None: - fallback_count += 1 + if first_group_effect: + fallback_count += 1 delay_ms = state.original_delay_ms actual_start_ms = previous_end_ms + delay_ms else: @@ -987,8 +1140,21 @@ def rebuild_animations( f'Derived animation group "{slide_name}/{state.group_id}" ' "must be an object" ) - group_value["order"] = state.order - group_value["delay"] = _seconds_from_ms(delay_ms) + if state.effect_index is None: + effect_value = group_value + else: + group_path = ( + f'slides[{json.dumps(slide_name, ensure_ascii=False)}]' + f'.groups[{json.dumps(state.group_id, ensure_ascii=False)}]' + ) + derived_effect_entries = animation_group_effect_entries( + group_value, + path=group_path, + ) + effect_value = derived_effect_entries[state.effect_index][1] + effect_value["order"] = state.order + effect_value["delay"] = _seconds_from_ms(delay_ms) + effect_value["trigger"] = "after-previous" previous_end_ms = actual_start_ms + state.duration_ms ignored_cue_count += len(cues) - len(referenced_cues) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/notes_to_audio.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/notes_to_audio.py index 790dc2b3..8e2324b0 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/notes_to_audio.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/notes_to_audio.py @@ -35,6 +35,7 @@ from pathlib import Path from console_encoding import configure_utf8_stdio from config import load_prefixed_env_file +from slide_roster import discover_slide_svgs from tts_backends import ( backend_cosyvoice, backend_edge, @@ -132,7 +133,7 @@ def _prepare_audio_jobs( def _expected_note_roster(project: Path) -> list[NoteRosterEntry]: """Resolve the owning route's complete per-slide notes roster.""" notes_dir = project / "notes" - svg_files = sorted((project / "svg_output").glob("*.svg")) + svg_files = discover_slide_svgs(project / "svg_output") if svg_files: aliases: dict[int, list[Path]] = {} for path in sorted(notes_dir.glob("*.md")): diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/page_context.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/page_context.py index 3d0d7c81..55b2689f 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/page_context.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/page_context.py @@ -1,917 +1,50 @@ #!/usr/bin/env python3 """ -PPT Master - Page Context Projection +PPT Master - Page Context Compatibility Module -Build deterministic per-page execution views and optional token telemetry. +Re-export the project-management page-context API from its domain package. +New internal imports should use ``project_management.page_context``. Usage: - Imported by project_manager.py. + Import the public projection helpers from this module. Examples: - build_page_context(Path("projects/demo"), "P07") + from page_context import build_page_context Dependencies: - None for projection; tiktoken is optional for exact usage counts. + Same as project_management.page_context. """ -from __future__ import annotations - -import hashlib -import json -import math -import re -import statistics -import xml.etree.ElementTree as ET -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, Iterable - -from project_specs import ( - default_spec_lock_forbidden, - parse_markdown_artifact, - parse_spec_lock_artifact, - validate_project_artifacts, -) -from svg_to_pptx.pptx_package.template_structure import ( - PptxStructureLock, - TemplateStructureError, - load_pptx_structure_lock, +from project_management.page_context import ( + LOCK_PROJECTION_TOKEN_TARGET, + PAGE_CONTEXT_REPORT_SCHEMA, + PAGE_CONTEXT_SCHEMA, + PAGE_CONTEXT_TOKEN_TARGET, + PAGE_CONTEXT_USAGE_SCHEMA, + TOKEN_ENCODING, + PageContextError, + PageContextResult, + PageRead, + build_page_context, + normalize_page_key, + page_context_usage_report, + record_page_context_usage, + render_page_context, ) - -PAGE_CONTEXT_SCHEMA = "ppt-master.page-context.v2" -PAGE_CONTEXT_USAGE_SCHEMA = "ppt-master.page-context-usage.v2" -PAGE_CONTEXT_REPORT_SCHEMA = "ppt-master.page-context-usage-report.v2" -TOKEN_ENCODING = "o200k_base" -PAGE_CONTEXT_TOKEN_TARGET = 2000 -LOCK_PROJECTION_TOKEN_TARGET = 1000 - -_SKILL_DIR = Path(__file__).resolve().parent.parent -_CHARTS_DIR = _SKILL_DIR / "templates" / "charts" - -_PAGE_RE = re.compile(r"^(?:P)?([0-9]+)$", re.IGNORECASE) -_SLIDE_HEADING_RE = re.compile( - r"^#{3,6}[ \t]+Slide[ \t]+0*([0-9]+)(?:[ \t]*(?:[-:–—]).*)?$", - re.IGNORECASE | re.MULTILINE, -) -_BLOCK_BOUNDARY_RE = re.compile(r"^#{2,6}[ \t]+", re.MULTILINE) -_PART_HEADING_RE = re.compile(r"^###[ \t]+(?!#)(.+?)[ \t]*$", re.MULTILINE) -_PAGE_TOKEN_RE = re.compile( - r"(? tuple[str, int]: - """Normalize a positive page identifier to the schema's P form.""" - match = _PAGE_RE.fullmatch(raw_page.strip()) - if match is None or int(match.group(1)) <= 0: - raise PageContextError("page must be a positive P identifier") - number = int(match.group(1)) - return f"P{number:02d}", number - - -def _section_index( - sections: Iterable[dict[str, object]], -) -> dict[str, dict[str, object]]: - return { - str(section["heading"]).strip().casefold(): section - for section in sections - } - - -def _section_fields( - sections: dict[str, dict[str, object]], - heading: str, -) -> dict[str, str]: - section = sections.get(heading.casefold()) - if section is None: - return {} - fields = section.get("fields", {}) - if not isinstance(fields, dict): - return {} - return {str(key): str(value) for key, value in fields.items()} - - -def _forbidden_items( - sections: dict[str, dict[str, object]], -) -> list[str]: - section = sections.get("forbidden") - if section is None: - return [] - items: list[str] = [] - default_items = default_spec_lock_forbidden() - for raw_line in str(section.get("body", "")).splitlines(): - line = raw_line.strip() - if not line: - continue - item = re.sub(r"^-[ \t]+", "", line) - if item not in default_items: - items.append(item) - return items - - -def _outline_section( - sections: Iterable[dict[str, object]], -) -> dict[str, object] | None: - for section in sections: - heading = str(section.get("heading", "")).strip().casefold() - if heading == "content outline" or heading.endswith(". content outline"): - return section - return None - - -def _page_image_filenames( - design_sections: Iterable[dict[str, object]], - page_number: int, -) -> tuple[set[str], set[str]]: - """Read explicit P usage from the canonical image-resource table.""" - section = next( - ( - item - for item in design_sections - if ( - (heading := str(item.get("heading", "")).strip().casefold()) - == "image resource list" - or heading.startswith("viii. image resource list") - ) - ), - None, - ) - if section is None: - return set(), set() - table_rows = [ - [ - cell.strip().replace(r"\|", "|") - for cell in re.split(r"(? str: - return Path(value.split("|", 1)[0].strip()).name - - -def _outline_image_assignments( - design_sections: Iterable[dict[str, object]], - locked_images: dict[str, str], -) -> set[str]: - outline = _outline_section(design_sections) - if outline is None: - return set() - body = str(outline.get("body", "")) - return { - _locked_image_basename(value) - for key, value in locked_images.items() - if any( - _contains_token(body, token) - for token in ( - key, - value.split("|", 1)[0].strip(), - _locked_image_basename(value), - ) - ) - } - - -def _slide_block( - design_sections: Iterable[dict[str, object]], - page_number: int, -) -> tuple[str | None, str]: - outline = _outline_section(design_sections) - if outline is None: - raise PageContextError("design_spec.md has no Content Outline section") - body = str(outline.get("body", "")) - matches = [ - match - for match in _SLIDE_HEADING_RE.finditer(body) - if int(match.group(1)) == page_number - ] - if not matches: - raise PageContextError( - f"design_spec.md Content Outline has no Slide {page_number:02d} block" - ) - if len(matches) > 1: - raise PageContextError( - f"design_spec.md Content Outline repeats Slide {page_number:02d}" - ) - match = matches[0] - next_boundary = _BLOCK_BOUNDARY_RE.search(body, match.end()) - block_end = next_boundary.start() if next_boundary else len(body) - block = body[match.start():block_end].strip() - part_matches = list(_PART_HEADING_RE.finditer(body, 0, match.start())) - part = part_matches[-1].group(1).strip() if part_matches else None - return part, block - - -def _relative_project_path(project_path: Path, path: Path) -> str: - try: - return path.resolve().relative_to(project_path).as_posix() - except ValueError as exc: - raise PageContextError(f"path escapes project: {path}") from exc - - -def _prototype_image_refs(svg_path: Path) -> list[str]: - try: - root = ET.parse(svg_path).getroot() - except (OSError, ET.ParseError) as exc: - raise PageContextError(f"cannot read prototype SVG {svg_path}: {exc}") from exc - refs: set[str] = set() - for element in root.iter(): - if element.tag.rsplit("}", 1)[-1] != "image": - continue - for name, value in element.attrib.items(): - if name.rsplit("}", 1)[-1] != "href": - continue - normalized = value.strip() - if normalized and not normalized.startswith(("data:", "#")): - refs.add(normalized) - return sorted(refs) - - -def _contains_token(text: str, token: str) -> bool: - if not token: - return False - if re.fullmatch(r"[A-Za-z0-9_]+", token): - return re.search( - rf"(? tuple[str, dict[str, str]]: - if not locked_images: - return "none", {} - ref_basenames = {Path(ref).name for ref in prototype_refs} - selected: dict[str, str] = {} - unresolved: dict[str, str] = {} - for key, value in locked_images.items(): - basename = _locked_image_basename(value) - if ( - _contains_token(brief, key) - or _contains_token(brief, value) - or _contains_token(brief, basename) - or basename in ref_basenames - or basename in assigned_filenames - ): - selected[key] = value - elif basename not in resolved_filenames: - unresolved[key] = value - if selected and unresolved: - return "explicit+unassigned", {**selected, **unresolved} - if selected: - return "explicit", selected - if unresolved: - return "unassigned", unresolved - return "confirmed-none", {} - - -def _page_template( - project_path: Path, - structure_lock: PptxStructureLock | None, - page_number: int, -) -> tuple[dict[str, object] | None, Path | None]: - if structure_lock is None or structure_lock.mode != "structured": - return None, None - prototype = next( - (item for item in structure_lock.prototypes if item.slide_num == page_number), - None, - ) - assignment = next( - (item for item in structure_lock.layouts if item.slide_num == page_number), - None, - ) - if prototype is None or assignment is None: - raise PageContextError( - f"structured lock has no complete mapping for P{page_number:02d}" - ) - definition = next( - ( - item - for item in structure_lock.layout_definitions - if item.layout_key == assignment.layout_key - ), - None, - ) - if definition is None: - raise PageContextError( - f"structured lock has no definition for Layout {assignment.layout_key!r}" - ) - master = next( - ( - item - for item in structure_lock.masters - if item.master_key == definition.master_key - ), - None, - ) - if master is None: - raise PageContextError( - f"structured lock has no definition for Master {definition.master_key!r}" - ) - template = { - "reuse_scope": structure_lock.template_reuse_scope, - "adherence": structure_lock.template_adherence, - "prototype": prototype.template_basename, - "prototype_path": _relative_project_path(project_path, prototype.svg_path), - "layout": { - "key": definition.layout_key, - "name": definition.layout_name, - "source": ( - f"P{definition.prototype_slide_num:02d}" - if definition.prototype_slide_num is not None - else _relative_project_path( - project_path, - definition.prototype_svg_path, - ) - ), - }, - "master": { - "key": master.master_key, - "name": master.master_name, - }, - } - return template, prototype.svg_path - - -def _reference_payload( - kind: str, - path: Path, - *, - scope: str, - display_path: str, - same_context_edit_policy: str | None = None, -) -> dict[str, str]: - """Describe one large reference without injecting its contents per page.""" - payload = { - "kind": kind, - "scope": scope, - "path": display_path, - "sha256": _file_sha256(path), - "load_policy": "once-per-execution-context", - } - if same_context_edit_policy is not None: - payload["same_context_edit_policy"] = same_context_edit_policy - return payload - - -def _chart_reference(chart_key: str) -> tuple[dict[str, str], Path]: - """Resolve one locked chart key to the shared Skill catalog.""" - if Path(chart_key).name != chart_key or not chart_key: - raise PageContextError(f"invalid page_charts key: {chart_key!r}") - chart_path = (_CHARTS_DIR / f"{chart_key}.svg").resolve() - if not chart_path.is_file(): - raise PageContextError( - f"page_charts key {chart_key!r} has no shared SVG reference" - ) - return ( - _reference_payload( - "chart-svg", - chart_path, - scope="skill", - display_path=f"templates/charts/{chart_path.name}", - ), - chart_path, - ) - - -def build_page_context(project: str | Path, raw_page: str) -> PageContextResult: - """Build one current per-page projection without writing the project.""" - project_path = Path(project).resolve() - if not project_path.is_dir(): - raise PageContextError(f"project directory not found: {project_path}") - page, page_number = normalize_page_key(raw_page) - lock_path = project_path / "spec_lock.md" - design_path = project_path / "design_spec.md" - for required in (lock_path, design_path): - if not required.is_file(): - raise PageContextError(f"required artifact not found: {required.name}") - preflight_errors, _preflight_warnings = validate_project_artifacts( - project_path, - include_design=False, - ) - if preflight_errors: - preview = "; ".join(preflight_errors[:8]) - suffix = ( - "" - if len(preflight_errors) <= 8 - else f"; +{len(preflight_errors) - 8} more" - ) - raise PageContextError( - "spec_lock/template preflight failed before page generation: " - f"{preview}{suffix}" - ) - try: - lock_sections_raw = parse_spec_lock_artifact( - lock_path, - report_duplicate_fields=True, - ) - design_sections = parse_markdown_artifact(design_path) - except (OSError, ValueError) as exc: - raise PageContextError(str(exc)) from exc - lock_sections = _section_index(lock_sections_raw) - part, brief = _slide_block(design_sections, page_number) - warnings: list[str] = [] - rhythm_fields = _section_fields(lock_sections, "page_rhythm") - rhythm = rhythm_fields.get(page) - if rhythm is None: - rhythm = "dense" - warnings.append(f"page_rhythm has no {page}; using compatibility default dense") - chart_key = _section_fields(lock_sections, "page_charts").get(page) - try: - structure_lock = load_pptx_structure_lock(project_path) - except TemplateStructureError as exc: - raise PageContextError(str(exc)) from exc - template, prototype_path = _page_template( - project_path, - structure_lock, - page_number, - ) - prototype_refs = ( - _prototype_image_refs(prototype_path) - if prototype_path is not None - else [] - ) - table_assigned_filenames, assigned_filenames = _page_image_filenames( - design_sections, - page_number, - ) - locked_images = _section_fields(lock_sections, "images") - resolved_filenames = ( - {_locked_image_basename(value) for value in locked_images.values()} - if structure_lock is not None - and structure_lock.template_reuse_scope == "mirror" - else table_assigned_filenames - | _outline_image_assignments(design_sections, locked_images) - ) - image_selection, selected_images = _page_images( - locked_images, - brief, - ( - prototype_refs - if structure_lock is not None - and structure_lock.template_reuse_scope == "mirror" - else [] - ), - assigned_filenames, - resolved_filenames, - ) - inputs = [lock_path, design_path] - reference_set: list[dict[str, str]] = [ - _reference_payload( - "design-spec", - design_path, - scope="project", - display_path="design_spec.md", - same_context_edit_policy="targeted-readback-and-rebind", - ), - ] - template_design_path = project_path / "templates" / "design_spec.md" - if template_design_path.is_file(): - inputs.append(template_design_path) - reference_set.append( - _reference_payload( - "template-design-spec", - template_design_path, - scope="project", - display_path="templates/design_spec.md", - ) - ) - if prototype_path is not None: - inputs.append(prototype_path) - reference_set.append( - _reference_payload( - "prototype-svg", - prototype_path, - scope="project", - display_path=_relative_project_path(project_path, prototype_path), - ) - ) - if chart_key is not None: - chart_reference, chart_path = _chart_reference(chart_key) - inputs.append(chart_path) - reference_set.append(chart_reference) - mode_fields = _section_fields(lock_sections, "mode") - visual_style_fields = _section_fields(lock_sections, "visual_style") - # Each on-demand projection includes bounded lock anchors; large reference - # payloads stay outside it and are represented by reference_set. - global_context = { - "communication": _section_fields(lock_sections, "communication"), - "canvas": _section_fields(lock_sections, "canvas"), - "mode": mode_fields.get("mode"), - "mode_behavior": mode_fields.get("mode_behavior"), - "visual_style": visual_style_fields.get("visual_style"), - "visual_style_behavior": visual_style_fields.get( - "visual_style_behavior" - ), - "colors": _section_fields(lock_sections, "colors"), - "typography": _section_fields(lock_sections, "typography"), - "icons": _section_fields(lock_sections, "icons"), - "pptx_structure": _section_fields(lock_sections, "pptx_structure"), - "forbidden": _forbidden_items(lock_sections), - } - global_context = { - key: value - for key, value in global_context.items() - if value not in ({}, [], None, "") - } - current_page: dict[str, object] = { - "part": part, - "brief_markdown": brief, - "rhythm": rhythm, - "image_selection": image_selection, - } - if chart_key is not None: - current_page["chart"] = chart_key - if selected_images: - current_page["images"] = selected_images - if template is not None: - current_page["template"] = template - context: dict[str, object] = { - "schema": PAGE_CONTEXT_SCHEMA, - "page": page, - "lock_source": { - "path": "spec_lock.md", - "sha256": _file_sha256(lock_path), - "load_policy": "on-demand-anchor-projection", - }, - "global": global_context, - "page_context": current_page, - "reference_set": reference_set, - } - if warnings: - context["warnings"] = warnings - unique_inputs = tuple(dict.fromkeys(path.resolve() for path in inputs)) - return PageContextResult( - project_path=project_path, - page=page, - context=context, - inputs=unique_inputs, - ) - - -def _compact_json(payload: object) -> str: - return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n" - - -def _pretty_json(payload: object) -> str: - return json.dumps(payload, ensure_ascii=False, indent=2) + "\n" - - -def render_page_context( - result: PageContextResult, - *, - bundle: bool = False, - pretty: bool = False, -) -> tuple[str, tuple[PageRead, ...]]: - """Render compact stdout; ``bundle`` remains a compatibility no-op.""" - context_payload = ( - _pretty_json(result.context) if pretty else _compact_json(result.context) - ) - context_read = PageRead( - kind="page-context", - path="stdout:page-context", - payload=context_payload, - ) - return context_payload, (context_read,) - - -def _sha256_bytes(payload: bytes) -> str: - return hashlib.sha256(payload).hexdigest() - - -def _file_sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as stream: - for chunk in iter(lambda: stream.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def _input_location(project_path: Path, path: Path) -> tuple[str, str]: - """Return a stable project- or Skill-relative locator for telemetry.""" - resolved = path.resolve() - for scope, root in (("project", project_path), ("skill", _SKILL_DIR)): - try: - return scope, resolved.relative_to(root.resolve()).as_posix() - except ValueError: - continue - raise PageContextError(f"input escapes project and Skill roots: {path}") - - -def _resolve_input_location( - project_path: Path, - scope: str, - relative_path: str, -) -> Path | None: - """Resolve one recorded input without accepting arbitrary filesystem roots.""" - roots = {"project": project_path, "skill": _SKILL_DIR} - root = roots.get(scope) - if root is None: - return None - resolved = (root / relative_path).resolve() - try: - resolved.relative_to(root.resolve()) - except ValueError: - return None - return resolved - - -def _token_counter() -> tuple[Callable[[str], int] | None, str]: - try: - import tiktoken - except ImportError: - return None, "unavailable" - try: - encoder = tiktoken.get_encoding(TOKEN_ENCODING) - except Exception: - return None, "unavailable" - return ( - lambda text: len(encoder.encode(text, disallowed_special=())), - "exact", - ) - - -def _payload_measurement( - read: PageRead, - count_tokens: Callable[[str], int] | None, -) -> dict[str, object]: - payload = read.payload.encode("utf-8") - measurement: dict[str, object] = { - "kind": read.kind, - "scope": "component" if read.kind == "lock-projection" else "page", - "path": read.path, - "sha256": _sha256_bytes(payload), - "utf8_bytes": len(payload), - "characters": len(read.payload), - "tokens": count_tokens(read.payload) if count_tokens else None, - } - return measurement - - -def record_page_context_usage( - result: PageContextResult, - output: str, - measured_reads: tuple[PageRead, ...], -) -> tuple[Path, str]: - """Write one deterministic, derived token snapshot for the current page.""" - count_tokens, token_status = _token_counter() - lock_read = PageRead( - kind="lock-projection", - path="stdout:global", - payload=_compact_json(result.context["global"]), - ) - documents = [ - _payload_measurement(read, count_tokens) - for read in (*measured_reads, lock_read) - ] - output_bytes = output.encode("utf-8") - input_records: list[dict[str, object]] = [] - for path in result.inputs: - scope, relative_path = _input_location(result.project_path, path) - input_records.append( - { - "scope": scope, - "path": relative_path, - "exists": True, - "sha256": _file_sha256(path), - } - ) - by_kind = { - str(item["kind"]): item.get("tokens") - for item in documents - } - route = dict(result.context["global"].get("pptx_structure", {})) - template = result.context["page_context"].get("template") - if isinstance(template, dict): - if isinstance(value := template.get("reuse_scope"), str): - route["template_reuse_scope"] = value - usage = { - "schema": PAGE_CONTEXT_USAGE_SCHEMA, - "page": result.page, - "output_mode": "compact", - "route": route, - "encoding": TOKEN_ENCODING, - "token_status": token_status, - "image_selection": result.context["page_context"]["image_selection"], - "inputs": input_records, - "references": result.context.get("reference_set", []), - "documents": documents, - "controlled_output": { - "sha256": _sha256_bytes(output_bytes), - "utf8_bytes": len(output_bytes), - "characters": len(output), - "tokens": count_tokens(output) if count_tokens else None, - }, - "totals": { - "page_context": by_kind.get("page-context"), - "lock_projection": by_kind.get("lock-projection"), - }, - "targets": { - "page_context_max_tokens": PAGE_CONTEXT_TOKEN_TARGET, - "lock_projection_max_tokens": LOCK_PROJECTION_TOKEN_TARGET, - }, - "untracked": [ - "source-material reads", - "once-per-execution-context reference payloads", - "other session-level prompt references", - ], - } - usage_dir = result.project_path / "analysis" / "page-context" - usage_dir.mkdir(parents=True, exist_ok=True) - usage_path = usage_dir / f"{result.page}.usage.json" - temporary_path = usage_path.with_suffix(".usage.json.tmp") - temporary_path.write_text(_pretty_json(usage), encoding="utf-8") - temporary_path.replace(usage_path) - return usage_path, token_status - - -def _nearest_rank(values: list[int], percentile: float) -> int: - rank = max(1, math.ceil(percentile * len(values))) - return sorted(values)[rank - 1] - - -def _metric(values: list[int], *, target: int | None = None) -> dict[str, object]: - if not values: - return { - "count": 0, - "sum": 0, - "min": None, - "p50": None, - "p95": None, - "max": None, - **({"over_target_count": 0, "target": target} if target else {}), - } - metric: dict[str, object] = { - "count": len(values), - "sum": sum(values), - "min": min(values), - "p50": round(statistics.median(values)), - "p95": _nearest_rank(values, 0.95), - "max": max(values), - } - if target is not None: - metric.update({ - "target": target, - "over_target_count": sum(value > target for value in values), - }) - return metric - - -def page_context_usage_report(project: str | Path) -> dict[str, object]: - """Summarize fresh per-page telemetry without changing recorded history.""" - project_path = Path(project).resolve() - usage_dir = project_path / "analysis" / "page-context" - records: list[dict[str, object]] = [] - stale_pages: list[str] = [] - unavailable_pages: list[str] = [] - if usage_dir.is_dir(): - for usage_path in sorted(usage_dir.glob("P*.usage.json")): - try: - record = json.loads(usage_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - stale_pages.append(usage_path.stem.split(".", 1)[0]) - continue - page = str(record.get("page", usage_path.stem.split(".", 1)[0])) - if record.get("schema") != PAGE_CONTEXT_USAGE_SCHEMA: - stale_pages.append(page) - continue - if record.get("output_mode") != "compact": - stale_pages.append(page) - continue - stale = False - for item in record.get("inputs", []): - if not isinstance(item, dict): - stale = True - break - source_path = _resolve_input_location( - project_path, - str(item.get("scope", "project")), - str(item.get("path", "")), - ) - if source_path is None: - stale = True - break - expected_exists = item.get("exists", True) - if expected_exists is False: - if source_path.exists(): - stale = True - break - elif ( - not source_path.is_file() - or _file_sha256(source_path) != item.get("sha256") - ): - stale = True - break - if stale: - stale_pages.append(page) - continue - if record.get("token_status") != "exact": - unavailable_pages.append(page) - records.append(record) - - def tokens_for(kind: str) -> list[int]: - values: list[int] = [] - for record in records: - for document in record.get("documents", []): - if not isinstance(document, dict) or document.get("kind") != kind: - continue - value = document.get("tokens") - if isinstance(value, int): - values.append(value) - return values - - controlled = [ - value - for record in records - if isinstance( - value := record.get("controlled_output", {}).get("tokens"), - int, - ) - ] - unique_references = sorted({ - f"{reference.get('scope', 'project')}:{reference.get('path', '')}" - for record in records - for reference in record.get("references", []) - if isinstance(reference, dict) and reference.get("path") - }) - return { - "schema": PAGE_CONTEXT_REPORT_SCHEMA, - "project": project_path.name, - "record_count": len(records), - "pages": sorted(str(record["page"]) for record in records), - "stale_pages": sorted(set(stale_pages)), - "token_unavailable_pages": sorted(set(unavailable_pages)), - "unique_reference_count": len(unique_references), - "unique_references": unique_references, - "metrics": { - "page_context": _metric( - tokens_for("page-context"), - target=PAGE_CONTEXT_TOKEN_TARGET, - ), - "lock_projection": _metric( - tokens_for("lock-projection"), - target=LOCK_PROJECTION_TOKEN_TARGET, - ), - "controlled_output": _metric(controlled), - }, - } +__all__ = [ + "LOCK_PROJECTION_TOKEN_TARGET", + "PAGE_CONTEXT_REPORT_SCHEMA", + "PAGE_CONTEXT_SCHEMA", + "PAGE_CONTEXT_TOKEN_TARGET", + "PAGE_CONTEXT_USAGE_SCHEMA", + "TOKEN_ENCODING", + "PageContextError", + "PageContextResult", + "PageRead", + "build_page_context", + "normalize_page_key", + "page_context_usage_report", + "record_page_context_usage", + "render_page_context", +] 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 110325fc..fae4d227 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 @@ -402,6 +402,7 @@ class AnimationTarget: effect: str duration_ms: int effect_options: Mapping[str, object] + trigger: str = 'after-previous' trigger_shape_id: int | None = None repeat_count: float | None = None repeat_duration_ms: int | None = None @@ -849,6 +850,7 @@ def _normalize_sound(value: object) -> tuple[str | None, str | None]: def _normalize_target_mapping( target: Mapping[str, object], default_duration_ms: int, + default_trigger: str, ) -> AnimationTarget: allowed = { 'shape_id', @@ -856,6 +858,7 @@ def _normalize_target_mapping( 'effect', 'duration', 'effect_options', + 'trigger', 'trigger_shape_id', *ANIMATION_TIMING_OPTION_FIELDS, 'after_effect', @@ -896,6 +899,18 @@ def _normalize_target_mapping( raise ValueError( 'animation trigger_shape_id must target a different shape' ) + target_trigger = ( + normalize_animation_trigger(target['trigger']) + if 'trigger' in target + else default_trigger + ) + if trigger_shape_id is not None: + if 'trigger' in target and target_trigger != 'on-click': + raise ValueError( + 'animation target with trigger_shape_id must use ' + 'trigger "on-click"' + ) + target_trigger = 'on-click' repeat_count = ( _normalize_repeat_count(target['repeat_count']) if 'repeat_count' in target @@ -968,6 +983,7 @@ def _normalize_target_mapping( effect=effect, duration_ms=duration_ms, effect_options=effect_options, + trigger=target_trigger, trigger_shape_id=trigger_shape_id, repeat_count=repeat_count, repeat_duration_ms=repeat_duration_ms, @@ -987,9 +1003,14 @@ def _normalize_target_mapping( def _normalize_target( target: Sequence[object] | Mapping[str, object], default_duration_ms: int, + default_trigger: str = 'after-previous', ) -> AnimationTarget: if isinstance(target, Mapping): - return _normalize_target_mapping(target, default_duration_ms) + return _normalize_target_mapping( + target, + default_duration_ms, + default_trigger, + ) if isinstance(target, (str, bytes)) or not isinstance(target, Sequence): raise ValueError(f'animation target must be a 3- or 4-item sequence: {target!r}') if len(target) not in (3, 4): @@ -1014,6 +1035,7 @@ def _normalize_target( effect=effect, duration_ms=duration_ms, effect_options=effect_options, + trigger=default_trigger, ) # Pool used by 'mixed' / 'random' modes. Every entry is a canonical @@ -1694,17 +1716,7 @@ def _instantiate_animation_row( direct_conditions[0].attrib.clear() direct_conditions[0].set( 'delay', - str( - target.delay_ms - if ( - node_type == 'afterEffect' - or ( - node_type == 'clickEffect' - and target.trigger_shape_id is not None - ) - ) - else 0 - ), + str(target.delay_ms), ) if spec['durationScalable']: @@ -1746,6 +1758,90 @@ def _build_animation_row_xml( ) +def _main_target_offsets(targets: Sequence[AnimationTarget]) -> list[int]: + """Return each regular row's start offset within its click group.""" + offsets: list[int] = [] + previous_start_ms = 0 + previous_duration_ms = 0 + has_previous = False + for target in targets: + if target.trigger == 'on-click': + start_ms = target.delay_ms + elif target.trigger == 'with-previous': + start_ms = ( + previous_start_ms if has_previous else 0 + ) + target.delay_ms + else: + start_ms = ( + previous_start_ms + previous_duration_ms + if has_previous + else 0 + ) + target.delay_ms + if start_ms > MAX_OOXML_MILLISECONDS: + raise ValueError( + 'animation sequence offset exceeds the OOXML millisecond ' + f'limit at target {len(offsets) + 1}: {start_ms}' + ) + offsets.append(start_ms) + previous_start_ms = start_ms + previous_duration_ms = target.playback_duration_ms + has_previous = True + return offsets + + +def _build_mixed_main_steps( + targets: Sequence[AnimationTarget], + next_id: int, +) -> tuple[str, int]: + """Build one mainSeq containing mixed per-row PowerPoint Start modes.""" + offsets = _main_target_offsets(targets) + groups: list[list[tuple[AnimationTarget, int]]] = [] + for target, offset_ms in zip(targets, offsets): + if not groups or target.trigger == 'on-click': + groups.append([]) + groups[-1].append((target, offset_ms)) + + rendered_groups: list[str] = [] + for group in groups: + group_id = next_id + next_id += 1 + first_target = group[0][0] + if first_target.trigger == 'on-click': + group_conditions = '' + else: + group_conditions = ( + '' + '' + ) + rendered_rows: list[str] = [] + for target, offset_ms in group: + wrapper_id = next_id + row_id = next_id + 1 + row_xml, next_id = _build_animation_row_xml( + target, + target.trigger, + row_id, + next_id + 2, + ) + wrapper_offset_ms = offset_ms - target.delay_ms + rendered_rows.append(f''' + + + {row_xml} + + ''') + rows_xml = '\n '.join(rendered_rows) + rendered_groups.append(f''' + + {group_conditions} + + {rows_xml} + + + ''') + return '\n '.join(rendered_groups), next_id + + def create_sequence_timing_xml( targets: list, duration: float = 0.3, @@ -1757,9 +1853,10 @@ def create_sequence_timing_xml( targets: list of (shape_id, delay_ms, animation_name) or (shape_id, delay_ms, animation_name, duration_seconds) tuples, in the order they should play. ``delay_ms`` is the gap before - this element starts in ``after-previous`` mode. For a mapping - target with ``trigger_shape_id``, it is the delay after that - shape is clicked; otherwise the other Start modes ignore it. + this element starts relative to its Start mode. Mapping targets + may set an independent ``trigger``; otherwise they inherit the + function-level ``trigger``. A target with ``trigger_shape_id`` + must use ``on-click`` and runs in an interactive sequence. duration: per-element animation duration in seconds. Instantaneous native presets retain their PowerPoint-authored duration. trigger: PowerPoint-standard Start mode for each element. @@ -1785,12 +1882,9 @@ def create_sequence_timing_xml( if not targets: return '' normalized_targets = [ - _normalize_target(target, default_dur_ms) + _normalize_target(target, default_dur_ms, trigger) for target in targets ] - shape_ids = [target.shape_id for target in normalized_targets] - if len(shape_ids) != len(set(shape_ids)): - raise ValueError('animation targets must not contain duplicate shape ids') next_id = 3 main_targets = [ target @@ -1803,7 +1897,23 @@ def create_sequence_timing_xml( if target.trigger_shape_id is not None ] - if trigger == 'on-click': + main_triggers = {target.trigger for target in main_targets} + main_trigger = ( + next(iter(main_triggers)) + if len(main_triggers) == 1 + else None + ) + needs_per_target_layout = ( + main_trigger is None + or ( + main_trigger == 'with-previous' + and any(target.delay_ms for target in main_targets) + ) + ) + + if needs_per_target_layout and main_targets: + all_steps, next_id = _build_mixed_main_steps(main_targets, next_id) + elif main_trigger == 'on-click': # Each element is an independent click-driven par directly under # mainSeq. Three-level nesting per element: outer cTn holds for # the click via delay="indefinite", innermost cTn owns the @@ -1815,7 +1925,7 @@ def create_sequence_timing_xml( leaf_id = next_id + 2 row_xml, next_id = _build_animation_row_xml( target, - trigger, + main_trigger, leaf_id, next_id + 3, ) @@ -1848,16 +1958,16 @@ def create_sequence_timing_xml( next_id += 1 inner_steps = [] with_wrapper_id = None - if trigger == 'with-previous': + if main_trigger == 'with-previous': with_wrapper_id = next_id next_id += 1 elapsed_ms = 0 for target_index, target in enumerate(main_targets, 1): - if trigger == 'with-previous': + if main_trigger == 'with-previous': leaf_id = next_id row_xml, next_id = _build_animation_row_xml( target, - trigger, + main_trigger, leaf_id, next_id + 1, ) @@ -1874,7 +1984,7 @@ def create_sequence_timing_xml( leaf_id = next_id + 1 row_xml, next_id = _build_animation_row_xml( target, - trigger, + main_trigger, leaf_id, next_id + 2, ) @@ -1891,7 +2001,7 @@ def create_sequence_timing_xml( elapsed_ms += target.delay_ms + target.playback_duration_ms inner_xml = '\n '.join(inner_steps) - if trigger == 'with-previous': + if main_trigger == 'with-previous': # Match PowerPoint's native "Start: With Previous" export: # one delay=0 wrapper begins on slide entry, and all withEffect # rows live under that wrapper so they truly start in parallel. @@ -1903,7 +2013,7 @@ def create_sequence_timing_xml( ''' - if trigger in ('with-previous', 'after-previous'): + if main_trigger in ('with-previous', 'after-previous'): # Match PowerPoint's native slide-entry export: the wrapper waits # for mainSeq to begin, then child nodes resolve their Start modes. outer_start_conditions = ( @@ -2621,34 +2731,14 @@ def _row_offset_ms( errors.append( 'object-animation row must have one numeric leaf start condition' ) - if ( - trigger != 'after-previous' - and trigger_shape_id is None - and leaf_delay not in {None, 0} - ): - errors.append( - f'{trigger} object-animation row must have leaf delay="0"' - ) - current = parent_map.get(row) saw_indefinite = False - saw_main_begin = False numeric_offset: int | None = None while current is not None: if current.tag == _qn(PML_NS, 'cTn'): conditions = _direct_conditions(current) if any(condition.get('delay') == 'indefinite' for condition in conditions): saw_indefinite = True - if any( - condition.get('evt') == 'onBegin' - and condition.get('delay') == '0' - and any( - target.get('val') == '2' - for target in condition.iter(_qn(PML_NS, 'tn')) - ) - for condition in conditions - ): - saw_main_begin = True if trigger in {'with-previous', 'after-previous'}: numeric = [ condition.get('delay') @@ -2673,15 +2763,13 @@ def _row_offset_ms( errors.append( 'on-click object-animation row is missing an indefinite click wrapper' ) - if trigger in {'with-previous', 'after-previous'} and not ( - saw_indefinite and saw_main_begin - ): - errors.append(f'{trigger} sequence is missing the slide-entry onBegin anchor') - if trigger == 'after-previous' and numeric_offset is None: + if trigger in {'with-previous', 'after-previous'} and not saw_indefinite: + errors.append(f'{trigger} sequence is missing its sequence anchor') + if trigger in {'with-previous', 'after-previous'} and numeric_offset is None: errors.append( - 'after-previous object-animation row is missing its numeric offset wrapper' + f'{trigger} object-animation row is missing its numeric offset wrapper' ) - if trigger == 'after-previous': + if trigger in {'with-previous', 'after-previous'}: absolute_offset = (numeric_offset or 0) + (leaf_delay or 0) if absolute_offset > MAX_OOXML_MILLISECONDS: errors.append( @@ -2691,12 +2779,97 @@ def _row_offset_ms( return absolute_offset if trigger_shape_id is not None: return leaf_delay or 0 - return 0 + return leaf_delay or 0 + + +def _row_matches_powerpoint_behavior( + row: ET.Element, + *, + shape_id: int, + effect: str, + effect_options: Mapping[str, object], + trigger: str, + duration_ms: int, + repeat_count: float | None, + repeat_duration_ms: int | None, + auto_reverse: bool, + rewind: bool, + accelerate: float, + decelerate: float, + bounce_end: float, + restart: str, + after_effect: str, + after_effect_color: str | None, + sound_relationship_id: str | None, + sound_name: str | None, +) -> bool: + """Match one read-back row to its reconstructed native behavior tree.""" + spec = NATIVE_ANIMATIONS[effect] + target = AnimationTarget( + shape_id=shape_id, + delay_ms=0, + effect=effect, + duration_ms=duration_ms, + effect_options=effect_options, + trigger=trigger, + repeat_count=repeat_count, + repeat_duration_ms=repeat_duration_ms, + auto_reverse=True if auto_reverse else None, + rewind=True if rewind else None, + accelerate=accelerate or None, + decelerate=decelerate or None, + bounce_end=bounce_end or None, + restart=restart if row.get('restart') is not None else None, + after_effect=after_effect, + after_effect_color=after_effect_color, + sound_relationship_id=sound_relationship_id, + sound_name=sound_name, + ) + option_candidates = [dict(effect_options)] + option_candidates.extend( + { + name: value + for name, value in effect_options.items() + if name != omitted + } + for omitted in effect_options + ) + option_candidates.append({}) + seen: set[tuple[tuple[str, object], ...]] = set() + for candidate in option_candidates: + candidate_key = tuple(sorted(candidate.items())) + if candidate_key in seen: + continue + seen.add(candidate_key) + expected = _animation_row_for_options(effect, candidate) + if spec['durationScalable']: + _scale_animation_row_duration( + expected, + base_duration_ms=int(spec['defaultDurationMs']), + requested_duration_ms=duration_ms, + ) + _apply_timing_options(expected, target) + row_id = row.get('id') + expected.set('id', row_id if row_id and row_id.isdigit() else '1') + _append_after_effect( + expected, + target, + int(expected.get('id', '1')), + ) + _append_animation_sound(expected, target) + if _animation_spec_matches_row( + row, + {'rowXml': ET.tostring(expected, encoding='unicode')}, + ): + return True + return False def _animation_rows( slide_root: ET.Element, errors: list[str], + *, + require_behavior_signatures: bool = False, ) -> list[AnimationRowSummary]: parent_map = { child: parent @@ -2761,6 +2934,35 @@ def _animation_rows( or preset_subtype is None ): continue + if ( + require_behavior_signatures + and resolved_effect is not None + and duration_ms is not None + and not _row_matches_powerpoint_behavior( + row, + shape_id=shape_id, + effect=resolved_effect, + effect_options=effect_options, + trigger=trigger, + duration_ms=duration_ms, + repeat_count=repeat_count, + repeat_duration_ms=repeat_duration_ms, + auto_reverse=auto_reverse, + rewind=rewind, + accelerate=accelerate, + decelerate=decelerate, + bounce_end=bounce_end, + restart=restart, + after_effect=after_effect, + after_effect_color=after_effect_color, + sound_relationship_id=sound_relationship_id, + sound_name=sound_name, + ) + ): + errors.append( + 'object-animation PowerPoint-authored behavior tree changed ' + f'for shape {shape_id}' + ) rows.append( AnimationRowSummary( shape_id=shape_id, @@ -2980,7 +3182,11 @@ def validate_slide_animation_structure( if node.get('presetClass') in set(_PRESET_CLASS_BY_CATEGORY.values()) ] if require_supported_effects: - rows = _animation_rows(slide_root, errors) + rows = _animation_rows( + slide_root, + errors, + require_behavior_signatures=True, + ) if not rows and animation_nodes: errors.append('generated object-animation rows could not be read back') else: @@ -3012,16 +3218,6 @@ def validate_slide_animation_structure( 'each generated trigger-shape animation must have one ' 'interactiveSeq time node' ) - regular_triggers = {row.trigger for row in regular_rows} - if len(regular_triggers) > 1: - errors.append( - 'one generated main object-animation sequence must use one ' - 'Start mode; found ' - + ', '.join(sorted(regular_triggers)) - ) - row_shape_ids = [row.shape_id for row in rows] - if len(row_shape_ids) != len(set(row_shape_ids)): - errors.append('generated object-animation sequence repeats a shape target') for row in rows: if ( row.trigger_shape_id is not None @@ -3064,7 +3260,11 @@ def read_slide_animation_sequence( require_supported_effects=require_supported_effects, ) row_errors: list[str] = [] - rows = _animation_rows(root, row_errors) + rows = _animation_rows( + root, + row_errors, + require_behavior_signatures=require_supported_effects, + ) for error in row_errors: if error not in errors: errors.append(error) @@ -3111,7 +3311,7 @@ def validate_generated_animation_xml( allow_zero=False, ) normalized_expected = tuple( - _normalize_target(target, default_duration_ms) + _normalize_target(target, default_duration_ms, trigger) for target in targets ) # PowerPoint stores ordinary rows in mainSeq and shape-triggered rows in @@ -3130,6 +3330,13 @@ def validate_generated_animation_xml( slide_xml, require_supported_effects=True, ) + data = slide_xml.encode('utf-8') if isinstance(slide_xml, str) else slide_xml + actual_root = _select_supported_timing_branch(ET.fromstring(data)) + actual_row_elements = [ + row + for row in actual_root.iter(_qn(PML_NS, 'cTn')) + if row.get('presetClass') in set(_PRESET_CLASS_BY_CATEGORY.values()) + ] errors: list[str] = [] if len(summary.rows) != len(expected): errors.append( @@ -3139,8 +3346,17 @@ def validate_generated_animation_xml( expected_main_targets = tuple( target for target in expected if target.trigger_shape_id is None ) + expected_main_triggers = { + target.trigger for target in expected_main_targets + } expected_sequence_trigger = ( - trigger if expected_main_targets else ('on-click' if expected else None) + ( + next(iter(expected_main_triggers)) + if len(expected_main_triggers) == 1 + else None + ) + if expected_main_targets + else ('on-click' if expected else None) ) if expected and summary.trigger != expected_sequence_trigger: errors.append( @@ -3148,28 +3364,19 @@ def validate_generated_animation_xml( f'expected {expected_sequence_trigger!r}' ) - expected_offsets: list[int] = [] - elapsed_ms = 0 - previous_duration_ms = 0 - main_index = 0 - for index, target in enumerate(expected): - if target.trigger_shape_id is not None: - expected_offsets.append(target.delay_ms) - elif trigger == 'after-previous': - if main_index == 0: - elapsed_ms = target.delay_ms - else: - elapsed_ms += previous_duration_ms + target.delay_ms - if elapsed_ms > MAX_OOXML_MILLISECONDS: - errors.append( - 'requested animation sequence offset exceeds the OOXML ' - f'millisecond limit at row {index + 1}: {elapsed_ms}' - ) - expected_offsets.append(elapsed_ms) - previous_duration_ms = target.playback_duration_ms - main_index += 1 - else: - expected_offsets.append(0) + try: + main_offsets = iter(_main_target_offsets(expected_main_targets)) + except ValueError as exc: + errors.append(str(exc)) + main_offsets = iter(()) + expected_offsets = [ + ( + target.delay_ms + if target.trigger_shape_id is not None + else next(main_offsets, 0) + ) + for target in expected + ] for index, (actual, target) in enumerate(zip(summary.rows, expected), 1): spec = NATIVE_ANIMATIONS[target.effect] @@ -3220,13 +3427,43 @@ def validate_generated_animation_xml( f'animation row {index} expected-option model failed: ' + '; '.join(option_errors) ) + if index <= len(actual_row_elements): + actual_row_element = actual_row_elements[index - 1] + expected_behavior_row = copy.deepcopy(expected_row) + actual_row_id = actual_row_element.get('id') + expected_behavior_row.set( + 'id', + actual_row_id + if actual_row_id and actual_row_id.isdigit() + else '1', + ) + _append_after_effect( + expected_behavior_row, + target, + int(expected_behavior_row.get('id', '1')), + ) + _append_animation_sound(expected_behavior_row, target) + behavior_spec = { + 'rowXml': ET.tostring( + expected_behavior_row, + encoding='unicode', + ) + } + if not _animation_spec_matches_row( + actual_row_element, + behavior_spec, + ): + errors.append( + f'animation row {index} PowerPoint-authored behavior ' + 'tree changed' + ) if actual.shape_id != target.shape_id: errors.append( f'animation row {index} targets shape {actual.shape_id}; ' f'expected {target.shape_id}' ) expected_row_trigger = ( - 'on-click' if target.trigger_shape_id is not None else trigger + target.trigger ) if actual.trigger != expected_row_trigger: errors.append( @@ -3581,13 +3818,22 @@ def describe_animation_effect(effect: object) -> dict[str, Any]: 'implied_effect_options': implied_options, 'effect_options': option_contract, 'timing': { - 'duration': 'positive seconds', - 'delay': 'non-negative seconds; group scope only', + 'duration': ( + 'positive seconds; legacy group effect or effects[] row' + ), + 'delay': ( + 'non-negative seconds; legacy group effect or effects[] row' + ), 'stagger': 'non-negative seconds; animation scope only', 'trigger': list(ANIMATION_TRIGGERS), + 'trigger_scope': ( + 'animation default for a legacy group effect; each effects[] ' + 'row may override it' + ), 'trigger_shape': ( - 'other top-level SVG group id; group scope only; maps to ' - 'PowerPoint "On Click of"' + 'other top-level SVG group id; legacy group effect or ' + 'effects[] row; maps to PowerPoint "On Click of" and requires ' + 'trigger on-click' ), 'repeat_count': 'positive number; mutually exclusive with repeat_duration', 'repeat_duration': 'positive seconds; mutually exclusive with repeat_count', diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/effect_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/effect_to_svg.py index 4b8c998d..7ea6e400 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/effect_to_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/effect_to_svg.py @@ -24,6 +24,28 @@ _OOXML_HEX_COLOR_RE = re.compile(r"[0-9A-Fa-f]{6}") _DRAWINGML_NAMESPACE = NS["a"] _DRAWINGML_TAG_PREFIX = f"{{{_DRAWINGML_NAMESPACE}}}" _EFFECT_CONTAINER_NAMES = frozenset({"effectLst", "effectDag"}) +_OUTER_SHADOW_ATTRIBUTES = frozenset({ + "algn", + "blurRad", + "dir", + "dist", + "kx", + "ky", + "rotWithShape", + "sx", + "sy", +}) +_OUTER_SHADOW_ALIGNMENTS = frozenset({ + "b", + "bl", + "br", + "ctr", + "l", + "r", + "t", + "tl", + "tr", +}) @dataclass(frozen=True) @@ -75,6 +97,7 @@ def convert_effects( *, id_prefix: str = "fx", id_seq: list[int] | None = None, + target_rotation_degrees: float = 0.0, ) -> EffectResult: """Return one supported filter or blocking metadata for source effects.""" if sp_pr is None: @@ -121,6 +144,15 @@ def convert_effects( ) try: if effect_name == "outerShdw": + unsupported_attributes = _unsupported_outer_shadow_attributes( + effect, + target_rotation_degrees=target_rotation_degrees, + ) + if unsupported_attributes: + return EffectResult.unsupported( + "unsupported-effect-attributes:outerShdw:" + + ",".join(unsupported_attributes) + ) primitives = _outer_shadow(effect, palette) elif effect_name == "glow": primitives = _glow(effect, palette) @@ -262,6 +294,55 @@ def _effect_integer( return value +def _unsupported_outer_shadow_attributes( + elem: ET.Element, + *, + target_rotation_degrees: float, +) -> tuple[str, ...]: + """Return source shadow attributes the local SVG filter cannot preserve.""" + unsupported = set(elem.attrib) - _OUTER_SHADOW_ATTRIBUTES + neutral_transforms = ( + ("sx", 100000), + ("sy", 100000), + ("kx", 0), + ("ky", 0), + ) + for attr, neutral in neutral_transforms: + if attr in elem.attrib and _effect_integer(elem, attr) != neutral: + unsupported.add(attr) + + raw_alignment = elem.get("algn") + if ( + raw_alignment is not None + and raw_alignment.strip() not in _OUTER_SHADOW_ALIGNMENTS + ): + raise ValueError(f"algn={raw_alignment!r}") + + raw_rotates = elem.get("rotWithShape") + if raw_rotates is None: + rotates_with_shape = True + else: + token = raw_rotates.strip() + if token in {"1", "true"}: + rotates_with_shape = True + elif token in {"0", "false"}: + rotates_with_shape = False + else: + raise ValueError(f"rotWithShape={raw_rotates!r}") + target_is_rotated = not math.isclose( + math.remainder(target_rotation_degrees, 360.0), + 0.0, + abs_tol=1e-9, + ) + if rotates_with_shape and target_is_rotated: + # CT_OuterShadowEffect defaults rotWithShape to true, while the local + # SVG-to-PPTX mapping writes false. Blocking the visible distinction + # prevents the next export from changing the source shadow direction. + unsupported.add("rotWithShape") + + return tuple(sorted(unsupported)) + + def _direction_offset(elem: ET.Element) -> tuple[float, float]: """Read dir / dist into (dx, dy) px.""" direction_units = _effect_integer( diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/fill_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/fill_to_svg.py index 6469e287..760af4a4 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/fill_to_svg.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/fill_to_svg.py @@ -43,6 +43,7 @@ _OOXML_PERCENT_LITERAL_RE = re.compile( _OOXML_FULL_CIRCLE = 360 * ANGLE_UNIT _OOXML_PERCENTAGE_MIN = Decimal(-(2**31)) / Decimal(PERCENT_UNIT) _OOXML_PERCENTAGE_MAX = Decimal(2**31 - 1) / Decimal(PERCENT_UNIT) +_SVG_RADIAL_FOCUS_TOLERANCE = Decimal(1) / Decimal(PERCENT_UNIT) _DRAWINGML_FILL_NAMES = ( "noFill", "solidFill", @@ -260,11 +261,46 @@ def _resolve_grad_fill(elem: ET.Element, palette: ColorPalette | None, elif rad is not None: _validate_path_gradient_structure(rad) _validate_path_gradient_type(rad) - _validate_path_gradient_focus(rad) + focus = _validate_path_gradient_focus(rad) + focus_attrs = "" + if focus is not None: + focus_x = focus["l"] + focus_y = focus["t"] + point_focus = ( + focus_x + focus["r"] == Decimal(1) + and focus_y + focus["b"] == Decimal(1) + and Decimal(0) <= focus_x <= Decimal(1) + and Decimal(0) <= focus_y <= Decimal(1) + and ( + (focus_x - Decimal("0.5")) ** 2 + + (focus_y - Decimal("0.5")) ** 2 + <= Decimal("0.25") + _SVG_RADIAL_FOCUS_TOLERANCE + ) + ) + if ( + point_focus + and ( + focus_x != Decimal("0.5") + or focus_y != Decimal("0.5") + ) + ): + focus_attrs = ( + f' fx="{format_ooxml_unit_ratio(float(focus_x))}"' + f' fy="{format_ooxml_unit_ratio(float(focus_y))}"' + ) + elif not point_focus and palette is not None: + palette._diagnose( + "path-gradient-focus-normalized", + "DrawingML path gradient focus is not one point within " + "the canonical SVG radial circle", + "center the radial gradient while preserving its stops", + ) # Treat as radial regardless of path="circle" / "rect" / "shape" — SVG - # only has circle/ellipse, and path="circle" maps to fillToRect=center. + # only has circle/ellipse. Point-style fillToRect retains its focus; + # the outer center and radius remain normalized. defs_xml = ( - f'' + f'' + "".join(stops_xml) + "" ) @@ -459,18 +495,25 @@ def _validate_gradient_tile_rect(gradient: ET.Element) -> None: ) -def _validate_path_gradient_focus(path: ET.Element) -> None: - """Validate the focus rectangle normalized by the radial approximation.""" +def _validate_path_gradient_focus( + path: ET.Element, +) -> dict[str, Decimal] | None: + """Validate and return one path-gradient focus rectangle.""" focus_rects = path.findall("a:fillToRect", NS) if len(focus_rects) > 1: raise ValueError( "DrawingML path gradient must contain at most one fillToRect" ) - if focus_rects: - _relative_rect_values( - focus_rects[0], - label="path gradient fillToRect", - ) + if not focus_rects: + return None + values = _relative_rect_values( + focus_rects[0], + label="path gradient fillToRect", + ) + return { + edge: values.get(edge, Decimal(0)) + for edge in ("l", "t", "r", "b") + } def _relative_rect_values( 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 04f14b54..d6325225 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 @@ -510,8 +510,8 @@ def _apply_blip_image_effects( ) -> tuple[str, bytes, tuple[PictureDiagnostic, ...]]: """Bake supported DrawingML blip effects into extracted image bytes. - Keeping the SVG as a plain avoids introducing CSS filters that the - downstream native PPTX converter cannot reliably map back to DrawingML. + Brightness and contrast are pixel operations, not picture-shape + shadow/glow effects, so preserve them in the extracted bitmap. """ lum_effects = blip.findall("a:lum", NS) if not lum_effects: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/shape_walker.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/shape_walker.py index ca21020f..945e80bb 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/shape_walker.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/shape_walker.py @@ -74,6 +74,9 @@ class ShapeNode: placeholder: PlaceholderInfo | None = None inherited_lst_styles: tuple[ET.Element, ...] = () inherited_body_properties: tuple[ET.Element, ...] = () + # Local plus ancestor group rotation; used for effect-fidelity decisions + # without applying the group transform twice to the rendered geometry. + effective_rotation: float = 0.0 # GROUP only: children, in z-order children: list["ShapeNode"] = field(default_factory=list) @@ -216,6 +219,7 @@ def _resolve_alternate_content(wrapper: ET.Element) -> ET.Element | None: def _walk_container( container: ET.Element, parent_group_xfrm: Xfrm | None, + ancestor_rotation: float = 0.0, placeholder_xfrms: dict[tuple[str | None, str | None], Xfrm] | None = None, placeholder_lst_styles: dict[ tuple[str | None, str | None], @@ -246,6 +250,7 @@ def _walk_container( name, spid, hidden, ph = _read_nv_sp_pr(child, nv_tag) xfrm = parse_xfrm(_resolve_xfrm(child, kind)) + effective_rotation = (ancestor_rotation + xfrm.rot) % 360.0 # Placeholders without their own xfrm inherit geometry from a matching # placeholder in the layout, then the master. This is what PowerPoint @@ -285,11 +290,12 @@ def _walk_container( name=name, spid=spid, hidden=hidden, placeholder=ph, inherited_lst_styles=inherited_lst_styles, inherited_body_properties=inherited_body_properties, + effective_rotation=effective_rotation, ) if kind == GROUP: node.children = _walk_container( - child, xfrm, + child, xfrm, effective_rotation, placeholder_xfrms=placeholder_xfrms, placeholder_lst_styles=placeholder_lst_styles, placeholder_body_properties=placeholder_body_properties, 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 04e23192..8fb881e8 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 @@ -772,6 +772,7 @@ def _build_geometry_xml(node: ShapeNode, sp_pr: ET.Element | None, ctx.palette, id_prefix="fx", id_seq=ctx.filter_seq, + target_rotation_degrees=node.effective_rotation, ) except ValueError as exc: if ctx.strict: @@ -964,22 +965,40 @@ def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) return "" _diagnose_picture_result(ctx, result) ctx.media.update(result.media) - effect_metadata = unsupported_target_effect_metadata( + effect = convert_effects( sp_pr, - "picture", + ctx.palette, + id_prefix="fx", + id_seq=ctx.filter_seq, + target_rotation_degrees=node.effective_rotation, ) + ctx.defs.extend(effect.defs) + effect_metadata = dict(effect.metadata) _diagnose_unsupported_effect(ctx, effect_metadata) clipped_svg = _clip_blip_image(result.svg, geom, ctx) + picture_attrs = {**_object_metadata(node, ctx), **effect_metadata} + group_attrs = _metadata_group_attrs(effect_metadata) + if effect.filter_id is not None: + filter_attr = f"url(#{effect.filter_id})" + if ( + clipped_svg.startswith(" [--format ppt169] [--dir ] [--quick-generate] + python3 scripts/project_manager.py import-sources [ ...] [--move | --copy] + python3 scripts/project_manager.py scaffold-spec + python3 scripts/project_manager.py scaffold-lock + python3 scripts/project_manager.py validate + python3 scripts/project_manager.py info + python3 scripts/project_manager.py page-context P07 [--record-usage] + python3 scripts/project_manager.py page-context-report + +Examples: + python3 scripts/project_manager.py init demo --format ppt169 + python3 scripts/project_manager.py validate projects/demo + +Dependencies: + Standard library plus local PPT Master project and source-conversion modules. +""" + +from __future__ import annotations + +import argparse +import filecmp +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path +from urllib.parse import urlparse + +from .page_context import ( + build_page_context, + page_context_usage_report, + record_page_context_usage, + render_page_context, +) +from .paths import ( + PROJECTS_ROOT, + REPO_ROOT, + SCRIPTS_DIR, + SOURCE_TO_MD_DIR, +) +from .project_specs import scaffold_project_artifact, validate_project_artifacts + +try: + from project_utils import ( + CANVAS_FORMATS, + get_project_info as get_project_info_common, + normalize_canvas_format, + validate_project_structure, + validate_svg_viewbox, + ) +except ImportError: + tools_dir = SCRIPTS_DIR + if str(tools_dir) not in sys.path: + sys.path.insert(0, str(tools_dir)) + from project_utils import ( # type: ignore + CANVAS_FORMATS, + get_project_info as get_project_info_common, + normalize_canvas_format, + validate_project_structure, + validate_svg_viewbox, + ) + +TOOLS_DIR = SCRIPTS_DIR +SOURCE_TO_MD_TOOLS_DIR = SOURCE_TO_MD_DIR +if str(SOURCE_TO_MD_TOOLS_DIR) not in sys.path: + sys.path.insert(0, str(SOURCE_TO_MD_TOOLS_DIR)) + +from _dispatcher import ( # noqa: E402 + DOC_SUFFIXES, + EXCEL_SUFFIXES, + LEGACY_EXCEL_SUFFIXES, + PDF_SUFFIXES, + PRESENTATION_SUFFIXES, + build_conversion_command, +) + +SOURCE_DIRNAME = "sources" +TEXT_SOURCE_SUFFIXES = {".md", ".markdown", ".txt"} +TABLE_TEXT_SUFFIXES = {".csv", ".tsv"} +BITMAP_IMAGE_SUFFIXES = { + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", +} +IMAGE_ASSET_SUFFIXES = BITMAP_IMAGE_SUFFIXES | { + ".emf", ".wmf", ".svg", +} + + +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) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + + +def sanitize_name(value: str) -> str: + """Sanitize a user-facing name into a filesystem-safe token.""" + safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in value.strip()) + safe = safe.strip("._") + while "__" in safe: + safe = safe.replace("__", "_") + return safe[:120] or "source" + + +def derive_url_basename(url: str) -> str: + """Derive a stable base filename from a URL.""" + parsed = urlparse(url) + parts = [sanitize_name(parsed.netloc)] + if parsed.path and parsed.path != "/": + path_part = sanitize_name(parsed.path.strip("/").replace("/", "_")) + if path_part: + parts.append(path_part) + return "_".join(part for part in parts if part) or "web_source" + + +def is_within_path(path: Path, parent: Path) -> bool: + """Return whether `path` resolves inside `parent`.""" + try: + path.resolve().relative_to(parent.resolve()) + return True + except ValueError: + return False + + +def _has_usable_import(summary: dict[str, list[str]]) -> bool: + """Return whether import-sources produced at least one usable source artifact.""" + return any( + summary.get(key) + for key in ("archived", "markdown", "assets", "images", "analysis") + ) + + +class ProjectManager: + """Create, inspect, validate, and populate project folders.""" + + CANVAS_FORMATS = CANVAS_FORMATS + + def __init__(self, base_dir: str | Path | None = None) -> None: + self.base_dir = Path(base_dir) if base_dir is not None else Path.cwd() / "projects" + + def scaffold_artifact(self, project_path: str, artifact: str) -> str: + """Delegate deterministic Markdown scaffold rendering.""" + return scaffold_project_artifact(Path(project_path), artifact) + + def init_project( + self, + project_name: str, + canvas_format: str = "ppt169", + base_dir: str | None = None, + *, + quick_generate: bool = False, + ) -> 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())) + raise ValueError( + f"Unsupported canvas format: {canvas_format} " + f"(available: {available}; common alias: xhs -> xiaohongshu)" + ) + + date_str = datetime.now().strftime("%Y%m%d") + # A name already carrying a `__` suffix (e.g. a full + # project dir name pasted back into init) is used as-is — re-appending + # would produce `name_ppt169_20260101_ppt169_20260102`. + if re.search(rf"_{re.escape(normalized_format)}_\d{{8}}$", project_name): + project_dir_name = project_name + else: + 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}") + + project_dirs = ( + ("svg_output",) + if quick_generate + else ( + "svg_output", + "svg_final", + "images", + "icons", + "notes", + "templates", + "live_preview", + SOURCE_DIRNAME, + "analysis", + "validation", + "exports", + ) + ) + for rel_path in project_dirs: + (project_path / rel_path).mkdir(parents=True, exist_ok=True) + + canvas_info = self.CANVAS_FORMATS[normalized_format] + if not quick_generate: + readme_path = project_path / "README.md" + readme_path.write_text( + ( + f"# {project_name}\n\n" + f"- Canvas format: {normalized_format}\n" + f"- Created: {date_str}\n\n" + "## Directories\n\n" + "- `svg_output/`: raw SVG output\n" + "- `svg_final/`: self-contained SVG visual preview; may be inserted manually as an SVG image, but PowerPoint Convert to Shape is unsupported\n" + "- `images/`: runtime image pool; converter assets keep their original short filenames when possible\n" + "- `icons/`: project icon set — selected library icons copied in (via icon_sync.py) plus any custom icons you add; embedded from here at export\n" + "- `notes/`: speaker notes\n" + "- `templates/`: project templates\n" + "- `live_preview/`: browser preview runtime files and history (lock.json, server.log, edits.jsonl, annotations.jsonl)\n" + "- `sources/`: source materials and normalized markdown\n" + "- `analysis/`: machine-extracted intermediate analysis (PPTX intake, image_analysis.csv) — the pipeline's canonical must-read source/asset facts\n" + "- `validation/`: SVG quality reports and PPTX postflight audit reports\n" + "- `exports/`: final native DrawingML pptx deliverables only (timestamped); `_native_charts_tables.pptx` name with `--native-charts-and-tables`, `_narrated.pptx` name when narration audio is embedded\n" + "- `backup//`: svg_output/ archive (always written in default-flow mode; safe to delete old timestamps)\n" + ), + encoding="utf-8", + ) + + print(f"Project created: {project_path}") + print(f"Canvas: {canvas_info['name']} ({canvas_info['dimensions']})") + return str(project_path) + + def _source_dir(self, project_path: Path) -> Path: + sources_dir = project_path / SOURCE_DIRNAME + sources_dir.mkdir(parents=True, exist_ok=True) + return sources_dir + + def _analysis_dir(self, project_path: Path) -> Path: + analysis_dir = project_path / "analysis" + analysis_dir.mkdir(parents=True, exist_ok=True) + return analysis_dir + + def _ensure_unique_path(self, path: Path) -> Path: + if not path.exists(): + return path + + suffix = path.suffix + stem = path.stem + counter = 2 + while True: + candidate = path.with_name(f"{stem}_{counter}{suffix}") + if not candidate.exists(): + return candidate + counter += 1 + + def _copy_or_move_file(self, source: Path, destination: Path, move: bool) -> Path: + try: + if source.resolve() == destination.resolve(): + return destination + except FileNotFoundError: + pass + + destination = self._ensure_unique_path(destination) + if move: + shutil.move(str(source), str(destination)) + else: + shutil.copy2(source, destination) + return destination + + def _copy_or_move_tree(self, source: Path, destination: Path, move: bool) -> Path: + try: + if source.resolve() == destination.resolve(): + return destination + except FileNotFoundError: + pass + + destination = self._ensure_unique_path(destination) + if move: + shutil.move(str(source), str(destination)) + else: + shutil.copytree(source, destination) + return destination + + def _run_tool(self, args: list[str]) -> None: + child_env = os.environ.copy() + child_env["PYTHONUTF8"] = "1" + child_env["PYTHONIOENCODING"] = "utf-8:replace" + try: + result = subprocess.run( + args, + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=child_env, + ) + except FileNotFoundError as exc: + raise RuntimeError(f"Missing executable: {args[0]}") from exc + except subprocess.CalledProcessError as exc: + details = (exc.stderr or exc.stdout or "").strip() + raise RuntimeError(details or "tool execution failed") from exc + + if result.stdout.strip(): + print(result.stdout.strip()) + + def _import_pdf(self, pdf_path: Path, markdown_path: Path) -> None: + route = build_conversion_command( + str(pdf_path), + markdown_path, + forced_type="pdf", + ) + self._run_tool(route.command) + + def _import_doc(self, doc_path: Path, markdown_path: Path) -> None: + route = build_conversion_command( + str(doc_path), + markdown_path, + forced_type="doc", + ) + self._run_tool(route.command) + + def _import_presentation(self, presentation_path: Path, markdown_path: Path) -> None: + route = build_conversion_command( + str(presentation_path), + markdown_path, + forced_type="pptx", + ) + self._run_tool(route.command) + + def _import_pptx_intake(self, presentation_path: Path, project_dir: Path) -> Path: + # Multi-deck intake: each PPTX writes its own `.identity.json` / + # `.slide_library.json` and is merged into the single multi-deck + # index `analysis/source_profile.json` (one entry per source deck). + analysis_dir = self._analysis_dir(project_dir) + self._run_tool( + [ + sys.executable, + str(TOOLS_DIR / "pptx_intake.py"), + str(presentation_path), + "-o", + str(analysis_dir), + ] + ) + return analysis_dir + + def _import_excel(self, excel_path: Path, markdown_path: Path) -> None: + route = build_conversion_command( + str(excel_path), + markdown_path, + forced_type="excel", + ) + self._run_tool(route.command) + + def _import_url(self, url: str, markdown_path: Path) -> None: + route = build_conversion_command( + url, + markdown_path, + forced_type="web", + ) + self._run_tool(route.command) + + def _is_valid_imported_url_markdown(self, markdown_path: Path) -> bool: + """Return whether web_to_md produced a usable Markdown source.""" + if not markdown_path.is_file(): + return False + content = markdown_path.read_text(encoding="utf-8", errors="replace") + if "[Failed URLs]:" in content: + return False + return bool(content.strip()) + + def _archive_url_record(self, sources_dir: Path, url: str) -> Path: + file_path = self._ensure_unique_path(sources_dir / f"{derive_url_basename(url)}.url.txt") + file_path.write_text( + f"URL: {url}\nImported: {datetime.now().isoformat(timespec='seconds')}\n", + encoding="utf-8", + ) + return file_path + + def _normalize_text_source(self, source_path: Path, sources_dir: Path) -> Path: + target = self._ensure_unique_path(sources_dir / f"{source_path.stem}.md") + content = source_path.read_text(encoding="utf-8", errors="replace") + target.write_text(content, encoding="utf-8") + return target + + def _canonicalize_markdown_content(self, content: str) -> str: + canonical = content.replace("\r\n", "\n") + canonical = re.sub(r"(?m)^(\s*Crawled:\s+).*$", r"\1__IGNORED__", canonical) + canonical = re.sub(r"(?m)^(\s*Imported:\s+).*$", r"\1__IGNORED__", canonical) + canonical = re.sub(r"([^\s\]()/]+_files)/", "__ASSET_DIR__/", canonical) + return canonical.strip() + + def _find_equivalent_markdown(self, source_path: Path, sources_dir: Path) -> Path | None: + source_content = source_path.read_text(encoding="utf-8", errors="replace") + canonical_source = self._canonicalize_markdown_content(source_content) + + for existing in sorted(sources_dir.iterdir()): + if existing.suffix.lower() not in {".md", ".markdown"}: + continue + try: + if existing.resolve() == source_path.resolve(): + continue + except FileNotFoundError: + pass + + existing_content = existing.read_text(encoding="utf-8", errors="replace") + if self._canonicalize_markdown_content(existing_content) == canonical_source: + return existing + + return None + + def _companion_asset_dir(self, source_path: Path) -> Path | None: + candidate = source_path.with_name(f"{source_path.stem}_files") + if candidate.exists() and candidate.is_dir(): + return candidate + return None + + def _rewrite_markdown_asset_refs( + self, + markdown_path: Path, + original_asset_dirname: str, + imported_asset_dirname: str, + ) -> None: + if original_asset_dirname == imported_asset_dirname: + return + + content = markdown_path.read_text(encoding="utf-8", errors="replace") + updated = content.replace(f"{original_asset_dirname}/", f"{imported_asset_dirname}/") + if updated != content: + markdown_path.write_text(updated, encoding="utf-8") + + 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.""" + _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] = [] + for item in source_items: + filename = item.get("filename") + if not isinstance(filename, str): + continue + 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() + for item in existing_data: + if not isinstance(item, dict): + continue + filename = item.get("filename") + if not isinstance(filename, str): + continue + normalized_filename = filename.casefold() + if normalized_filename in new_by_filename: + merged.append(new_by_filename[normalized_filename]) + else: + merged.append(item) + seen.add(normalized_filename) + + for normalized_filename in new_order: + if normalized_filename not in seen: + merged.append(new_by_filename[normalized_filename]) + + _validate_image_manifest(merged, destination_manifest) + _write_json_atomic(destination_manifest, merged) + + @staticmethod + def _namespace_from_asset_dir(asset_dir: Path) -> str: + """Derive a per-source namespace from a `_files` companion directory name.""" + name = asset_dir.name + suffix = "_files" + return name[:-len(suffix)] if name.endswith(suffix) else name + + def _image_destination_name( + self, + images_dir: Path, + 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 candidate.name.casefold() not in occupied_names: + return source_file.name + try: + meta = existing_manifest.get(candidate.name, {}) + if ( + meta.get("source_namespace") == namespace + and candidate.is_file() + and filecmp.cmp(source_file, candidate, shallow=False) + ): + return candidate.name + except OSError: + pass + + stem = source_file.stem + suffix = source_file.suffix + counter = 2 + while True: + candidate = images_dir / f"{stem}_{counter}{suffix}" + if candidate.name.casefold() not in occupied_names: + return candidate.name + try: + meta = existing_manifest.get(candidate.name, {}) + if ( + meta.get("source_namespace") == namespace + and candidate.is_file() + and filecmp.cmp(source_file, candidate, shallow=False) + ): + return candidate.name + except OSError: + pass + counter += 1 + + def _propagate_image_assets(self, asset_dir: Path, project_dir: Path) -> None: + """Copy converter-generated image assets and manifest into project images/. + + Filenames are preserved when possible because source Markdown commonly + uses short names that are meaningful in context. Only real collisions + receive a compact numeric suffix. + """ + manifest_path = asset_dir / "image_manifest.json" + if not manifest_path.is_file(): + return + + try: + 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 + 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) + + 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 + for source_file in sorted(asset_dir.iterdir()): + if not source_file.is_file(): + continue + if source_file.suffix.lower() not in IMAGE_ASSET_SUFFIXES: + continue + new_name = self._image_destination_name( + images_dir, + 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 + + rebased_items: list[dict] = [] + for item in source_data: + if not isinstance(item, dict): + continue + original = item.get("filename") + if not isinstance(original, str): + continue + new_item = dict(item) + new_item["filename"] = rename_map.get(original, original) + new_item["source_namespace"] = namespace + rebased_items.append(new_item) + + self._merge_image_manifest(rebased_items, images_dir / "image_manifest.json") + print( + f"Propagated {copied_count} image asset(s) + manifest " + f"from {asset_dir} → images/ (namespace: {namespace})" + ) + + def _propagate_companion_image_assets(self, markdown_path: Path, project_dir: Path) -> None: + asset_dir = markdown_path.with_name(f"{markdown_path.stem}_files") + if asset_dir.is_dir(): + self._propagate_image_assets(asset_dir, project_dir) + + def _import_markdown_with_assets( + self, + source_path: Path, + sources_dir: Path, + move: bool, + ) -> tuple[Path, Path | None, str | None]: + archived_markdown = self._copy_or_move_file( + source_path, + sources_dir / source_path.name, + move=move, + ) + + profile_src = source_path.with_name(f"{source_path.stem}.conversion_profile.json") + if profile_src.is_file(): + self._copy_or_move_file( + profile_src, + sources_dir / f"{archived_markdown.stem}.conversion_profile.json", + move=move, + ) + + asset_dir = self._companion_asset_dir(source_path) + if asset_dir is None: + return archived_markdown, None, None + + imported_asset_dir = self._copy_or_move_tree( + asset_dir, + sources_dir / f"{archived_markdown.stem}_files", + move=move, + ) + self._rewrite_markdown_asset_refs( + archived_markdown, + original_asset_dirname=asset_dir.name, + imported_asset_dirname=imported_asset_dir.name, + ) + + note = None + if archived_markdown.stem != source_path.stem: + note = ( + f"{source_path}: renamed imported markdown to {archived_markdown.name} " + f"and rewrote asset references to {imported_asset_dir.name}/" + ) + return archived_markdown, imported_asset_dir, note + + def import_sources( + self, + project_path: str, + source_items: list[str], + move: bool = False, + copy: bool = False, + ) -> dict[str, list[str]]: + if move and copy: + raise ValueError("--move and --copy are mutually exclusive") + project_dir = Path(project_path) + if not project_dir.exists() or not project_dir.is_dir(): + raise FileNotFoundError(f"Project directory not found: {project_dir}") + if not source_items: + raise ValueError("At least one source path or URL is required") + + sources_dir = self._source_dir(project_dir) + summary: dict[str, list[str]] = { + "archived": [], + "url_records": [], + "markdown": [], + "assets": [], + "images": [], + "analysis": [], + "notes": [], + "skipped": [], + } + + expanded_items: list[str] = [] + supplied_dirs: list[Path] = [] + for item in source_items: + if is_url(item): + expanded_items.append(item) + continue + item_path = Path(item) + if item_path.is_dir(): + supplied_dirs.append(item_path) + directory_files = sorted( + path for path in item_path.iterdir() if path.is_file() + ) + if directory_files: + expanded_items.extend(str(path) for path in directory_files) + summary["notes"].append( + f"{item}: expanded directory into {len(directory_files)} file(s)" + ) + else: + summary["skipped"].append(f"{item}: directory contains no files") + continue + expanded_items.append(item) + + explicit_markdown_stems = { + Path(item).stem + for item in expanded_items + if not is_url(item) + and Path(item).exists() + and Path(item).is_file() + and Path(item).suffix.lower() in {".md", ".markdown"} + } + + for item in expanded_items: + if is_url(item): + markdown_path = self._ensure_unique_path( + sources_dir / f"{derive_url_basename(item)}.md" + ) + try: + self._import_url(item, markdown_path) + except Exception as exc: # pragma: no cover - summary path + archived = self._archive_url_record(sources_dir, item) + summary["url_records"].append(str(archived)) + summary["skipped"].append(f"{item}: {exc}") + continue + + if not self._is_valid_imported_url_markdown(markdown_path): + markdown_path.unlink(missing_ok=True) + archived = self._archive_url_record(sources_dir, item) + summary["url_records"].append(str(archived)) + summary["skipped"].append(f"{item}: URL conversion produced no usable Markdown") + continue + + summary["markdown"].append(str(markdown_path)) + self._propagate_companion_image_assets(markdown_path, project_dir) + continue + + source_path = Path(item) + if not source_path.exists(): + summary["skipped"].append(f"{item}: path not found") + continue + if source_path.is_dir(): + summary["skipped"].append(f"{item}: directories are not supported") + continue + + inside_projects = is_within_path(source_path, PROJECTS_ROOT) + if copy: + effective_move = False + elif inside_projects: + effective_move = True + else: + effective_move = False + if move and not inside_projects: + print( + f"note: {source_path} is outside {PROJECTS_ROOT}; copied " + f"(not moved). Only sources under projects/ may be moved.", + file=sys.stderr, + ) + elif inside_projects and not move and not copy: + print( + f"note: {source_path} is under projects/; moved into the target " + f"project. Pass --copy to preserve it.", + file=sys.stderr, + ) + suffix = source_path.suffix.lower() + + if suffix in {".md", ".markdown"}: + duplicate_markdown = self._find_equivalent_markdown(source_path, sources_dir) + if duplicate_markdown is not None: + summary["markdown"].append(str(duplicate_markdown)) + self._propagate_companion_image_assets(duplicate_markdown, project_dir) + summary["notes"].append( + f"{item}: skipped duplicate markdown import because equivalent content already exists as {duplicate_markdown.name}" + ) + continue + + archived_markdown, asset_dir, note = self._import_markdown_with_assets( + source_path, + sources_dir, + move=effective_move, + ) + summary["archived"].append(str(archived_markdown)) + summary["markdown"].append(str(archived_markdown)) + if asset_dir is not None: + summary["assets"].append(str(asset_dir)) + self._propagate_image_assets(asset_dir, project_dir) + if note: + summary["notes"].append(note) + continue + + archived_path = self._copy_or_move_file( + source_path, + sources_dir / source_path.name, + move=effective_move, + ) + summary["archived"].append(str(archived_path)) + + 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( + f"{item}: skipped PDF auto-conversion because a same-stem Markdown source was provided" + ) + continue + if canonical_markdown_path.exists(): + summary["markdown"].append(str(canonical_markdown_path)) + self._propagate_companion_image_assets(canonical_markdown_path, project_dir) + summary["notes"].append( + f"{item}: skipped PDF auto-conversion because {canonical_markdown_path.name} already exists" + ) + continue + markdown_path = canonical_markdown_path + try: + self._import_pdf(archived_path, markdown_path) + summary["markdown"].append(str(markdown_path)) + self._propagate_companion_image_assets(markdown_path, project_dir) + except Exception as exc: # pragma: no cover - summary path + summary["skipped"].append(f"{item}: PDF conversion failed ({exc})") + elif suffix in PRESENTATION_SUFFIXES: + canonical_markdown_path = sources_dir / f"{archived_path.stem}.md" + try: + intake_dir = self._import_pptx_intake(archived_path, project_dir) + intake_str = str(intake_dir) + if intake_str not in summary["analysis"]: + summary["analysis"].append(intake_str) + except Exception as exc: # pragma: no cover - summary path + summary["notes"].append(f"{item}: PPTX intake analysis failed ({exc})") + if archived_path.stem in explicit_markdown_stems: + summary["notes"].append( + f"{item}: skipped presentation auto-conversion because a same-stem Markdown source was provided" + ) + continue + if canonical_markdown_path.exists(): + summary["markdown"].append(str(canonical_markdown_path)) + self._propagate_companion_image_assets(canonical_markdown_path, project_dir) + summary["notes"].append( + f"{item}: skipped presentation auto-conversion because {canonical_markdown_path.name} already exists" + ) + continue + markdown_path = canonical_markdown_path + try: + self._import_presentation(archived_path, markdown_path) + summary["markdown"].append(str(markdown_path)) + self._propagate_companion_image_assets(markdown_path, project_dir) + except Exception as exc: # pragma: no cover - summary path + summary["skipped"].append(f"{item}: presentation conversion failed ({exc})") + elif suffix in EXCEL_SUFFIXES: + canonical_markdown_path = sources_dir / f"{archived_path.stem}.md" + if archived_path.stem in explicit_markdown_stems: + summary["notes"].append( + f"{item}: skipped Excel auto-conversion because a same-stem Markdown source was provided" + ) + continue + if canonical_markdown_path.exists(): + summary["markdown"].append(str(canonical_markdown_path)) + self._propagate_companion_image_assets(canonical_markdown_path, project_dir) + summary["notes"].append( + f"{item}: skipped Excel auto-conversion because {canonical_markdown_path.name} already exists" + ) + continue + markdown_path = canonical_markdown_path + try: + self._import_excel(archived_path, markdown_path) + summary["markdown"].append(str(markdown_path)) + self._propagate_companion_image_assets(markdown_path, project_dir) + except Exception as exc: # pragma: no cover - summary path + summary["skipped"].append(f"{item}: Excel conversion failed ({exc})") + elif suffix in LEGACY_EXCEL_SUFFIXES: + summary["notes"].append( + f"{item}: archived only; legacy .xls is not converted automatically. " + "Resave as .xlsx to generate Markdown." + ) + elif suffix in TABLE_TEXT_SUFFIXES: + summary["notes"].append( + f"{item}: archived as a plain-text table source; no Markdown conversion needed" + ) + elif suffix in DOC_SUFFIXES: + canonical_markdown_path = sources_dir / f"{archived_path.stem}.md" + if archived_path.stem in explicit_markdown_stems: + summary["notes"].append( + f"{item}: skipped document auto-conversion because a same-stem Markdown source was provided" + ) + continue + if canonical_markdown_path.exists(): + summary["markdown"].append(str(canonical_markdown_path)) + self._propagate_companion_image_assets(canonical_markdown_path, project_dir) + summary["notes"].append( + f"{item}: skipped document auto-conversion because {canonical_markdown_path.name} already exists" + ) + continue + markdown_path = canonical_markdown_path + try: + self._import_doc(archived_path, markdown_path) + summary["markdown"].append(str(markdown_path)) + self._propagate_companion_image_assets(markdown_path, project_dir) + except Exception as exc: # pragma: no cover - summary path + summary["skipped"].append(f"{item}: document conversion failed ({exc})") + elif suffix == ".txt": + markdown_path = self._normalize_text_source(archived_path, sources_dir) + summary["markdown"].append(str(markdown_path)) + else: + summary["notes"].append(f"{item}: archived only, no automatic conversion") + + # Cleanup: only a projects-local source directory may be removed after + # its files move into the target project. Every other location is copied + # and remains untouched, even when the caller passes --move. + for directory in supplied_dirs: + if copy or not is_within_path(directory, PROJECTS_ROOT): + continue + if directory.is_dir() and not any(directory.iterdir()): + try: + directory.rmdir() + except OSError: + continue + summary["notes"].append( + f"{directory}: removed empty source directory after import" + ) + + return summary + + def validate_project(self, project_path: str) -> tuple[bool, list[str], list[str]]: + project_path_obj = Path(project_path) + _, errors, warnings = validate_project_structure( + str(project_path_obj), + validate_communication=False, + ) + + if project_path_obj.exists() and project_path_obj.is_dir(): + project_info = get_project_info_common(str(project_path_obj)) + artifact_errors, artifact_warnings = validate_project_artifacts( + project_path_obj, + project_info, + ) + errors.extend(artifact_errors) + warnings.extend(artifact_warnings) + + if project_path_obj.exists() and project_path_obj.is_dir(): + info = get_project_info_common(str(project_path_obj)) + if info.get("svg_files"): + svg_files = [project_path_obj / "svg_output" / name for name in info["svg_files"]] + expected_format = info.get("format") + if expected_format == "unknown": + expected_format = None + warnings.extend(validate_svg_viewbox(svg_files, expected_format)) + + return not errors, list(dict.fromkeys(errors)), warnings + + def get_project_info(self, project_path: str) -> dict[str, object]: + shared = get_project_info_common(project_path) + return { + "name": shared.get("name", Path(project_path).name), + "path": shared.get("path", str(project_path)), + "exists": shared.get("exists", False), + "svg_count": shared.get("svg_count", 0), + "has_spec": shared.get("has_spec", False), + "has_source": shared.get("has_source", False), + "source_count": shared.get("source_count", 0), + "canvas_format": shared.get("format_name", "Unknown"), + "create_date": shared.get("date_formatted", "Unknown"), + } + + +def build_parser() -> argparse.ArgumentParser: + """Build the command-line parser.""" + parser = argparse.ArgumentParser( + description="PPT Master project management helpers.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""Examples: + python3 scripts/project_manager.py init demo --format ppt169 + python3 scripts/project_manager.py import-sources projects/demo file.md + python3 scripts/project_manager.py scaffold-spec projects/demo_ppt169_20260718 + python3 scripts/project_manager.py scaffold-lock projects/demo_ppt169_20260718 + python3 scripts/project_manager.py validate projects/demo + python3 scripts/project_manager.py info projects/demo + python3 scripts/project_manager.py page-context projects/demo P07 --record-usage + python3 scripts/project_manager.py page-context-report projects/demo +""", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + init = subparsers.add_parser("init", help="Create a project directory") + init.add_argument("project_name", help="Project name") + init.add_argument("--format", default="ppt169", help="Canvas format (default: ppt169)") + init.add_argument("--dir", default=None, help="Base directory for the project") + init.add_argument( + "--quick-generate", + action="store_true", + help="Create only the svg_output directory and omit README.md", + ) + + import_sources = subparsers.add_parser( + "import-sources", + help="Import source files or URLs into a project", + ) + import_sources.add_argument("project_path", help="Project directory") + import_sources.add_argument("sources", nargs="+", help="Source files, directories, or URLs") + mode = import_sources.add_mutually_exclusive_group() + mode.add_argument( + "--move", + action="store_true", + help="Move local sources under projects/; sources elsewhere are copied", + ) + mode.add_argument("--copy", action="store_true", help="Copy local source files") + + scaffold_spec = subparsers.add_parser( + "scaffold-spec", + help="Create design_spec.md from the versioned scaffold", + ) + scaffold_spec.add_argument("project_path", help="Project directory") + + scaffold_lock = subparsers.add_parser( + "scaffold-lock", + help="Create spec_lock.md from the versioned scaffold", + ) + scaffold_lock.add_argument("project_path", help="Project directory") + + validate = subparsers.add_parser("validate", help="Validate a project directory") + validate.add_argument("project_path", help="Project directory") + + info = subparsers.add_parser("info", help="Print project metadata") + info.add_argument("project_path", help="Project directory") + + page_context = subparsers.add_parser( + "page-context", + help="Print one deterministic per-page execution view", + ) + page_context.add_argument("project_path", help="Project directory") + page_context.add_argument("page", help="Positive page key such as P07") + page_context.add_argument( + "--bundle", + action="store_true", + help="Deprecated compatibility flag; output remains compact", + ) + page_context.add_argument( + "--pretty", + action="store_true", + help="Pretty-print the page-context JSON payload", + ) + page_context.add_argument( + "--record-usage", + action="store_true", + help="Write compact-output token telemetry under analysis/page-context/", + ) + + page_context_report = subparsers.add_parser( + "page-context-report", + help="Summarize fresh per-page context telemetry", + ) + page_context_report.add_argument("project_path", help="Project directory") + return parser + + +def main(argv: list[str] | None = None) -> int: + """Run the CLI entry point.""" + parser = build_parser() + args = parser.parse_args(argv) + manager = ProjectManager() + + try: + if args.command == "init": + project_path = manager.init_project( + args.project_name, + args.format, + base_dir=args.dir, + quick_generate=args.quick_generate, + ) + print(f"[OK] Project initialized: {project_path}") + print("Next:") + if args.quick_generate: + print("1. Generate SVG files into svg_output/") + print("2. Run the Quick Generate final checker and exporter") + else: + print("1. Put source files into sources/ (or use import-sources)") + print("2. Save your design spec to the project root") + print("3. Generate SVG files into svg_output/") + return 0 + + if args.command == "import-sources": + summary = manager.import_sources( + args.project_path, + args.sources, + move=args.move, + copy=args.copy, + ) + has_usable_import = _has_usable_import(summary) + if has_usable_import: + print(f"[OK] Imported sources into: {args.project_path}") + else: + print( + f"[ERROR] No usable sources imported into: {args.project_path}", + file=sys.stderr, + ) + if summary["archived"]: + print("\nArchived originals:") + for item in summary["archived"]: + print(f" - {item}") + if summary["url_records"]: + print("\nArchived URL records:") + for item in summary["url_records"]: + print(f" - {item}") + if summary["markdown"]: + print("\nNormalized markdown:") + for item in summary["markdown"]: + print(f" - {item}") + if summary["assets"]: + 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"]: + print(f" - {item}") + if summary["notes"]: + print("\nNotes:") + for item in summary["notes"]: + print(f" - {item}") + if summary["skipped"]: + print("\nSkipped:") + for item in summary["skipped"]: + print(f" - {item}") + return 0 if has_usable_import else 1 + + if args.command == "scaffold-spec": + artifact_path = manager.scaffold_artifact(args.project_path, "design_spec") + print(f"[OK] Design spec scaffold created: {artifact_path}") + return 0 + + if args.command == "scaffold-lock": + artifact_path = manager.scaffold_artifact(args.project_path, "spec_lock") + print(f"[OK] Execution lock scaffold created: {artifact_path}") + return 0 + + if args.command == "validate": + project_path = args.project_path + is_valid, errors, warnings = manager.validate_project(project_path) + + print(f"\nProject validation: {project_path}") + print("=" * 60) + + if errors: + print("\n[ERROR]") + for error in errors: + print(f" - {error}") + + if warnings: + print("\n[WARN]") + for warning in warnings: + print(f" - {warning}") + + if is_valid and not warnings: + print("\n[OK] Project structure is complete.") + elif is_valid: + print("\n[OK] Project structure is valid, with warnings.") + else: + print("\n[ERROR] Project structure is invalid.") + return 1 + return 0 + + if args.command == "info": + project_path = args.project_path + info = manager.get_project_info(project_path) + + print(f"\nProject info: {info['name']}") + print("=" * 60) + print(f"Path: {info['path']}") + print(f"Exists: {'Yes' if info['exists'] else 'No'}") + print(f"SVG files: {info['svg_count']}") + print(f"Design spec: {'Yes' if info['has_spec'] else 'No'}") + print(f"Source materials: {'Yes' if info['has_source'] else 'No'}") + print(f"Source count: {info['source_count']}") + print(f"Canvas format: {info['canvas_format']}") + print(f"Created: {info['create_date']}") + return 0 + + if args.command == "page-context": + result = build_page_context(args.project_path, args.page) + output, measured_reads = render_page_context( + result, + bundle=args.bundle, + pretty=args.pretty, + ) + if args.record_usage: + _usage_path, token_status = record_page_context_usage( + result, + output, + measured_reads, + ) + if token_status != "exact": + print( + "[WARN] tiktoken/o200k_base unavailable; recorded bytes " + "and hashes without token counts", + file=sys.stderr, + ) + print(output, end="") + return 0 + + if args.command == "page-context-report": + report = page_context_usage_report(args.project_path) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + + parser.error(f"Unknown command: {args.command}") + except Exception as exc: + print(f"[ERROR] {exc}") + return 1 diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_management/page_context.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_management/page_context.py new file mode 100644 index 00000000..3bbf2732 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_management/page_context.py @@ -0,0 +1,916 @@ +#!/usr/bin/env python3 +""" +PPT Master - Page Context Projection + +Build deterministic per-page execution views and optional token telemetry. + +Usage: + Imported by project_management.cli. + +Examples: + build_page_context(Path("projects/demo"), "P07") + +Dependencies: + None for projection; tiktoken is optional for exact usage counts. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +import statistics +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable + +from .paths import CHARTS_DIR as _CHARTS_DIR +from .paths import SKILL_DIR as _SKILL_DIR +from .project_specs import ( + default_spec_lock_forbidden, + parse_markdown_artifact, + parse_spec_lock_artifact, + validate_project_artifacts, +) +from svg_to_pptx.pptx_package.template_structure import ( + PptxStructureLock, + TemplateStructureError, + load_pptx_structure_lock, +) + + +PAGE_CONTEXT_SCHEMA = "ppt-master.page-context.v2" +PAGE_CONTEXT_USAGE_SCHEMA = "ppt-master.page-context-usage.v2" +PAGE_CONTEXT_REPORT_SCHEMA = "ppt-master.page-context-usage-report.v2" +TOKEN_ENCODING = "o200k_base" +PAGE_CONTEXT_TOKEN_TARGET = 2000 +LOCK_PROJECTION_TOKEN_TARGET = 1000 + +_PAGE_RE = re.compile(r"^(?:P)?([0-9]+)$", re.IGNORECASE) +_SLIDE_HEADING_RE = re.compile( + r"^#{3,6}[ \t]+Slide[ \t]+0*([0-9]+)(?:[ \t]*(?:[-:–—]).*)?$", + re.IGNORECASE | re.MULTILINE, +) +_BLOCK_BOUNDARY_RE = re.compile(r"^#{2,6}[ \t]+", re.MULTILINE) +_PART_HEADING_RE = re.compile(r"^###[ \t]+(?!#)(.+?)[ \t]*$", re.MULTILINE) +_PAGE_TOKEN_RE = re.compile( + r"(? tuple[str, int]: + """Normalize a positive page identifier to the schema's P form.""" + match = _PAGE_RE.fullmatch(raw_page.strip()) + if match is None or int(match.group(1)) <= 0: + raise PageContextError("page must be a positive P identifier") + number = int(match.group(1)) + return f"P{number:02d}", number + + +def _section_index( + sections: Iterable[dict[str, object]], +) -> dict[str, dict[str, object]]: + return { + str(section["heading"]).strip().casefold(): section + for section in sections + } + + +def _section_fields( + sections: dict[str, dict[str, object]], + heading: str, +) -> dict[str, str]: + section = sections.get(heading.casefold()) + if section is None: + return {} + fields = section.get("fields", {}) + if not isinstance(fields, dict): + return {} + return {str(key): str(value) for key, value in fields.items()} + + +def _forbidden_items( + sections: dict[str, dict[str, object]], +) -> list[str]: + section = sections.get("forbidden") + if section is None: + return [] + items: list[str] = [] + default_items = default_spec_lock_forbidden() + for raw_line in str(section.get("body", "")).splitlines(): + line = raw_line.strip() + if not line: + continue + item = re.sub(r"^-[ \t]+", "", line) + if item not in default_items: + items.append(item) + return items + + +def _outline_section( + sections: Iterable[dict[str, object]], +) -> dict[str, object] | None: + for section in sections: + heading = str(section.get("heading", "")).strip().casefold() + if heading == "content outline" or heading.endswith(". content outline"): + return section + return None + + +def _page_image_filenames( + design_sections: Iterable[dict[str, object]], + page_number: int, +) -> tuple[set[str], set[str]]: + """Read explicit P usage from the canonical image-resource table.""" + section = next( + ( + item + for item in design_sections + if ( + (heading := str(item.get("heading", "")).strip().casefold()) + == "image resource list" + or heading.startswith("viii. image resource list") + ) + ), + None, + ) + if section is None: + return set(), set() + table_rows = [ + [ + cell.strip().replace(r"\|", "|") + for cell in re.split(r"(? str: + return Path(value.split("|", 1)[0].strip()).name + + +def _outline_image_assignments( + design_sections: Iterable[dict[str, object]], + locked_images: dict[str, str], +) -> set[str]: + outline = _outline_section(design_sections) + if outline is None: + return set() + body = str(outline.get("body", "")) + return { + _locked_image_basename(value) + for key, value in locked_images.items() + if any( + _contains_token(body, token) + for token in ( + key, + value.split("|", 1)[0].strip(), + _locked_image_basename(value), + ) + ) + } + + +def _slide_block( + design_sections: Iterable[dict[str, object]], + page_number: int, +) -> tuple[str | None, str]: + outline = _outline_section(design_sections) + if outline is None: + raise PageContextError("design_spec.md has no Content Outline section") + body = str(outline.get("body", "")) + matches = [ + match + for match in _SLIDE_HEADING_RE.finditer(body) + if int(match.group(1)) == page_number + ] + if not matches: + raise PageContextError( + f"design_spec.md Content Outline has no Slide {page_number:02d} block" + ) + if len(matches) > 1: + raise PageContextError( + f"design_spec.md Content Outline repeats Slide {page_number:02d}" + ) + match = matches[0] + next_boundary = _BLOCK_BOUNDARY_RE.search(body, match.end()) + block_end = next_boundary.start() if next_boundary else len(body) + block = body[match.start():block_end].strip() + part_matches = list(_PART_HEADING_RE.finditer(body, 0, match.start())) + part = part_matches[-1].group(1).strip() if part_matches else None + return part, block + + +def _relative_project_path(project_path: Path, path: Path) -> str: + try: + return path.resolve().relative_to(project_path).as_posix() + except ValueError as exc: + raise PageContextError(f"path escapes project: {path}") from exc + + +def _prototype_image_refs(svg_path: Path) -> list[str]: + try: + root = ET.parse(svg_path).getroot() + except (OSError, ET.ParseError) as exc: + raise PageContextError(f"cannot read prototype SVG {svg_path}: {exc}") from exc + refs: set[str] = set() + for element in root.iter(): + if element.tag.rsplit("}", 1)[-1] != "image": + continue + for name, value in element.attrib.items(): + if name.rsplit("}", 1)[-1] != "href": + continue + normalized = value.strip() + if normalized and not normalized.startswith(("data:", "#")): + refs.add(normalized) + return sorted(refs) + + +def _contains_token(text: str, token: str) -> bool: + if not token: + return False + if re.fullmatch(r"[A-Za-z0-9_]+", token): + return re.search( + rf"(? tuple[str, dict[str, str]]: + if not locked_images: + return "none", {} + ref_basenames = {Path(ref).name for ref in prototype_refs} + selected: dict[str, str] = {} + unresolved: dict[str, str] = {} + for key, value in locked_images.items(): + basename = _locked_image_basename(value) + if ( + _contains_token(brief, key) + or _contains_token(brief, value) + or _contains_token(brief, basename) + or basename in ref_basenames + or basename in assigned_filenames + ): + selected[key] = value + elif basename not in resolved_filenames: + unresolved[key] = value + if selected and unresolved: + return "explicit+unassigned", {**selected, **unresolved} + if selected: + return "explicit", selected + if unresolved: + return "unassigned", unresolved + return "confirmed-none", {} + + +def _page_template( + project_path: Path, + structure_lock: PptxStructureLock | None, + page_number: int, +) -> tuple[dict[str, object] | None, Path | None]: + if structure_lock is None or structure_lock.mode != "structured": + return None, None + prototype = next( + (item for item in structure_lock.prototypes if item.slide_num == page_number), + None, + ) + assignment = next( + (item for item in structure_lock.layouts if item.slide_num == page_number), + None, + ) + if prototype is None or assignment is None: + raise PageContextError( + f"structured lock has no complete mapping for P{page_number:02d}" + ) + definition = next( + ( + item + for item in structure_lock.layout_definitions + if item.layout_key == assignment.layout_key + ), + None, + ) + if definition is None: + raise PageContextError( + f"structured lock has no definition for Layout {assignment.layout_key!r}" + ) + master = next( + ( + item + for item in structure_lock.masters + if item.master_key == definition.master_key + ), + None, + ) + if master is None: + raise PageContextError( + f"structured lock has no definition for Master {definition.master_key!r}" + ) + template = { + "reuse_scope": structure_lock.template_reuse_scope, + "adherence": structure_lock.template_adherence, + "prototype": prototype.template_basename, + "prototype_path": _relative_project_path(project_path, prototype.svg_path), + "layout": { + "key": definition.layout_key, + "name": definition.layout_name, + "source": ( + f"P{definition.prototype_slide_num:02d}" + if definition.prototype_slide_num is not None + else _relative_project_path( + project_path, + definition.prototype_svg_path, + ) + ), + }, + "master": { + "key": master.master_key, + "name": master.master_name, + }, + } + return template, prototype.svg_path + + +def _reference_payload( + kind: str, + path: Path, + *, + scope: str, + display_path: str, + same_context_edit_policy: str | None = None, +) -> dict[str, str]: + """Describe one large reference without injecting its contents per page.""" + payload = { + "kind": kind, + "scope": scope, + "path": display_path, + "sha256": _file_sha256(path), + "load_policy": "once-per-execution-context", + } + if same_context_edit_policy is not None: + payload["same_context_edit_policy"] = same_context_edit_policy + return payload + + +def _chart_reference(chart_key: str) -> tuple[dict[str, str], Path]: + """Resolve one locked chart key to the shared Skill catalog.""" + if Path(chart_key).name != chart_key or not chart_key: + raise PageContextError(f"invalid page_charts key: {chart_key!r}") + chart_path = (_CHARTS_DIR / f"{chart_key}.svg").resolve() + if not chart_path.is_file(): + raise PageContextError( + f"page_charts key {chart_key!r} has no shared SVG reference" + ) + return ( + _reference_payload( + "chart-svg", + chart_path, + scope="skill", + display_path=f"templates/charts/{chart_path.name}", + ), + chart_path, + ) + + +def build_page_context(project: str | Path, raw_page: str) -> PageContextResult: + """Build one current per-page projection without writing the project.""" + project_path = Path(project).resolve() + if not project_path.is_dir(): + raise PageContextError(f"project directory not found: {project_path}") + page, page_number = normalize_page_key(raw_page) + lock_path = project_path / "spec_lock.md" + design_path = project_path / "design_spec.md" + for required in (lock_path, design_path): + if not required.is_file(): + raise PageContextError(f"required artifact not found: {required.name}") + preflight_errors, _preflight_warnings = validate_project_artifacts( + project_path, + include_design=False, + ) + if preflight_errors: + preview = "; ".join(preflight_errors[:8]) + suffix = ( + "" + if len(preflight_errors) <= 8 + else f"; +{len(preflight_errors) - 8} more" + ) + raise PageContextError( + "spec_lock/template preflight failed before page generation: " + f"{preview}{suffix}" + ) + try: + lock_sections_raw = parse_spec_lock_artifact( + lock_path, + report_duplicate_fields=True, + ) + design_sections = parse_markdown_artifact(design_path) + except (OSError, ValueError) as exc: + raise PageContextError(str(exc)) from exc + lock_sections = _section_index(lock_sections_raw) + part, brief = _slide_block(design_sections, page_number) + warnings: list[str] = [] + rhythm_fields = _section_fields(lock_sections, "page_rhythm") + rhythm = rhythm_fields.get(page) + if rhythm is None: + rhythm = "dense" + warnings.append(f"page_rhythm has no {page}; using compatibility default dense") + chart_key = _section_fields(lock_sections, "page_charts").get(page) + try: + structure_lock = load_pptx_structure_lock(project_path) + except TemplateStructureError as exc: + raise PageContextError(str(exc)) from exc + template, prototype_path = _page_template( + project_path, + structure_lock, + page_number, + ) + prototype_refs = ( + _prototype_image_refs(prototype_path) + if prototype_path is not None + else [] + ) + table_assigned_filenames, assigned_filenames = _page_image_filenames( + design_sections, + page_number, + ) + locked_images = _section_fields(lock_sections, "images") + resolved_filenames = ( + {_locked_image_basename(value) for value in locked_images.values()} + if structure_lock is not None + and structure_lock.template_reuse_scope == "mirror" + else table_assigned_filenames + | _outline_image_assignments(design_sections, locked_images) + ) + image_selection, selected_images = _page_images( + locked_images, + brief, + ( + prototype_refs + if structure_lock is not None + and structure_lock.template_reuse_scope == "mirror" + else [] + ), + assigned_filenames, + resolved_filenames, + ) + inputs = [lock_path, design_path] + reference_set: list[dict[str, str]] = [ + _reference_payload( + "design-spec", + design_path, + scope="project", + display_path="design_spec.md", + same_context_edit_policy="targeted-readback-and-rebind", + ), + ] + template_design_path = project_path / "templates" / "design_spec.md" + if template_design_path.is_file(): + inputs.append(template_design_path) + reference_set.append( + _reference_payload( + "template-design-spec", + template_design_path, + scope="project", + display_path="templates/design_spec.md", + ) + ) + if prototype_path is not None: + inputs.append(prototype_path) + reference_set.append( + _reference_payload( + "prototype-svg", + prototype_path, + scope="project", + display_path=_relative_project_path(project_path, prototype_path), + ) + ) + if chart_key is not None: + chart_reference, chart_path = _chart_reference(chart_key) + inputs.append(chart_path) + reference_set.append(chart_reference) + mode_fields = _section_fields(lock_sections, "mode") + visual_style_fields = _section_fields(lock_sections, "visual_style") + # Each on-demand projection includes bounded lock anchors; large reference + # payloads stay outside it and are represented by reference_set. + global_context = { + "communication": _section_fields(lock_sections, "communication"), + "canvas": _section_fields(lock_sections, "canvas"), + "mode": mode_fields.get("mode"), + "mode_behavior": mode_fields.get("mode_behavior"), + "visual_style": visual_style_fields.get("visual_style"), + "visual_style_behavior": visual_style_fields.get( + "visual_style_behavior" + ), + "colors": _section_fields(lock_sections, "colors"), + "typography": _section_fields(lock_sections, "typography"), + "icons": _section_fields(lock_sections, "icons"), + "pptx_structure": _section_fields(lock_sections, "pptx_structure"), + "forbidden": _forbidden_items(lock_sections), + } + global_context = { + key: value + for key, value in global_context.items() + if value not in ({}, [], None, "") + } + current_page: dict[str, object] = { + "part": part, + "brief_markdown": brief, + "rhythm": rhythm, + "image_selection": image_selection, + } + if chart_key is not None: + current_page["chart"] = chart_key + if selected_images: + current_page["images"] = selected_images + if template is not None: + current_page["template"] = template + context: dict[str, object] = { + "schema": PAGE_CONTEXT_SCHEMA, + "page": page, + "lock_source": { + "path": "spec_lock.md", + "sha256": _file_sha256(lock_path), + "load_policy": "on-demand-anchor-projection", + }, + "global": global_context, + "page_context": current_page, + "reference_set": reference_set, + } + if warnings: + context["warnings"] = warnings + unique_inputs = tuple(dict.fromkeys(path.resolve() for path in inputs)) + return PageContextResult( + project_path=project_path, + page=page, + context=context, + inputs=unique_inputs, + ) + + +def _compact_json(payload: object) -> str: + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n" + + +def _pretty_json(payload: object) -> str: + return json.dumps(payload, ensure_ascii=False, indent=2) + "\n" + + +def render_page_context( + result: PageContextResult, + *, + bundle: bool = False, + pretty: bool = False, +) -> tuple[str, tuple[PageRead, ...]]: + """Render compact stdout; ``bundle`` remains a compatibility no-op.""" + context_payload = ( + _pretty_json(result.context) if pretty else _compact_json(result.context) + ) + context_read = PageRead( + kind="page-context", + path="stdout:page-context", + payload=context_payload, + ) + return context_payload, (context_read,) + + +def _sha256_bytes(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _input_location(project_path: Path, path: Path) -> tuple[str, str]: + """Return a stable project- or Skill-relative locator for telemetry.""" + resolved = path.resolve() + for scope, root in (("project", project_path), ("skill", _SKILL_DIR)): + try: + return scope, resolved.relative_to(root.resolve()).as_posix() + except ValueError: + continue + raise PageContextError(f"input escapes project and Skill roots: {path}") + + +def _resolve_input_location( + project_path: Path, + scope: str, + relative_path: str, +) -> Path | None: + """Resolve one recorded input without accepting arbitrary filesystem roots.""" + roots = {"project": project_path, "skill": _SKILL_DIR} + root = roots.get(scope) + if root is None: + return None + resolved = (root / relative_path).resolve() + try: + resolved.relative_to(root.resolve()) + except ValueError: + return None + return resolved + + +def _token_counter() -> tuple[Callable[[str], int] | None, str]: + try: + import tiktoken + except ImportError: + return None, "unavailable" + try: + encoder = tiktoken.get_encoding(TOKEN_ENCODING) + except Exception: + return None, "unavailable" + return ( + lambda text: len(encoder.encode(text, disallowed_special=())), + "exact", + ) + + +def _payload_measurement( + read: PageRead, + count_tokens: Callable[[str], int] | None, +) -> dict[str, object]: + payload = read.payload.encode("utf-8") + measurement: dict[str, object] = { + "kind": read.kind, + "scope": "component" if read.kind == "lock-projection" else "page", + "path": read.path, + "sha256": _sha256_bytes(payload), + "utf8_bytes": len(payload), + "characters": len(read.payload), + "tokens": count_tokens(read.payload) if count_tokens else None, + } + return measurement + + +def record_page_context_usage( + result: PageContextResult, + output: str, + measured_reads: tuple[PageRead, ...], +) -> tuple[Path, str]: + """Write one deterministic, derived token snapshot for the current page.""" + count_tokens, token_status = _token_counter() + lock_read = PageRead( + kind="lock-projection", + path="stdout:global", + payload=_compact_json(result.context["global"]), + ) + documents = [ + _payload_measurement(read, count_tokens) + for read in (*measured_reads, lock_read) + ] + output_bytes = output.encode("utf-8") + input_records: list[dict[str, object]] = [] + for path in result.inputs: + scope, relative_path = _input_location(result.project_path, path) + input_records.append( + { + "scope": scope, + "path": relative_path, + "exists": True, + "sha256": _file_sha256(path), + } + ) + by_kind = { + str(item["kind"]): item.get("tokens") + for item in documents + } + route = dict(result.context["global"].get("pptx_structure", {})) + template = result.context["page_context"].get("template") + if isinstance(template, dict): + if isinstance(value := template.get("reuse_scope"), str): + route["template_reuse_scope"] = value + usage = { + "schema": PAGE_CONTEXT_USAGE_SCHEMA, + "page": result.page, + "output_mode": "compact", + "route": route, + "encoding": TOKEN_ENCODING, + "token_status": token_status, + "image_selection": result.context["page_context"]["image_selection"], + "inputs": input_records, + "references": result.context.get("reference_set", []), + "documents": documents, + "controlled_output": { + "sha256": _sha256_bytes(output_bytes), + "utf8_bytes": len(output_bytes), + "characters": len(output), + "tokens": count_tokens(output) if count_tokens else None, + }, + "totals": { + "page_context": by_kind.get("page-context"), + "lock_projection": by_kind.get("lock-projection"), + }, + "targets": { + "page_context_max_tokens": PAGE_CONTEXT_TOKEN_TARGET, + "lock_projection_max_tokens": LOCK_PROJECTION_TOKEN_TARGET, + }, + "untracked": [ + "source-material reads", + "once-per-execution-context reference payloads", + "other session-level prompt references", + ], + } + usage_dir = result.project_path / "analysis" / "page-context" + usage_dir.mkdir(parents=True, exist_ok=True) + usage_path = usage_dir / f"{result.page}.usage.json" + temporary_path = usage_path.with_suffix(".usage.json.tmp") + temporary_path.write_text(_pretty_json(usage), encoding="utf-8") + temporary_path.replace(usage_path) + return usage_path, token_status + + +def _nearest_rank(values: list[int], percentile: float) -> int: + rank = max(1, math.ceil(percentile * len(values))) + return sorted(values)[rank - 1] + + +def _metric(values: list[int], *, target: int | None = None) -> dict[str, object]: + if not values: + return { + "count": 0, + "sum": 0, + "min": None, + "p50": None, + "p95": None, + "max": None, + **({"over_target_count": 0, "target": target} if target else {}), + } + metric: dict[str, object] = { + "count": len(values), + "sum": sum(values), + "min": min(values), + "p50": round(statistics.median(values)), + "p95": _nearest_rank(values, 0.95), + "max": max(values), + } + if target is not None: + metric.update({ + "target": target, + "over_target_count": sum(value > target for value in values), + }) + return metric + + +def page_context_usage_report(project: str | Path) -> dict[str, object]: + """Summarize fresh per-page telemetry without changing recorded history.""" + project_path = Path(project).resolve() + usage_dir = project_path / "analysis" / "page-context" + records: list[dict[str, object]] = [] + stale_pages: list[str] = [] + unavailable_pages: list[str] = [] + if usage_dir.is_dir(): + for usage_path in sorted(usage_dir.glob("P*.usage.json")): + try: + record = json.loads(usage_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + stale_pages.append(usage_path.stem.split(".", 1)[0]) + continue + page = str(record.get("page", usage_path.stem.split(".", 1)[0])) + if record.get("schema") != PAGE_CONTEXT_USAGE_SCHEMA: + stale_pages.append(page) + continue + if record.get("output_mode") != "compact": + stale_pages.append(page) + continue + stale = False + for item in record.get("inputs", []): + if not isinstance(item, dict): + stale = True + break + source_path = _resolve_input_location( + project_path, + str(item.get("scope", "project")), + str(item.get("path", "")), + ) + if source_path is None: + stale = True + break + expected_exists = item.get("exists", True) + if expected_exists is False: + if source_path.exists(): + stale = True + break + elif ( + not source_path.is_file() + or _file_sha256(source_path) != item.get("sha256") + ): + stale = True + break + if stale: + stale_pages.append(page) + continue + if record.get("token_status") != "exact": + unavailable_pages.append(page) + records.append(record) + + def tokens_for(kind: str) -> list[int]: + values: list[int] = [] + for record in records: + for document in record.get("documents", []): + if not isinstance(document, dict) or document.get("kind") != kind: + continue + value = document.get("tokens") + if isinstance(value, int): + values.append(value) + return values + + controlled = [ + value + for record in records + if isinstance( + value := record.get("controlled_output", {}).get("tokens"), + int, + ) + ] + unique_references = sorted({ + f"{reference.get('scope', 'project')}:{reference.get('path', '')}" + for record in records + for reference in record.get("references", []) + if isinstance(reference, dict) and reference.get("path") + }) + return { + "schema": PAGE_CONTEXT_REPORT_SCHEMA, + "project": project_path.name, + "record_count": len(records), + "pages": sorted(str(record["page"]) for record in records), + "stale_pages": sorted(set(stale_pages)), + "token_unavailable_pages": sorted(set(unavailable_pages)), + "unique_reference_count": len(unique_references), + "unique_references": unique_references, + "metrics": { + "page_context": _metric( + tokens_for("page-context"), + target=PAGE_CONTEXT_TOKEN_TARGET, + ), + "lock_projection": _metric( + tokens_for("lock-projection"), + target=LOCK_PROJECTION_TOKEN_TARGET, + ), + "controlled_output": _metric(controlled), + }, + } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_management/paths.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_management/paths.py new file mode 100644 index 00000000..fbb3b9ca --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_management/paths.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +""" +PPT Master - Project Management Paths + +Own the repository and Skill resource roots used by project-management modules. + +Usage: + Import the required path constants from project_management.paths. + +Examples: + from project_management.paths import PROJECTS_ROOT, SCHEMA_DIR + +Dependencies: + None (only uses the standard library) +""" + +from pathlib import Path + +PACKAGE_DIR = Path(__file__).resolve().parent +SCRIPTS_DIR = PACKAGE_DIR.parent +SKILL_DIR = SCRIPTS_DIR.parent +REPO_ROOT = SKILL_DIR.parent.parent +PROJECTS_ROOT = REPO_ROOT / "projects" +SOURCE_TO_MD_DIR = SCRIPTS_DIR / "source_to_md" +CHARTS_DIR = SKILL_DIR / "templates" / "charts" +SCHEMA_DIR = SKILL_DIR / "templates" / "schemas" +SCAFFOLD_DIR = SKILL_DIR / "templates" / "scaffolds" diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_management/project_specs.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_management/project_specs.py new file mode 100644 index 00000000..3260e0ee --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/project_management/project_specs.py @@ -0,0 +1,1370 @@ +#!/usr/bin/env python3 +""" +PPT Master - Project Specification Helpers + +Scaffold and validate the Markdown planning artifacts used by project_manager.py. +The module keeps schema parsing and deterministic scaffold rendering independent +from the broader project-management command surface. + +Usage: + Import validate_project_artifacts() or scaffold_project_artifact(). + +Examples: + from project_management.project_specs import validate_markdown_schema + +Dependencies: + None (only uses the standard library and local project modules) +""" + +from __future__ import annotations + +import json +import math +import re +from pathlib import Path +from typing import Mapping + +from .paths import ( + SCAFFOLD_DIR, + SCHEMA_DIR, + SCRIPTS_DIR, + SKILL_DIR, +) + +try: + from project_utils import ( + CANVAS_FORMATS, + get_project_info as get_project_info_common, + validate_communication_trace, + ) +except ImportError: + import sys + + tools_dir = SCRIPTS_DIR + if str(tools_dir) not in sys.path: + sys.path.insert(0, str(tools_dir)) + from project_utils import ( # type: ignore + CANVAS_FORMATS, + get_project_info as get_project_info_common, + validate_communication_trace, + ) + + +TOOLS_DIR = SCRIPTS_DIR + +_CUSTOM_REFERENCE_CATALOGS = ( + ("mode", "mode", "mode_references", SKILL_DIR / "references" / "modes"), + ( + "visual_style", + "visual_style", + "visual_style_references", + SKILL_DIR / "references" / "visual-styles", + ), + ( + "colors", + "image_rendering", + "image_rendering_references", + SKILL_DIR / "references" / "image-renderings", + ), +) + +_MARKDOWN_H2_RE = re.compile(r"^##[ \t]+(.+?)[ \t]*$", re.MULTILINE) +_MARKDOWN_SUBHEADING_RE = re.compile(r"^#{3,6}[ \t]+(.+?)[ \t]*$", re.MULTILINE) +_MARKDOWN_DATA_LINE_RE = re.compile( + r"^[ \t]*-[ \t]+(?:\*\*)?([^:\n*]+?)(?:\*\*)?[ \t]*:[ \t]*(.*)$", + re.MULTILINE, +) +_IMAGE_PATH_SUFFIXES = frozenset( + { + ".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_]+\}\}") +_SCHEMA_MARKER_RE = re.compile( + r"^$", + re.IGNORECASE, +) + +def _normalize_schema_value(value: str) -> str: + """Normalize a Markdown scalar before enum, pattern, and catalog checks.""" + normalized = value.strip() + if ( + len(normalized) >= 2 + and normalized[0] == normalized[-1] + and normalized[0] in "'\"`" + ): + return normalized[1:-1].strip() + return normalized + + +def _extract_schema_marker(text: str) -> tuple[str | None, str | None]: + """Read the optional version marker from the first non-empty line.""" + first_line = next((line.strip() for line in text.splitlines() if line.strip()), "") + if not first_line.startswith("$", - re.IGNORECASE, -) - -def _normalize_schema_value(value: str) -> str: - """Normalize a Markdown scalar before enum, pattern, and catalog checks.""" - normalized = value.strip() - if ( - len(normalized) >= 2 - and normalized[0] == normalized[-1] - and normalized[0] in "'\"`" - ): - return normalized[1:-1].strip() - return normalized - - -def _extract_schema_marker(text: str) -> tuple[str | None, str | None]: - """Read the optional version marker from the first non-empty line.""" - first_line = next((line.strip() for line in text.splitlines() if line.strip()), "") - if not first_line.startswith("', + first_line, + re.IGNORECASE, + ) + ) + + def _check_unsupported_visual_elements( + self, + root: ET.Element, + result: Dict, + ) -> None: + """Reject authored visual elements with no native converter dispatch.""" + if _collect_unsupported_visuals is None: + result['errors'].append( + "Unable to import native visual-element preflight; " + "cannot verify SVG element support" + ) + return + if _expand_local_use_references is None or _UseExpansionError is None: + result['errors'].append( + "Unable to import local expansion; " + "cannot verify SVG element support" + ) + return + + expanded_root = copy.deepcopy(root) + try: + _expand_local_use_references(expanded_root) + except _UseExpansionError: + # _check_forbidden_elements already reports the actionable + # local-reference validation error. + return + + unsupported = _collect_unsupported_visuals( + expanded_root, + allow_data_icon_use=True, + ) + if not unsupported: + return + + preview = '; '.join(unsupported[:8]) + suffix = '' if len(unsupported) <= 8 else f'; +{len(unsupported) - 8} more' + result['errors'].append( + f"Unsupported visual SVG element(s) for native PPTX export: " + f"{preview}{suffix}" + ) + + def _check_preset_geometry_metadata( + self, + root: ET.Element, + result: Dict, + ) -> None: + """Validate round-trip preset metadata with the exporter's parser.""" + marked = [ + elem + for elem in root.iter() + if ( + elem.get('data-pptx-prst') is not None + or elem.get('data-pptx-frame') is not None + or elem.get('data-pptx-geometry-status') is not None + or elem.get('data-pptx-geometry-reason') is not None + or elem.get('data-pptx-geometry-kind') is not None + or elem.get('data-pptx-custgeom') is not None + or elem.get('data-pptx-preview-sha256') is not None + or elem.get('data-pptx-shape-id') is not None + or elem.get('data-pptx-shape-scope') is not None + or elem.get('data-pptx-shape-style') is not None + or elem.get(_AUTHORING_ATTR) is not None + or any(attr.startswith('data-pptx-av-') for attr in elem.attrib) + ) + ] + if not marked: + return + if _validate_preset_geometry_metadata is None: + result['errors'].append( + 'Unable to import PPTX preset metadata validator; ' + 'cannot verify native shape restoration' + ) + return + + issues = set() + for elem in marked: + tag = _local_name(elem) + elem_id = elem.get('id') + label = f'<{tag} id="{elem_id}">' if elem_id else f'<{tag}>' + for error in _validate_preset_geometry_metadata(elem): + issues.add(f'{label} has invalid PPTX shape metadata: {error}') + if _validate_authored_preset_tree is None: + if any( + elem.get(_AUTHORING_ATTR) is not None + for elem in root.iter() + ): + issues.add( + 'Unable to import authored PPTX preset validator' + ) + else: + for error in _validate_authored_preset_tree(root): + issues.add(f'Invalid authored PPTX preset: {error}') + if ( + _svg_preset_preview_fingerprint is None + or _resolve_preset_preview_hash is None + ): + issues.add('Unable to import PPTX preset preview fingerprint validator') + else: + for elem in root.iter(): + if ( + _local_name(elem) != 'g' + or elem.get('data-pptx-object') not in {'shape', 'connector'} + or elem.get('data-pptx-prst') is None + ): + continue + try: + expected = _resolve_preset_preview_hash(elem) + except ValueError as exc: + elem_id = elem.get('id') or '(no id)' + issues.add( + f' has an invalid PPTX preset ' + f'preview contract: {exc}' + ) + continue + if expected is None: + continue + actual = _svg_preset_preview_fingerprint(elem) + if actual != expected: + elem_id = elem.get('id') or '(no id)' + issues.add( + f' has a stale PPTX preset preview; ' + 'update the native carrier or restore the generated detail paths' + ) + result['errors'].extend(sorted(issues)) + if ( + _authored_preset_encoding is not None + and _validate_authored_preset_group is not None + ): + expanded = [ + elem.get('id') or '(no id)' + for elem in root.iter() + if _authored_preset_encoding(elem) == 'expanded' + and not _validate_authored_preset_group(elem) + ] + if expanded: + examples = ', '.join(expanded[:3]) + suffix = '' if len(expanded) <= 3 else f', +{len(expanded) - 3} more' + result['warnings'].append( + 'Compatible expanded authored-preset fragment(s) detected ' + f'({len(expanded)}: {examples}{suffix}). New project-authored ' + 'pages and templates use the compact helper form; the ' + 'expanded carrier/preview form remains readable for compatibility. ' + 'No change is required while it remains ordinary Slide-local input.' + ) + inherited_paint = _compact_preset_ancestor_paint(root) + if inherited_paint: + examples = ', '.join( + f'{element_id} ({"/".join(properties)})' + for element_id, properties in inherited_paint[:3] + ) + suffix = ( + '' + if len(inherited_paint) <= 3 + else f', +{len(inherited_paint) - 3} more' + ) + result['warnings'].append( + 'Compact authored preset(s) use compatible ancestor paint or ' + f'opacity ({examples}{suffix}). Canonical page/template authoring ' + 'keeps preset paint local and reruns the helper with channel alpha; ' + 'export remains supported.' + ) + + def _check_preset_geometry_transforms( + self, + root: ET.Element, + result: Dict, + ) -> None: + """Reject preset transforms that DrawingML cannot represent exactly.""" + helpers = ( + _IDENTITY_MATRIX, + _matrix_multiply, + _parse_transform_matrix, + _rect_to_dml_xfrm, + _validate_dml_shape_matrix, + ) + if any(helper is None for helper in helpers): + return + + relevant: set[ET.Element] = set() + + def mark_relevant(element: ET.Element) -> bool: + found = element.get('data-pptx-prst') is not None + for child in element: + found = mark_relevant(child) or found + if found: + relevant.add(element) + return found + + mark_relevant(root) + issues = set() + + def visit(element: ET.Element, parent_matrix) -> None: + if element not in relevant: + return + matrix = parent_matrix + transform = element.get('transform') + if transform: + try: + local_matrix = _parse_transform_matrix(transform) + matrix = _matrix_multiply(parent_matrix, local_matrix) + except ValueError as exc: + issues.add( + f'<{_local_name(element)}> has invalid preset ' + f'transform: {exc}' + ) + return + if element.get('data-pptx-prst') is not None: + try: + raw_frame = element.get('data-pptx-frame') + if raw_frame: + frame = tuple( + float(part) + for part in re.split(r'[\s,]+', raw_frame.strip()) + ) + if len(frame) != 4: + raise ValueError( + 'data-pptx-frame must contain four numbers' + ) + preset = element.get('data-pptx-prst') or '' + _rect_to_dml_xfrm( + frame[0], + frame[1], + frame[2], + frame[3], + matrix, + preserve_degenerate_axes=( + element.get('data-pptx-object') == 'connector' + or preset in _CONNECTOR_PRESET_TYPES + ), + ) + else: + _validate_dml_shape_matrix(matrix) + except ValueError as exc: + elem_id = element.get('id') or '(no id)' + issues.add( + f'<{_local_name(element)} id="{elem_id}"> has ' + f'unsupported preset transform: {exc}' + ) + for child in element: + visit(child, matrix) + + visit(root, _IDENTITY_MATRIX) + result['errors'].extend(sorted(issues)) + + @staticmethod + def _is_full_canvas_root_rect( + root: ET.Element, + element: ET.Element, + ) -> bool: + """Return whether one direct rect is the ordinary full-page backdrop.""" + if ( + _local_name(element) != 'rect' + or _parse_project_geometry_length is None + or any( + element.get(attribute) + for attribute in ('transform', 'filter', 'clip-path') + ) + ): + return False + viewbox = _parse_viewbox_values(root.get('viewBox') or '') + if viewbox is None: + return False + + parent_by_id = {id(element): root} + + def inherited(name: str, default: str) -> str: + return _effective_presentation_value( + element, + name, + parent_by_id, + ) or default + + try: + values = { + name: _parse_project_geometry_length( + element.get(name) or '0', + name, + ) + for name in ('x', 'y', 'width', 'height', 'rx', 'ry') + } + stroke_width = _parse_project_geometry_length( + inherited('stroke-width', '1'), + 'stroke-width', + ) + stroke_opacity = ( + _parse_project_opacity(inherited('stroke-opacity', '1')) + if _parse_project_opacity is not None else 1.0 + ) + except ValueError: + return False + fill = inherited('fill', '#000000').strip().lower() + stroke = inherited('stroke', 'none').strip().lower() + if ( + fill == 'none' + or ( + stroke != 'none' + and stroke_width > 0 + and stroke_opacity > 0 + ) + ): + return False + + view_x, view_y, view_width, view_height = viewbox + tolerance = 0.5 + return ( + values['rx'] == 0 + and values['ry'] == 0 + and abs(values['x'] - view_x) <= tolerance + and abs(values['y'] - view_y) <= tolerance + and abs(values['width'] - view_width) <= tolerance + and abs(values['height'] - view_height) <= tolerance + ) + + def _check_animation_group_ids( + self, + root: ET.Element, + svg_path: Path, + result: Dict, + ): + """Validate top-level animation anchors without policing inner groups.""" + non_visual = {'defs', 'title', 'desc', 'metadata', 'style'} + group_indexes: Dict[str, List[int]] = defaultdict(list) + ungrouped: List[str] = [] + ungrouped_signatures: List[Tuple[object, ...]] = [] + visual_index = 0 + + for child in root: + tag = _local_name(child) + if tag in non_visual: + continue + visual_index += 1 + is_first_visual = visual_index == 1 + + if tag == 'g': + group_id = _usable_animation_group_id(child.get('id')) + if group_id is None: + result['warnings'].append( + f"Top-level visible #{visual_index} has no id; " + "object-level animation config cannot reference it" + ) + continue + group_indexes[group_id].append(visual_index) + continue + + if svg_path.parent.name != 'svg_output': + continue + if child.get('data-pptx-layer') is not None: + continue + if ( + _is_static_page_frame is not None + and _is_static_page_frame( + child.get('data-pptx-role'), + child.get('data-pptx-placeholder'), + ) + ): + continue + if is_first_visual and self._is_full_canvas_root_rect(root, child): + continue + child_id = (child.get('id') or '').strip() + ungrouped.append( + f'<{tag} id="{child_id}">' + if child_id else f'<{tag}> #{visual_index}' + ) + ungrouped_signatures.append( + self._prototype_element_signature(child) + ) + + for group_id, indexes in sorted(group_indexes.items()): + if len(indexes) > 1: + positions = ', '.join(str(item) for item in indexes) + result['errors'].append( + f'Duplicate top-level group id {group_id!r} at visible ' + f'positions {positions}; animation target ids must be unique' + ) + + if ungrouped: + samples = ', '.join(ungrouped[:3]) + if len(ungrouped) > 3: + samples += ', ...' + message = ( + f'{len(ungrouped)} ungrouped top-level Slide-local element(s) ' + f'in svg_output ({samples}); group only logical content units ' + 'in a top-level . Keep genuine static page framing ' + 'as a root primitive and declare a supported data-pptx-role such ' + 'as "background" or "decoration"' + ) + prototype_root = self._active_prototype_root() + prototype_ungrouped = ( + self._ungrouped_slide_local_facts(prototype_root) + if prototype_root is not None + else ([], []) + ) + if ( + prototype_root is not None + and ungrouped == prototype_ungrouped[0] + and ungrouped_signatures == prototype_ungrouped[1] + ): + self._append_inherited_info( + result, + 'animation_anchor', + message, + ) + else: + result['warnings'].append(message) + + @staticmethod + def _prototype_element_signature( + element: ET.Element, + ) -> Tuple[object, ...]: + """Compare warning-owned topology/style while ignoring visible text.""" + return ( + _local_name(element), + tuple(sorted(element.attrib.items())), + tuple( + SVGQualityChecker._prototype_element_signature(child) + for child in element + ), + ) + + def _ungrouped_slide_local_facts( + self, + root: ET.Element, + ) -> Tuple[List[str], List[Tuple[object, ...]]]: + """Describe and fingerprint top-level non-group Slide-local atoms.""" + non_visual = {'defs', 'title', 'desc', 'metadata', 'style'} + descriptors: List[str] = [] + signatures: List[Tuple[object, ...]] = [] + visual_index = 0 + for child in root: + tag = _local_name(child) + if tag in non_visual: + continue + visual_index += 1 + if tag == 'g' or child.get('data-pptx-layer') is not None: + continue + if ( + _is_static_page_frame is not None + and _is_static_page_frame( + child.get('data-pptx-role'), + child.get('data-pptx-placeholder'), + ) + ): + continue + if visual_index == 1 and self._is_full_canvas_root_rect(root, child): + continue + child_id = (child.get('id') or '').strip() + descriptors.append( + f'<{tag} id="{child_id}">' + if child_id else f'<{tag}> #{visual_index}' + ) + signatures.append(self._prototype_element_signature(child)) + return descriptors, signatures + + # OOXML ST_PresetPatternVal enum — anything outside this set produces a + # PPTX schema violation ("PowerPoint found a problem with the content"). + _OOXML_PATTERN_PRESETS = frozenset({ + 'pct5', 'pct10', 'pct20', 'pct25', 'pct30', 'pct40', 'pct50', 'pct60', + 'pct70', 'pct75', 'pct80', 'pct90', + 'horz', 'vert', 'ltHorz', 'ltVert', 'dkHorz', 'dkVert', + 'narHorz', 'narVert', 'dashHorz', 'dashVert', + 'cross', 'dnDiag', 'upDiag', 'ltDnDiag', 'ltUpDiag', 'dkDnDiag', + 'dkUpDiag', 'wdDnDiag', 'wdUpDiag', + 'dashDnDiag', 'dashUpDiag', 'diagCross', + 'smCheck', 'lgCheck', 'smGrid', 'lgGrid', 'dotGrid', 'smConfetti', + 'lgConfetti', 'horzBrick', 'diagBrick', 'solidDmnd', 'openDmnd', + 'dotDmnd', 'plaid', 'sphere', 'weave', 'wave', 'trellis', 'zigZag', + 'divot', 'shingle', + }) + + def _check_pattern_fills(self, root: ET.Element, result: Dict): + """Audit defs that drive PPTX output. + + svg_to_pptx maps to native . The + preset name comes from `data-pptx-pattern` (e.g. `lgGrid` / `smGrid` / + `dkUpDiag`). Two failure modes worth catching pre-export: + + 1. Missing annotation → the converter compatibility fallback chooses + `ltUpDiag` (diagonal stripes), which is not an authoring contract. + 2. Invalid preset name → PPTX schema rejects the file; PowerPoint + opens it with "needs to be repaired". OOXML + `ST_PresetPatternVal` is a closed enum — only the names in + `_OOXML_PATTERN_PRESETS` are legal. Inventing `ltGrid` (no such + value) is the canonical mistake; the only grids are `smGrid` / + `lgGrid` / `dotGrid`. + """ + definitions, _duplicates = _direct_defs_index(root) + referenced_patterns: set[str] = set() + for elem in root.iter(): + style_values = ( + _parse_inline_style(elem.get('style')) + if _parse_inline_style is not None else {} + ) + fill = style_values.get('fill') or elem.get('fill') + match = re.fullmatch(r'url\(#([^)]+)\)', (fill or '').strip()) + if match is None: + continue + definition = definitions.get(match.group(1)) + if definition is not None and _local_name(definition) == 'pattern': + referenced_patterns.add(match.group(1)) + + for pattern in ( + elem for elem in root.iter() + if _local_name(elem) == 'pattern' + ): + pat_id = pattern.get('id', '') + prst = pattern.get('data-pptx-pattern') + if pat_id in referenced_patterns and not prst: + result['warnings'].append( + f"Fidelity warning: has no " + "data-pptx-pattern attribute, so the converter will use its " + "compatible `ltUpDiag` fallback. Generated SVG should declare a valid " + "data-pptx-pattern to make the intended preset explicit; " + "set data-pptx-fg/data-pptx-bg or matching child paints " + "when explicit pattern colors are required. No change is " + "required for export." + ) + if pat_id in referenced_patterns and pattern.get('patternTransform'): + result['errors'].append( + f" cannot use patternTransform; " + "the native preset mapping does not preserve custom tile transforms" + ) + if prst not in self._OOXML_PATTERN_PRESETS: + if not prst: + continue + result['errors'].append( + f" uses data-pptx-pattern=\"{prst}\" " + "which is not in OOXML ST_PresetPatternVal — exported PPTX " + "will fail schema validation ('needs to be repaired'). " + "Use one of: smGrid / lgGrid / dotGrid (grids), " + "ltUpDiag / dkUpDiag / cross / diagCross / weave / plaid / " + "horzBrick (others); see references/native-data-interface.md §1 " + "for the full authoring enum." + ) + + def _check_native_object_markers(self, root: ET.Element, result: Dict) -> None: + """Validate opt-in native table/chart markers before PPTX export.""" + invalid_status_elements: set[ET.Element] = set() + for elem in root.iter(): + marker_id = elem.get('id') or elem.get('data-name') or '' + if elem.tag.rsplit('}', 1)[-1] == 'metadata': + continue + has_status = any( + elem.get(name) is not None + for name in ( + 'data-pptx-replace-with', + 'data-pptx-native', + 'data-pptx-fallback-kind', + 'data-pptx-visual-status', + 'data-pptx-route-status', + 'data-pptx-replacement-status', + 'data-pptx-native-status', + 'data-pptx-import-source', + 'data-pptx-native-source', + ) + ) + if not has_status: + continue + if ( + _native_marker_status_errors is None + or _native_marker_release_block_reason is None + ): + result['errors'].append( + "Unable to import native-object status validator; " + f"cannot verify PPTX graphic {marker_id}" + ) + continue + status_errors = _native_marker_status_errors(elem) + for error in status_errors: + result['errors'].append( + f"PPTX graphic {marker_id} has invalid status metadata: {error}" + ) + if status_errors: + invalid_status_elements.add(elem) + continue + if _native_marker_legacy_warnings is not None: + for warning in _native_marker_legacy_warnings(elem): + result['warnings'].append( + f"PPTX replacement marker {marker_id}: {warning}" + ) + try: + fallback_kind = ( + _native_fallback_kind(elem) + if _native_fallback_kind is not None else None + ) + replacement_kind = ( + _native_replacement_kind(elem) + if _native_replacement_kind is not None else '' + ) + except ValueError: + # The shared status validator reported the alias conflict. + continue + if fallback_kind == 'placeholder': + route = ( + "the native Chart/Table route may reconstruct its active marker" + if replacement_kind + else "default export keeps the visible placeholder" + ) + result['warnings'].append( + f"PPTX graphic {marker_id} is a reconstruction-only placeholder; " + f"it has no baked preview and {route}" + ) + + for elem in root.iter(): + if elem.tag.rsplit('}', 1)[-1] == 'metadata': + continue + if _native_replacement_status is None or _native_replacement_kind is None: + continue + try: + status = _native_replacement_status(elem) + replacement_kind = _native_replacement_kind(elem) + except ValueError: + continue + if not status or replacement_kind: + continue + marker_id = elem.get('id') or elem.get('data-name') or '' + result['warnings'].append( + f"Native PPTX object {marker_id} is fallback-only: {status}" + ) + + markers = [ + elem for elem in root.iter() + if ( + _native_replacement_kind is not None + and elem.tag.rsplit('}', 1)[-1] != 'metadata' + and elem not in invalid_status_elements + and _native_replacement_kind(elem) + ) + ] + if not markers: + return + if _validate_native_object_marker is None: + result['warnings'].append( + "Detected data-pptx-replace-with markers, but replacement validator " + "could not be imported; export-time validation will still run." + ) + return + + parent_map = { + child: parent + for parent in root.iter() + for child in parent + } + + def append_metadata_legacy_warnings(marker: ET.Element) -> None: + if _native_marker_legacy_warnings is None: + return + marker_id = marker.get('id') or '' + for child in marker: + if child.tag.rsplit('}', 1)[-1] != 'metadata': + continue + for warning in _native_marker_legacy_warnings(child): + result['warnings'].append( + f"PPTX replacement marker {marker_id}: {warning}" + ) + + for marker in markers: + marker_id = marker.get('id') or '' + ancestors = [] + parent = parent_map.get(marker) + while parent is not None and parent is not root: + if parent.tag.rsplit('}', 1)[-1] == 'g': + ancestors.append(parent) + parent = parent_map.get(parent) + ancestors_tuple = tuple(reversed(ancestors)) + if _validate_native_object_marker_with_warnings is not None: + try: + warnings = _validate_native_object_marker_with_warnings( + marker, + ancestors=ancestors_tuple, + document_root=root, + ) + except RuntimeError as exc: + result['errors'].append( + f"Invalid data-pptx-replace-with marker {marker_id}: {exc}" + ) + continue + for warning in warnings: + result['warnings'].append( + f"data-pptx-replace-with marker {marker_id}: {warning}" + ) + append_metadata_legacy_warnings(marker) + continue + + try: + _validate_native_object_marker(marker, ancestors=ancestors_tuple) + except RuntimeError as exc: + result['errors'].append( + f"Invalid data-pptx-replace-with marker {marker_id}: {exc}" + ) + continue + append_metadata_legacy_warnings(marker) + if _native_object_marker_warnings is None: + continue + for warning in _native_object_marker_warnings( + marker, + ancestors=ancestors_tuple, + document_root=root, + ): + result['warnings'].append( + f"data-pptx-replace-with marker {marker_id}: {warning}" + ) + + def _check_pptx_structure_metadata( + self, + root: ET.Element, + svg_path: Path, + result: Dict, + ) -> None: + """Validate the intrinsic structured Master/Layout SVG contract.""" + if self.quick_generate: + forbidden_attrs = sorted({ + attr + for elem in root.iter() + for attr in _PPTX_STRUCTURE_ATTRS + if elem.get(attr) is not None + }) + if forbidden_attrs: + result['errors'].append( + f"{svg_path.name}: Quick Generate uses flat export and " + "forbids Master/Layout/layer/placeholder metadata; remove " + + ', '.join(forbidden_attrs) + ) + return + if not self.template_mode and svg_path.parent.name == 'svg_output': + declared_mode = _declared_pptx_structure_mode( + self._resolve_project_path(svg_path) + ) + if declared_mode == 'flat': + forbidden_attrs = sorted({ + attr + for elem in root.iter() + for attr in _PPTX_STRUCTURE_ATTRS + if elem.get(attr) is not None + }) + if forbidden_attrs: + result['errors'].append( + f"{svg_path.name}: pptx_structure.mode: flat forbids " + "Master/Layout/layer/placeholder metadata; remove " + + ', '.join(forbidden_attrs) + ) + return + if declared_mode != 'structured': + # The project-level gate emits one actionable migration error. + # Avoid burying it under repeated per-page structure failures. + return + has_structure_metadata = any( + elem.get(attr) is not None + for elem in root.iter() + for attr in _PPTX_STRUCTURE_ATTRS + ) + require_structure = bool( + self.template_mode + or svg_path.parent.name == 'svg_output' + ) + if not has_structure_metadata and not require_structure: + return + result['errors'].extend(_local_pptx_structure_errors( + root, + svg_path, + require_structure=require_structure, + )) + self._check_placeholder_carrier_flattening(root, svg_path, result) + if svg_path.parent.name == 'svg_output': + self._append_structure_coverage_warnings(root, result) + if _validate_template_structure_svg is None: + result['errors'].append( + "Structured PPTX metadata validator could not be imported; " + "the quality gate cannot verify this SVG" + ) + return + result['errors'].extend(_validate_template_structure_svg(svg_path)) + result['errors'] = list(dict.fromkeys(result['errors'])) + + @staticmethod + def _check_placeholder_carrier_flattening( + root: ET.Element, + svg_path: Path, + result: Dict, + ) -> None: + """Reject slot carriers that export as multiple native children. + + Default export flattens non-mergeable positional ```` lines + before converting the surrounding slot group to DrawingML. Reuse that + exact transform here so the quality gate fails before the later + placeholder-unwrapping step does. + """ + if _flatten_positional_tspans is None: + return + + candidate_ids: List[str] = [] + for slot in root.iter(f'{{{SVG_NS}}}g'): + if not (slot.get('data-pptx-placeholder') or '').strip(): + continue + binding = ( + slot.get('data-pptx-binding') or 'carrier' + ).strip().lower() + if binding != 'carrier': + continue + visual_children = [ + child for child in list(slot) + if _local_name(child) not in _NON_VISUAL_SVG_TAGS + ] + carriers = [ + child for child in visual_children + if (child.get('data-pptx-carrier') or '') + .strip() + .lower() + == 'true' + ] + slot_id = (slot.get('id') or '').strip() + if not slot_id or len(visual_children) != 1 or len(carriers) != 1: + continue + if not any( + _local_name(descendant) == 'tspan' + and any( + descendant.get(name) is not None + for name in ('x', 'y', 'dy') + ) + for descendant in carriers[0].iter() + ): + continue + candidate_ids.append(slot_id) + + if not candidate_ids: + return + + flattened_root = copy.deepcopy(root) + try: + _flatten_positional_tspans( + ET.ElementTree(flattened_root), + merge_paragraphs=True, + preserve_line_breaks=True, + ) + except ValueError: + # The shared text check reports the unsupported nested-position + # contract; avoid turning a quality result into a checker crash. + return + slots_by_id = { + (slot.get('id') or '').strip(): slot + for slot in flattened_root.iter(f'{{{SVG_NS}}}g') + if (slot.get('id') or '').strip() + } + for slot_id in candidate_ids: + slot = slots_by_id.get(slot_id) + if slot is None: + continue + native_children = [ + child for child in list(slot) + if _local_name(child) not in _NON_VISUAL_SVG_TAGS + ] + if len(native_children) == 1: + continue + result['errors'].append( + f"{svg_path.name}: placeholder slot {slot_id} becomes " + f"{len(native_children)} native children after positional " + " flattening; a carrier-bound slot must export as one " + "text or picture carrier. Use one single-frame dy-stacked text " + "frame, or move independently positioned lines outside the slot" + ) + + def _append_structure_coverage_warnings( + self, + root: ET.Element, + result: Dict, + ) -> None: + """Warn on mapped pages that compile to bare Masters / empty Layouts. + + Zero-slot and framing-only Layouts are legal contracts, so these stay + advisory warnings. They neither fail the workflow gate nor require a + per-warning disposition. + """ + messages = self._structure_coverage_messages(root) + if not messages: + return + prototype_root = self._active_prototype_root() + if ( + prototype_root is not None + and messages == self._structure_coverage_messages(prototype_root) + ): + for message in messages: + self._append_inherited_info( + result, + 'structure_coverage', + message, + ) + return + result['warnings'].extend(messages) + + @staticmethod + def _structure_coverage_messages(root: ET.Element) -> List[str]: + """Return advisory coverage messages for one structured page.""" + if not (root.get('data-pptx-layout') or '').strip(): + return [] + messages: List[str] = [] + has_layer_mark = any( + elem.get('data-pptx-layer') is not None + for elem in root.iter() + ) + has_layout_atom = any( + child.get('data-pptx-layer') == 'layout' + for child in list(root) + ) + has_placeholder = any( + elem.get('data-pptx-placeholder') is not None + for elem in root.iter() + ) + if not has_layer_mark: + messages.append( + 'Mapped page declares data-pptx-layout but no data-pptx-layer ' + 'mark; the exported Master gets no shared background/chrome ' + 'and the Layout gets no static framing. Generated templates ' + 'should mark the deck-wide ' + 'background data-pptx-layer="master" and this layout key\'s ' + 'framing data-pptx-layer="layout". No change or disposition ' + 'is required.' + ) + if not has_placeholder and not has_layout_atom: + messages.append( + 'Mapped page has no placeholder slot and no ' + 'data-pptx-layer="layout" atom; its Layout exports empty. ' + 'Generated templates should declare the slots the page actually ' + 'has (title / subtitle / ' + 'body / picture / slide-number / footer) and mark the layout ' + 'key\'s static framing unless this is intentionally a fixed ' + 'zero-slot composition. No change or disposition is required.' + ) + elif not has_placeholder: + messages.append( + 'Mapped Layout has static framing but no insertable ' + 'placeholder slot. Generated templates should declare the ' + 'slots the page actually has (title / subtitle / body / ' + 'picture / slide-number / footer) unless zero-slot is the ' + 'intended reusable contract. No change or disposition is required.' + ) + return messages + + @staticmethod + def _check_legacy_pptx_attributes( + root: ET.Element, + svg_path: Path, + result: Dict, + ) -> None: + """Reject superseded long-form authoring attributes.""" + for element in root.iter(): + for legacy, canonical in _LEGACY_PPTX_ATTRIBUTE_RENAMES.items(): + if element.get(legacy) is None: + continue + result['errors'].append( + f'{svg_path.name}: {_element_label(element)} uses legacy ' + f'{legacy}; rename it to {canonical}' + ) + + def _check_semantic_markers( + self, + root: ET.Element, + svg_path: Path, + result: Dict, + ) -> None: + """Validate minimal compiler hints without changing SVG rendering.""" + has_semantics = any( + elem.get(attr) is not None + for elem in root.iter() + for attr in _SEMANTIC_ATTRS + ) + require_page_role = ( + svg_path.parent.name in {'svg_output', 'svg_final'} + and root.get('data-pptx-layout') is None + ) + if _validate_semantic_markers is None: + if has_semantics: + result['warnings'].append( + "Detected Semantic SVG markers, but their validator could " + "not be imported." + ) + return + for issue in _validate_semantic_markers( + root, + require_page_role=require_page_role, + ): + if issue.severity == 'error': + result['errors'].append(issue.message) + else: + result['warnings'].append(issue.message) + + def _get_spec_lock(self, svg_path: Path): + """Locate and parse spec_lock.md near the SVG. Returns dict or None. + + Looks in svg_path.parent and svg_path.parent.parent (covers the two + common layouts: SVG directly under / or under + /svg_output/). Results are cached per lock path. + """ + if self.quick_generate: + return None + if _parse_spec_lock is None: + return None + for candidate in (svg_path.parent / 'spec_lock.md', + svg_path.parent.parent / 'spec_lock.md'): + if candidate in self._lock_cache: + return self._lock_cache[candidate] + if candidate.exists(): + try: + data = _parse_spec_lock(candidate) + except Exception: + data = None + self._lock_cache[candidate] = data + if data is not None: + self._lock_seen = True + return data + return None + + def _prototype_drift_allowances( + self, + ) -> Tuple[set[str], set[str], set[str]]: + """Return color/font/size values owned by the selected mirror page.""" + prototype_root = self._active_prototype_root() + if prototype_root is None: + return set(), set(), set() + try: + content = self._active_prototype_path.read_text(encoding='utf-8') + except (AttributeError, OSError): + return set(), set(), set() + + colors: set[str] = set() + for attribute in _PAINT_PROPERTIES or (): + for raw_value in self._svg_property_values(content, attribute): + normalized = raw_value.strip() + if normalized.lower() in {'none', 'transparent'} or re.fullmatch( + r'url\(#[^)]+\)', normalized + ): + continue + if _parse_export_color is not None: + color, _alpha = _parse_export_color(normalized) + else: + color = _normalize_hex_rgb(normalized) + if color: + colors.add(color) + fonts = { + self._normalize_font_stack(value) + for value in self._font_family_values(content) + if self._normalize_font_stack(value) + } + sizes = set(self._effective_text_size_counts(prototype_root)) + return colors, fonts, sizes + + def _declared_typography_size_anchors( + self, + lock: Dict, + ) -> Tuple[Dict, set[str], List[float], List[str]]: + """Return valid declared size anchors and malformed lock rows.""" + typography = lock.get('typography', {}) + positive_numeric_re = re.compile( + r'^(?=.*[1-9])(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)$' + ) + locked_sizes: set[str] = set() + anchor_sizes: List[float] = [] + invalid_sizes: List[str] = [] + for key, raw_value in typography.items(): + if key == 'font_family' or key.endswith('_family'): + continue + value = raw_value.strip() + if positive_numeric_re.fullmatch(value) is None: + invalid_sizes.append(f"{key}: {raw_value}") + continue + try: + anchor = float(value) + except (TypeError, ValueError): + invalid_sizes.append(f"{key}: {raw_value}") + continue + if not math.isfinite(anchor) or anchor <= 0: + invalid_sizes.append(f"{key}: {raw_value}") + continue + locked_sizes.add(self._canonical_font_size_key(anchor)) + anchor_sizes.append(anchor) + return typography, locked_sizes, anchor_sizes, invalid_sizes + + def _count_undeclared_size_occurrences( + self, + root: ET.Element, + *, + locked_sizes: set[str], + anchor_sizes: List[float], + prototype_sizes: set[str], + ) -> Counter[str]: + """Count text objects using valid sizes outside all declared bands.""" + counts: Counter[str] = Counter() + if not locked_sizes: + return counts + for value, occurrence_count in self._effective_text_size_counts(root).items(): + if value in prototype_sizes and value not in locked_sizes: + continue + if value in locked_sizes: + continue + try: + used_px = float(value) + except (TypeError, ValueError): + continue + if not math.isfinite(used_px) or used_px < 0: + continue + if any( + abs(used_px - anchor_px) <= FONT_SIZE_ANCHOR_TOLERANCE_PX + for anchor_px in anchor_sizes + ): + continue + counts[value] += occurrence_count + return counts + + def _effective_text_size_counts(self, root: ET.Element) -> Counter[str]: + """Count each effective size once per non-empty SVG text object.""" + counts: Counter[str] = Counter() + if _resolve_project_font_sizes is None: + return counts + working_root = root + if ( + _expand_local_use_references is not None + and _UseExpansionError is not None + ): + expanded_root = copy.deepcopy(root) + try: + _expand_local_use_references(expanded_root) + except _UseExpansionError: + pass + else: + working_root = expanded_root + try: + effective_sizes = _resolve_project_font_sizes(working_root) + except ValueError: + return counts + + def collect_text_object_sizes(element: ET.Element) -> set[str]: + values: set[str] = set() + + def visit(node: ET.Element) -> None: + if (node.text or '').strip(): + values.add( + self._canonical_font_size_key(effective_sizes[id(node)]) + ) + for child in node: + visit(child) + if (child.tail or '').strip(): + values.add( + self._canonical_font_size_key( + effective_sizes[id(node)] + ) + ) + + visit(element) + return values + + definition_containers = { + 'clippath', + 'defs', + 'marker', + 'mask', + 'pattern', + 'symbol', + } + + def visit_visible(element: ET.Element) -> None: + local_name = _local_name(element).casefold() + if local_name in definition_containers: + return + if local_name == 'text': + counts.update(collect_text_object_sizes(element)) + return + for child in element: + visit_visible(child) + + visit_visible(working_root) + return counts + + @staticmethod + def _canonical_font_size_key(value: float) -> str: + """Canonicalize equivalent numeric spellings for deck-wide counting.""" + return format(value, '.12g') + + def _prepare_undeclared_size_occurrences( + self, + svg_files: List[Path], + ) -> None: + """Pre-count sparse undeclared sizes before per-file diagnostics.""" + previous_prototype = self._active_prototype_path + try: + for svg_path in svg_files: + lock = self._get_spec_lock(svg_path) + if lock is None: + continue + _typography, locked_sizes, anchor_sizes, _invalid = ( + self._declared_typography_size_anchors(lock) + ) + self._active_prototype_path = self._prototype_by_output.get( + svg_path.resolve() + ) + _colors, _fonts, prototype_sizes = ( + self._prototype_drift_allowances() + ) + try: + content = svg_path.read_text(encoding='utf-8') + except OSError: + continue + try: + root = ET.fromstring(content) + except ET.ParseError: + continue + self._undeclared_size_occurrences.update( + self._count_undeclared_size_occurrences( + root, + locked_sizes=locked_sizes, + anchor_sizes=anchor_sizes, + prototype_sizes=prototype_sizes, + ) + ) + finally: + self._active_prototype_path = previous_prototype + self._undeclared_size_counts_ready = True + + def _check_spec_lock_alignment( + self, + content: str, + svg_path: Path, + result: Dict, + *, + root: ET.Element, + ): + """Compare SVG values with reusable anchors in spec_lock.md. + + Covers colors (fill / stroke / stop-color / flood-color / pattern + metadata), font-family, and font-size. + Additional colors and font families are valid contextual authoring and + are recorded as information. A valid undeclared display size may occur + at most twice across generated pages; its third occurrence makes it a + recurring role and blocks ``svg_output`` until the role is declared. + Structural text still maps to declared role bands. Exact mirror- + prototype values remain inherited information. Exact values are + accumulated in self._anchor_value_summary for end-of-run aggregation. + When spec_lock.md is missing, silently skip this local comparison; the + Generate route's required-artifact gate owns whether execution may begin. + """ + lock = self._get_spec_lock(svg_path) + if lock is None: + return + prototype_colors, prototype_fonts, prototype_sizes = ( + self._prototype_drift_allowances() + ) + + # Build allow-sets from the lock + allowed_colors = set() + for v in lock.get('colors', {}).values(): + if _parse_export_color is not None: + color, _alpha = _parse_export_color(v) + if color: + allowed_colors.add(color) + else: + color = _normalize_hex_rgb(v) + if color: + allowed_colors.add(color) + + # A validated compact preset may contain registry-derived darken/lighten + # layer colors. Their base paint still comes from spec_lock; the exact + # child HEX values are deterministic compiler evidence, not color drift. + if ( + _authored_preset_encoding is not None + and _validate_authored_preset_group is not None + ): + for group in root.iter(): + if ( + _authored_preset_encoding(group) != 'compact' + or _validate_authored_preset_group(group) + ): + continue + for child in group: + for attribute in ('fill', 'stroke'): + raw_value = child.get(attribute) + if raw_value is None: + continue + if _parse_export_color is not None: + color, _alpha = _parse_export_color(raw_value) + else: + color = _normalize_hex_rgb(raw_value) + if color: + allowed_colors.add(color) + locked_colors = set(allowed_colors) + allowed_colors.update(prototype_colors) + + typo, locked_sizes, anchor_sizes, invalid_lock_sizes = ( + self._declared_typography_size_anchors(lock) + ) + if invalid_lock_sizes: + shown = ', '.join(invalid_lock_sizes[:5]) + more = len(invalid_lock_sizes) - 5 + suffix = f" (+{more} more)" if more > 0 else "" + result['errors'].append( + f"spec_lock typography sizes must be positive finite unitless px values; " + f"found {shown}{suffix}." + ) + + # Font families: default `font_family` plus any per-role `*_family` + # override (title_family / body_family / emphasis_family / code_family, + # per templates/schemas/spec_lock.schema.json). Any of these is a legitimate declared + # value; an SVG that uses any one of them is not drifting. + allowed_fonts = set() + if typo: + default_font = typo.get('font_family', '').strip() + if default_font: + allowed_fonts.add(self._normalize_font_stack(default_font)) + for k, v in typo.items(): + if k == 'font_family' or not k.endswith('_family'): + continue + v_clean = v.strip() + # Skip placeholder text like "same as body (omit if identical)" + if not v_clean or v_clean.lower().startswith('same as'): + continue + allowed_fonts.add(self._normalize_font_stack(v_clean)) + locked_fonts = set(allowed_fonts) + allowed_fonts.update(prototype_fonts) + + # Sizes: declared slots are anchors. Checker cannot infer which role a + # text node carries, so it uses the union of their ±2px bands as a cheap + # numeric safety net; prompt rules own semantic role mapping. + # Scan SVG for used values + color_drifts = set() + inherited_colors = set() + for attr in _PAINT_PROPERTIES or (): + for raw_value in self._svg_property_values(content, attr): + normalized = raw_value.strip() + if normalized.lower() in {'none', 'transparent'} or re.fullmatch( + r'url\(#[^)]+\)', normalized + ): + continue + if _BARE_HEX_VALUE_RE.fullmatch(normalized): + continue + if _parse_export_color is not None: + val, _alpha = _parse_export_color(normalized) + if val is None: + continue + else: + val = _normalize_hex_rgb(normalized) + if val is None: + continue + if val not in allowed_colors: + color_drifts.add(f'#{val}') + elif val in prototype_colors and val not in locked_colors: + inherited_colors.add(f'#{val}') + + font_drifts = set() + inherited_fonts = set() + for val in self._font_family_values(content): + normalized_font = self._normalize_font_stack(val) + if allowed_fonts and normalized_font not in allowed_fonts: + font_drifts.add(val) + elif ( + normalized_font in prototype_fonts + and normalized_font not in locked_fonts + ): + inherited_fonts.add(val) + + size_drift_counts = self._count_undeclared_size_occurrences( + root, + locked_sizes=locked_sizes, + anchor_sizes=anchor_sizes, + prototype_sizes=prototype_sizes, + ) + size_drifts = set(size_drift_counts) + inherited_sizes = set() + for val in self._effective_text_size_counts(root): + if val in prototype_sizes and val not in locked_sizes: + inherited_sizes.add(val) + + # Record in run-wide aggregation. Colors/fonts beyond the anchor set are + # contextual values, not release issues. Generated-page sizes enforce + # role-anchor ownership; other spec-backed locations retain review. + fname = svg_path.name + for v in color_drifts: + self._anchor_value_summary['colors'][v].add(fname) + for v in font_drifts: + self._anchor_value_summary['fonts'][v].add(fname) + for v in size_drifts: + self._anchor_value_summary['sizes'][v].add(fname) + + contextual_values = {} + if color_drifts: + contextual_values['colors'] = sorted(color_drifts) + if font_drifts: + contextual_values['font_families'] = sorted(font_drifts) + if contextual_values: + result['info']['contextual_values'] = contextual_values + + sparse_sizes = {} + recurring_sizes = {} + for value, local_count in size_drift_counts.items(): + total_count = ( + self._undeclared_size_occurrences.get(value, local_count) + if self._undeclared_size_counts_ready + else local_count + ) + target = ( + sparse_sizes + if total_count <= SPARSE_UNDECLARED_FONT_SIZE_MAX_OCCURRENCES + else recurring_sizes + ) + target[value] = total_count + + if sparse_sizes: + result['info']['sparse_typography_sizes'] = { + value: count for value, count in sorted(sparse_sizes.items()) + } + + if recurring_sizes: + shown = ', '.join( + f"{value} ({count} occurrences)" + for value, count in sorted(recurring_sizes.items()) + ) + size_issue = ( + f"undeclared font-size {shown} exceeds the sparse-display limit " + f"of {SPARSE_UNDECLARED_FONT_SIZE_MAX_OCCURRENCES} occurrences" + ) + if svg_path.parent.name == 'svg_output': + result['errors'].append( + "spec_lock typography-size recurrence: " + f"{size_issue}. Structural text must return to its declared " + "role band; a genuinely recurring display treatment needs a " + "justified named role in the Design Spec and spec_lock." + ) + else: + result['warnings'].append( + f"spec_lock typography-size recurrence review: {size_issue}" + ) + inherited_parts = [] + if inherited_colors: + inherited_parts.append(f"{len(inherited_colors)} color(s)") + if inherited_fonts: + inherited_parts.append(f"{len(inherited_fonts)} font-family value(s)") + if inherited_sizes: + inherited_parts.append(f"{len(inherited_sizes)} font-size value(s)") + if inherited_parts: + self._append_inherited_info( + result, + 'spec_lock_alignment', + f"{', '.join(inherited_parts)} come unchanged from mirror " + "prototype and are accepted without expanding spec_lock.md", + ) + + def _find_image_sources_manifest(self, svg_path: Path) -> Path | None: + """Locate image_sources.json for a project SVG. + + Quality checks run primarily on /svg_output/*.svg, but this + also supports SVGs checked from project root or svg_final. + """ + bases = (svg_path.parent, svg_path.parent.parent, svg_path.parent.parent.parent) + for base in bases: + candidate = base / 'images' / 'image_sources.json' + if candidate.exists(): + return candidate + return None + + def _load_image_sources_manifest( + self, + svg_path: Path, + ) -> Tuple[Dict, str | None, Path | None]: + manifest_path = self._find_image_sources_manifest(svg_path) + if manifest_path is None: + return {}, None, None + payload, error = self._read_image_sources_manifest(manifest_path) + return payload, error, manifest_path + + def _read_image_sources_manifest( + self, + manifest_path: Path, + ) -> Tuple[Dict, str | None]: + """Read one provenance manifest without accepting damaged state.""" + if manifest_path in self._source_manifest_cache: + return self._source_manifest_cache[manifest_path] + try: + payload = json.loads(manifest_path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as exc: + payload = {} + error = f"cannot read {manifest_path}: {exc}" + else: + if not isinstance(payload, dict): + error = f"{manifest_path} must contain a JSON object" + payload = {} + elif not isinstance(payload.get('items'), list): + error = f"{manifest_path} must contain an items array" + payload = {} + elif any(not isinstance(item, dict) for item in payload['items']): + error = f"{manifest_path} items must contain JSON objects" + payload = {} + else: + seen_filenames: set[str] = set() + error = None + for index, item in enumerate(payload['items']): + 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() + ): + error = ( + f"{manifest_path} items[{index}].filename must be " + "a non-empty bare filename" + ) + break + if filename in seen_filenames: + error = ( + f"{manifest_path} contains duplicate filename " + f"{filename!r}" + ) + break + seen_filenames.add(filename) + if error: + payload = {} + self._source_manifest_cache[manifest_path] = (payload, error) + return payload, error + + @staticmethod + def _external_image_reference_basename(href: str) -> str | None: + """Return a decoded basename for one local external image href.""" + if not href or href.startswith('data:'): + return None + decoded_href = html.unescape(href) + parsed = urlsplit(decoded_href) + if parsed.scheme and parsed.scheme != 'file': + return None + path_part = ( + parsed.path + if parsed.scheme + else decoded_href.split('?', 1)[0].split('#', 1)[0] + ) + return Path(unquote(path_part)).name or None + + @classmethod + def _referenced_image_basenames(cls, root: ET.Element) -> set[str]: + """Return external image basenames rendered by one parsed SVG.""" + filenames = set() + _working_root, _parent_by_id, images = cls._visible_image_elements(root) + for elem in images: + href = elem.get('href') or elem.get(f'{{{XLINK_NS}}}href') + filename = cls._external_image_reference_basename(href or '') + if filename: + filenames.add(filename) + return filenames + + def _check_sourced_image_attribution( + self, + root: ET.Element, + svg_path: Path, + result: Dict, + ): + """Require visible credit text for attribution-required web images. + + image_search.py records the legal tier in images/image_sources.json; + Executor must render compact credit text into the SVG. This check + binds each credit to the referenced image's author and license instead + of accepting one generic deck-level CC token. + """ + manifest, error, manifest_path = self._load_image_sources_manifest(svg_path) + if error: + if ( + manifest_path is not None + and manifest_path not in self._source_manifest_errors_reported + ): + result['errors'].append( + f"Invalid image source manifest: {error}" + ) + self._source_manifest_errors_reported.add(manifest_path) + return + + items = manifest.get('items') or [] + if not items: + return + + credit_blocks = self._visible_svg_text_blocks(root) + referenced_filenames = self._referenced_image_basenames(root) + + for item in items: + if not item.get('attribution_required') and item.get('license_tier') != 'attribution-required': + continue + + filename = str(item.get('filename') or '') + if not filename or filename not in referenced_filenames: + continue + + license_name = str(item.get('license_name') or '').upper() + license_token = 'CC BY-SA' if 'BY-SA' in license_name else 'CC BY' + author = str(item.get('author') or '').strip() + has_credit = bool(author) and any( + author.casefold() in block.casefold() + and license_token in block.upper() + for block in credit_blocks + ) + if not has_credit: + result['errors'].append( + f"Missing image-specific inline attribution for sourced " + f"image {filename} ({author or 'unknown author'}; " + f"{license_token}). Add compact author + license credit per " + f"references/image-searcher.md §7." + ) + + @classmethod + def _visible_svg_text_blocks(cls, root: ET.Element) -> List[str]: + """Return rendered text blocks, excluding hidden/non-visual content.""" + working_root = copy.deepcopy(root) + if ( + _expand_local_use_references is not None + and _UseExpansionError is not None + ): + try: + _expand_local_use_references(working_root) + except _UseExpansionError: + working_root = copy.deepcopy(root) + parent_by_id = { + id(child): parent + for parent in working_root.iter() + for child in list(parent) + } + + blocks: List[str] = [] + for element in working_root.iter(f'{{{SVG_NS}}}text'): + if ( + cls._is_hidden_element(element, parent_by_id) + or cls._has_non_visual_ancestor( + element, + working_root, + parent_by_id, + ) + or cls._has_zero_opacity(element, parent_by_id) + ): + continue + text = re.sub(r'\s+', ' ', ' '.join(element.itertext())).strip() + if text: + blocks.append(text) + return blocks + + @staticmethod + def _normalize_size(value: str) -> str: + """Normalize a font-size value for drift comparison. + + Unit-bearing SVG values are reported as errors before drift checking. + The legacy `px` strip remains to avoid a duplicate drift warning after + the hard error has already identified the unit problem. + """ + v = value.strip().lower() + if v.endswith('px'): + v = v[:-2].strip() + return v + + @staticmethod + def _normalize_font_stack(stack: str) -> str: + """Normalize a font-family stack for comparison: split on commas, strip + quotes / whitespace, lowercase, rejoin. Collapses cosmetic differences + (comma spacing, single vs double quotes, case) so that + `Consolas,'Courier New',monospace` matches `Consolas, "Courier New", monospace`.""" + parts = [p.strip().strip('"\'').lower() for p in stack.split(',')] + return ','.join(p for p in parts if p) + + def _categorize_issue(self, error_msg: str) -> str: + """Categorize issue type""" + if 'Invalid XML' in error_msg: + return 'XML well-formedness' + elif 'viewBox' in error_msg: + return 'viewBox issues' + elif 'foreignObject' in error_msg: + return 'foreignObject' + elif 'paint' in error_msg.lower() or 'color value' in error_msg.lower(): + return 'Paint issues' + elif 'font' in error_msg.lower(): + return 'Font issues' + else: + return 'Other' + + def _configure_prototype_context( + self, + target_path: Path, + svg_files: List[Path], + ) -> None: + """Map generated pages to selected prototypes for inherited diagnostics.""" + self._prototype_by_output = {} + self._active_prototype_path = None + self._active_template_reuse_scope = None + self._source_import_summary = { + 'warning_count': 0, + 'by_code': {}, + } + if ( + self.template_mode + or self.quick_generate + or _load_pptx_structure_lock is None + ): + return + project_path = self._resolve_project_path(target_path) + try: + structure_lock = _load_pptx_structure_lock(project_path) + except (_TemplateStructureError, OSError): + # The project-level structure gate reports the actionable parser + # error. Inherited classification is optional and stays silent. + return + if structure_lock is None: + return + self._active_template_reuse_scope = getattr( + structure_lock, + 'template_reuse_scope', + None, + ) + references = { + reference.slide_num: reference.svg_path + for reference in structure_lock.prototypes + } + if target_path.is_file(): + sibling_files = discover_slide_svgs(target_path.parent) + resolved_target = target_path.resolve() + slide_num = next( + ( + index + for index, sibling in enumerate(sibling_files, start=1) + if sibling.resolve() == resolved_target + ), + 1, + ) + prototype = references.get(slide_num) + if prototype is not None: + self._prototype_by_output[resolved_target] = prototype.resolve() + else: + for slide_num, svg_path in enumerate(svg_files, start=1): + prototype = references.get(slide_num) + if prototype is not None: + self._prototype_by_output[svg_path.resolve()] = prototype.resolve() + + if self._active_template_reuse_scope not in {'mirror', 'layout'}: + return + manifest_path = ( + project_path / 'templates' / 'template_execution_manifest.json' + ) + try: + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + except (FileNotFoundError, OSError, json.JSONDecodeError): + return + if manifest.get('schema') != 'ppt-master.template-execution-manifest.v1': + return + source_import = manifest.get('source_import') + if isinstance(source_import, dict): + self._source_import_summary = source_import + + def check_directory(self, directory: str, expected_format: str = None) -> List[Dict]: + """ + Check all SVG files in a directory + + Args: + directory: Directory path + expected_format: Expected canvas format + + Returns: + List of check results + """ + dir_path = Path(directory) + self._has_incomplete_page_roster = False + self._undeclared_size_occurrences = Counter() + self._undeclared_size_counts_ready = False + + if not dir_path.exists(): + print(f"[ERROR] Directory does not exist: {directory}") + self.summary['errors'] += 1 + self.issue_types['Input issues'] += 1 + return [] + + # Brand-only workspaces have no SVG roster. Validate their portable + # identity schema through the same authority used by library + # registration, while keeping project scope independent of global + # indexes and directory names. + if self.template_mode and dir_path.is_dir(): + nested_spec = dir_path / 'templates' / 'design_spec.md' + spec = nested_spec if nested_spec.is_file() else dir_path / 'design_spec.md' + if spec.exists() and _design_spec_is_brand(spec): + self._brand_template_checked = True + self.summary['total'] += 1 + brand_valid = True + print( + f"[INFO] Brand directory detected (kind: brand) — " + f"validating design_spec.md and referenced assets." + ) + workspace_root = ( + spec.parent.parent + if spec.parent.name == 'templates' + else spec.parent + ) + try: + from register_template import ( + SpecParseError, + validate_brand_workspace, + ) + validate_brand_workspace(workspace_root) + except ImportError as exc: + brand_valid = False + self._template_issues.append(( + 'error', + 'brand_contract', + f"Brand schema validator could not be imported: {exc}", + )) + except (OSError, SpecParseError) as exc: + brand_valid = False + self._template_issues.append(( + 'error', + 'brand_contract', + str(exc), + )) + if brand_valid: + self.summary['passed'] += 1 + return self.results + + # Find all SVG files + if dir_path.is_file(): + svg_files = [dir_path] + else: + if self.template_mode: + # Template directories live at templates/{layouts,decks}//. + svg_files = discover_slide_svgs(dir_path) + else: + svg_output = dir_path / \ + 'svg_output' if ( + dir_path / 'svg_output').exists() else dir_path + svg_files = discover_slide_svgs(svg_output) + + if not svg_files: + print(f"[ERROR] No SVG files found in: {directory}") + self.summary['errors'] += 1 + self.issue_types['Input issues'] += 1 + return [] + + self._configure_prototype_context(dir_path, svg_files) + if not self.template_mode: + self._prepare_undeclared_size_occurrences(svg_files) + + directory_expected_viewbox: str | None = None + directory_expected_label = "the first SVG canvas" + directory_lock_has_canvas = False + if self.template_mode: + template_viewbox = _declared_template_canvas_viewbox(dir_path) + if template_viewbox: + directory_expected_viewbox = template_viewbox + directory_expected_label = "design_spec canvas_viewbox" + else: + directory_expected_viewbox = "" + directory_expected_label = "design_spec canvas_viewbox" + if expected_format is None and directory_expected_viewbox is None: + lock = ( + None + if self.template_mode + else self._get_spec_lock(svg_files[0]) + ) + if lock is not None: + if 'canvas' in lock: + directory_lock_has_canvas = True + locked_viewbox = lock.get('canvas', {}).get('viewBox') + if locked_viewbox: + directory_expected_viewbox = locked_viewbox + directory_expected_label = "spec_lock canvas" + else: + directory_expected_viewbox = "" + directory_expected_label = "spec_lock canvas" + if ( + directory_expected_viewbox is None + and not directory_lock_has_canvas + ): + for svg_file in svg_files: + try: + root = ET.parse(svg_file).getroot() + first_canvas = parse_project_viewbox( + root.get('viewBox'), + context=f"{svg_file.name} root viewBox", + ) + except (OSError, ET.ParseError, CanvasContractError): + continue + directory_expected_viewbox = first_canvas.canonical + directory_expected_label = f"first SVG {svg_file.name}" + break + + print(f"\n[SCAN] Checking {len(svg_files)} SVG file(s)...\n") + + for svg_file in svg_files: + self._active_prototype_path = self._prototype_by_output.get( + svg_file.resolve() + ) + result = self.check_file( + str(svg_file), + expected_format, + expected_viewbox=directory_expected_viewbox, + expected_viewbox_label=directory_expected_label, + ) + self._print_result(result) + + if self.template_mode: + check_structure = _template_structure_checks_enabled(dir_path) + if check_structure: + self._check_pptx_structure_contract(dir_path, svg_files) + if dir_path.is_dir(): + self._check_template_contract( + dir_path, + svg_files, + check_structure=check_structure, + ) + elif _CHECK_PPTX_STRUCTURED_PROJECT: + self._check_pptx_structure_contract(dir_path, svg_files) + if ( + not self.template_mode + and not self.quick_generate + and dir_path.is_dir() + ): + self._check_animation_config_contract(dir_path) + self._check_illustration_resource_contract(dir_path) + if ( + not self.template_mode + and not self.quick_generate + and validate_communication_trace is not None + ): + project_path = self._resolve_project_path(dir_path) + self._communication_trace_issues.extend( + ('error', message) + for message in validate_communication_trace(project_path) + ) + + return self.results + + def _check_pptx_structure_contract( + self, + target_path: Path, + svg_files: List[Path], + ) -> None: + """Validate the all-page structured lock and reusable contracts.""" + if self.quick_generate: + return + project_path = self._resolve_project_path(target_path) + standard_project = bool( + not self.template_mode + and (project_path / 'svg_output').is_dir() + ) + declared_mode = ( + _declared_pptx_structure_mode(project_path) + if standard_project + else None + ) + if standard_project and declared_mode in {'flat', 'structured'}: + self._pptx_structure_issues.extend( + ('error', message) + for message in _generated_theme_contract_errors(project_path) + ) + if standard_project and declared_mode == 'flat': + if ( + _load_pptx_structure_lock is None + or _TemplateStructureError is None + ): + self._pptx_structure_issues.append(( + 'error', + 'Flat PPTX project validation is unavailable because the ' + 'template_structure module could not be imported.', + )) + return + try: + structure_lock = _load_pptx_structure_lock(project_path) + except _TemplateStructureError as exc: + self._pptx_structure_issues.append(('error', str(exc))) + return + if structure_lock is None or structure_lock.mode != 'flat': + self._pptx_structure_issues.append(( + 'error', + 'spec_lock.md must contain one complete ' + 'pptx_structure.mode: flat contract.', + )) + return + has_metadata = False + for svg_path in svg_files: + try: + root = ET.parse(svg_path).getroot() + except (OSError, ET.ParseError): + continue + if any( + elem.get(attr) is not None + for elem in root.iter() + for attr in _PPTX_STRUCTURE_ATTRS + ): + has_metadata = True + break + + if not standard_project and not self.template_mode and not has_metadata: + return + if ( + _load_pptx_structure_lock is None + or _parse_template_structure_slide is None + or _parse_template_structure_slides is None + or _structure_subtree_signature is None + or _template_lock_errors is None + or _TemplateStructureError is None + ): + self._pptx_structure_issues.append(( + 'error', + 'Structured PPTX project validation is unavailable because the ' + 'template_structure module could not be imported.', + )) + return + + if self.template_mode: + try: + specs = _parse_template_structure_slides(svg_files) + except _TemplateStructureError as exc: + self._pptx_structure_issues.append(('error', str(exc))) + return + self._pptx_structure_issues.extend( + ('error', message) + for message in self._shared_fixed_layer_errors(specs) + ) + self._pptx_structure_issues.extend( + ('warning', message) + for message in self._duplicate_layout_key_warnings(specs) + ) + return + + if standard_project and declared_mode != 'structured': + label = repr(declared_mode) if declared_mode else ( + 'missing (legacy implicit baseline)' + ) + self._pptx_structure_issues.append(( + 'error', + 'release SVG projects require an explicit spec_lock.md ' + 'pptx_structure.mode: flat (free design / brand-only) or ' + f'structured (deck/layout template); found {label}. New ' + 'free-design projects use mode: flat; create a new template ' + 'workspace through skills/ppt-master/workflows/create-template.md, ' + 'then generate new structured SVG pages before export. Existing ' + 'PPTX/SVG files are not upgraded in place.', + )) + return + + try: + structure_lock = _load_pptx_structure_lock(project_path) + except _TemplateStructureError as exc: + self._pptx_structure_issues.append(('error', str(exc))) + return + if structure_lock is None or structure_lock.mode != 'structured': + self._pptx_structure_issues.append(( + 'error', + 'spec_lock.md must contain one complete ' + 'pptx_structure.mode: structured contract.', + )) + return + complete_roster = target_path.is_dir() + try: + if not complete_roster and target_path.is_file(): + sibling_files = discover_slide_svgs(target_path.parent) + resolved_target = target_path.resolve() + slide_num = next( + ( + index + for index, sibling in enumerate(sibling_files, start=1) + if sibling.resolve() == resolved_target + ), + 1, + ) + specs = [ + _parse_template_structure_slide(target_path, slide_num) + ] + else: + specs = _parse_template_structure_slides(svg_files) + except _TemplateStructureError as exc: + self._pptx_structure_issues.append(('error', str(exc))) + return + + if complete_roster: + actual_slides = {spec.slide_num for spec in specs} + expected_slides = { + reference.slide_num + for reference in structure_lock.layouts + } + expected_slides.update( + reference.slide_num + for reference in structure_lock.prototypes + ) + self._has_incomplete_page_roster = bool( + expected_slides - actual_slides + ) + self._pptx_structure_issues.extend( + ('error', message) + for message in _template_lock_errors(specs, structure_lock) + ) + else: + self._pptx_structure_issues.extend( + ('error', message) + for message in self._partial_structure_lock_errors( + specs, + structure_lock, + ) + ) + if _template_prototype_errors is not None: + self._pptx_structure_issues.extend( + ('error', message) + for message in _template_prototype_errors( + specs, + structure_lock, + require_complete_roster=complete_roster, + ) + ) + self._pptx_structure_issues.extend( + ('error', message) + for message in self._shared_fixed_layer_errors(specs) + ) + self._pptx_structure_issues.extend( + ('warning', message) + for message in self._duplicate_layout_key_warnings(specs) + ) + + @staticmethod + def _partial_structure_lock_errors(specs, structure_lock) -> List[str]: + """Compare explicitly checked pages without requiring the full roster.""" + references = { + reference.slide_num: reference + for reference in structure_lock.layouts + } + master_names = { + master.master_key: master.master_name + for master in structure_lock.masters + } + definitions = { + definition.layout_key: definition + for definition in structure_lock.layout_definitions + } + errors: List[str] = [] + for spec in specs: + page = f"P{spec.slide_num:02d}" + reference = references.get(spec.slide_num) + if reference is None: + errors.append( + f"spec_lock.md page_pptx_layouts is missing {page}" + ) + continue + definition = definitions.get(reference.layout_key) + if definition is None: + errors.append( + f"spec_lock.md pptx_layouts is missing Layout " + f"{reference.layout_key!r}" + ) + continue + if spec.master_key != definition.master_key: + errors.append( + f"{spec.svg_path.name}: data-pptx-master={spec.master_key!r} " + f"does not match spec_lock Layout {reference.layout_key!r} " + f"Master key {definition.master_key!r}" + ) + if spec.layout_key != reference.layout_key: + errors.append( + f"{spec.svg_path.name}: data-pptx-layout={spec.layout_key!r} " + f"does not match spec_lock {page} layout key " + f"{reference.layout_key!r}" + ) + if spec.layout_name != definition.layout_name: + errors.append( + f"{spec.svg_path.name}: data-pptx-layout-name=" + f"{spec.layout_name!r} does not match spec_lock Layout " + f"{reference.layout_key!r} name {definition.layout_name!r}" + ) + expected_master_name = master_names.get(spec.master_key) + if expected_master_name != spec.master_name: + errors.append( + f"{spec.svg_path.name}: data-pptx-master-name=" + f"{spec.master_name!r} does not match spec_lock Master " + f"{spec.master_key!r} name {expected_master_name!r}" + ) + return errors + + def _duplicate_layout_key_warnings(self, specs) -> List[str]: + """Flag distinct layout keys whose static contracts are identical. + + Keys split by page topic over one shared skeleton compile into + duplicate PowerPoint Layouts; the fingerprint compares the + id-insensitive layout-layer drawing plus the placeholder contract. + """ + prototypes: Dict[Tuple[str, str], Path] = {} + for spec in specs: + prototypes.setdefault( + (getattr(spec, 'master_key', ''), spec.layout_key), + spec.svg_path, + ) + if len(prototypes) < 2: + return [] + fingerprint_keys: Dict[tuple, List[str]] = {} + for (master_key, layout_key), svg_path in prototypes.items(): + fingerprint = self._layout_contract_fingerprint(svg_path) + if fingerprint is None: + continue + fingerprint_keys.setdefault( + (master_key, fingerprint), + [], + ).append(layout_key) + messages = [] + for keys in fingerprint_keys.values(): + if len(keys) < 2: + continue + joined = ', '.join(sorted(keys)) + messages.append( + f"layout keys {joined} declare identical static Layout framing " + "and placeholder contracts; they compile to duplicate Layouts. " + "Either merge them into one reusable key (spec_lock.md " + "pptx_layouts + each SVG root), or — when their reusable " + "contracts genuinely differ — assign distinct explicit default " + "placeholder bounds and/or mark only truly stable framing as " + 'data-pptx-layer="layout". Slide-local content geometry does not ' + "define a Layout. This recommendation is advisory; no change or " + "disposition is required." + ) + return messages + + @classmethod + def _shared_fixed_layer_errors(cls, specs) -> List[str]: + """Reject fixed atoms whose payload varies inside one reuse scope.""" + master_groups = defaultdict(list) + layout_groups = defaultdict(list) + for spec in specs: + master_groups[spec.master_key].append(spec) + layout_groups[(spec.master_key, spec.layout_key)].append(spec) + + try: + errors = cls._fixed_layer_group_errors(master_groups, 'master') + errors.extend(cls._fixed_layer_group_errors(layout_groups, 'layout')) + except _TemplateStructureError as exc: + return [str(exc)] + return errors + + @classmethod + def _fixed_layer_group_errors(cls, groups, layer: str) -> List[str]: + """Compare fixed atom payloads across grouped slide specifications.""" + errors = [] + for scope_key, group_specs in groups.items(): + if len(group_specs) < 2: + continue + variants = defaultdict(lambda: defaultdict(list)) + for spec in group_specs: + payloads = cls._fixed_layer_payloads(spec, layer) + for element_id, payload in payloads.items(): + variants[element_id][payload].append(spec) + for element_id, payload_specs in variants.items(): + if len(payload_specs) < 2: + continue + slide_names = ', '.join( + spec.svg_path.name + for spec in sorted(group_specs, key=lambda item: item.slide_num) + ) + if layer == 'master': + scope = f"Master {scope_key!r}" + else: + master_key, layout_key = scope_key + scope = ( + f"Layout {layout_key!r} under Master {master_key!r}" + ) + if element_id is None: + subject = "fixed visual resources" + verb = "differ" + else: + subject = f"fixed element {element_id!r}" + verb = "differs" + errors.append( + f"{scope} {subject} {verb} across slides: " + f"{slide_names}. Values marked data-pptx-layer={layer!r} must " + "remain identical throughout their reuse scope; move variable " + "text or images into a placeholder slot or keep them Slide-local." + ) + return errors + + @staticmethod + def _fixed_layer_payloads(spec, layer: str) -> Dict[object, tuple]: + """Return resolved fixed-layer visual payloads keyed by SVG id.""" + elements = ( + spec.master_elements if layer == 'master' else spec.layout_elements + ) + if not elements: + return {} + signature = _structure_subtree_signature( + spec.svg_path, + elements, + include_skin=True, + include_text=True, + asset_identity=True, + ) + return { + None if element_id == '__visual_resources__' else element_id: payload + for element_id, payload in signature + } + + @staticmethod + def _layout_contract_fingerprint(svg_path: Path): + """Id-insensitive static contract: layout-layer XML + placeholder slots.""" + try: + root = ET.parse(str(svg_path)).getroot() + except (OSError, ET.ParseError): + return None + layout_parts = [] + placeholder_parts = [] + for child in list(root): + if child.get('data-pptx-layer') == 'layout': + clone = copy.deepcopy(child) + for elem in clone.iter(): + elem.attrib.pop('id', None) + xml = ET.tostring(clone, encoding='unicode') + layout_parts.append(re.sub(r'\s+', ' ', xml).strip()) + placeholder = child.get('data-pptx-placeholder') + if placeholder is not None: + carrier_tags = tuple( + grandchild.tag.rsplit('}', 1)[-1] + for grandchild in list(child) + if ( + grandchild.get('data-pptx-carrier') or '' + ).strip().lower() == 'true' + ) + placeholder_parts.append(( + placeholder, + child.tag.rsplit('}', 1)[-1], + child.get('data-pptx-bounds') or '', + child.get('data-pptx-idx') or '', + ( + child.get('data-pptx-binding') or 'carrier' + ).strip().lower(), + carrier_tags, + )) + return ( + tuple(layout_parts), + tuple(sorted(placeholder_parts)), + ) + + def _check_illustration_resource_contract(self, dir_path: Path) -> None: + """Project-level planned-image and illustration resource checks.""" + project_path = self._resolve_project_path(dir_path) + spec_path = project_path / 'design_spec.md' + if not spec_path.exists(): + return + + try: + spec_text = spec_path.read_text(encoding='utf-8') + except OSError as exc: + self._illustration_issues.append(( + 'warning', + 'spec_unreadable', + f"could not read {spec_path}: {exc}", + )) + return + + current_contract = ( + '' in spec_text + ) + rows = self._extract_image_resource_rows(spec_text) + if not rows and not current_contract: + return + + lock_entries, lock_error = self._load_project_lock_image_entries( + project_path + ) + lock_images = set(lock_entries) + svg_references, inline_image_counts, image_placements = ( + self._load_project_svg_image_references(project_path) + ) + all_svg_references = ( + set().union(*( + set(references) + for references in svg_references.values() + )) + if svg_references + else set() + ) + + sheet_rows = [ + row + for row in rows + if self._row_type(row).lower() == 'illustration sheet' + ] + slice_rows = [row for row in rows if self._row_acquire(row) == 'slice'] + for row in sheet_rows: + filename = self._row_filename(row) + if not filename: + continue + if filename in lock_images: + self._illustration_issues.append(( + 'error', + 'sheet_in_lock', + f"{filename} is an Illustration Sheet but is listed in spec_lock.md images; " + "only sliced element rows may be listed.", + )) + if filename in all_svg_references: + self._illustration_issues.append(( + 'error', + 'sheet_referenced', + f"{filename} is an Illustration Sheet but is referenced by an SVG; " + "generate it only as a slice source, never place it.", + )) + if ( + self._row_status(row) == 'generated' + and not (project_path / 'images' / filename).is_file() + ): + self._illustration_issues.append(( + 'error', + 'sheet_file_missing', + f"{filename} is a Generated Illustration Sheet but " + f"images/{filename} does not exist.", + )) + + if current_contract: + self._check_planned_image_closure( + rows, + project_path, + lock_entries, + lock_error, + svg_references, + inline_image_counts, + image_placements, + ) + else: + for row in slice_rows: + filename = self._row_filename(row) + if not filename: + continue + if filename not in lock_images: + self._illustration_issues.append(( + 'error', + 'slice_missing_lock', + f"{filename} is a slice row but is absent from spec_lock.md images.", + )) + if ( + self._row_status(row) == 'generated' + and not (project_path / 'images' / filename).exists() + ): + self._illustration_issues.append(( + 'error', + 'slice_file_missing', + f"{filename} is a Generated slice row but " + f"images/{filename} does not exist.", + )) + + @staticmethod + def _resolve_project_path(dir_path: Path) -> Path: + """Resolve a checker target directory to its project root.""" + candidate = dir_path.parent if dir_path.is_file() else dir_path + if ( + _project_root_for_svg_path is not None + and candidate.name in _SVG_WORK_DIR_NAMES + ): + return _project_root_for_svg_path(candidate) + if ( + (candidate / 'svg_output').exists() + or (candidate / 'design_spec.md').exists() + ): + return candidate + return candidate.parent + + @staticmethod + def _split_md_table_row(line: str) -> List[str]: + """Split a simple Markdown table row into stripped cells.""" + return [cell.strip().strip('`') for cell in line.strip().strip('|').split('|')] + + @classmethod + def _extract_image_resource_rows(cls, spec_text: str) -> List[Dict[str, str]]: + """Extract rows from design_spec.md §VIII Image Resource List.""" + section_match = re.search( + r"^##\s+VIII\.\s+Image Resource List\b.*?(?=^##\s+|\Z)", + spec_text, + re.MULTILINE | re.DOTALL, + ) + if not section_match: + return [] + + lines = section_match.group(0).splitlines() + header = None + rows: List[Dict[str, str]] = [] + in_resource_table = False + for line in lines: + if not line.strip().startswith('|'): + if in_resource_table and rows: + break + continue + + cells = cls._split_md_table_row(line) + if not cells: + continue + if header is None: + if any(cell.lower() == 'filename' for cell in cells): + header = cells + in_resource_table = True + continue + if set(cell.replace('-', '').strip() for cell in cells) == {''}: + continue + if not in_resource_table: + continue + row = {header[i]: cells[i] if i < len(cells) else '' for i in range(len(header))} + filename = row.get('Filename', '').strip() + if ( + filename.lower() != 'filename' + and any(value.strip() for value in row.values()) + ): + rows.append(row) + + return rows + + @staticmethod + def _row_filename(row: Dict[str, str]) -> str: + return Path(row.get('Filename', '').strip()).name + + @staticmethod + def _row_raw_filename(row: Dict[str, str]) -> str: + return row.get('Filename', '').strip() + + @staticmethod + def _row_type(row: Dict[str, str]) -> str: + return row.get('Type', '').strip() + + @staticmethod + def _row_acquire(row: Dict[str, str]) -> str: + return row.get('Acquire Via', '').strip().lower() + + @staticmethod + def _row_status(row: Dict[str, str]) -> str: + return row.get('Status', '').strip().lower() + + @staticmethod + def _row_layout(row: Dict[str, str]) -> str: + return row.get('Layout pattern', '').strip() + + @staticmethod + def _row_crop(row: Dict[str, str]) -> str: + return row.get('Crop Policy', '').strip().lower() + + @staticmethod + def _layout_projection_matches(left: str, right: str) -> bool: + """Compare one Strategist recommendation without locking its wording.""" + left_ids = re.findall(r'#([0-9]+)(?![0-9])', left) + right_ids = re.findall(r'#([0-9]+)(?![0-9])', right) + if left_ids or right_ids: + return left_ids == right_ids + + def normalize(value: str) -> str: + return re.sub(r'\s+', ' ', value.replace('`', '')).strip() + + return normalize(left) == normalize(right) + + def _load_project_lock_image_entries( + self, + project_path: Path, + ) -> Tuple[Dict[str, List[Dict[str, str]]], str | None]: + """Return parsed image-lock rows keyed by basename.""" + lock_path = project_path / 'spec_lock.md' + if not lock_path.exists(): + return {}, f"{lock_path} does not exist" + if _parse_spec_lock is None: + return {}, "spec_lock parser is unavailable" + if _parse_spec_lock_image_value is None: + return {}, "spec_lock image parser is unavailable" + try: + lock = _parse_spec_lock(lock_path) + except Exception as exc: + return {}, f"cannot parse {lock_path}: {exc}" + + entries: Dict[str, List[Dict[str, str]]] = defaultdict(list) + legacy_metadata_keys = { + 'image_rendering', + 'image_rendering_references', + 'image_rendering_behavior', + } + errors: List[str] = [] + for key, value in lock.get('images', {}).items(): + if str(key).strip().lower() in legacy_metadata_keys: + continue + try: + parsed = _parse_spec_lock_image_value(str(key), str(value)) + except ValueError as exc: + errors.append(f"images row {key!r} {exc}") + continue + path_part = parsed['path'] + filename = Path(path_part).name + if not filename: + continue + entries[filename].append({ + 'key': str(key), + 'path': path_part, + 'source': parsed['source'], + 'pattern': parsed['pattern'], + 'crop': parsed['crop'], + 'legacy': parsed['legacy'], + }) + error = ( + f"{lock_path}: " + "; ".join(errors) + if errors + else None + ) + return dict(entries), error + + def _load_project_lock_images(self, project_path: Path) -> set[str]: + """Return filenames listed under spec_lock.md images.""" + entries, _error = self._load_project_lock_image_entries(project_path) + return set(entries) + + @classmethod + def _load_project_svg_image_references( + cls, + project_path: Path, + ) -> Tuple[ + Dict[Path, Dict[str, set[Path]]], + Dict[Path, int], + Dict[str, List[Tuple[Path, str, Tuple[str, ...]]]], + ]: + """Parse rendered image instances, paths, and crop mechanisms.""" + svg_dir = project_path / 'svg_output' + if not svg_dir.exists(): + return {}, {}, {} + out: Dict[Path, Dict[str, set[Path]]] = {} + inline_counts: Dict[Path, int] = {} + placements: Dict[ + str, + List[Tuple[Path, str, Tuple[str, ...]]], + ] = defaultdict(list) + for svg_path in discover_slide_svgs(svg_dir): + try: + root = ET.parse(svg_path).getroot() + except (OSError, ET.ParseError): + continue + working_root, parent_by_id, images = cls._visible_image_elements(root) + references: Dict[str, set[Path]] = defaultdict(set) + inline_count = 0 + for element in images: + href = ( + element.get('href') + or element.get(f'{{{XLINK_NS}}}href') + or '' + ) + if href.lstrip().lower().startswith('data:'): + inline_count += 1 + continue + filename = cls._external_image_reference_basename(href) + if not filename: + continue + references.setdefault(filename, set()) + placements[filename].append(( + svg_path, + element.get('preserveAspectRatio') or '', + cls._image_crop_mechanisms( + element, + working_root, + parent_by_id, + ), + )) + if _resolve_external_image_reference is not None: + resolved = _resolve_external_image_reference( + svg_path.parent, + href, + ) + if resolved is not None: + references[filename].add(resolved.resolve()) + out[svg_path] = dict(references) + if inline_count: + inline_counts[svg_path] = inline_count + return out, inline_counts, dict(placements) + + @staticmethod + def _image_crop_mechanisms( + image: ET.Element, + root: ET.Element, + parent_by_id: Dict[int, ET.Element], + ) -> Tuple[str, ...]: + """Return objective clipping mechanisms affecting one image instance.""" + mechanisms: List[str] = [] + current: ET.Element | None = image + while current is not None: + tag = _local_name(current) + style_values = ( + _parse_inline_style(current.get('style')) + if _parse_inline_style is not None + else {} + ) + for property_name in ('clip-path', 'mask'): + value = style_values.get(property_name) + if value is None: + value = current.get(property_name) + if value and value.strip().lower() != 'none': + mechanisms.append(f"<{tag}> {property_name}") + overflow = style_values.get('overflow') + if overflow is None: + overflow = current.get('overflow') + if overflow and overflow.strip().lower() in {'hidden', 'clip'}: + mechanisms.append(f"<{tag}> overflow={overflow.strip()!r}") + if current is not root and tag == 'svg': + mechanisms.append('nested viewport') + current = parent_by_id.get(id(current)) + return tuple(dict.fromkeys(mechanisms)) + + def _check_planned_image_closure( + self, + rows: List[Dict[str, str]], + project_path: Path, + lock_entries: Dict[str, List[Dict[str, str]]], + lock_error: str | None, + svg_references: Dict[Path, Dict[str, set[Path]]], + inline_image_counts: Dict[Path, int], + image_placements: Dict[ + str, + List[Tuple[Path, str, Tuple[str, ...]]], + ], + ) -> None: + """Close Design Spec, execution lock, files, SVGs, and provenance.""" + project_root = project_path.resolve() + if inline_image_counts: + total = sum(inline_image_counts.values()) + shown = ', '.join( + f"{path.name} ({count})" + for path, count in sorted(inline_image_counts.items()) + ) + self._illustration_issues.append(( + 'error', + 'svg_inline_image_untracked', + f"svg_output contains {total} inline data-URI image(s): " + f"{shown}. Current projects must keep external project-local " + "image hrefs so every placement closes through Design Spec " + "§VIII and spec_lock.md.", + )) + valid_acquisitions = { + 'ai', + 'web', + 'user', + 'formula', + 'placeholder', + 'slice', + } + valid_statuses = { + 'pending', + 'failed', + 'generated', + 'sourced', + 'rendered', + 'needs-manual', + 'existing', + 'placeholder', + } + terminal_by_acquisition = { + 'ai': {'generated', 'needs-manual'}, + 'web': {'sourced', 'needs-manual'}, + 'user': {'existing', 'needs-manual'}, + 'formula': {'rendered', 'needs-manual'}, + 'placeholder': {'placeholder'}, + 'slice': {'generated', 'needs-manual'}, + } + current_image_contract = ( + any('Crop Policy' in row for row in rows) + or any( + entry.get('legacy') == 'false' + for entries in lock_entries.values() + for entry in entries + ) + ) + seen_filenames: set[str] = set() + for row in rows: + raw_filename = self._row_raw_filename(row) + filename = self._row_filename(row) + acquire = self._row_acquire(row) + status = self._row_status(row) + layout = self._row_layout(row) + crop = self._row_crop(row) + filename_is_bare = bool(filename) and ( + filename not in {'.', '..'} + and '/' not in filename + and '\\' not in filename + and ':' not in filename + ) + filename_is_canonical = raw_filename in { + filename, + f"images/{filename}", + } + if not filename_is_bare or not filename_is_canonical: + self._illustration_issues.append(( + 'error', + 'planned_image_invalid_filename', + f"Design Spec §VIII Filename {raw_filename!r} must be " + "a non-empty bare filename or canonical " + "images/ path.", + )) + elif filename in seen_filenames: + self._illustration_issues.append(( + 'error', + 'planned_image_duplicate_filename', + f"Design Spec §VIII repeats Filename {filename!r}; " + "one resource must have one authoritative row.", + )) + else: + seen_filenames.add(filename) + + if current_image_contract and not layout: + self._illustration_issues.append(( + 'error', + 'planned_image_missing_pattern', + f"{filename or '(missing filename)'} has an empty Design " + "Spec §VIII Layout pattern; preserve one non-empty " + "Strategist recommendation without locking SVG geometry.", + )) + if current_image_contract and crop not in {'adaptive', 'no-crop'}: + self._illustration_issues.append(( + 'error', + 'planned_image_invalid_crop_policy', + f"{filename or '(missing filename)'} has invalid Design " + f"Spec §VIII Crop Policy " + f"{row.get('Crop Policy', '').strip()!r}; use adaptive " + "or no-crop.", + )) + + if acquire not in valid_acquisitions: + self._illustration_issues.append(( + 'error', + 'planned_image_invalid_acquisition', + f"{filename or '(missing filename)'} has invalid " + f"Acquire Via {row.get('Acquire Via', '').strip()!r}.", + )) + continue + if status not in valid_statuses: + self._illustration_issues.append(( + 'error', + 'planned_image_invalid_status', + f"{filename or '(missing filename)'} has invalid " + f"Status {row.get('Status', '').strip()!r}.", + )) + continue + if status in {'pending', 'failed'}: + self._illustration_issues.append(( + 'error', + 'planned_image_not_terminal', + f"{filename or '(missing filename)'} has non-terminal " + f"Status {row.get('Status', '').strip()!r}; finish the " + "owning acquisition or mark it Needs-Manual before export.", + )) + elif status not in terminal_by_acquisition[acquire]: + expected = ', '.join(sorted(terminal_by_acquisition[acquire])) + self._illustration_issues.append(( + 'error', + 'planned_image_status_mismatch', + f"{filename or '(missing filename)'} uses Acquire Via " + f"{acquire!r} but Status {status!r}; terminal status must " + f"be one of: {expected}.", + )) + + if lock_error: + self._illustration_issues.append(( + 'error', + 'image_lock_unreadable', + lock_error, + )) + return + + placed_rows = [ + row for row in rows + if self._row_type(row).lower() != 'illustration sheet' + and self._row_acquire(row) + in {'ai', 'web', 'user', 'formula', 'placeholder', 'slice'} + ] + rows_by_filename = { + self._row_filename(row): row + for row in placed_rows + if self._row_filename(row) + } + referenced_paths: Dict[str, set[Path]] = defaultdict(set) + for references in svg_references.values(): + for filename, paths in references.items(): + referenced_paths[filename].update(paths) + referenced = set(referenced_paths) + + for filename, row in rows_by_filename.items(): + if filename not in lock_entries: + self._illustration_issues.append(( + 'error', + 'planned_image_missing_lock', + f"{filename} is a placed Design Spec image row but is " + "absent from spec_lock.md images.", + )) + continue + if current_image_contract and any( + entry.get('legacy') != 'false' + for entry in lock_entries[filename] + ): + self._illustration_issues.append(( + 'error', + 'planned_image_legacy_lock_projection', + f"{filename} uses the current Design Spec image contract " + "but its spec_lock.md row does not provide complete " + "source=..., pattern=..., and crop=... metadata.", + )) + + for filename in sorted(referenced - set(rows_by_filename)): + lock_note = ( + "" + if filename in lock_entries + else " and is absent from spec_lock.md images" + ) + self._illustration_issues.append(( + 'error', + 'svg_image_missing_spec', + f"svg_output references {filename}, but it has no placed " + f"Design Spec §VIII row{lock_note}.", + )) + + for filename, entries in lock_entries.items(): + row = rows_by_filename.get(filename) + if row is None: + self._illustration_issues.append(( + 'error', + 'locked_image_missing_spec', + f"{filename} is listed in spec_lock.md images but has no " + "placed row in Design Spec §VIII.", + )) + continue + + acquire = self._row_acquire(row) + status = self._row_status(row) + layout_pattern = self._row_layout(row) + crop_policy = self._row_crop(row) + if len(entries) > 1: + keys = ', '.join(repr(entry.get('key', '')) for entry in entries) + self._illustration_issues.append(( + 'error', + 'locked_image_duplicate_entries', + f"{filename} appears in multiple spec_lock.md image rows " + f"({keys}); one resource must have one authoritative row.", + )) + for entry in entries: + if entry.get('legacy') != 'false': + continue + if entry.get('source') != acquire: + self._illustration_issues.append(( + 'error', + 'locked_image_source_mismatch', + f"{filename} spec_lock source={entry.get('source')!r} " + f"does not match Design Spec §VIII Acquire Via " + f"{acquire!r}.", + )) + if entry.get('crop') != crop_policy: + self._illustration_issues.append(( + 'error', + 'locked_image_crop_mismatch', + f"{filename} spec_lock crop={entry.get('crop')!r} " + f"does not match Design Spec §VIII Crop Policy " + f"{crop_policy!r}.", + )) + if not self._layout_projection_matches( + entry.get('pattern', ''), + layout_pattern, + ): + self._illustration_issues.append(( + 'error', + 'locked_image_pattern_mismatch', + f"{filename} spec_lock pattern=" + f"{entry.get('pattern')!r} does not preserve the " + "Design Spec §VIII Layout pattern recommendation " + f"{layout_pattern!r}. This Design Spec-to-spec_lock " + "projection check compares ordered catalog ids when " + "present, otherwise normalized text; it does not " + "compare SVG geometry or restrict the Executor's " + "realization.", + )) + candidate_paths: List[Path] = [] + for entry in entries: + raw_path = entry.get('path', '') + if not raw_path: + continue + lock_path = Path(raw_path) + legacy_bare_filename = ( + not lock_path.is_absolute() + and raw_path not in {'.', '..'} + and '/' not in raw_path + and '\\' not in raw_path + and ':' not in raw_path + ) + if lock_path.is_absolute(): + path = lock_path + elif legacy_bare_filename: + path = project_path / 'images' / raw_path + else: + path = project_path / raw_path + resolved_path = path.resolve() + try: + resolved_path.relative_to(project_root) + except ValueError: + self._illustration_issues.append(( + 'error', + 'locked_image_path_outside_project', + f"{filename} lock path {entry['path']!r} resolves " + "outside the project workspace.", + )) + continue + candidate_paths.append(resolved_path) + + distinct_candidate_paths = set(candidate_paths) + if len(distinct_candidate_paths) > 1: + shown = ', '.join( + str(path.relative_to(project_root)) + for path in sorted(distinct_candidate_paths) + ) + self._illustration_issues.append(( + 'error', + 'locked_image_ambiguous_paths', + f"{filename} resolves to multiple locked project paths: " + f"{shown}. One resource must have one authoritative asset.", + )) + + expected_paths = { + path + for path in distinct_candidate_paths + if path.is_file() + } + asset_exists = bool(expected_paths) + file_required = status in { + 'existing', + 'generated', + 'sourced', + 'rendered', + } + if not asset_exists and file_required: + expected = entries[0].get('path') or f"images/{filename}" + self._illustration_issues.append(( + 'error', + 'locked_image_file_missing', + f"{filename} is locked and has terminal Status " + f"{row.get('Status', '').strip()!r}, but {expected} " + "does not exist.", + )) + + actual_paths = referenced_paths.get(filename, set()) + unexpected_paths = actual_paths - expected_paths + if unexpected_paths: + shown = ', '.join( + str(path.relative_to(project_root)) + if path.is_relative_to(project_root) + else str(path) + for path in sorted(unexpected_paths) + ) + self._illustration_issues.append(( + 'error', + 'locked_image_reference_mismatch', + f"{filename} is referenced from {shown}, not exclusively " + "from its locked project path.", + )) + + should_be_referenced = ( + acquire != 'placeholder' + and asset_exists + and status + in { + 'existing', + 'generated', + 'sourced', + 'rendered', + 'needs-manual', + } + ) + if should_be_referenced and not (actual_paths & expected_paths): + self._illustration_issues.append(( + 'error', + 'locked_image_unreferenced', + f"{filename} has usable terminal content but its locked " + "file is not referenced by any svg_output element.", + )) + + effective_no_crop = ( + crop_policy == 'no-crop' + or acquire == 'formula' + or any(entry.get('crop') == 'no-crop' for entry in entries) + ) + if effective_no_crop: + placements_by_svg: Dict[ + Path, + List[Tuple[str, Tuple[str, ...]]], + ] = defaultdict(list) + for svg_path, raw_aspect, mechanisms in image_placements.get( + filename, + [], + ): + placements_by_svg[svg_path].append(( + raw_aspect, + mechanisms, + )) + + for svg_path, placements in placements_by_svg.items(): + parsed_placements = [] + for raw_aspect, mechanisms in placements: + try: + align, mode = ( + _parse_project_image_aspect_ratio(raw_aspect or None) + if _parse_project_image_aspect_ratio is not None + else ('', '') + ) + except ValueError: + # The per-SVG aspect-ratio validator owns malformed syntax. + continue + parsed_placements.append(( + raw_aspect, + mechanisms, + align, + mode, + )) + + has_complete_placement = any( + align != 'none' + and mode == 'meet' + and not mechanisms + for _raw_aspect, mechanisms, align, mode + in parsed_placements + ) + + for raw_aspect, _mechanisms, align, _mode in parsed_placements: + if align != 'none': + continue + actual = raw_aspect or '(implicit xMidYMid meet)' + self._illustration_issues.append(( + 'error', + 'no_crop_image_fit_mismatch', + f"{svg_path.name}: {filename} is no-crop but its " + f"rendered placement uses " + f"preserveAspectRatio={actual!r}; stretching is not " + "a detail crop and remains forbidden.", + )) + + if has_complete_placement: + continue + + for raw_aspect, mechanisms, align, mode in parsed_placements: + if align != 'none' and mode != 'meet': + actual = raw_aspect or '(implicit xMidYMid meet)' + self._illustration_issues.append(( + 'error', + 'no_crop_image_fit_mismatch', + f"{svg_path.name}: {filename} is no-crop but " + "this page has no complete placement and uses " + f"preserveAspectRatio={actual!r}; keep at least " + "one unclipped placement with a legal alignment " + "anchor and meet.", + )) + if mechanisms: + self._illustration_issues.append(( + 'error', + 'no_crop_image_clipped', + f"{svg_path.name}: {filename} is no-crop but " + "this page has no complete placement; its " + "rendered placement is affected by " + f"{', '.join(mechanisms)}. Keep at least one " + "unclipped meet placement so every source pixel " + "remains visible.", + )) + + self._check_sourced_image_provenance( + rows_by_filename, + project_path, + ) + + def _check_sourced_image_provenance( + self, + rows_by_filename: Dict[str, Dict[str, str]], + project_path: Path, + ) -> None: + """Require one valid provenance item for every Sourced web row.""" + sourced = { + filename: row + for filename, row in rows_by_filename.items() + if self._row_acquire(row) == 'web' + and self._row_status(row) == 'sourced' + } + if not sourced: + return + + manifest_path = project_path / 'images' / 'image_sources.json' + if not manifest_path.exists(): + self._illustration_issues.append(( + 'error', + 'image_sources_missing', + "Sourced web images are used, but " + "images/image_sources.json does not exist.", + )) + return + + payload, error = self._read_image_sources_manifest(manifest_path) + if error: + if manifest_path not in self._source_manifest_errors_reported: + self._illustration_issues.append(( + 'error', + 'image_sources_invalid', + error, + )) + self._source_manifest_errors_reported.add(manifest_path) + return + + manifest_items = { + str(item.get('filename') or ''): item + for item in payload['items'] + if item.get('filename') + } + valid_tiers = { + 'no-attribution', + 'attribution-required', + 'manual', + } + for filename in sourced: + item = manifest_items.get(filename) + if item is None: + self._illustration_issues.append(( + 'error', + 'sourced_image_missing_provenance', + f"{filename} is Sourced but has no matching entry in " + "images/image_sources.json.", + )) + continue + + tier = str(item.get('license_tier') or '').strip() + if tier not in valid_tiers: + self._illustration_issues.append(( + 'error', + 'sourced_image_invalid_license_tier', + f"{filename} has invalid license_tier {tier!r} in " + "images/image_sources.json.", + )) + if tier != 'manual' and not str( + item.get('attribution_text') or '' + ).strip(): + self._illustration_issues.append(( + 'error', + 'sourced_image_missing_attribution_text', + f"{filename} has license_tier {tier!r} but no " + "attribution_text in images/image_sources.json.", + )) + if tier == 'attribution-required' and not str( + item.get('author') or '' + ).strip(): + self._illustration_issues.append(( + 'error', + 'sourced_image_missing_author', + f"{filename} requires attribution but has no author in " + "images/image_sources.json.", + )) + + def _check_animation_config_contract(self, dir_path: Path) -> None: + """Project-level animations.json reference checks.""" + project_path = self._resolve_project_path(dir_path) + config_path = project_path / 'animations.json' + if ( + _load_animation_config is None + or _validate_animation_config is None + or _validate_animation_config_errors is None + or _validate_transition_config is None + ): + if config_path.is_file(): + detail = _animation_config_import_error or 'unknown import error' + self._animation_issues.append(( + 'error', + f'animations.json validation is unavailable: {detail}', + )) + return + try: + config = _load_animation_config(project_path) + except Exception as exc: + self._animation_issues.append(('error', f"animations.json is invalid: {exc}")) + return + if not config: + return + fatal_errors = list(dict.fromkeys( + _validate_transition_config(config) + + _validate_animation_config_errors(config) + )) + for error in fatal_errors: + self._animation_issues.append(('error', error)) + for message in _validate_animation_config(project_path, config): + severity = ( + 'warning' + if ' has no id and cannot be customized in animations.json' in message + else 'error' + ) + self._animation_issues.append((severity, message)) + + def _check_template_contract( + self, + dir_path: Path, + svg_files: List[Path], + *, + check_structure: bool, + ) -> None: + """Check reusable-template structure, roster, and placeholder hints. + + - **Roster mismatch (orphan / missing)** is reported as an *error*: a + stale roster will produce a wrong ``layouts_index.json`` entry. + - **Explicit structure gaps** are errors when positive structure checks + are enabled: every current reusable SVG declares its Master and Layout + identity. Zero-placeholder Layouts are valid. Legacy template-mode + packages fail and must be replaced by a new create-template workspace. + - **Placeholder gaps** are reported as *warnings*. Templates may + legitimately omit conventional placeholders or swap them out (e.g. + ``{{CLOSING_MESSAGE}}`` instead of ``{{THANK_YOU}}``), and a content + variant may use a bespoke slot vocabulary. Designers can declare + their own per-stem expectations via ``placeholders:`` frontmatter + in ``design_spec.md`` to suppress these warnings explicitly. + + Issues are aggregated and printed in :py:meth:`print_summary` so the + per-file report stays focused on intrinsic SVG validity. + """ + spec_path = dir_path / 'design_spec.md' + spec_text = spec_path.read_text(encoding='utf-8') if spec_path.exists() else "" + declared_structure_mode = _declared_template_structure_mode(dir_path) + mode_error_recorded = False + if declared_structure_mode != 'structured': + mode_error_recorded = True + self._template_issues.append(( + 'error', + 'explicit_structure_mode', + "design_spec.md frontmatter must declare " + "native_structure_mode: structured; legacy template-mode " + "workspaces must be re-created through create-template", + )) + if check_structure: + native_contract_path = dir_path / 'native_structure.json' + source_template_path = dir_path / 'source_template.pptx' + legacy_structure_detected = False + for svg_file in svg_files: + try: + root = ET.parse(svg_file).getroot() + except (OSError, ET.ParseError): + continue + if not root.get('data-pptx-master'): + legacy_structure_detected = True + self._template_issues.append(( + 'error', + 'explicit_master_missing', + f"{svg_file.name}: reusable templates require root " + "data-pptx-master metadata", + )) + if not root.get('data-pptx-master-name'): + legacy_structure_detected = True + self._template_issues.append(( + 'error', + 'explicit_master_name_missing', + f"{svg_file.name}: reusable templates require root " + "data-pptx-master-name metadata", + )) + if not root.get('data-pptx-layout'): + self._template_issues.append(( + 'error', + 'explicit_structure_missing', + f"{svg_file.name}: reusable templates require root " + "data-pptx-layout metadata", + )) + if not root.get('data-pptx-layout-name'): + self._template_issues.append(( + 'error', + 'explicit_structure_name_missing', + f"{svg_file.name}: reusable templates require root " + "data-pptx-layout-name metadata", + )) + if root.get('data-pptx-layout-kind') is not None: + legacy_structure_detected = True + self._template_issues.append(( + 'error', + 'deck_instance_layout_kind', + f"{svg_file.name}: reusable template prototypes must omit " + "legacy data-pptx-layout-kind metadata", + )) + if any( + child.get('data-pptx-placeholder') is not None + and child.tag.rsplit('}', 1)[-1] != 'g' + for child in list(root) + ): + legacy_structure_detected = True + missing_bounds = [ + child.get('id') or child.tag.rsplit('}', 1)[-1] + for child in list(root) + if child.get('data-pptx-placeholder') is not None + and child.get('data-pptx-bounds') is None + ] + if missing_bounds: + legacy_structure_detected = True + self._template_issues.append(( + 'error', + 'placeholder_bounds_missing', + f"{svg_file.name}: reusable templates require " + "explicit design-zone data-pptx-bounds; missing: " + + ', '.join(missing_bounds), + )) + if native_contract_path.exists() or source_template_path.exists(): + legacy_structure_detected = True + self._template_issues.append(( + 'error', + 'legacy_native_structure_pair', + "legacy native_structure.json/source_template.pptx template " + "contracts must be replaced through " + "skills/ppt-master/workflows/create-template.md", + )) + + if declared_structure_mode != 'structured': + legacy_structure_detected = True + if not mode_error_recorded: + self._template_issues.append(( + 'error', + 'explicit_structure_mode', + "design_spec.md frontmatter must declare " + "native_structure_mode: structured", + )) + if legacy_structure_detected: + self._template_issues.append(( + 'error', + 'legacy_structure_contract', + "legacy template structure detected; create a new current " + "workspace through skills/ppt-master/workflows/" + "create-template.md before Step 3 consumption", + )) + spec_pages = self._extract_spec_roster(spec_text) if spec_text else [] + custom_contract = self._extract_frontmatter_placeholders(spec_text) if spec_text else {} + + on_disk = {p.stem for p in svg_files} + + if spec_pages: + spec_set = set(spec_pages) + orphan = sorted(on_disk - spec_set) + missing = sorted(spec_set - on_disk) + for page in orphan: + self._template_issues.append(( + 'error', + 'roster_orphan', + f"{page}.svg exists on disk but is not listed in design_spec.md Page Roster", + )) + for page in missing: + self._template_issues.append(( + 'error', + 'roster_missing', + f"design_spec.md Page Roster lists {page} but {page}.svg is missing on disk", + )) + elif spec_path.exists(): + # design_spec.md is present but the roster parser found nothing — + # reusable template workspaces always fail closed. + self._template_issues.append(( + 'error', + 'roster_unknown', + f"could not extract page roster from {spec_path.name}; " + "skipping orphan/missing checks", + )) + else: + self._template_issues.append(( + 'error', + 'spec_missing', + f"{spec_path.name} not found — required for every library template", + )) + + # Per-file placeholder coverage. Variants reuse the parent type's set + # (e.g. 03a_content_two_col.svg ↔ 03_content rules) unless the spec + # frontmatter overrides that page (custom_contract takes precedence). + for svg_file in svg_files: + expected = self._lookup_template_contract( + svg_file.stem, overrides=custom_contract, + ) + if expected is None: + continue # extension pages or stems with no convention + try: + content = svg_file.read_text(encoding='utf-8') + except OSError: + continue + for placeholder in expected: + if placeholder not in content: + self._template_issues.append(( + 'warning', + 'placeholder_hint', + f"{svg_file.name}: missing conventional placeholder {placeholder} " + "(declare 'placeholders:' frontmatter in design_spec.md to silence)", + )) + + @staticmethod + def _extract_frontmatter_placeholders(spec_text: str) -> Dict[str, Tuple[str, ...]]: + """Read the optional ``placeholders:`` map from design_spec.md frontmatter. + + Shape: + + .. code-block:: yaml + + placeholders: + 01_cover: ["{{TITLE}}", "{{BRAND_LOGO}}"] + 03_content: [] # explicitly assert "no expectation" + 03a_content_two_col: # variant-specific override + - "{{LEFT_TITLE}}" + - "{{RIGHT_TITLE}}" + + Each key is a stem (full filename without ``.svg``) or page-type prefix + (``01_cover``). An empty list silences the default convention for that + stem; a populated list replaces the default. Stems / prefixes not + listed fall back to ``DEFAULT_PLACEHOLDER_CONVENTION``. + + We parse with PyYAML when available; otherwise we fall back to a + minimal regex that handles the documented shape. + """ + if not spec_text.startswith("---\n"): + return {} + end = spec_text.find("\n---\n", 4) + if end == -1: + return {} + block = spec_text[4:end] + + try: + import yaml # type: ignore + except ImportError: + return _parse_placeholders_fallback(block) + + try: + data = yaml.safe_load(block) or {} + except yaml.YAMLError: + return {} + if not isinstance(data, dict): + return {} + raw = data.get("placeholders") + if not isinstance(raw, dict): + return {} + + out: Dict[str, Tuple[str, ...]] = {} + for stem, value in raw.items(): + if not isinstance(stem, str): + continue + if isinstance(value, list): + out[stem] = tuple(str(v) for v in value) + elif value is None: + out[stem] = () + return out + + @staticmethod + def _extract_spec_roster(spec_text: str) -> List[str]: + """Best-effort: extract the page roster from design_spec.md. + + Templates do not share a uniform section index for the roster — the + personality-only skeleton puts it at §V "Page Roster"; legacy specs use + §VI "Page Roster" or bury filenames under §VII "Page Types" as + ``### N. Cover Page (01_cover.svg)``. We match by title (any roman + index), then fall back to scanning the whole document for any + backtick-wrapped ``.svg`` reference. + + Returns the deduplicated stem list in document order. Empty result + means we can't determine the roster confidently — caller should treat + that as "skip orphan/missing checks", not as "no pages declared". + """ + # Pass 1: explicit roster section, any roman numeral. + sections = list(re.finditer( + r"^##\s+[IVX]+\.\s+(?:(?:SVG\s+)?Page Roster|Page Structure|Pages|Page Types)\b.*?(?=^##\s+|\Z)", + spec_text, + re.MULTILINE | re.DOTALL | re.IGNORECASE, + )) + roster_scope = next( + ( + section.group(0) + for section in sections + if re.match( + r"^##\s+[IVX]+\.\s+(?:SVG\s+)?Page Roster\b", + section.group(0), + re.IGNORECASE, + ) + ), + None, + ) + scope = roster_scope or next( + ( + section.group(0) + for section in sections + if re.search(r"[`\(][0-9A-Za-z_]+\.svg[`\)]", section.group(0)) + ), + sections[0].group(0) if sections else None, + ) + + # Pass 2: full document. We *only* trust this scan when the explicit + # roster scan came up empty (no `.svg` references inside it) — + # otherwise the explicit section's deliberate roster wins over loose + # mentions elsewhere. + explicit_scope = bool( + scope and re.search(r"[`\(][0-9A-Za-z_]+\.svg[`\)]", scope) + ) + if explicit_scope: + text = scope + else: + text = spec_text + + stems: List[str] = [] + seen: set = set() + # Accept backtick-quoted (`01_cover.svg`) and parenthesized + # (01_cover.svg) forms — existing specs use either. + svg_ref_re = re.compile(r"[`\(]([0-9A-Za-z_]+\.svg)[`\)]") + for match in svg_ref_re.finditer(text): + stem = match.group(1)[:-4] + if stem in seen or (not explicit_scope and not re.match(r"^\d", stem)): + continue + seen.add(stem) + stems.append(stem) + + # If the explicit §VI scan listed bare stems (without .svg), accept + # those as fallback — but only when they were inside that section. + if not stems and scope: + for match in re.finditer(r"`([0-9]{2}[a-z]?_[A-Za-z0-9_]+)`", scope): + stem = match.group(1) + if stem in seen: + continue + seen.add(stem) + stems.append(stem) + + return stems + + @classmethod + def _lookup_template_contract( + cls, stem: str, *, + overrides: Dict[str, Tuple[str, ...]] | None = None, + ) -> Tuple[str, ...] | None: + """Resolve a SVG stem to its expected placeholder set. + + Resolution order, first hit wins: + 1. ``overrides[stem]`` — frontmatter entry for the exact filename + 2. ``overrides[]`` — frontmatter entry for the + variant's parent type (e.g. ``03_content`` for + ``03a_content_two_col``) + 3. ``DEFAULT_PLACEHOLDER_CONVENTION[]`` — keyed by the + type token alone, so it applies regardless of where the type + lands in the template's presentation-order numbering + + Returns ``None`` for stems with no matching convention or override — + e.g. extension pages like ``05_section_break``. ``()`` (empty tuple) + is a valid value meaning "no expected placeholders" — used to + explicitly silence the default convention. + """ + overrides = overrides or {} + if stem in overrides: + return overrides[stem] + + # Variant convention: ?_; strip the letter to find + # the parent type prefix, e.g. "03a_content_two_col" -> "03_content". + match = re.match(r"^(\d{2})([a-z])?_([a-z]+)", stem) + if not match: + return None + num, _letter, kind = match.groups() + key = f"{num}_{kind}" + if key in overrides: + return overrides[key] + return cls.DEFAULT_PLACEHOLDER_CONVENTION.get(kind) + + def _print_result(self, result: Dict): + """Print check result for a single file""" + if result['passed']: + if result['warnings']: + icon = "[WARN]" + status = "Passed (with warnings)" + else: + icon = "[OK]" + status = "Passed" + else: + icon = "[ERROR]" + status = "Failed" + + print(f"{icon} {result['file']} - {status}") + + # Display basic info + if result['info']: + info_items = [] + if 'viewbox' in result['info']: + info_items.append(f"viewBox: {result['info']['viewbox']}") + if info_items: + print(f" {' | '.join(info_items)}") + + # Display errors + if result['errors']: + for error in result['errors']: + print(f" [ERROR] {error}") + + # Display the complete warning set from this run. The generation + # workflow reviews all findings before one consolidated repair pass. + if result['warnings']: + for warning in result['warnings']: + print(f" [WARN] {warning}") + + print() + + def print_summary(self): + """Print check summary""" + self._apply_aggregated_issue_counts() + + print("=" * 80) + print("[SUMMARY] Check Summary") + print("=" * 80) + + print(f"\nTotal files: {self.summary['total']}") + print( + f" [OK] Fully passed: {self.summary['passed']} ({self._percentage(self.summary['passed'])}%)") + print( + f" [WARN] With warnings: {self.summary['warnings']} ({self._percentage(self.summary['warnings'])}%)") + print( + f" [ERROR] With errors: {self.summary['errors']} ({self._percentage(self.summary['errors'])}%)") + + if self.issue_types: + print(f"\nIssue categories:") + for issue_type, count in sorted(self.issue_types.items(), key=lambda x: x[1], reverse=True): + print(f" {issue_type}: {count}") + + # spec_lock anchor comparison (only printed when a lock was found) + self._print_anchor_value_summary() + + # Template-mode aggregation (orphan/missing roster + placeholder hints) + self._print_template_summary() + + # Animation config aggregation. + self._print_animation_summary() + + # Illustration strategy aggregation. + self._print_illustration_summary() + + # Communication contract and per-page audience movement. + self._print_communication_trace_summary() + + # Explicit PowerPoint master/layout structure aggregation. + self._print_pptx_structure_summary() + + # Source-owned import recovery belongs to the template, not this run. + self._print_source_import_summary() + + # Fix suggestions + if self.summary['errors'] > 0 or self.summary['warnings'] > 0: + print(f"\n[TIP] Common fixes:") + print(f" 1. XML well-formedness: write typography as raw Unicode (—, ©, →, NBSP); escape XML reserved chars as & < > " ' — never use HTML named entities like   — ©") + print(f" 2. viewBox issues: root viewBox is the canvas authority (see references/canvas-formats.md)") + print( + " 3. Paint recommendation: generated SVG prefers uppercase " + "#RRGGBB plus channel-specific opacity; compatible alternatives " + "remain non-blocking" + ) + print(f" 4. foreignObject: Use + for manual line breaks") + print(f" 5. Font issues: use PPT-safe exported typefaces (e.g. Microsoft YaHei / Arial / Consolas)") + + def _print_animation_summary(self): + """Print animations.json validation issues if present.""" + if not self._animation_issues: + return + + errors = [item for item in self._animation_issues if item[0] == 'error'] + warnings = [item for item in self._animation_issues if item[0] == 'warning'] + + print("\n[ANIMATION] animations.json checks") + for _severity, msg in errors: + print(f" [ERROR] {msg}") + for _severity, msg in warnings: + print(f" [WARN] {msg}") + + def _print_illustration_summary(self): + """Print project-level illustration strategy issues if present.""" + if not self._illustration_issues: + return + + errors = [item for item in self._illustration_issues if item[0] == 'error'] + warnings = [item for item in self._illustration_issues if item[0] == 'warning'] + + print("\n[IMAGES] Image resource checks") + if errors: + print(f" Errors ({len(errors)}):") + for _severity, kind, msg in errors: + print(f" [{kind}] {msg}") + if warnings: + print(f" Warnings ({len(warnings)}):") + for _severity, kind, msg in warnings: + print(f" [{kind}] {msg}") + + def _print_pptx_structure_summary(self): + """Print project-level PowerPoint structure contract issues.""" + if not self._pptx_structure_issues: + return + print("\n[PPTX STRUCTURE] Master/layout contract checks") + for severity, message in self._pptx_structure_issues: + print(f" [{severity.upper()}] {message}") + + def _print_communication_trace_summary(self): + """Print project-level communication trace issues.""" + if not self._communication_trace_issues: + return + print("\n[COMMUNICATION TRACE] Contract and Audience move checks") + for severity, message in self._communication_trace_issues: + print(f" [{severity.upper()}] {message}") + + def _print_source_import_summary(self): + """Print source-owned tolerant-import diagnostics as information.""" + warning_count = _source_import_warning_count( + self._source_import_summary + ) + if warning_count <= 0: + return + print("\n[SOURCE IMPORT] Template-owned compatibility diagnostics") + print( + f" [INFO] {warning_count} source-import warning(s); unchanged " + "template recovery is not attributed to generated content." + ) + by_code = self._source_import_summary.get('by_code') + if isinstance(by_code, dict): + for code, count in sorted(by_code.items()): + print(f" {code}: {count}") + + def _print_template_summary(self): + """Aggregate template-mode roster / placeholder issues at the bottom. + + Errors land under the ``errors`` summary count (so the exit signal + from ``main`` agrees), warnings under ``warnings``. Both are listed + per file so the user can act on them directly. + """ + if not self._template_issues and not self._brand_template_checked: + return + + errors = [item for item in self._template_issues if item[0] == 'error'] + warnings = [item for item in self._template_issues if item[0] == 'warning'] + + print("\n[TEMPLATE] Template mode checks") + if errors: + print(f" Errors ({len(errors)}):") + for _sev, kind, msg in errors: + print(f" [{kind}] {msg}") + if warnings: + print(f" Warnings ({len(warnings)}):") + for _sev, kind, msg in warnings: + print(f" [{kind}] {msg}") + if self._brand_template_checked and not errors: + print(" Brand design_spec.md schema and asset references passed.") + if not errors: + if not self._brand_template_checked: + print(" No structural roster issues.") + print(" Conventional placeholder-name hints may be declared through " + "'placeholders:' frontmatter. Placeholder bounds are mandatory " + "design-zone metadata.") + + def _apply_aggregated_issue_counts(self): + """Mirror project-level aggregate issues into summary counters once.""" + if self._aggregate_counts_applied: + return + self._aggregate_counts_applied = True + + animation_errors = [item for item in self._animation_issues if item[0] == 'error'] + animation_warnings = [item for item in self._animation_issues if item[0] == 'warning'] + self.summary['errors'] += len(animation_errors) + self.summary['warnings'] += len(animation_warnings) + for severity, _msg in self._animation_issues: + self.issue_types[f'animation_config_{severity}'] += 1 + + template_errors = [item for item in self._template_issues if item[0] == 'error'] + template_warnings = [item for item in self._template_issues if item[0] == 'warning'] + self.summary['errors'] += len(template_errors) + self.summary['warnings'] += len(template_warnings) + for severity, kind, _msg in self._template_issues: + self.issue_types[f'template_{kind}_{severity}'] += 1 + + illustration_errors = [item for item in self._illustration_issues if item[0] == 'error'] + illustration_warnings = [item for item in self._illustration_issues if item[0] == 'warning'] + self.summary['errors'] += len(illustration_errors) + self.summary['warnings'] += len(illustration_warnings) + for severity, kind, _msg in self._illustration_issues: + self.issue_types[f'illustration_{kind}_{severity}'] += 1 + + communication_errors = [ + item for item in self._communication_trace_issues + if item[0] == 'error' + ] + communication_warnings = [ + item for item in self._communication_trace_issues + if item[0] == 'warning' + ] + self.summary['errors'] += len(communication_errors) + self.summary['warnings'] += len(communication_warnings) + for severity, _msg in self._communication_trace_issues: + self.issue_types[f'communication_trace_{severity}'] += 1 + + structure_errors = [item for item in self._pptx_structure_issues if item[0] == 'error'] + structure_warnings = [item for item in self._pptx_structure_issues if item[0] == 'warning'] + self.summary['errors'] += len(structure_errors) + self.summary['warnings'] += len(structure_warnings) + for severity, _msg in self._pptx_structure_issues: + self.issue_types[f'pptx_structure_{severity}'] += 1 + + def _print_anchor_value_summary(self): + """Print anchor comparisons without treating contextual paint/type as drift.""" + if not self._lock_seen: + return + has_contextual = any( + self._anchor_value_summary[category] + for category in ('colors', 'fonts') + ) + has_undeclared_sizes = bool(self._anchor_value_summary['sizes']) + if not has_contextual and not has_undeclared_sizes: + print( + "\n[OK] spec_lock anchor comparison: no additional contextual " + "colors/fonts or out-of-band font sizes" + ) + return + + if has_contextual: + print("\nContextual values beyond spec_lock anchors (informational):") + for category, label in ( + ('colors', 'Colors'), + ('fonts', 'Font families'), + ): + items = self._anchor_value_summary.get(category, {}) + if not items: + continue + entries = sorted( + items.items(), key=lambda item: (-len(item[1]), item[0]) + ) + print(f" {label}:") + for val, files in entries: + count = len(files) + suffix = "file" if count == 1 else "files" + print(f" {val} ({count} {suffix})") + print( + "Note: contextual page paint, gradient/effect colors, and " + "export-safe typefaces are allowed.\n" + " Add a spec_lock row only when a value becomes a " + "recurring named semantic role." + ) + + if has_undeclared_sizes: + print( + "\nTypography sizes outside every declared role anchor ±2px " + "(up to 2 occurrences are sparse; the 3rd is recurring):" + ) + entries = sorted( + self._anchor_value_summary['sizes'].items(), + key=lambda item: (-len(item[1]), item[0]), + ) + for val, files in entries: + occurrences = self._undeclared_size_occurrences.get( + val, + len(files), + ) + file_count = len(files) + file_suffix = "file" if file_count == 1 else "files" + policy = ( + "sparse" + if occurrences <= SPARSE_UNDECLARED_FONT_SIZE_MAX_OCCURRENCES + else "recurring — declare a role" + ) + print( + f" {val} ({occurrences} occurrences in {file_count} " + f"{file_suffix}; {policy})" + ) + + def _percentage(self, count: int) -> int: + """Calculate percentage""" + if self.summary['total'] == 0: + return 0 + return min(100, int(count / self.summary['total'] * 100)) + + def export_report(self, output_file: str = 'svg_quality_report.txt'): + """Export check report""" + with open(output_file, 'w', encoding='utf-8') as f: + f.write("PPT Master SVG Quality Check Report\n") + f.write("=" * 80 + "\n\n") + + for result in self.results: + status = "[OK] Passed" if result['passed'] else "[ERROR] Failed" + f.write(f"{status} - {result['file']}\n") + f.write(f"Path: {result.get('path', 'N/A')}\n") + + if result['info']: + f.write(f"Info: {result['info']}\n") + + if result['errors']: + f.write(f"\nErrors:\n") + for error in result['errors']: + f.write(f" - {error}\n") + + if result['warnings']: + f.write(f"\nWarnings:\n") + for warning in result['warnings']: + f.write(f" - {warning}\n") + + f.write("\n" + "-" * 80 + "\n\n") + + # Write summary + f.write("\n" + "=" * 80 + "\n") + f.write("Check Summary\n") + f.write("=" * 80 + "\n\n") + f.write(f"Total files: {self.summary['total']}\n") + f.write(f"Fully passed: {self.summary['passed']}\n") + f.write(f"With warnings: {self.summary['warnings']}\n") + f.write(f"With errors: {self.summary['errors']}\n") + + print(f"\n[REPORT] Check report exported: {output_file}") + + def export_json_report( + self, + output_file: str, + *, + target: str, + stage: str, + ) -> None: + """Write a machine-readable quality report with provenance classes.""" + self._apply_aggregated_issue_counts() + introduced: List[Dict[str, str]] = [] + blocking: List[Dict[str, str]] = [] + inherited: List[Dict[str, str]] = [] + for result in self.results: + filename = str(result.get('file') or '') + introduced.extend({ + 'file': filename, + 'message': warning, + } for warning in result.get('warnings', [])) + blocking.extend({ + 'file': filename, + 'message': error, + } for error in result.get('errors', [])) + info = result.get('info') or {} + for item in info.get('inherited', []): + if isinstance(item, dict): + inherited.append({ + 'file': filename, + 'kind': str(item.get('kind') or 'prototype'), + 'message': str(item.get('message') or ''), + }) + + project_issues = { + 'template': [ + {'severity': severity, 'kind': kind, 'message': message} + for severity, kind, message in self._template_issues + ], + 'animation': [ + {'severity': severity, 'message': message} + for severity, message in self._animation_issues + ], + 'illustration': [ + {'severity': severity, 'kind': kind, 'message': message} + for severity, kind, message in self._illustration_issues + ], + 'communication_trace': [ + {'severity': severity, 'message': message} + for severity, message in self._communication_trace_issues + ], + 'pptx_structure': [ + {'severity': severity, 'message': message} + for severity, message in self._pptx_structure_issues + ], + } + for group, issues in project_issues.items(): + for issue in issues: + item = { + 'scope': group, + 'message': issue['message'], + } + if issue['severity'] == 'error': + blocking.append(item) + else: + introduced.append(item) + + # Keep the legacy `drift` JSON field for report compatibility. Its + # colors/fonts entries are informational anchor comparisons; sparse + # size entries are informational until their third occurrence. + drift = { + category: { + value: sorted(files) + for value, files in sorted(values.items()) + } + for category, values in self._anchor_value_summary.items() + } + source_import = dict(self._source_import_summary) + payload = { + 'schema': 'ppt-master.svg-quality-report.v1', + 'stage': stage, + 'target': str(Path(target).resolve()), + 'source_fingerprint': _quality_source_fingerprint(self.results), + 'summary': dict(self.summary), + 'issue_types': dict(sorted(self.issue_types.items())), + 'categories': { + 'blocking': { + 'count': len(blocking), + 'issues': blocking, + }, + 'introduced': { + 'count': len(introduced), + 'issues': introduced, + }, + 'inherited': { + 'count': len(inherited), + 'issues': inherited, + }, + 'source-import': { + 'count': _source_import_warning_count(source_import), + 'summary': source_import, + }, + }, + 'drift': drift, + 'project_issues': project_issues, + 'files': self.results, + } + report_path = Path(output_file) + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + '\n', + encoding='utf-8', + ) + print(f"\n[REPORT] JSON quality report exported: {report_path}") + + +def _source_import_warning_count(summary: Dict[str, object]) -> int: + """Return only a schema-compatible non-negative warning count.""" + value = summary.get('warning_count') + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + return 0 + return value + + +def _quality_source_fingerprint(results: List[Dict]) -> Dict[str, object]: + """Bind a quality report to the exact SVG bytes that were checked.""" + files: List[Dict[str, object]] = [] + aggregate = hashlib.sha256() + candidates = sorted( + ( + result + for result in results + if result.get('exists') and result.get('path') + ), + key=lambda result: Path(str(result['path'])).name, + ) + for result in candidates: + path = Path(str(result['path'])) + file_sha256 = result.get('source_sha256') + if not isinstance(file_sha256, str): + files.append({ + 'file': path.name, + 'sha256': None, + 'error': 'source bytes were not available during validation', + }) + file_sha256 = 'unreadable' + else: + files.append({'file': path.name, 'sha256': file_sha256}) + aggregate.update(path.name.encode('utf-8')) + aggregate.update(b'\0') + aggregate.update(file_sha256.encode('ascii')) + aggregate.update(b'\n') + return { + 'algorithm': 'sha256', + 'digest': aggregate.hexdigest(), + 'file_count': len(files), + 'files': files, + } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/cli.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/cli.py new file mode 100644 index 00000000..b3819dff --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/cli.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""PPT Master SVG quality-check CLI implementation. + +Parses the legacy command-line contract and delegates validation to the checker. + +Usage: + python3 scripts/svg_quality_checker.py [options] + +Examples: + python3 scripts/svg_quality_checker.py projects/demo --stage final --json + +Dependencies: + Standard library plus local PPT Master validation modules. +""" + +import sys +from pathlib import Path + +from slide_roster import discover_slide_svgs + +from .checker import SVGQualityChecker + + +def _first_page_target(target: str) -> str: + """Resolve a project/directory target to its first authored SVG page.""" + path = Path(target) + if path.is_file(): + return str(path) + svg_root = path / "svg_output" if (path / "svg_output").is_dir() else path + svg_files = discover_slide_svgs(svg_root) if svg_root.is_dir() else [] + return str(svg_files[0]) if svg_files else target + + +def _default_json_report_path( + checker: SVGQualityChecker, + target: str, + stage: str, +) -> Path: + """Choose a stage-specific report path without overwriting the final gate.""" + target_path = Path(target) + project_path = checker._resolve_project_path(target_path) + report_name = ( + "svg_quality_report.json" + if stage == "final" + else "svg_quality_first_page_report.json" + ) + if ( + (project_path / "svg_output").is_dir() + or (project_path / "design_spec.md").is_file() + ): + return project_path / "validation" / report_name + base = target_path if target_path.is_dir() else target_path.parent + return base / report_name + + +def print_usage() -> None: + """Print CLI usage information.""" + print("PPT Master - SVG Quality Check Tool\n") + print("Usage:") + print(" python3 scripts/svg_quality_checker.py ") + print(" python3 scripts/svg_quality_checker.py ") + print(" python3 scripts/svg_quality_checker.py /templates --template-mode") + print(" python3 scripts/svg_quality_checker.py --all examples") + print("\nExamples:") + print(" python3 scripts/svg_quality_checker.py examples/project/svg_output/slide_01.svg") + print(" python3 scripts/svg_quality_checker.py examples/project/svg_output") + print(" python3 scripts/svg_quality_checker.py examples/project") + print(" python3 scripts/svg_quality_checker.py templates/layouts/presentation_core/templates --template-mode") + print(" python3 scripts/svg_quality_checker.py templates/decks/中国电信/templates --template-mode") + print("\nOptions:") + print(" --format Expected canvas format") + print(" --stage first-page checks only the first authored SVG") + print(" with a partial structure roster; final (default)") + print(" requires the complete declared page roster.") + print(" --json Write a machine-readable quality report") + print(" --json-output Override the JSON report path") + print(" --quick-generate Validate lockless flat Quick Generate SVGs;") + print(" ignore design_spec.md and spec_lock.md.") + print(" --template-mode Validate a template workspace's templates/ directory:") + print(" Brand validates design_spec.md and referenced assets;") + print(" Layout/Deck glob *.svg directly, skip spec_lock checks,") + print(" enforce roster consistency, and emit placeholder hints.") + print(" native_structure_mode: structured also enables complete") + print(" per-file and cross-page structure validation. Legacy") + print(" native_structure_mode: template fails and must be") + print(" re-created through create-template before validation.") + print(" Warnings are advisory: they require no modification and do not affect exit status;") + print(" only errors make the command exit with status 1.") + + +def main() -> None: + """Run the CLI entry point.""" + if len(sys.argv) < 2: + print_usage() + sys.exit(0) + + if sys.argv[1] in {"-h", "--help", "help"}: + print_usage() + sys.exit(0) + + if sys.argv[1].startswith("--") and sys.argv[1] not in {"--all"}: + print(f"[ERROR] Missing target before option: {sys.argv[1]}") + print_usage() + sys.exit(1) + + template_mode = "--template-mode" in sys.argv + quick_generate = "--quick-generate" in sys.argv + if template_mode and quick_generate: + print("[ERROR] --template-mode cannot be combined with --quick-generate") + sys.exit(1) + checker = SVGQualityChecker( + template_mode=template_mode, + quick_generate=quick_generate, + ) + + target = sys.argv[1] + expected_format = None + stage = "final" + + if "--format" in sys.argv: + idx = sys.argv.index("--format") + if idx + 1 < len(sys.argv): + expected_format = sys.argv[idx + 1] + if "--stage" in sys.argv: + idx = sys.argv.index("--stage") + if idx + 1 >= len(sys.argv): + print("[ERROR] --stage requires first-page or final") + sys.exit(1) + stage = sys.argv[idx + 1] + if stage not in {"first-page", "final"}: + print(f"[ERROR] Unsupported quality-check stage: {stage}") + sys.exit(1) + + if target == "--all": + if quick_generate: + print("[ERROR] --quick-generate does not support --all") + sys.exit(1) + if stage != "final": + print("[ERROR] --stage first-page does not support --all") + sys.exit(1) + base_dir = sys.argv[2] if len(sys.argv) > 2 else "examples" + from project_utils import find_all_projects + + projects = find_all_projects(base_dir) + + for project in projects: + print(f"\n{'=' * 80}") + print(f"Checking project: {project.name}") + print("=" * 80) + checker.check_directory(str(project)) + else: + check_target = _first_page_target(target) if stage == "first-page" else target + checker.check_directory(check_target, expected_format) + + if stage == "final" and Path(target).is_dir(): + if checker._has_incomplete_page_roster: + print( + "[TIP] This final-stage run found an incomplete page roster. " + "During serial authoring, use --stage first-page for the first-page " + "gate; keep --stage final for the complete deck." + ) + + checker.print_summary() + + if "--export" in sys.argv: + output_file = "svg_quality_report.txt" + if "--output" in sys.argv: + idx = sys.argv.index("--output") + if idx + 1 < len(sys.argv): + output_file = sys.argv[idx + 1] + checker.export_report(output_file) + + if "--json" in sys.argv or "--json-output" in sys.argv: + if "--json-output" in sys.argv: + idx = sys.argv.index("--json-output") + if idx + 1 >= len(sys.argv): + print("[ERROR] --json-output requires a path") + sys.exit(1) + json_output = Path(sys.argv[idx + 1]) + else: + json_output = _default_json_report_path(checker, target, stage) + checker.export_json_report( + str(json_output), + target=target, + stage=stage, + ) + + if checker.summary["errors"] > 0: + sys.exit(1) + else: + sys.exit(0) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/svg_contracts.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/svg_contracts.py new file mode 100644 index 00000000..b135061a --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/svg_contracts.py @@ -0,0 +1,1121 @@ +#!/usr/bin/env python3 +"""PPT Master stateless SVG contract checks. + +Validates the SVG property surface shared with the native DrawingML exporter. +Each check receives an explicit XML root or source string and appends findings +to the supplied result dictionary. + +Usage: + Import checks from ``svg_quality.svg_contracts``. + +Examples: + from svg_quality.svg_contracts import check_paint_compatibility + +Dependencies: + Standard library plus local PPT Master SVG-to-PPTX modules. +""" + +import copy +import math +import re +from collections import Counter, defaultdict +from typing import Dict, List +from xml.etree import ElementTree as ET + +from .xml_support import ( + XLINK_NS, + element_label as _element_label, + local_name as _local_name, +) + +try: + from pptx_effects import ( + EFFECT_REASON_ATTR as _EFFECT_REASON_ATTR, + EFFECT_STATUS_ATTR as _EFFECT_STATUS_ATTR, + project_effect_status_errors as _project_effect_status_errors, + ) +except ImportError: + _EFFECT_REASON_ATTR = "data-pptx-effect-reason" + _EFFECT_STATUS_ATTR = "data-pptx-effect-status" + _project_effect_status_errors = None + +try: + from svg_to_pptx.drawingml.utils import ( + DRAWINGML_TEXT_FONT_SIZE_MAX as _DRAWINGML_TEXT_FONT_SIZE_MAX, + DRAWINGML_TEXT_FONT_SIZE_MIN as _DRAWINGML_TEXT_FONT_SIZE_MIN, + PROJECT_OPACITY_PROPERTIES as _OPACITY_PROPERTIES, + PROJECT_PAINT_PROPERTIES as _PAINT_PROPERTIES, + PROJECT_PERCENTAGE_OPACITY_PROPERTIES as _PERCENTAGE_OPACITY_PROPERTIES, + format_project_geometry_length as _format_project_geometry_length, + format_project_image_aspect_ratio as _format_project_image_aspect_ratio, + format_project_opacity as _format_project_opacity, + font_px_to_hpt as _font_px_to_hpt, + is_canonical_project_geometry_length as _is_canonical_project_geometry_length, + is_project_opacity_default_form as _is_project_opacity_default_form, + is_project_paint_default_form as _is_project_paint_default_form, + iter_project_geometry_lengths as _iter_project_geometry_lengths, + iter_project_image_aspect_ratios as _iter_project_image_aspect_ratios, + iter_project_opacities as _iter_project_opacities, + iter_project_paints as _iter_project_paints, + iter_project_stroke_styles as _iter_project_stroke_styles, + iter_project_transforms as _iter_project_transforms, + noncanonical_stroke_dash_numbers as _noncanonical_stroke_dash_numbers, + noncanonical_transform_numbers as _noncanonical_transform_numbers, + 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, + parse_project_opacity as _parse_project_opacity, + parse_project_paint as _parse_project_paint, + parse_project_stroke_dasharray as _parse_project_stroke_dasharray, + parse_project_stroke_enum as _parse_project_stroke_enum, + parse_svg_length as _parse_export_length, + 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, + project_paint_reference_errors as _project_paint_reference_errors, + project_stroke_style_errors as _project_stroke_style_errors, + project_transform_errors as _project_transform_errors, + ) +except ImportError: + _DRAWINGML_TEXT_FONT_SIZE_MAX = None + _DRAWINGML_TEXT_FONT_SIZE_MIN = None + _OPACITY_PROPERTIES = None + _PAINT_PROPERTIES = None + _PERCENTAGE_OPACITY_PROPERTIES = None + _format_project_geometry_length = None + _format_project_image_aspect_ratio = None + _format_project_opacity = None + _font_px_to_hpt = None + _is_canonical_project_geometry_length = None + _is_project_opacity_default_form = None + _is_project_paint_default_form = None + _iter_project_geometry_lengths = None + _iter_project_image_aspect_ratios = None + _iter_project_opacities = None + _iter_project_paints = None + _iter_project_stroke_styles = None + _iter_project_transforms = None + _noncanonical_stroke_dash_numbers = None + _noncanonical_transform_numbers = None + _parse_inline_style = None + _parse_project_geometry_length = None + _parse_project_image_aspect_ratio = None + _parse_project_opacity = None + _parse_project_paint = None + _parse_project_stroke_dasharray = None + _parse_project_stroke_enum = None + _parse_export_length = 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 + _project_paint_reference_errors = None + _project_stroke_style_errors = None + _project_transform_errors = None + +try: + from svg_to_pptx.drawingml.paths import ( + iter_project_freeform_geometry as _iter_project_freeform_geometry, + noncanonical_path_numbers as _noncanonical_path_numbers, + noncanonical_points_numbers as _noncanonical_points_numbers, + project_gradient_geometry_errors as _project_gradient_geometry_errors, + ) +except ImportError: + _iter_project_freeform_geometry = None + _noncanonical_path_numbers = None + _noncanonical_points_numbers = None + _project_gradient_geometry_errors = None + +try: + from svg_to_pptx.drawingml.elements import ( + project_clip_path_errors as _project_clip_path_errors, + project_nested_svg_crop_errors as _project_nested_svg_crop_errors, + ) +except ImportError: + _project_clip_path_errors = None + _project_nested_svg_crop_errors = None + +try: + from svg_to_pptx.drawingml.text_properties import ( + project_text_property_diagnostics as _project_text_property_diagnostics, + ) +except ImportError: + _project_text_property_diagnostics = None + +try: + from svg_to_pptx.geometry_properties import ( + materialize_inline_geometry_properties as _materialize_inline_geometry_properties, + validate_inline_geometry_properties as _validate_inline_geometry_properties, + ) +except ImportError: + _materialize_inline_geometry_properties = None + _validate_inline_geometry_properties = None + +try: + from svg_to_pptx.use_expander import ( + UseExpansionError as _UseExpansionError, + expand_local_use_references as _expand_local_use_references, + validate_local_use_references as _validate_local_use_references, + ) +except ImportError: + _UseExpansionError = None + _expand_local_use_references = None + _validate_local_use_references = None + +_CANONICAL_PAINT_ALPHA_PROPERTY = { + "fill": "fill-opacity", + "stroke": "stroke-opacity", + "stop-color": "stop-opacity", + "flood-color": "flood-opacity", +} +_SUPPORTED_INLINE_STYLE_PROPERTIES = frozenset({ + "cx", "cy", "fill", "fill-opacity", "filter", "flood-color", + "flood-opacity", "font-family", "font-size", "font-style", "font-weight", + "height", "letter-spacing", "opacity", "r", "rx", "ry", + "shape-rendering", "stop-color", "stop-opacity", "stroke", + "stroke-dasharray", "stroke-linecap", "stroke-linejoin", "stroke-opacity", + "stroke-width", "text-anchor", "text-decoration", "vector-effect", + "width", "x", "y", +}) +_BAKE_REQUIRED_VISUAL_PROPERTIES = frozenset({ + "backdrop-filter", + "isolation", + "mix-blend-mode", +}) +_SHARED_FAIL_CLOSED_STYLE_PROPERTIES = frozenset({"mask"}) + + +def check_forbidden_elements( + content: str, + root: ET.Element, + result: Dict, +) -> None: + """Check forbidden elements (blocklist)""" + content_lower = content.lower() + elems = list(root.iter()) + local_names = {_local_name(elem).lower() for elem in elems} + + # ============================================================ + # Forbidden elements blocklist - PPT incompatible + # ============================================================ + + # Style system + if 'style' in local_names: + result['errors'].append("Detected forbidden