Sync third-party and MCP marketplace plugins
Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources. Confidence: high Scope-risk: narrow Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history. Tested: Marketplace validation passed.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-07-29T16:04:09Z"
|
||||
"syncedAt": "2026-07-31T16:01:47Z"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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/<name>_<timestamp>.pptx`. The normal delivery flow runs `finalize_svg.py`, produces self-contained previews in `svg_final/`, and snapshots `svg_output/` to `backup/<timestamp>/svg_output/`; PowerPoint's manual **Convert to Shape** command is outside the supported contract. Explicit disposable few-page tests may instead use [quick-test mode](./skills/ppt-master/workflows/profiles/quick-test.md), which writes only the authored SVGs and one PPTX—no planning, preview, notes, validation, or backup artifacts. By default charts and tables export as individually editable SVG-derived DrawingML shapes, which prioritize cross-app visual consistency. Pass `--native-charts-and-tables` to replace eligible groups with PowerPoint-native Chart/Table objects backed by data, which provide **Edit Data** and object-specific controls but may render differently across apps; this variant is saved as `exports/<name>_<timestamp>_native_charts_tables.pptx`. Both routes are editable—the distinction is the PowerPoint object model, not editability itself.
|
||||
> **Output:** The SVG pipeline has one PPTX converter: it reads `svg_output/` and writes a directly editable native DrawingML deck to `exports/<name>_<timestamp>.pptx`. The 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/<timestamp>/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/<name>_<timestamp>_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).
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: ppt-master
|
||||
description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。"
|
||||
metadata:
|
||||
version: "4.2.0"
|
||||
version: "4.3.0"
|
||||
---
|
||||
|
||||
# PPT Master Skill
|
||||
|
||||
@@ -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 <project>
|
||||
|
||||
# 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 <project>
|
||||
|
||||
# Validate references before export
|
||||
@@ -75,18 +82,26 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project>
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project>
|
||||
```
|
||||
|
||||
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 `<g id="...">` 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
|
||||
<canonical_effect>` 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 <project> -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 `<g id="...">`
|
||||
|
||||
Per-element animations are anchored on **top-level `<g id="...">` content groups** in the SVG (e.g. `<g id="cover-title">`, `<g id="card-1">`). IDs must be unique within the page. One group produces one animation-pane row; whether that row needs a click depends on the selected Start mode. Nested implementation groups may remain anonymous because the sidecar does not target them.
|
||||
Per-element animations are anchored on **top-level `<g id="...">` content
|
||||
groups** in the SVG (e.g. `<g id="cover-title">`, `<g id="card-1">`). IDs must
|
||||
be unique within the page. 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
|
||||
|
||||
+24
-14
@@ -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 `<stem>.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/<stem>.identity.json` | Native deck identity facts | Canvas, theme palette/fonts, observed usage | Read selectively when detailed identity facts are needed |
|
||||
| `analysis/<stem>.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<NN>.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. |
|
||||
| `<import_workspace>/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/<output_stem>.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/<timestamp>/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/<output_stem>.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/<timestamp>/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`, `<stem>.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.
|
||||
| `<import_workspace>/authoring-svg/authoring_summary.json` | Current authoring SVGs plus tool-only manifest roster | `python3 ${SKILL_DIR}/scripts/svg_authoring_view.py <import_workspace>/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 <project_path>` |
|
||||
| `svg_final/` | `svg_output/` plus project assets | `python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>` |
|
||||
| `validation/svg_quality_report.json` | `svg_output/`, locks, template provenance | `python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> --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 <project_path> --stage final --json`; Quick: append `--quick-generate` |
|
||||
| Native PPTX + `validation/<output_stem>.report.json` | `svg_output/` plus notes/assets and final quality report | `python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>` |
|
||||
| Quick native PPTX | `svg_output/`, prepared resources, passing Quick final report | `python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path> --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.
|
||||
|
||||
@@ -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 `#<id>` 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 `<mask>` / 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<NN>`
|
||||
- **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 `<line>`; 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: `<NN>_<page_name>.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: `<index>_<page_name>.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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/<name>` 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 `#<id>` 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 `<g id>`; 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 `<svg>` 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 `<image>` is pattern `#82`, not a
|
||||
rescaling the scene. A compound clip on one `<image>` 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.
|
||||
|
||||
@@ -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/<slide>.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/<slide>.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 `<g id>`; structured placeholder content still counts. Coverage requires its unique claim, evidence, example, relationship, qualifier, or implication—not merely its label—to enter the narration.
|
||||
|
||||
|
||||
+1
-1
@@ -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 `<image>` points at**, `<g>` grouping, sprite-sheet `<svg viewBox>` wrappers, decorative `<rect>` / `<path>` / `<circle>` / `<polygon>` shapes, `<use data-icon="...">` 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/<name>"` (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<NN> content does not fit mirror reference <basename>; 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 (`<NN>_<page_name>.svg` where `<NN>` 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 (`<index>_<page_name>.svg` where `<index>` 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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `<planned file>` | `<planned size>` | `<planned role>` | `<Strategist recommendation>` | `adaptive` / `no-crop` | `ai` / `web` / `slice` | Pending | `<acquisition brief>` |
|
||||
| `<planned file>` | `<planned size>` | `<planned role>` | `<owner-resolved recommendation>` | `adaptive` / `no-crop` | `ai` / `web` / `slice` | Pending | `<acquisition brief>` |
|
||||
|
||||
**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 <project_path>/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/<filename>`). 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/<filename>`). 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}` | `<image>` 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.
|
||||
|
||||
@@ -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/<id>.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/<type>.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 <project>/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/<filename>` |
|
||||
| **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/<filename>` |
|
||||
|
||||
**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/<filename>`. 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/<filename>` 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
|
||||
|
||||
+232
-419
@@ -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 `<image>` plus geometry** — no extra asset, no generation cost, no second render — and they are the SVG equivalents of what PowerPoint users reach for under Merge Shapes.
|
||||
|
||||
**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** — `<image x=0 y=0 width=1280 height=720 preserveAspectRatio="xMidYMid slice"/>` + scrim `<rect>` for legibility + overlay `<text>`.
|
||||
|
||||
2. **Left-third image + right text body** — `<image x=0 y=0 width=~427 height=720>` 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** — `<image>` 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** — `<image x=0 y=0 width=1280 height=~340>` 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 `<line>` leader lines + small `<circle>` endpoints + offset text labels in surrounding space.
|
||||
|
||||
11. **Diagonal split with directional gradient (not hard polygon cut)** — full-bleed `<image>` + overlay `<rect fill="url(#grad)">` whose gradient axis runs along the diagonal, plus a `<line>` to make the divider read. Do NOT hard-clip: polygon cuts give stair-stepped edges on text panels.
|
||||
|
||||
12. **Faded image as backdrop with oversized overlay text** — `<image>` + heavy semi-transparent `<rect fill="bg-color" fill-opacity="0.5–0.7">` over it + huge `<text>` (80–120px) on top. Image becomes texture; text is the subject.
|
||||
|
||||
13. **Narrow vertical image strip + giant horizontal title** — `<image x=0 y=0 width=200–280 height=720>` + thick divider `<rect>` + large `<text>` (60–90px) in the remaining width.
|
||||
|
||||
14. **Horizontal banner strip cutting through mid-section** — `<image y=middle width=1280 height=200–280>` with edge fades; text blocks above and below the band.
|
||||
|
||||
15. **Multi-image montage with bold text spanning across** — `<image>` tiled with 2–4px gaps + large `<text>` (60–100px) in a `<rect fill-opacity="0.5–0.7">` band spanning the montage, so the text stays legible across every tile beneath it.
|
||||
|
||||
16. **Negative-space dominant — small image, mostly whitespace** — image and text together occupy less than 40% of the canvas; rest is empty.
|
||||
|
||||
17. **Picture-in-picture inset** — large `<image>` background + small `<image>` overlaid inside it with a `<rect>` frame.
|
||||
|
||||
18. **Image as full-height sidebar column** — narrow `<image x=0 y=0 width=~200–280 height=720>`; rest of canvas is content area.
|
||||
|
||||
19. **Image floating in whitespace with thin frame and caption** — `<image>` + thin `<rect fill="none" stroke="…">` frame around it + `<text>` 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 `<image>` + 2–4 small info cards (`<rect rx>` + icon + title + one-line text) placed in the image's calm regions. Point to each subject with a straight `<line>` 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 `<circle>` stops with straight `<line>` 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 `<rect>` 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 (`<line>` + perpendicular ticks) spanning a feature, with a centered label box reading dimensions or part names. Add tagged callouts with `<rect>` + 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 (`<line>` axes, `<path>` series, `<circle>` data points) directly on or next to it. Required marker if exporting: `<!-- chart-plot-area: x_min,y_min,x_max,y_max -->` 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 `<circle>` 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 `<rect fill="none" stroke="accent" stroke-width="3"/>` 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 `<image>` of equal size in 50/50 split with thin divider `<line>` and "before" / "after" labels.
|
||||
|
||||
49. **Asymmetric collage** — one large `<image>` + 2–3 smaller `<image>` 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 |
|
||||
| Boundary | Rule |
|
||||
|---|---|
|
||||
| Circle + 2 crossed bars | Quadrant wheel |
|
||||
| Annulus + radial bars | Ring segments |
|
||||
| Wave band + vertical bars | Rhythmic strip |
|
||||
| Trapezoid + slanted bars | Perspective row |
|
||||
| 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 |
|
||||
|
||||
**Authoring**: compute each cell's contour and write it as its own `<path>` clip — the geometry is deterministic, so derive the cells rather than eyeballing them. `shape_boolean_svg.py render <svg-file> --operation fragment --source <id> --source <id> --id <result-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.
|
||||
| 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 |
|
||||
|
||||
**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 `<image>` with thin consistent gaps: horizontal, equal height and varying widths (**#52**), or vertical, aligned by width with shared annotations down one side (**#53**).
|
||||
|
||||
54. **Overlapping image stack** — `<image>` elements with overlapping `x/y` positions; each subsequent one in front (z-order by document order); often combined with slight rotation for layered photo-print look.
|
||||
|
||||
55–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 `<line>`. 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** — `<clipPath>` holding one shape, referenced by `<image clip-path="url(#id)"/>`: `<circle>` (**#20**), `<rect rx ry>` (**#21**, `rx` sets roundness), `<ellipse>` (**#22**), `<polygon points>` (**#23**, keep every vertex inside the image's display rect). #24 supersedes all four whenever the contour is curved or organic.
|
||||
|
||||
24. **Custom path crop (blob, leaf, silhouette)** — use `<clipPath><path d="…"/></clipPath>` 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 `<image>` with one `<path>` 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 `<path>` containing both contours, running in **opposite directions**. Outer clockwise, inner counter-clockwise — e.g. panel `M 80,80 H 1200 V 640 H 80 Z` followed by hole `M 420,220 V 500 H 760 V 220 H 420 Z` (note the second one descends first, reversing the winding). Under nonzero winding the reversed subpath subtracts, producing a true hole, so the effect never needs `fill-rule` and stays inside the [`shared-standards-core.md`](./shared-standards-core.md) §1.2 boundary. Verified end-to-end: both subpaths survive into a single `<a:path>` in the exported `custGeom`. 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 `<image>` 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 `<image>` elements from the same file: one clipped to the container (optionally tinted, #31), one clipped to only the escaping region, positioned so the two halves stay in perfect register. Produces depth with no shadow at all.
|
||||
|
||||
Let the *subject* be what escapes, not a corner of background, and break out only once per page — a page where everything escapes has no frame left to break.
|
||||
|
||||
26. **Triptych baked into a single wide image** — one wide `<image width=1160 height=334>` whose internal composition already contains 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 `<mask>`**: 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
|
||||
`<mask>` 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** — `<linearGradient>` in `<defs>` (set `x1/y1/x2/y2` for direction) + overlay `<rect fill="url(#grad)">`. Most common is top-to-bottom darkening on full-bleed cover images.
|
||||
|
||||
28. **Radial gradient vignette** — `<radialGradient cx cy r>` with dark outer stops; overlay `<rect>`. Focuses attention by darkening the periphery.
|
||||
|
||||
29. **Two-stop scrim — opaque on text side, transparent on focal side** — `<linearGradient>` with one stop at `stop-opacity="0.9"` and another at `stop-opacity="0"`. Use when text sits on one side and the image's subject on the other.
|
||||
|
||||
30–31. **Flat overlay wash** — one `<rect fill-opacity>` 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 `<linearGradient>` where stops are different colors (e.g. dark navy → transparent → warm orange). This re-grades the image's color world without regenerating — particularly useful when an AI image came back with the right composition but wrong color temperature.
|
||||
|
||||
90. **Full-canvas scrim with shapes cut out of it (the cover / divider formula)** — the single highest-yield formula in this catalog, and the one real decks reuse most: a full-slide `<path>` whose outer contour is the canvas and whose inner subpath(s) are cut out using the opposite-winding rule from #83, laid over a full-bleed image. The scrim mutes the photo everywhere except through the cuts, so one ordinary image becomes a designed page. Three elements total: image, scrim, title.
|
||||
|
||||
**The cut contour** — any of these, all authored as reversed inner subpaths in the same `<path>`:
|
||||
|
||||
| Contour | Reads as |
|
||||
| Mechanism, not generic “mask” | Owner |
|
||||
|---|---|
|
||||
| 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 `<image>` itself (#36).
|
||||
|
||||
**Numeral / lettering caveat**: cutting *text* out of the scrim needs the glyph as a `<path>` outline, which is not something to author by hand — least of all for CJK. Set the numeral as ordinary `<text>` over the scrim (nearly as strong, fully editable), or pre-render a knocked-out numeral as an RGBA PNG (#68). Do not approximate glyph outlines.
|
||||
|
||||
**Motion pairing**: the scrim stays fixed while the image beneath drifts slowly (a 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 `<rect>` filled by a `<radialGradient>` whose inner stop is fully transparent and outer stop is opaque dark. Reads as a flashlight beam on the focal area. Use sparingly — it kills everything outside the spotlight.
|
||||
|
||||
34. **Gaussian-blur backdrop** — blur the background in the source image, then layer sharp SVG content above it. Native filter export maps the supported blur graph to a glow/shadow effect; it does not preserve a blurred-image backdrop.
|
||||
|
||||
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** — `<filter><feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000000" flood-opacity="0.10"/></filter>` applied to the image panel's backing `<rect>`. Standard depth lift; filters do not apply directly to `<image>` under the project contract.
|
||||
|
||||
37. **Inner / outer glow on overlay shape** — `<filter><feGaussianBlur stdDeviation="6"/><feMerge/></filter>` on a shape, or simply a slightly larger blurred `<rect>` 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 `<rect fill="bg-color" fill-opacity="0.7–0.85"/>`), low-contrast ambient atmosphere that is seen but never read (**#60**), or a watermark sitting behind body copy (**#61**). Suppress it with an overlay `<rect>` or a pre-dimmed asset — never a runtime filter.
|
||||
|
||||
58. **Image fragment as decorative corner element** — small `<image>` (often with `clipPath`) placed in one corner; not the focus, just visual seasoning.
|
||||
|
||||
59. **Image as horizontal divider band** — narrow `<image height=80–150>` placed between two text sections instead of a `<line>` divider.
|
||||
|
||||
## Special Techniques
|
||||
|
||||
62. **Same image, two references — full view + zoom-callout** — reference the same image file twice in two `<image>` elements: one shows the full scene at normal size; the second uses `clipPath` (circle or rectangle) plus a larger display size to "zoom into" a sub-region. Connect them with a straight `<line>` 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 `<circle stroke>` so it reads as a magnifying lens. No special asset needed — the zoom effect comes from same-source-different-display.
|
||||
|
||||
63. **Transparent PNG sticker / cutout** — an RGBA PNG placed via plain `<image>`; the transparency lives in the file, so no `clipPath` is needed. Sources: `slice_images.py --alpha` output (see [image-generator.md](./image-generator.md) §4.3), an AI backend with native transparent output, or a user asset.
|
||||
|
||||
Never box a cutout in a rectangle — that throws away the only thing it offers. Combine with #4 (bleed off the edge), #58 (corner fragment), #66 (fade into background), #69 (slight rotation), or #49 (asymmetric collage).
|
||||
|
||||
64. **Image with embedded text rendered by the AI** — text becomes part of the artwork: decorative lettering, 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 `<text>` 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 `<text>` 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 `<linearGradient>` 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 `<image>` or its container `<g>`; 2–6 degrees typical. Adds dynamism without breaking layout.
|
||||
|
||||
70–71. **Frames** — a single `<rect fill="none" stroke="#color" stroke-width="2–6"/>` 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 `<image>` as the page background, positioned in root coordinates exactly as the background is, and clip it to the shape contour (§1.2). Because the fill stays registered to the page rather than to the shape, the object reads as a hole in whatever is above it.
|
||||
|
||||
**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 `<rect>` (#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 `<path fill="none" stroke="accent"/>`, drawn slightly larger or offset a few px. The outline repeats the cut geometry instead of boxing it in a rectangle, which is what #70 / #71 do. One extra element, no new asset. Offset it in a single consistent direction across the page; an echo on every side reads as a border, not an echo.
|
||||
|
||||
91. **Faceted gradients for folded / dimensional form (origami, ribbon, folded band)** — build a folded or faceted object from several adjacent `<path>` facets, then give each facet its own `<linearGradient>` whose direction and lightness differ from its neighbours — one face catching light, the next in shade. The fold is created by the *lightness break between adjacent facets*, not by any shadow effect, so it survives export intact as ordinary shapes.
|
||||
|
||||
Keep every facet on one hue and vary only lightness (a white → light-grey → white ramp across three facets already reads as a crease), remove all strokes so the facets meet seamlessly, and keep the light direction consistent across the whole object. Combine with #82 by using the assembled facet outline as the clip contour, which puts a photo inside the folded form. Do not reach for `<filter>` shadows to fake depth here — [`svg-effects.md`](./svg-effects.md) owns effect limits, and the gradient break is both cheaper and more reliable.
|
||||
|
||||
87. **One image panned across consecutive pages** — a single wide image referenced by 2–4 consecutive slides, each showing a different horizontal segment (same `<image>` file and container geometry per page, only `x` shifts). Static on its own, it makes the deck read as one continuous scene; the audience recognizes the place before reading a word.
|
||||
|
||||
**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.
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
## Composition Guidance
|
||||
## 2. Situation Router
|
||||
|
||||
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 |
|
||||
| Page need | Pattern options |
|
||||
|---|---|
|
||||
| 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` |
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
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.
|
||||
## 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.
|
||||
|
||||
+135
-188
@@ -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 | `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-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 `<image>` 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
|
||||
<image href="../images/xxx.png"
|
||||
x="60" y="80" width="780" height="446"
|
||||
preserveAspectRatio="xMidYMid meet"/>
|
||||
```
|
||||
|
||||
**Hard rule — no-crop placement**: On every slide using the source, retain one visible complete instance with one of the nine legal anchors plus `meet`, never `none`, and no `clip-path`, `mask`, clipping overflow, or nested `<svg>` 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 `<use>` is.
|
||||
|
||||
Crop-to-fill (an `adaptive` asset with a verified focal-safe crop):
|
||||
|
||||
```xml
|
||||
<image href="../images/bg.png"
|
||||
x="0" y="0" width="1280" height="720"
|
||||
preserveAspectRatio="xMidYMid slice"/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Automation Tool
|
||||
|
||||
```bash
|
||||
python3 scripts/analyze_images.py <project_path>/images # Infer project canvas; fallback PPT 16:9
|
||||
python3 scripts/analyze_images.py <project_path>/images --canvas ppt43 # PPT 4:3
|
||||
python3 scripts/analyze_images.py <project_path>/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.
|
||||
|
||||
@@ -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/<stem>.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/<stem>.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 <image-url> --filename <name>.jpg -o <project_path>/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/<filename>` OR is marked `Needs-Manual`
|
||||
- [ ] Each `Sourced` web image was reviewed against the locked Reference/Crop Policy — a multimodal model via `images/.review/<stem>.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/<stem>.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
|
||||
|
||||
@@ -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*.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+9
-9
@@ -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 `<line>`; 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 `<path>` / `<polygon>` 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 <svg-file> \
|
||||
|
||||
| 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 `<text>` 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 `<svg>` child. |
|
||||
| Placement | Ordinary Slide-local results belong in the applicable untransformed direct-root semantic `<g>` 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
|
||||
|
||||
+9
-9
@@ -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 `<svg>` transform is forbidden; nested crop and `<symbol viewBox>` 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 `<text>` per logical paragraph and non-positional `<tspan>` children for inline runs. Keep the first authored line as direct text; later lines use direct positioned `<tspan>` children that repeat parent `x` with positive relative `dy`; an all-`<tspan>` 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 `<text>` 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 `<g id="...">`. Content grouping is **mandatory** per §4.3 — a top-level `<g id>` is also the animation anchor; it is not an optional convenience. |
|
||||
| Native PowerPoint background promotion | Outside structured mode, the first eligible visual layer may be a direct full-canvas `<rect>` or one inside a simple single-child group. Its fill must have a registered native mapping (solid, linear/radial gradient, or preset pattern), and it must have no transform, filter, clip, rounding, or visible stroke. Export writes the fill as Slide `p:bg`; image elements remain pictures. Structured routes use the narrower explicit solid-background ownership contract in [`pptx-structure-interface.md`](./pptx-structure-interface.md). |
|
||||
| Free-design / brand-only PowerPoint structure | Use `pptx_structure.mode: flat`. Keep every represented object Slide-local; export materializes one clean project-owned Master plus one Blank Layout from the current lock, removes stock content placeholders/Layout inventory, and retains only the standard date/footer/slide-number capability hooks. Do not author Master/Layout identities, layers, or placeholder slots. 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 `<g>` 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 `<text>` descendants against it using the shared SVG-to-PPTX per-run width estimate and safety headroom. Nested groups and all shapes, images, paths, `<use>` 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 `<g id>`; 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 `<g>` solely to silence an ungrouped-element advisory.
|
||||
Wrap each logical Slide-local body unit in one descriptive top-level `<g id>`; 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 `<g>` solely to silence an ungrouped-element advisory.
|
||||
|
||||
**Reference — not a constraint**: A top-level semantic group may contain
|
||||
descriptive nested `<g>` 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.
|
||||
|
||||
|
||||
@@ -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/<id>.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 `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-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 `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 `<project>/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 `<project>/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**:
|
||||
|
||||
|
||||
@@ -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 | `<project_path>/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: `<project_path>/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 `<use>` | §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 `<mask>`; [`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 | `<image opacity="0..1">` | Picture `<a:alphaModFix>`; `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 `<linearGradient>` / `<radialGradient>` child of `<defs>` with unique `id` |
|
||||
| Reference | Exact local `url(#id)` |
|
||||
| Stops | ≥2 direct `<stop>` 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
|
||||
| `<image>` | 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 `<defs><filter id="...">` child with unique id; direct `filter="url(#id)"` attribute, never inline style |
|
||||
| Public targets | `<rect>`, `<circle>`, `<path>`, `<text>` |
|
||||
| Public targets | `<rect>`, `<circle>`, `<image>`, `<path>`, `<text>`; an exact outer `<g filter>` is also registered when its sole visual child is one clipped `<image>` |
|
||||
| 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
|
||||
`<image>` / `<tspan>` / `<g>` / unsupported targets are forbidden; apply the
|
||||
`<tspan>` / ordinary `<g>` / unsupported targets are forbidden; apply the
|
||||
effect to supported objects or use explicit layers.
|
||||
The sole `<g filter>` 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 `<g filter>` 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 `<image>`.
|
||||
frame-fit. Match the image box to the source ratio or use `slice`. Put one §6.4
|
||||
filter directly on an unclipped `<image>`. For a clipped picture, keep
|
||||
`clip-path` on the `<image>` and put the filter on an exact outer `<g>` whose
|
||||
sole visual child is that image. Never combine `filter` and `clip-path` on the
|
||||
same `<image>`: 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 `<image>` has explicit positive `width`/`height` and exactly one non-empty
|
||||
@@ -285,7 +360,7 @@ every non-root `<svg>` 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 `<image>` with one non-empty `href`/`xlink:href`, `x="0" y="0" width="1" height="1" preserveAspectRatio="none"` |
|
||||
| Context | Only root SVG / ordinary visual `<g>` 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 `<g>` 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 `<image>`. 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 `<line>`; 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 `<line>`; 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 `<use>` | §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 → `<line>` + 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 |
|
||||
|
||||
---
|
||||
|
||||
+28
-15
@@ -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 `<image>` |
|
||||
| **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 → <image href="../images/xxx.png" .../>
|
||||
├── Sourced + license_tier=no-attribution → <image href=...> only
|
||||
├── Sourced + license_tier=attribution-required → <image href=...> + small <text> credit element on the slide
|
||||
├── Sourced + license_tier=manual → <image href=...> only (user-supplied --from-url; rights/credit are user responsibility)
|
||||
├── Rendered formula → <image href="../images/formula_001.png" preserveAspectRatio="xMidYMid meet" .../>
|
||||
└── 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 <project_path> 8000 → /svg_output/<filename>.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 <project_path> 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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 `<text>` 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.
|
||||
|
||||
|
||||
@@ -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 <images_folder_path>
|
||||
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:
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+151
-128
@@ -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;
|
||||
}
|
||||
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]));
|
||||
|
||||
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);
|
||||
}
|
||||
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));
|
||||
});
|
||||
card.addEventListener("click", function () { selectImageStrategy(idx, card); });
|
||||
strategyGrid.appendChild(card);
|
||||
});
|
||||
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 */ }
|
||||
|
||||
+2
-13
@@ -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; }
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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 <project_path> --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 <project_path> --daemon # healthy launch; return for chat handoff
|
||||
python3 scripts/confirm_ui/server.py <project_path> --wait-only --wait-stage stage1 # Stage 1
|
||||
@@ -45,12 +86,12 @@ python3 scripts/confirm_ui/server.py <project_path> --timeout 0 # disable idle
|
||||
python3 scripts/confirm_ui/server.py <project_path> --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 <other>` 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:<port>` URL. If health never becomes reachable, the command fails before presenting a dead page.
|
||||
- **Shares port 5050 with the live preview server** (`svg_editor/server.py`). The two never run at once: confirm is Step 4, live preview is Step 6, and Step 4 always shuts this server down on exit (see `--shutdown`) so the port is free. One port = one forward rule for the whole pipeline. They still keep **separate processes and locks** (`.confirm_ui.lock` vs `.live_preview.lock`).
|
||||
- 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:<port>` 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 `<project_path>/confirm_ui/recommendations.stage1.json`; `--shutdown` needs no recommendations).
|
||||
- Per-project lock at `<project_path>/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten.
|
||||
- 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": "<text>" }` 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
|
||||
|
||||
|
||||
@@ -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 <project_path>
|
||||
@@ -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 <project_path>/images
|
||||
python3 scripts/analyze_images.py <project_path>/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`
|
||||
|
||||
|
||||
+71
-8
@@ -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(
|
||||
<stop offset="0" stop-color="#2563EB"/>
|
||||
<stop offset="100%" stop-color="#F97316" stop-opacity="0.4"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="radial" cx="0.5" cy="0.5" r="0.5">
|
||||
<radialGradient id="radial" cx="0.25" cy="0.7" r="0.8">
|
||||
<stop offset="0" stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#0F172A"/>
|
||||
</radialGradient>
|
||||
@@ -61,6 +62,66 @@ radial_xml = build_gradient_fill(radial)
|
||||
assert '<a:lin ang="0" scaled="1"/>' in linear_xml
|
||||
assert '<a:alpha val="40000"/>' in linear_xml
|
||||
assert '<a:path path="circle">' in radial_xml
|
||||
assert (
|
||||
'<a:fillToRect l="25000" t="70000" r="75000" b="30000"/>'
|
||||
in radial_xml
|
||||
)
|
||||
radial.set("fx", "0.8")
|
||||
radial.set("fy", "0.2")
|
||||
focused_xml = build_gradient_fill(radial)
|
||||
assert (
|
||||
'<a:fillToRect l="80000" t="20000" r="20000" b="80000"/>'
|
||||
in focused_xml
|
||||
)
|
||||
native_gradient = ET.fromstring(
|
||||
f'<root xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
|
||||
f'{focused_xml}</root>'
|
||||
)[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 '<a:fillToRect l="80000" t="20000" r="20000" b="80000"/>' 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'<root xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
|
||||
f"{outside_native_xml}</root>"
|
||||
)[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.
|
||||
|
||||
+26
-11
@@ -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.<id>` 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
|
||||
|
||||
@@ -22,6 +22,7 @@ python3 scripts/project_manager.py page-context-report <project_path>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -153,6 +153,12 @@ source.write_text(
|
||||
fill="#2563EB" stroke="#0F172A" stroke-width="5"
|
||||
stroke-dasharray="10 4"/>
|
||||
<circle id="cutout" cx="240" cy="160" r="70" fill="#F97316"/>
|
||||
<text id="text-cutout" x="240" y="235" text-anchor="middle"
|
||||
font-family="sans-serif" font-size="180" font-weight="700">01</text>
|
||||
<text id="missing-font" x="240" y="235"
|
||||
font-family="Definitely Missing Font" font-size="80">X</text>
|
||||
<text id="nested-text" x="240" y="235"
|
||||
font-family="sans-serif" font-size="80"><tspan>X</tspan></text>
|
||||
<path id="open" d="M 40 320 L 260 320 L 260 420" fill="#2563EB"/>
|
||||
<rect id="clipped" x="40" y="320" width="160" height="100"
|
||||
clip-path="url(#clip)" fill="#2563EB"/>
|
||||
@@ -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(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" '
|
||||
'data-pptx-page-role="content">'
|
||||
'<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>'
|
||||
@@ -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"<a:custGeom>")
|
||||
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 <project_path> --quick-test
|
||||
python3 scripts/svg_quality_checker.py <project_path> \
|
||||
--quick-generate --stage final --json
|
||||
python3 scripts/svg_to_pptx.py <project_path> --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/<project_name>_<timestamp>.pptx` — native editable pptx (canonical output)
|
||||
- `validation/<project_name>_<timestamp>.report.json` — package postflight, quality-gate linkage, unresolved resource audit, and published part counts
|
||||
- `backup/<timestamp>/svg_output/` — copy of Executor SVG source, always written so the pptx can be rebuilt via `finalize_svg → svg_to_pptx` without re-running the LLM
|
||||
- `backup/<timestamp>/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/<output_stem>.trace.json`. `--conversion-trace <path>` respects the explicit destination; relative paths are resolved from the project root, so `exports/<name>.trace.json` remains available when intentionally requested.
|
||||
- After normal-flow publication, native export writes `validation/<output_stem>.report.json`. The report distinguishes authored Slides from internal Layout definitions, reruns ZIP integrity and published Slide-count checks, records slide/layout/master/notes part counts, labels relationship/structured/transition/animation validation as enforced at build time, links the final SVG quality report only when its SHA-256 source fingerprint matches the exact export inputs, and surfaces stale/unverified gates, unresolved template tokens, generic-only font stacks, and external image references. A matching final quality report with introduced warnings yields `passed-with-warnings` and a `quality_introduced_warnings=<N>` receipt instead of a clean `passed` claim.
|
||||
- After publication, native export writes `validation/<output_stem>.report.json`. The report distinguishes authored Slides from internal Layout definitions, reruns ZIP integrity and published Slide-count checks, records slide/layout/master/notes part counts, labels relationship/structured/transition/animation validation as enforced at build time, links the final SVG quality report only when its SHA-256 source fingerprint matches the exact export inputs, and surfaces stale/unverified gates, unresolved template tokens, generic-only font stacks, and external image references. A matching final quality report with introduced warnings yields `passed-with-warnings` and a `quality_introduced_warnings=<N>` receipt instead of a clean `passed` claim.
|
||||
- By default, a successful command also prints a compact receipt instead of requiring a report read: `[POSTFLIGHT] status=<...> quality_gate=<...> slides=<N> warning_categories=<N>`, followed by one compact line per warning category and the `[PPTX]` / `[REPORT]` paths. Resource-warning lines carry counts; a non-passing quality gate carries its status. Routine agents use this receipt and do not load either complete validation JSON into model context. Full reports remain cold audit artifacts; failure investigation and explicit audits extract only the required fields. `--quiet` keeps suppressing successful-run output.
|
||||
- Before publishing structured template output, export reopens the temporary PPTX and validates the Slide → Layout → Master graph and registrations, Layout identity, placeholder identity, reusable bounds, and prompt/level-one sizes. A mismatch aborts publication. Flat release instead validates its single referenced Master/Layout shell and exact date/footer/slide-number hook roster before packaging.
|
||||
- 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 <name> --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 `<g id="...">` groups in z-order; use one group per logical Slide-local content unit rather than targeting a group count. Master/Layout atoms and slot groups are structural and excluded; exact id tokens remain a fallback only when explicit structural roles are absent
|
||||
- Per-element animation applies to ordinary top-level SVG `<g id="...">` 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 `<project>/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 `<project>/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:
|
||||
|
||||
@@ -6,8 +6,8 @@ Copy chosen library icons into `<project>/icons/<lib>/` 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:
|
||||
|
||||
+58
@@ -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:(?P<mime>image/[A-Za-z0-9.+-]+)(?P<params>;[^,]*)?,",
|
||||
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 ``)
|
||||
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()
|
||||
|
||||
+24
-5
@@ -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.")
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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 = 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)
|
||||
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 "{slide_name}/{group_id}" '
|
||||
f'Canonical animation order for "{effect_path}" '
|
||||
f"must be a positive integer: {order!r}"
|
||||
)
|
||||
preliminaries.append((order, source_index, group_id, group_cfg))
|
||||
preliminaries.sort(key=lambda item: (item[0], item[1]))
|
||||
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)
|
||||
)
|
||||
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,9 +1105,17 @@ 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:
|
||||
if first_group_effect:
|
||||
fallback_count += 1
|
||||
delay_ms = state.original_delay_ms
|
||||
actual_start_ms = previous_end_ms + delay_ms
|
||||
@@ -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)
|
||||
|
||||
@@ -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")):
|
||||
|
||||
@@ -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"(?<![A-Za-z0-9_])P0*([1-9][0-9]*)(?![A-Za-z0-9_])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class PageContextError(RuntimeError):
|
||||
"""Reject an incomplete or ambiguous page-context request."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageRead:
|
||||
"""One exact model-visible page payload."""
|
||||
|
||||
kind: str
|
||||
path: str
|
||||
payload: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageContextResult:
|
||||
"""One projected page view plus the files that make it current."""
|
||||
|
||||
project_path: Path
|
||||
page: str
|
||||
context: dict[str, object]
|
||||
inputs: tuple[Path, ...]
|
||||
|
||||
|
||||
def normalize_page_key(raw_page: str) -> tuple[str, int]:
|
||||
"""Normalize a positive page identifier to the schema's P<NN> 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<NN> 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<NN> 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"(?<!\\)\|", line.strip().strip("|"))
|
||||
__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",
|
||||
]
|
||||
for line in str(section.get("body", "")).splitlines()
|
||||
if line.strip().startswith("|") and line.strip().endswith("|")
|
||||
]
|
||||
if not table_rows:
|
||||
return set(), set()
|
||||
header = {
|
||||
name.casefold(): index
|
||||
for index, name in enumerate(table_rows[0])
|
||||
}
|
||||
filename_index = header.get("filename")
|
||||
purpose_index = header.get("purpose")
|
||||
if filename_index is None or purpose_index is None:
|
||||
return set(), set()
|
||||
assigned: set[str] = set()
|
||||
selected: set[str] = set()
|
||||
for row in table_rows[1:]:
|
||||
if len(row) <= max(filename_index, purpose_index):
|
||||
continue
|
||||
purpose = row[purpose_index]
|
||||
pages = {int(match.group(1)) for match in _PAGE_TOKEN_RE.finditer(purpose)}
|
||||
if not pages:
|
||||
continue
|
||||
filename = row[filename_index].strip().strip("`")
|
||||
if not filename:
|
||||
continue
|
||||
basename = Path(filename).name
|
||||
assigned.add(basename)
|
||||
if page_number in pages:
|
||||
selected.add(basename)
|
||||
return assigned, selected
|
||||
|
||||
|
||||
def _locked_image_basename(value: str) -> 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"(?<![A-Za-z0-9_]){re.escape(token)}(?![A-Za-z0-9_])",
|
||||
text,
|
||||
) is not None
|
||||
return token in text
|
||||
|
||||
|
||||
def _page_images(
|
||||
locked_images: dict[str, str],
|
||||
brief: str,
|
||||
prototype_refs: list[str],
|
||||
assigned_filenames: set[str],
|
||||
resolved_filenames: set[str],
|
||||
) -> 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),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 = '<p:cond delay="indefinite"/>'
|
||||
else:
|
||||
group_conditions = (
|
||||
'<p:cond delay="indefinite"/>'
|
||||
'<p:cond evt="onBegin" delay="0"><p:tn val="2"/></p:cond>'
|
||||
)
|
||||
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'''<p:par>
|
||||
<p:cTn id="{wrapper_id}" fill="hold">
|
||||
<p:stCondLst><p:cond delay="{wrapper_offset_ms}"/></p:stCondLst>
|
||||
<p:childTnLst><p:par>{row_xml}</p:par></p:childTnLst>
|
||||
</p:cTn>
|
||||
</p:par>''')
|
||||
rows_xml = '\n '.join(rendered_rows)
|
||||
rendered_groups.append(f'''<p:par>
|
||||
<p:cTn id="{group_id}" fill="hold">
|
||||
<p:stCondLst>{group_conditions}</p:stCondLst>
|
||||
<p:childTnLst>
|
||||
{rows_xml}
|
||||
</p:childTnLst>
|
||||
</p:cTn>
|
||||
</p:par>''')
|
||||
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(
|
||||
</p:childTnLst>
|
||||
</p:cTn>
|
||||
</p:par>'''
|
||||
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}'
|
||||
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)
|
||||
)
|
||||
expected_offsets.append(elapsed_ms)
|
||||
previous_duration_ms = target.playback_duration_ms
|
||||
main_index += 1
|
||||
else:
|
||||
expected_offsets.append(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',
|
||||
|
||||
+81
@@ -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(
|
||||
|
||||
+50
-7
@@ -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'<radialGradient id="{grad_id}" cx="0.5" cy="0.5" r="0.5">'
|
||||
f'<radialGradient id="{grad_id}" cx="0.5" cy="0.5" '
|
||||
f'r="0.5"{focus_attrs}>'
|
||||
+ "".join(stops_xml)
|
||||
+ "</radialGradient>"
|
||||
)
|
||||
@@ -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(
|
||||
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(
|
||||
|
||||
+2
-2
@@ -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 <image> 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:
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+23
-4
@@ -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("<svg")
|
||||
or clipped_svg.startswith("<image clip-path=")
|
||||
):
|
||||
# Keep the effect outside the crop viewport so shadows and glows
|
||||
# remain visible beyond the picture geometry in SVG previews.
|
||||
group_attrs.append(f'filter="{filter_attr}"')
|
||||
else:
|
||||
picture_attrs["filter"] = filter_attr
|
||||
picture_svg = _inject_root_svg_attrs(
|
||||
clipped_svg,
|
||||
{**_object_metadata(node, ctx), **effect_metadata},
|
||||
picture_attrs,
|
||||
)
|
||||
return _wrap_shape_group(
|
||||
picture_svg,
|
||||
node,
|
||||
ctx,
|
||||
top_level=top_level,
|
||||
extra_attrs=_metadata_group_attrs(effect_metadata),
|
||||
extra_attrs=group_attrs,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Project Management Internals
|
||||
|
||||
Internal project lifecycle, planning-artifact, and page-context implementation.
|
||||
Use the stable ``scripts/project_manager.py`` entry point for CLI operations.
|
||||
|
||||
Usage:
|
||||
Imported by the top-level project-management compatibility modules.
|
||||
|
||||
Examples:
|
||||
from project_management.project_specs import validate_project_artifacts
|
||||
|
||||
Dependencies:
|
||||
Same as the selected project-management module.
|
||||
"""
|
||||
+1298
File diff suppressed because it is too large
Load Diff
+916
@@ -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"(?<![A-Za-z0-9_])P0*([1-9][0-9]*)(?![A-Za-z0-9_])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class PageContextError(RuntimeError):
|
||||
"""Reject an incomplete or ambiguous page-context request."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageRead:
|
||||
"""One exact model-visible page payload."""
|
||||
|
||||
kind: str
|
||||
path: str
|
||||
payload: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PageContextResult:
|
||||
"""One projected page view plus the files that make it current."""
|
||||
|
||||
project_path: Path
|
||||
page: str
|
||||
context: dict[str, object]
|
||||
inputs: tuple[Path, ...]
|
||||
|
||||
|
||||
def normalize_page_key(raw_page: str) -> tuple[str, int]:
|
||||
"""Normalize a positive page identifier to the schema's P<NN> 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<NN> 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<NN> 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"(?<!\\)\|", line.strip().strip("|"))
|
||||
]
|
||||
for line in str(section.get("body", "")).splitlines()
|
||||
if line.strip().startswith("|") and line.strip().endswith("|")
|
||||
]
|
||||
if not table_rows:
|
||||
return set(), set()
|
||||
header = {
|
||||
name.casefold(): index
|
||||
for index, name in enumerate(table_rows[0])
|
||||
}
|
||||
filename_index = header.get("filename")
|
||||
purpose_index = header.get("purpose")
|
||||
if filename_index is None or purpose_index is None:
|
||||
return set(), set()
|
||||
assigned: set[str] = set()
|
||||
selected: set[str] = set()
|
||||
for row in table_rows[1:]:
|
||||
if len(row) <= max(filename_index, purpose_index):
|
||||
continue
|
||||
purpose = row[purpose_index]
|
||||
pages = {int(match.group(1)) for match in _PAGE_TOKEN_RE.finditer(purpose)}
|
||||
if not pages:
|
||||
continue
|
||||
filename = row[filename_index].strip().strip("`")
|
||||
if not filename:
|
||||
continue
|
||||
basename = Path(filename).name
|
||||
assigned.add(basename)
|
||||
if page_number in pages:
|
||||
selected.add(basename)
|
||||
return assigned, selected
|
||||
|
||||
|
||||
def _locked_image_basename(value: str) -> 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"(?<![A-Za-z0-9_]){re.escape(token)}(?![A-Za-z0-9_])",
|
||||
text,
|
||||
) is not None
|
||||
return token in text
|
||||
|
||||
|
||||
def _page_images(
|
||||
locked_images: dict[str, str],
|
||||
brief: str,
|
||||
prototype_refs: list[str],
|
||||
assigned_filenames: set[str],
|
||||
resolved_filenames: set[str],
|
||||
) -> 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),
|
||||
},
|
||||
}
|
||||
+27
@@ -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"
|
||||
+1370
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from console_encoding import configure_utf8_stdio
|
||||
from slide_roster import discover_slide_svgs
|
||||
from svg_to_pptx.canvas_contract import (
|
||||
CanvasContractError,
|
||||
parse_project_viewbox,
|
||||
@@ -222,7 +223,7 @@ def get_project_info(project_path: str) -> Dict:
|
||||
# Count SVG files
|
||||
svg_output = project_path / 'svg_output'
|
||||
if svg_output.exists():
|
||||
svg_files = sorted(svg_output.glob('*.svg'))
|
||||
svg_files = discover_slide_svgs(svg_output)
|
||||
info['svg_count'] = len(svg_files)
|
||||
info['svg_files'] = [f.name for f in svg_files]
|
||||
|
||||
|
||||
@@ -991,28 +991,45 @@ def audit_authority_graph(
|
||||
return normalized, cycles, findings
|
||||
|
||||
|
||||
def _parse_numbered_markdown_ids(raw_ids: str) -> list[int]:
|
||||
"""Expand one numbered-Markdown heading expression into its stable ids."""
|
||||
ids: list[int] = []
|
||||
for part in re.split(r"\s*·\s*", raw_ids):
|
||||
match = re.fullmatch(r"(\d+)(?:\s*[-–]\s*(\d+))?", part)
|
||||
if match is None:
|
||||
raise AuditError(f"Invalid numbered Markdown id expression: {raw_ids!r}")
|
||||
start = int(match.group(1))
|
||||
end = int(match.group(2) or start)
|
||||
if end < start:
|
||||
raise AuditError(f"Descending numbered Markdown id range: {raw_ids!r}")
|
||||
ids.extend(range(start, end + 1))
|
||||
return ids
|
||||
def _structured_registry_expected_order(config: dict[str, Any]) -> list[str]:
|
||||
"""Build the canonical browse order for one structured registry."""
|
||||
prefix_counts = config.get("prefix_counts")
|
||||
sequence_width = config.get("sequence_width")
|
||||
if (
|
||||
not isinstance(prefix_counts, dict)
|
||||
or not prefix_counts
|
||||
or not all(
|
||||
isinstance(prefix, str)
|
||||
and re.fullmatch(r"[PMAC][1-9]\d*", prefix)
|
||||
and isinstance(count, int)
|
||||
and count > 0
|
||||
for prefix, count in prefix_counts.items()
|
||||
)
|
||||
):
|
||||
raise AuditError(
|
||||
f"Registry {config.get('name')} needs positive prefix_counts"
|
||||
)
|
||||
if not isinstance(sequence_width, int) or sequence_width < 1:
|
||||
raise AuditError(
|
||||
f"Registry {config.get('name')} needs a positive sequence_width"
|
||||
)
|
||||
return [
|
||||
f"{prefix}-{sequence:0{sequence_width}d}"
|
||||
for prefix, count in prefix_counts.items()
|
||||
for sequence in range(1, count + 1)
|
||||
]
|
||||
|
||||
|
||||
def _registry_ids(root: Path, config: dict[str, Any]) -> tuple[set[Any], str, list[Any]]:
|
||||
def _registry_ids(
|
||||
root: Path,
|
||||
config: dict[str, Any],
|
||||
) -> tuple[set[Any], str, list[Any], list[Any] | None]:
|
||||
kind = config.get("kind")
|
||||
source = config.get("source")
|
||||
if not isinstance(source, str) or not (root / source).is_file():
|
||||
raise AuditError(f"Registry has missing source: {source}")
|
||||
|
||||
if kind == "numbered_markdown":
|
||||
if kind == "structured_markdown":
|
||||
pattern = config.get("entry_pattern")
|
||||
if not isinstance(pattern, str):
|
||||
raise AuditError(f"Registry {config.get('name')} needs entry_pattern")
|
||||
@@ -1020,20 +1037,18 @@ def _registry_ids(root: Path, config: dict[str, Any]) -> tuple[set[Any], str, li
|
||||
regex = re.compile(pattern, re.MULTILINE)
|
||||
except re.error as exc:
|
||||
raise AuditError(f"Invalid registry entry_pattern: {exc}") from exc
|
||||
id_group = "ids" if "ids" in regex.groupindex else "id"
|
||||
if id_group not in regex.groupindex:
|
||||
if "id" not in regex.groupindex:
|
||||
raise AuditError(
|
||||
f"Registry {config.get('name')} entry_pattern needs an id or ids group"
|
||||
f"Registry {config.get('name')} entry_pattern needs an id group"
|
||||
)
|
||||
matched_ids = [
|
||||
item
|
||||
match.group("id")
|
||||
for match in regex.finditer(_read_utf8(root / source))
|
||||
for item in _parse_numbered_markdown_ids(match.group(id_group))
|
||||
]
|
||||
duplicates = sorted(
|
||||
item for item, count in Counter(matched_ids).items() if count > 1
|
||||
)
|
||||
return set(matched_ids), source, duplicates
|
||||
return set(matched_ids), source, duplicates, matched_ids
|
||||
|
||||
if kind == "directory":
|
||||
pattern = config.get("glob")
|
||||
@@ -1046,7 +1061,7 @@ def _registry_ids(root: Path, config: dict[str, Any]) -> tuple[set[Any], str, li
|
||||
for path in paths
|
||||
if not _matches_any(_relative_path(root, path), excludes)
|
||||
}
|
||||
return ids, source, []
|
||||
return ids, source, [], None
|
||||
|
||||
if kind == "json_collection":
|
||||
key = config.get("key")
|
||||
@@ -1061,9 +1076,9 @@ def _registry_ids(root: Path, config: dict[str, Any]) -> tuple[set[Any], str, li
|
||||
raise AuditError(f"Registry key {key} is missing in {source}")
|
||||
value = value[part]
|
||||
if isinstance(value, dict):
|
||||
return set(value), source, []
|
||||
return set(value), source, [], None
|
||||
if isinstance(value, list):
|
||||
return set(range(len(value))), source, []
|
||||
return set(range(len(value))), source, [], None
|
||||
raise AuditError(f"Registry key {key} in {source} is not a collection")
|
||||
|
||||
raise AuditError(f"Unsupported registry kind: {kind}")
|
||||
@@ -1085,27 +1100,13 @@ def _registry_count_claims(paragraph: Paragraph, nouns: list[str]) -> list[int]:
|
||||
return claims
|
||||
|
||||
|
||||
def _is_comprehensive_registry_reference(paragraph: Paragraph, nouns: list[str]) -> bool:
|
||||
"""Return whether a block claims complete registry coverage."""
|
||||
scope_nouns = sorted(set(nouns + ["catalog", "registry", "file"]))
|
||||
scope_pattern = "|".join(re.escape(noun) for noun in scope_nouns)
|
||||
return re.search(
|
||||
rf"\b(?:all|every|entire|full)\b(?:\W+\w+){{0,4}}\W+"
|
||||
rf"(?:{scope_pattern})\b|"
|
||||
rf"\b(?:{scope_pattern})\b(?:\W+\w+){{0,4}}\W+"
|
||||
rf"\b(?:all|every|entire|full)\b|"
|
||||
r"\bfile is split\b",
|
||||
paragraph.normalized,
|
||||
) is not None
|
||||
|
||||
|
||||
def audit_registries(
|
||||
root: Path,
|
||||
configs: list[dict[str, Any]],
|
||||
paragraphs: list[Paragraph],
|
||||
documents: list[Document],
|
||||
) -> tuple[list[dict[str, Any]], list[Finding]]:
|
||||
"""Compare live registry membership with numeric documentation claims."""
|
||||
"""Compare live registry membership with documentation claims."""
|
||||
findings: list[Finding] = []
|
||||
reports: list[dict[str, Any]] = []
|
||||
|
||||
@@ -1113,7 +1114,7 @@ def audit_registries(
|
||||
name = config.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
raise AuditError("Every registry requires a name")
|
||||
ids, source, duplicate_ids = _registry_ids(root, config)
|
||||
ids, source, duplicate_ids, source_order = _registry_ids(root, config)
|
||||
if not ids:
|
||||
raise AuditError(f"Registry {name} has no entries")
|
||||
for duplicate_id in duplicate_ids:
|
||||
@@ -1125,6 +1126,62 @@ def audit_registries(
|
||||
path=source,
|
||||
)
|
||||
)
|
||||
expected_order: list[str] | None = None
|
||||
expected_ids: set[str] | None = None
|
||||
if config.get("kind") == "structured_markdown":
|
||||
expected_order = _structured_registry_expected_order(config)
|
||||
expected_ids = set(expected_order)
|
||||
string_ids = {str(item) for item in ids}
|
||||
missing_ids = sorted(expected_ids - string_ids)
|
||||
extra_ids = sorted(string_ids - expected_ids)
|
||||
if missing_ids:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity="error",
|
||||
code="REGISTRY_IDS_MISSING",
|
||||
message=(
|
||||
f"{name} is missing canonical ids: "
|
||||
+ ", ".join(missing_ids)
|
||||
),
|
||||
path=source,
|
||||
)
|
||||
)
|
||||
if (
|
||||
not missing_ids
|
||||
and not extra_ids
|
||||
and not duplicate_ids
|
||||
and source_order != expected_order
|
||||
):
|
||||
mismatch = next(
|
||||
index
|
||||
for index, (actual, expected) in enumerate(
|
||||
zip(source_order or [], expected_order)
|
||||
)
|
||||
if actual != expected
|
||||
)
|
||||
findings.append(
|
||||
Finding(
|
||||
severity="error",
|
||||
code="REGISTRY_ID_ORDER",
|
||||
message=(
|
||||
f"{name} browse order expects {expected_order[mismatch]} "
|
||||
f"at position {mismatch + 1}; found {source_order[mismatch]}"
|
||||
),
|
||||
path=source,
|
||||
)
|
||||
)
|
||||
if extra_ids:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity="error",
|
||||
code="REGISTRY_IDS_EXTRA",
|
||||
message=(
|
||||
f"{name} defines unexpected canonical ids: "
|
||||
+ ", ".join(extra_ids)
|
||||
),
|
||||
path=source,
|
||||
)
|
||||
)
|
||||
index_labels: list[str] | None = None
|
||||
index_targets: list[str] | None = None
|
||||
if config.get("validate_index_links") is True:
|
||||
@@ -1228,54 +1285,63 @@ def audit_registries(
|
||||
record_count_claim(document.path, line_number, count)
|
||||
|
||||
for paragraph in paragraphs:
|
||||
if paragraph.path != source and not any(term in paragraph.normalized for term in terms):
|
||||
continue
|
||||
registry_context = paragraph.path == source or any(
|
||||
term in paragraph.normalized for term in terms
|
||||
)
|
||||
if registry_context:
|
||||
for count in _registry_count_claims(paragraph, nouns):
|
||||
if (paragraph.path, count) not in line_claim_keys:
|
||||
record_count_claim(paragraph.path, paragraph.line, count)
|
||||
if config.get("kind") != "numbered_markdown":
|
||||
continue
|
||||
numeric_ids = {int(item) for item in ids}
|
||||
id_pattern = re.compile(r"#([1-9]\d*)\b")
|
||||
range_pattern = re.compile(r"#([1-9]\d*)\s*[-–]\s*#?([1-9]\d*)\b")
|
||||
for match in id_pattern.finditer(paragraph.text):
|
||||
claimed_id = int(match.group(1))
|
||||
if claimed_id not in numeric_ids:
|
||||
kind = config.get("kind")
|
||||
if kind == "structured_markdown":
|
||||
canonical_ids = {str(item) for item in ids}
|
||||
structured_pattern = re.compile(r"#([PMAC][1-9]\d*-\d+)\b")
|
||||
for match in structured_pattern.finditer(paragraph.text):
|
||||
claimed_id = match.group(1)
|
||||
if claimed_id not in canonical_ids:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity="error",
|
||||
code="REGISTRY_ID_MISSING",
|
||||
message=f"{name} references missing id #{claimed_id}",
|
||||
path=paragraph.path,
|
||||
line=paragraph.line,
|
||||
)
|
||||
)
|
||||
|
||||
ranges = [
|
||||
tuple(sorted((int(match.group(1)), int(match.group(2)))))
|
||||
for match in range_pattern.finditer(paragraph.text)
|
||||
]
|
||||
comprehensive = _is_comprehensive_registry_reference(paragraph, nouns)
|
||||
if ranges and comprehensive:
|
||||
declared_ids = {
|
||||
int(match.group(1))
|
||||
for match in id_pattern.finditer(paragraph.text)
|
||||
}
|
||||
for start, end in ranges:
|
||||
declared_ids.update(range(start, end + 1))
|
||||
if declared_ids != numeric_ids:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity="error",
|
||||
code="REGISTRY_RANGE_MISMATCH",
|
||||
message=(
|
||||
f"{name} comprehensive ranges cover {len(declared_ids)} ids; "
|
||||
f"registry contains {len(numeric_ids)}"
|
||||
f"{name} references missing id #{claimed_id}"
|
||||
),
|
||||
path=paragraph.path,
|
||||
line=paragraph.line,
|
||||
)
|
||||
)
|
||||
legacy_named_pattern = re.compile(
|
||||
r"#(?P<id>"
|
||||
r"(?:single|canvas|multi|reveal|tone|depth|asset|continuity)_\d+"
|
||||
r")\b"
|
||||
)
|
||||
for match in legacy_named_pattern.finditer(paragraph.text):
|
||||
findings.append(
|
||||
Finding(
|
||||
severity="error",
|
||||
code="REGISTRY_ID_LEGACY",
|
||||
message=(
|
||||
f"{name} uses removed id #{match.group('id')}"
|
||||
),
|
||||
path=paragraph.path,
|
||||
line=paragraph.line,
|
||||
)
|
||||
)
|
||||
if registry_context:
|
||||
legacy_numeric_pattern = re.compile(r"#(?P<id>[1-9]\d*)\b")
|
||||
for match in legacy_numeric_pattern.finditer(paragraph.text):
|
||||
findings.append(
|
||||
Finding(
|
||||
severity="error",
|
||||
code="REGISTRY_ID_LEGACY",
|
||||
message=(
|
||||
f"{name} uses removed id #{match.group('id')}"
|
||||
),
|
||||
path=paragraph.path,
|
||||
line=paragraph.line,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
reports.append(
|
||||
{
|
||||
@@ -1285,6 +1351,7 @@ def audit_registries(
|
||||
"duplicate_ids": duplicate_ids,
|
||||
"minimum_id": min(ids) if all(isinstance(item, int) for item in ids) else None,
|
||||
"maximum_id": max(ids) if all(isinstance(item, int) for item in ids) else None,
|
||||
"expected_entries": len(expected_ids) if expected_ids is not None else None,
|
||||
"index_labels": index_labels,
|
||||
"index_targets": index_targets,
|
||||
"claims": sorted(claims, key=lambda item: (item["path"], item["line"])),
|
||||
@@ -1439,7 +1506,7 @@ def run_audit(
|
||||
raise AuditError("Every registry requires a name")
|
||||
if registry_name in registry_members:
|
||||
raise AuditError(f"Duplicate registry name: {registry_name}")
|
||||
members, _, _ = _registry_ids(root, registry_config)
|
||||
members, _, _, _ = _registry_ids(root, registry_config)
|
||||
registry_members[registry_name] = members
|
||||
|
||||
load_sets, load_findings, covered_paths = audit_load_sets(
|
||||
|
||||
+143
-70
@@ -12,28 +12,28 @@
|
||||
"skills/ppt-master/templates/schemas/*.json"
|
||||
],
|
||||
"exclude": [],
|
||||
"max_tokens": 397600
|
||||
"max_tokens": 399484
|
||||
},
|
||||
"file_budgets": {
|
||||
"AGENTS.md": 2400,
|
||||
"skills/ppt-master/SKILL.md": 850,
|
||||
"skills/ppt-master/references/executor-base.md": 8250,
|
||||
"skills/ppt-master/references/executor-base.md": 8245,
|
||||
"skills/ppt-master/references/executor-structured.md": 5300,
|
||||
"skills/ppt-master/references/executor-chart.md": 3100,
|
||||
"skills/ppt-master/references/executor-image.md": 1500,
|
||||
"skills/ppt-master/references/executor-image.md": 1728,
|
||||
"skills/ppt-master/references/executor-web-image.md": 500,
|
||||
"skills/ppt-master/references/executor-notes.md": 900,
|
||||
"skills/ppt-master/references/shared-standards.md": 250,
|
||||
"skills/ppt-master/references/shared-standards-core.md": 11100,
|
||||
"skills/ppt-master/references/svg-effects.md": 11725,
|
||||
"skills/ppt-master/references/svg-effects.md": 13337,
|
||||
"skills/ppt-master/references/native-data-interface.md": 6200,
|
||||
"skills/ppt-master/references/pptx-structure-interface.md": 4300,
|
||||
"skills/ppt-master/references/strategist.md": 14225,
|
||||
"skills/ppt-master/references/strategist-image.md": 2500,
|
||||
"skills/ppt-master/references/strategist.md": 14487,
|
||||
"skills/ppt-master/references/strategist-image.md": 2525,
|
||||
"skills/ppt-master/references/strategist-template.md": 2100,
|
||||
"skills/ppt-master/templates/design_spec_reference.md": 3275,
|
||||
"skills/ppt-master/templates/spec_lock_reference.md": 2375,
|
||||
"skills/ppt-master/workflows/generate-pptx.md": 13775,
|
||||
"skills/ppt-master/workflows/generate-pptx.md": 13850,
|
||||
"skills/ppt-master/workflows/stages/apply-template-workspace.md": 1800
|
||||
},
|
||||
"load_sets": {
|
||||
@@ -93,7 +93,7 @@
|
||||
"skills/ppt-master/templates/README.md",
|
||||
"skills/ppt-master/templates/decks/README.md"
|
||||
],
|
||||
"max_tokens": 58300
|
||||
"max_tokens": 58325
|
||||
},
|
||||
"route.create-template.layout": {
|
||||
"description": "Create Layout path through Template_Designer, SVG core, and the structured PPTX interface.",
|
||||
@@ -111,7 +111,7 @@
|
||||
"skills/ppt-master/templates/README.md",
|
||||
"skills/ppt-master/templates/layouts/README.md"
|
||||
],
|
||||
"max_tokens": 58475
|
||||
"max_tokens": 58500
|
||||
},
|
||||
"route.enhance-native-pptx": {
|
||||
"description": "Finished-PPTX native enhancement route.",
|
||||
@@ -122,7 +122,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/workflows/native-enhance-pptx.md"
|
||||
],
|
||||
"max_tokens": 9750
|
||||
"max_tokens": 9775
|
||||
},
|
||||
"route.fill-native-pptx": {
|
||||
"description": "Raw-PPTX native fill route.",
|
||||
@@ -133,7 +133,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/workflows/template-fill-pptx.md"
|
||||
],
|
||||
"max_tokens": 11450
|
||||
"max_tokens": 11475
|
||||
},
|
||||
"route.generate.planning": {
|
||||
"description": "Generate-PPTX planning through Strategist, including reference-first whole-document authoring and representative multi-source custom mode/style synthesis; schemas and optional scaffolds are tool-consumed.",
|
||||
@@ -170,40 +170,108 @@
|
||||
"registry": "visual-styles"
|
||||
}
|
||||
],
|
||||
"max_tokens": 62075
|
||||
"max_tokens": 63121
|
||||
},
|
||||
"route.generate.quick-test": {
|
||||
"description": "Explicit disposable Generate quick-test short circuit with the shared SVG authoring core.",
|
||||
"route.generate.quick-generate": {
|
||||
"description": "Explicit non-interactive Generate path with conditional source/resource preparation and the complete SVG visual-construction authorities.",
|
||||
"scope": "cumulative",
|
||||
"include": [
|
||||
"bootstrap.routing"
|
||||
],
|
||||
"files": [
|
||||
"skills/ppt-master/workflows/generate-pptx.md",
|
||||
"skills/ppt-master/workflows/profiles/quick-test.md",
|
||||
"skills/ppt-master/references/shared-standards-core.md"
|
||||
"skills/ppt-master/workflows/profiles/quick-generate.md",
|
||||
"skills/ppt-master/references/artifact-ownership.md",
|
||||
"skills/ppt-master/references/shared-standards-core.md",
|
||||
"skills/ppt-master/references/svg-effects.md",
|
||||
"skills/ppt-master/references/native-shape-authoring.md"
|
||||
],
|
||||
"max_tokens": 31550
|
||||
"max_tokens": 55068
|
||||
},
|
||||
"route.generate.quick-generate.topic-research": {
|
||||
"description": "Quick Generate with gap-targeted factual research before direct resource preparation and SVG authoring.",
|
||||
"scope": "cumulative",
|
||||
"include": [
|
||||
"route.generate.quick-generate",
|
||||
"stage.generate.topic-research"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 56580
|
||||
},
|
||||
"route.generate.quick-generate.icon": {
|
||||
"description": "Quick Generate with project-local bundled icon selection and synchronization.",
|
||||
"scope": "cumulative",
|
||||
"include": [
|
||||
"route.generate.quick-generate"
|
||||
],
|
||||
"files": [
|
||||
"skills/ppt-master/templates/icons/README.md"
|
||||
],
|
||||
"max_tokens": 56980
|
||||
},
|
||||
"route.generate.quick-generate.in-hand-image": {
|
||||
"description": "Quick Generate with supplied, extracted, or placeholder image resources prepared before SVG authoring.",
|
||||
"scope": "cumulative",
|
||||
"include": [
|
||||
"route.generate.quick-generate",
|
||||
"stage.generate.executor.image"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 66734
|
||||
},
|
||||
"route.generate.quick-generate.formula": {
|
||||
"description": "Quick Generate with manifest-driven formula rendering and formula-image realization.",
|
||||
"scope": "cumulative",
|
||||
"include": [
|
||||
"route.generate.quick-generate",
|
||||
"stage.generate.executor.image"
|
||||
],
|
||||
"files": [
|
||||
"skills/ppt-master/scripts/docs/image.md"
|
||||
],
|
||||
"max_tokens": 69920
|
||||
},
|
||||
"route.generate.quick-generate.ai-two-types": {
|
||||
"description": "Quick Generate with AI resource preparation, representative rendering inputs, and two local image types.",
|
||||
"scope": "cumulative",
|
||||
"include": [
|
||||
"route.generate.quick-generate",
|
||||
"stage.generate.image.ai-two-types",
|
||||
"stage.generate.executor.image"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 89839
|
||||
},
|
||||
"route.generate.quick-generate.web-image": {
|
||||
"description": "Quick Generate with web resource preparation, provenance, attribution, and image execution.",
|
||||
"scope": "cumulative",
|
||||
"include": [
|
||||
"route.generate.quick-generate",
|
||||
"stage.generate.image.web",
|
||||
"stage.generate.executor.web-image"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 74487
|
||||
},
|
||||
"route.generate.planning-image": {
|
||||
"description": "Generate-PPTX planning context after a non-none image source is proposed or confirmed; layout-catalog recall remains optional.",
|
||||
"description": "Generate-PPTX planning context after a non-none image source is proposed or confirmed, including the compact layout math and vocabulary.",
|
||||
"scope": "cumulative",
|
||||
"include": [
|
||||
"route.generate.planning",
|
||||
"stage.generate.strategist.image"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 74850
|
||||
"max_tokens": 72961
|
||||
},
|
||||
"route.generate.planning-formula": {
|
||||
"description": "Formula-only planning context with no non-formula image resource or layout catalog.",
|
||||
"description": "Formula-only planning context, including the shared image/formula layout math and vocabulary.",
|
||||
"scope": "cumulative",
|
||||
"include": [
|
||||
"route.generate.planning",
|
||||
"stage.generate.strategist.image"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 64500
|
||||
"max_tokens": 72961
|
||||
},
|
||||
"route.generate.planning-ai": {
|
||||
"description": "Generate-PPTX planning when AI image direction is available, including representative multi-source custom rendering synthesis.",
|
||||
@@ -223,7 +291,7 @@
|
||||
"registry": "image-renderings"
|
||||
}
|
||||
],
|
||||
"max_tokens": 79100
|
||||
"max_tokens": 77203
|
||||
},
|
||||
"stage.generate.apply-template-workspace": {
|
||||
"description": "Conditional Step 3 workspace validation, installation, and fusion framework.",
|
||||
@@ -335,7 +403,7 @@
|
||||
"stage.generate.executor.notes"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 87725
|
||||
"max_tokens": 106595
|
||||
},
|
||||
"route.generate.flat-ai-two-types": {
|
||||
"description": "Generate-PPTX context with AI images, representative multi-source custom rendering, and two local types.",
|
||||
@@ -348,7 +416,7 @@
|
||||
"stage.generate.image.ai-two-types"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 133200
|
||||
"max_tokens": 149290
|
||||
},
|
||||
"route.generate.flat-in-hand-image": {
|
||||
"description": "Generate-PPTX context with provided, placeholder, or formula images and no acquisition role.",
|
||||
@@ -360,7 +428,7 @@
|
||||
"stage.generate.executor.notes"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 106525
|
||||
"max_tokens": 122615
|
||||
},
|
||||
"route.generate.flat-web-image": {
|
||||
"description": "Generate-PPTX context with web image acquisition.",
|
||||
@@ -373,7 +441,7 @@
|
||||
"stage.generate.image.web"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 113800
|
||||
"max_tokens": 129890
|
||||
},
|
||||
"route.enhance-native-pptx.audio": {
|
||||
"description": "Enhance Native PPTX with the shared narration-audio stage.",
|
||||
@@ -383,7 +451,7 @@
|
||||
"stage.shared.generate-audio"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 15100
|
||||
"max_tokens": 15125
|
||||
},
|
||||
"route.generate.beautify-flat-no-image": {
|
||||
"description": "Generate-PPTX 1:1 beautify profile on the flat no-image path.",
|
||||
@@ -393,7 +461,7 @@
|
||||
"profile.generate.beautify-pptx"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 94675
|
||||
"max_tokens": 113602
|
||||
},
|
||||
"route.generate.flat-no-image-chart": {
|
||||
"description": "Default flat Generate-PPTX path with chart authoring, native-data replacement, and verification.",
|
||||
@@ -406,7 +474,7 @@
|
||||
"stage.generate.verify-charts"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 112075
|
||||
"max_tokens": 130931
|
||||
},
|
||||
"route.generate.brand-flat-no-image": {
|
||||
"description": "Brand-preset flat Generate-PPTX path without image acquisition.",
|
||||
@@ -418,7 +486,7 @@
|
||||
"stage.generate.template.brand"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 94750
|
||||
"max_tokens": 113627
|
||||
},
|
||||
"route.generate.deck-structured-no-image": {
|
||||
"description": "Deck-preset structured Generate-PPTX path without image acquisition.",
|
||||
@@ -430,7 +498,7 @@
|
||||
"stage.generate.template.deck"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 104700
|
||||
"max_tokens": 123564
|
||||
},
|
||||
"route.generate.layout-structured-no-image": {
|
||||
"description": "Layout-preset structured Generate-PPTX path without image acquisition.",
|
||||
@@ -442,7 +510,7 @@
|
||||
"stage.generate.template.layout"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 105150
|
||||
"max_tokens": 124013
|
||||
},
|
||||
"route.generate.topic-only-flat-no-image": {
|
||||
"description": "Topic research followed by the default flat no-image Generate-PPTX path.",
|
||||
@@ -452,14 +520,16 @@
|
||||
"route.generate.flat-no-image"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 89125
|
||||
"max_tokens": 108107
|
||||
},
|
||||
"stage.generate.executor.flat": {
|
||||
"description": "Incremental flat Executor core with representative multi-source custom mode/style execution.",
|
||||
"description": "Incremental flat Executor core with complete visual-construction authorities and representative multi-source custom mode/style execution.",
|
||||
"scope": "incremental",
|
||||
"files": [
|
||||
"skills/ppt-master/references/executor-base.md",
|
||||
"skills/ppt-master/references/shared-standards-core.md",
|
||||
"skills/ppt-master/references/svg-effects.md",
|
||||
"skills/ppt-master/references/native-shape-authoring.md",
|
||||
"skills/ppt-master/references/semantic-svg.md",
|
||||
{
|
||||
"glob": "skills/ppt-master/references/modes/*.md",
|
||||
@@ -482,7 +552,7 @@
|
||||
"allow_repeat": true
|
||||
}
|
||||
],
|
||||
"max_tokens": 36500
|
||||
"max_tokens": 52590
|
||||
},
|
||||
"stage.generate.executor.structured": {
|
||||
"description": "Structured template execution layered on the flat/shared core.",
|
||||
@@ -494,7 +564,7 @@
|
||||
"skills/ppt-master/references/executor-structured.md",
|
||||
"skills/ppt-master/references/pptx-structure-interface.md"
|
||||
],
|
||||
"max_tokens": 37000
|
||||
"max_tokens": 53090
|
||||
},
|
||||
"stage.generate.image.ai-two-types": {
|
||||
"description": "Incremental AI-image role with representative multi-source custom rendering and two selected local types.",
|
||||
@@ -528,7 +598,7 @@
|
||||
"registry": "image-type-templates"
|
||||
}
|
||||
],
|
||||
"max_tokens": 23000
|
||||
"max_tokens": 23125
|
||||
},
|
||||
"stage.generate.image.web": {
|
||||
"description": "Incremental web-image acquisition role.",
|
||||
@@ -537,7 +607,7 @@
|
||||
"skills/ppt-master/references/image-base.md",
|
||||
"skills/ppt-master/references/image-searcher.md"
|
||||
],
|
||||
"max_tokens": 7000
|
||||
"max_tokens": 7275
|
||||
},
|
||||
"stage.generate.resume-execute": {
|
||||
"description": "Fresh-session resume plus the flat Executor core and post-SVG notes rules.",
|
||||
@@ -552,7 +622,7 @@
|
||||
"skills/ppt-master/workflows/stages/resume-execute.md",
|
||||
"skills/ppt-master/references/artifact-ownership.md"
|
||||
],
|
||||
"max_tokens": 51000
|
||||
"max_tokens": 68804
|
||||
},
|
||||
"stage.generate.resume-execute-image": {
|
||||
"description": "Fresh-session resume when the locked resource plan contains image rows.",
|
||||
@@ -562,7 +632,7 @@
|
||||
"stage.generate.executor.image"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 67800
|
||||
"max_tokens": 83890
|
||||
},
|
||||
"stage.generate.topic-research": {
|
||||
"description": "Gap-targeted factual intake stage.",
|
||||
@@ -578,7 +648,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/workflows/profiles/beautify-pptx.md"
|
||||
],
|
||||
"max_tokens": 6950
|
||||
"max_tokens": 7007
|
||||
},
|
||||
"stage.generate.refine-spec": {
|
||||
"description": "Explicit post-confirmation spec refinement runbook.",
|
||||
@@ -632,7 +702,7 @@
|
||||
"skills/ppt-master/scripts/docs/pptx-transitions.md",
|
||||
"skills/ppt-master/scripts/docs/svg-pipeline.md"
|
||||
],
|
||||
"max_tokens": 28850
|
||||
"max_tokens": 31324
|
||||
},
|
||||
"stage.generate.video-motion-plan": {
|
||||
"description": "Conditional resolved animation-to-video handoff contract.",
|
||||
@@ -648,7 +718,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/references/animations.md"
|
||||
],
|
||||
"max_tokens": 6950
|
||||
"max_tokens": 8037
|
||||
},
|
||||
"stage.shared.generate-audio": {
|
||||
"description": "Shared narration-audio stage.",
|
||||
@@ -656,7 +726,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/workflows/stages/generate-audio.md"
|
||||
],
|
||||
"max_tokens": 5350
|
||||
"max_tokens": 5375
|
||||
},
|
||||
"governance.failure-recovery": {
|
||||
"description": "Global stop, retry, and resume policy.",
|
||||
@@ -664,7 +734,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/workflows/governance/failure-recovery.md"
|
||||
],
|
||||
"max_tokens": 2725
|
||||
"max_tokens": 2829
|
||||
},
|
||||
"stage.shared.conversion-reference": {
|
||||
"description": "Conditional source-conversion and import compatibility reference.",
|
||||
@@ -698,7 +768,7 @@
|
||||
"stage.generate.strategist.template"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 64150
|
||||
"max_tokens": 65197
|
||||
},
|
||||
"route.generate.planning-template-ai": {
|
||||
"description": "Explicit template workspace plus confirmed AI-image planning.",
|
||||
@@ -719,23 +789,14 @@
|
||||
"max_tokens": 3100
|
||||
},
|
||||
"stage.generate.strategist.image": {
|
||||
"description": "Conditional Strategist image/formula policy module without a resource-layout catalog.",
|
||||
"description": "Conditional Strategist image/formula policy with the always-read layout math and compact composition vocabulary.",
|
||||
"scope": "incremental",
|
||||
"files": [
|
||||
"skills/ppt-master/references/strategist-image.md"
|
||||
],
|
||||
"max_tokens": 2500
|
||||
},
|
||||
"stage.generate.strategist.image-layout": {
|
||||
"description": "Optional image-layout catalog recall layered on image planning when the Strategist wants additional composition vocabulary.",
|
||||
"scope": "incremental",
|
||||
"include": [
|
||||
"stage.generate.strategist.image"
|
||||
],
|
||||
"files": [
|
||||
"skills/ppt-master/references/strategist-image.md",
|
||||
"skills/ppt-master/references/image-layout-spec.md",
|
||||
"skills/ppt-master/references/image-layout-patterns.md"
|
||||
],
|
||||
"max_tokens": 14675
|
||||
"max_tokens": 9852
|
||||
},
|
||||
"stage.generate.strategist.template": {
|
||||
"description": "Conditional Strategist module for an explicitly installed template workspace.",
|
||||
@@ -754,14 +815,15 @@
|
||||
"max_tokens": 6200
|
||||
},
|
||||
"stage.generate.executor.image": {
|
||||
"description": "Conditional image embedding and layout execution rules; the optional layout catalog is not part of the required bundle.",
|
||||
"description": "Conditional image embedding and execution rules with the always-read layout math and compact composition vocabulary.",
|
||||
"scope": "incremental",
|
||||
"files": [
|
||||
"skills/ppt-master/references/executor-image.md",
|
||||
"skills/ppt-master/references/image-layout-spec.md",
|
||||
"skills/ppt-master/references/image-layout-patterns.md",
|
||||
"skills/ppt-master/references/svg-image-embedding.md"
|
||||
],
|
||||
"max_tokens": 18425
|
||||
"max_tokens": 11666
|
||||
},
|
||||
"stage.generate.executor.web-image": {
|
||||
"description": "Conditional sourced-image attribution layered on image execution.",
|
||||
@@ -783,7 +845,7 @@
|
||||
"max_tokens": 900
|
||||
},
|
||||
"stage.generate.executor.native-shape": {
|
||||
"description": "Conditional stock PowerPoint shape selection and Boolean materialization contract.",
|
||||
"description": "Stock PowerPoint shape selection and Boolean materialization authority, always included in Generate Executor contexts.",
|
||||
"scope": "incremental",
|
||||
"files": [
|
||||
"skills/ppt-master/references/native-shape-authoring.md"
|
||||
@@ -791,12 +853,12 @@
|
||||
"max_tokens": 4500
|
||||
},
|
||||
"stage.shared.svg-effects": {
|
||||
"description": "Conditional advanced SVG paint, effects, transforms, and geometry for Generate Executor or Create Template.",
|
||||
"description": "Advanced SVG paint, effects, transforms, and geometry authority, always included in Generate Executor contexts and conditionally loaded by other SVG-authoring routes.",
|
||||
"scope": "incremental",
|
||||
"files": [
|
||||
"skills/ppt-master/references/svg-effects.md"
|
||||
],
|
||||
"max_tokens": 11725
|
||||
"max_tokens": 13337
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
@@ -944,9 +1006,24 @@
|
||||
"registries": [
|
||||
{
|
||||
"name": "image-layout-patterns",
|
||||
"kind": "numbered_markdown",
|
||||
"kind": "structured_markdown",
|
||||
"source": "skills/ppt-master/references/image-layout-patterns.md",
|
||||
"entry_pattern": "^(?P<ids>\\d+(?:\\s*(?:[-–]|·)\\s*\\d+)*)\\.\\s+\\*\\*",
|
||||
"entry_pattern": "^-\\s+\\*\\*#(?P<id>[PMAC][1-9][0-9]*-[0-9]+)\\s+·\\s+",
|
||||
"sequence_width": 2,
|
||||
"prefix_counts": {
|
||||
"P1": 13,
|
||||
"P2": 10,
|
||||
"P3": 23,
|
||||
"M1": 11,
|
||||
"M2": 9,
|
||||
"M3": 7,
|
||||
"A1": 4,
|
||||
"A2": 3,
|
||||
"A3": 3,
|
||||
"C1": 1,
|
||||
"C2": 2,
|
||||
"C3": 1
|
||||
},
|
||||
"reference_terms": [
|
||||
"image-layout-patterns",
|
||||
"image-text layout patterns"
|
||||
@@ -1120,10 +1197,6 @@
|
||||
"glob": "skills/ppt-master/scripts/README.md",
|
||||
"reason": "Script inventory for maintainers; not loaded by generation roles."
|
||||
},
|
||||
{
|
||||
"glob": "skills/ppt-master/scripts/docs/image.md",
|
||||
"reason": "Maintainer command reference; image roles use their owning role documents instead."
|
||||
},
|
||||
{
|
||||
"glob": "skills/ppt-master/scripts/docs/project.md",
|
||||
"reason": "Maintainer command reference; route steps carry the required project commands."
|
||||
|
||||
@@ -9,7 +9,7 @@ cross-platform process-liveness check and the claim/read/release lock logic so
|
||||
the two servers cannot drift apart.
|
||||
|
||||
Usage:
|
||||
from server_common import process_alive, read_lock, lock_pid, claim_lock, release_lock, clear_lock, find_free_port
|
||||
from server_common import find_free_port, validate_port
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
@@ -24,14 +24,33 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
MIN_PORT = 1
|
||||
MAX_PORT = 65535
|
||||
|
||||
|
||||
def validate_port(port: int) -> int:
|
||||
"""Return a valid TCP port, raising ``ValueError`` outside 1..65535."""
|
||||
if isinstance(port, bool) or not isinstance(port, int):
|
||||
raise ValueError('port must be an integer between 1 and 65535')
|
||||
if not MIN_PORT <= port <= MAX_PORT:
|
||||
raise ValueError(f'port must be between {MIN_PORT} and {MAX_PORT}: {port}')
|
||||
return port
|
||||
|
||||
|
||||
def find_free_port(preferred: int, host: str = '127.0.0.1', span: int = 50) -> int:
|
||||
"""Return ``preferred`` if it is bindable, else the next free port within
|
||||
``span``. Lets a new project's UI server coexist with another project's
|
||||
server already holding the default port, instead of crashing on bind — each
|
||||
project ends up on its own port serving its own data. Falls back to
|
||||
``preferred`` if the whole span is taken (let the caller's bind surface it).
|
||||
"""Return the first bindable port from ``preferred`` through its scan span.
|
||||
|
||||
The scan remains sequential so callers can keep 5050 as their preferred
|
||||
port and advance predictably when it is occupied. Invalid ports fail before
|
||||
probing, and an exhausted valid range raises ``RuntimeError`` instead of
|
||||
returning a port already known to be unavailable.
|
||||
"""
|
||||
for port in range(preferred, preferred + span):
|
||||
preferred = validate_port(preferred)
|
||||
if isinstance(span, bool) or not isinstance(span, int) or span <= 0:
|
||||
raise ValueError(f'span must be a positive integer: {span}')
|
||||
|
||||
last_port = min(preferred + span - 1, MAX_PORT)
|
||||
for port in range(preferred, last_port + 1):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
@@ -39,7 +58,9 @@ def find_free_port(preferred: int, host: str = '127.0.0.1', span: int = 50) -> i
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
return preferred
|
||||
raise RuntimeError(
|
||||
f'no free TCP port on {host} in range {preferred}..{last_port}'
|
||||
)
|
||||
|
||||
|
||||
def popen_detached(
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
"""
|
||||
PPT Master - Shape Boolean SVG Fragment Tool
|
||||
|
||||
Combine closed SVG shapes and print the resulting canonical SVG path fragment
|
||||
to stdout. Result geometry is in SVG-root coordinate space: replace the
|
||||
operands at their original z-order under the final semantic or structured
|
||||
parent, never under the old transformed ancestor. The source SVG is read-only;
|
||||
this tool never rewrites the page.
|
||||
Combine closed SVG shapes or resolvable text outlines and print the resulting
|
||||
canonical SVG path fragment to stdout. Result geometry is in SVG-root coordinate
|
||||
space: replace the operands at their original z-order under the final semantic
|
||||
or structured parent, never under the old transformed ancestor. The source SVG
|
||||
is read-only; this tool never rewrites the page.
|
||||
|
||||
Usage:
|
||||
python3 scripts/shape_boolean_svg.py render SVG_FILE \
|
||||
@@ -18,9 +18,11 @@ Examples:
|
||||
python3 scripts/shape_boolean_svg.py render slide.svg \
|
||||
--operation subtract --source body --source cutout --id result \
|
||||
--fill "#2563EB" --stroke none
|
||||
python3 scripts/shape_boolean_svg.py render cover.svg \
|
||||
--operation subtract --source scrim --source chapter-number --id reveal
|
||||
|
||||
Dependencies:
|
||||
skia-pathops and local PPT Master modules
|
||||
skia-pathops, local PPT Master modules, and uharfbuzz for text operands
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -40,11 +42,11 @@ configure_utf8_stdio()
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Print the Boolean result of closed SVG shapes as canonical SVG "
|
||||
"path fragments in SVG-root coordinate space. Insert the result at "
|
||||
"the original z-order under the final semantic or structured "
|
||||
"parent, never under the old transformed ancestor. The source file "
|
||||
"is never modified."
|
||||
"Print the Boolean result of closed SVG shapes or resolvable text "
|
||||
"outlines as canonical SVG path fragments in SVG-root coordinate "
|
||||
"space. Insert the result at the original z-order under the final "
|
||||
"semantic or structured parent, never under the old transformed "
|
||||
"ancestor. The source file is never modified."
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
@@ -82,6 +84,18 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
dest="output_id",
|
||||
help="Stable id for the result, or the base id for fragment results.",
|
||||
)
|
||||
render_parser.add_argument(
|
||||
"--font-dir",
|
||||
action="append",
|
||||
default=[],
|
||||
dest="font_dirs",
|
||||
type=Path,
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"Additional font directory for text operands; repeat as needed. "
|
||||
"Explicit directories are searched before system font directories."
|
||||
),
|
||||
)
|
||||
render_parser.add_argument(
|
||||
"--fill",
|
||||
help="Override the primary shape's solid SVG fill, or use none.",
|
||||
@@ -125,6 +139,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
source_ids=args.source_ids,
|
||||
output_id=args.output_id,
|
||||
style=style or None,
|
||||
font_dirs=args.font_dirs,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
@@ -141,6 +156,9 @@ def _validate_render_args(args: argparse.Namespace) -> None:
|
||||
raise ValueError("--source ids must be unique")
|
||||
if not args.output_id.strip():
|
||||
raise ValueError("--id must not be empty")
|
||||
for font_dir in args.font_dirs:
|
||||
if not font_dir.is_dir():
|
||||
raise ValueError(f"--font-dir is not a directory: {font_dir}")
|
||||
|
||||
|
||||
def _style_from_args(args: argparse.Namespace) -> dict[str, str]:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Slide Roster Helpers
|
||||
|
||||
Orders slide SVG filenames by numeric segments for consistent page rosters.
|
||||
|
||||
Usage:
|
||||
Imported by export, validation, preview, animation, and narration tools.
|
||||
|
||||
Examples:
|
||||
discover_slide_svgs(Path("projects/demo/svg_output"))
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_NUMBER_RE = re.compile(r"(\d+)")
|
||||
|
||||
|
||||
def _slide_filename_sort_key(
|
||||
path: Path,
|
||||
) -> tuple[tuple[tuple[int, int | str], ...], str]:
|
||||
"""Order numeric filename segments by value, then break ties by name."""
|
||||
name = path.name
|
||||
folded = name.casefold()
|
||||
segments = tuple(
|
||||
(0, int(segment)) if segment.isdigit() else (1, segment)
|
||||
for segment in _NUMBER_RE.split(folded)
|
||||
)
|
||||
return segments, name
|
||||
|
||||
|
||||
def discover_slide_svgs(directory: Path) -> list[Path]:
|
||||
"""Return direct child SVG files in numeric filename order."""
|
||||
return sorted(
|
||||
directory.glob("*.svg"),
|
||||
key=_slide_filename_sort_key,
|
||||
)
|
||||
@@ -61,6 +61,7 @@ if str(_ROOT_SCRIPTS_DIR) not in sys.path:
|
||||
|
||||
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||
from resource_paths import icon_search_dirs_for_project # noqa: E402
|
||||
from slide_roster import discover_slide_svgs # noqa: E402
|
||||
from server_common import ( # noqa: E402
|
||||
claim_lock as _claim_lock,
|
||||
clear_lock as _clear_lock,
|
||||
@@ -70,6 +71,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()
|
||||
@@ -505,6 +507,8 @@ def create_app(
|
||||
slide_count = 0
|
||||
resp = jsonify({
|
||||
'status': 'ok',
|
||||
'service': 'live_preview',
|
||||
'pid': os.getpid(),
|
||||
'project': str(project_path),
|
||||
'live': app.config['LIVE_MODE'],
|
||||
'svg_output': str(svg_dir),
|
||||
@@ -575,7 +579,7 @@ def create_app(
|
||||
|
||||
annotations = app.config['ANNOTATIONS']
|
||||
slides = []
|
||||
for svg_file in sorted(svg_dir.glob('*.svg')):
|
||||
for svg_file in discover_slide_svgs(svg_dir):
|
||||
path_str = str(svg_file)
|
||||
try:
|
||||
mtime = svg_file.stat().st_mtime
|
||||
@@ -1071,9 +1075,10 @@ def _shutdown_existing(project_path: Path) -> int:
|
||||
def _wait_for_ready(
|
||||
port: int,
|
||||
proc: subprocess.Popen,
|
||||
project_path: Path,
|
||||
timeout: int = STARTUP_TIMEOUT,
|
||||
) -> bool:
|
||||
"""Wait until the server responds or the child exits."""
|
||||
"""Wait until this project's detached live-preview server responds."""
|
||||
deadline = time.time() + timeout
|
||||
health_url = _server_url(port, '/api/health')
|
||||
last_error = ''
|
||||
@@ -1083,9 +1088,17 @@ def _wait_for_ready(
|
||||
return False
|
||||
try:
|
||||
with urllib.request.urlopen(health_url, timeout=1) as response:
|
||||
if response.status == 200:
|
||||
data = json.load(response)
|
||||
if (
|
||||
response.status == 200
|
||||
and isinstance(data, dict)
|
||||
and data.get('service') == 'live_preview'
|
||||
and data.get('project') == str(project_path)
|
||||
and data.get('pid') == proc.pid
|
||||
):
|
||||
return True
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
last_error = 'health response belongs to another service or project'
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
|
||||
last_error = str(exc)
|
||||
time.sleep(0.25)
|
||||
logger.error(
|
||||
@@ -1111,7 +1124,12 @@ def _open_browser(url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _reuse_running_server(existing: dict, *, open_browser: bool) -> int:
|
||||
def _reuse_running_server(
|
||||
existing: dict,
|
||||
*,
|
||||
open_browser: bool,
|
||||
requested_port: Optional[int] = None,
|
||||
) -> int:
|
||||
"""Idempotent relaunch: point at the already-running preview instead of failing.
|
||||
|
||||
A relaunch while the server is alive is the normal second-preview flow
|
||||
@@ -1130,6 +1148,14 @@ def _reuse_running_server(existing: dict, *, open_browser: bool) -> int:
|
||||
pid,
|
||||
)
|
||||
return 1
|
||||
if requested_port is not None and port != requested_port:
|
||||
logger.error(
|
||||
'live preview is already running for this project on port %s; '
|
||||
'explicit --port %s cannot reuse it. Run --shutdown, then start again',
|
||||
port,
|
||||
requested_port,
|
||||
)
|
||||
return 1
|
||||
url = _server_url(port)
|
||||
logger.info(
|
||||
'live preview already running for this project (pid=%s), reusing: %s',
|
||||
@@ -1158,8 +1184,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument(
|
||||
'--port',
|
||||
type=int,
|
||||
default=DEFAULT_PORT,
|
||||
help=f'Port to listen on (default: {DEFAULT_PORT})',
|
||||
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(
|
||||
@@ -1196,6 +1222,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 args.shutdown:
|
||||
return _shutdown_existing(project_path)
|
||||
@@ -1213,7 +1246,11 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
|
||||
legacy_existing = _legacy_live_lock(project_path)
|
||||
if legacy_existing:
|
||||
return _reuse_running_server(legacy_existing, open_browser=not args.no_browser)
|
||||
return _reuse_running_server(
|
||||
legacy_existing,
|
||||
open_browser=not args.no_browser,
|
||||
requested_port=args.port,
|
||||
)
|
||||
|
||||
runtime_dir = _runtime_dir(project_path)
|
||||
lock_file = _lock_file(project_path)
|
||||
@@ -1221,7 +1258,11 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
if args.daemon:
|
||||
existing = _read_lock(lock_file)
|
||||
if existing and _process_alive(_lock_pid(existing)):
|
||||
return _reuse_running_server(existing, open_browser=not args.no_browser)
|
||||
return _reuse_running_server(
|
||||
existing,
|
||||
open_browser=not args.no_browser,
|
||||
requested_port=args.port,
|
||||
)
|
||||
|
||||
try:
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1229,7 +1270,11 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
logger.error('cannot create live preview runtime directory: %s (%s)', runtime_dir, exc)
|
||||
return 1
|
||||
log_path = runtime_dir / 'server.log'
|
||||
port = _find_free_port(args.port)
|
||||
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
|
||||
idle_timeout = args.timeout
|
||||
if idle_timeout is None:
|
||||
idle_timeout = 7200 if args.live else 900
|
||||
@@ -1258,7 +1303,9 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
logger.error('cannot write live preview log: %s (%s)', log_path, exc)
|
||||
return 1
|
||||
url = _server_url(port)
|
||||
if not _wait_for_ready(port, proc):
|
||||
if not _wait_for_ready(port, proc, project_path):
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
logger.error('live preview failed to become reachable: %s (log: %s)', url, log_path)
|
||||
return 1
|
||||
logger.info('started live preview in background: %s (pid=%s)', url, proc.pid)
|
||||
@@ -1270,7 +1317,11 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
# Pick a free port: another project's preview/confirm server may already
|
||||
# hold the default, so bind the next free one instead of crashing — each
|
||||
# project then serves its own data on its own port (no cross-project mix-up).
|
||||
port = _find_free_port(args.port)
|
||||
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. The major driver of orphaned servers is
|
||||
# --live mode (which used to disable idle timeout entirely) combined with
|
||||
@@ -1284,7 +1335,11 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
return 1
|
||||
existing = _claim_lock(lock_file, port)
|
||||
if existing:
|
||||
return _reuse_running_server(existing, open_browser=not args.no_browser)
|
||||
return _reuse_running_server(
|
||||
existing,
|
||||
open_browser=not args.no_browser,
|
||||
requested_port=args.port,
|
||||
)
|
||||
# atexit covers normal interpreter shutdown (Ctrl+C / SystemExit);
|
||||
# /api/shutdown and idle timeout call _release_lock directly before
|
||||
# os._exit since atexit handlers do not run on os._exit.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PPT Master SVG quality-check internals.
|
||||
|
||||
Use the stable ``scripts/svg_quality_checker.py`` entry point for CLI execution
|
||||
and compatibility imports.
|
||||
|
||||
Usage:
|
||||
Import through ``svg_quality_checker``.
|
||||
|
||||
Examples:
|
||||
from svg_quality_checker import SVGQualityChecker
|
||||
|
||||
Dependencies:
|
||||
Local PPT Master validation modules.
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 <svg_file_or_project> [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 <svg_file>")
|
||||
print(" python3 scripts/svg_quality_checker.py <directory>")
|
||||
print(" python3 scripts/svg_quality_checker.py <workspace>/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 <ppt169|ppt43|...> Expected canvas format")
|
||||
print(" --stage <first-page|final> 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 <path> 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)
|
||||
+1121
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PPT Master SVG quality XML helpers.
|
||||
|
||||
Provides namespace constants and compact element labels shared by quality-check
|
||||
domains.
|
||||
|
||||
Usage:
|
||||
Import from ``svg_quality.checker`` or another ``svg_quality`` module.
|
||||
|
||||
Examples:
|
||||
from svg_quality.xml_support import local_name
|
||||
|
||||
Dependencies:
|
||||
Standard library only.
|
||||
"""
|
||||
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
SVG_NS = "http://www.w3.org/2000/svg"
|
||||
XLINK_NS = "http://www.w3.org/1999/xlink"
|
||||
|
||||
|
||||
def local_name(elem: ET.Element) -> str:
|
||||
"""Return an XML element's namespace-free local tag name."""
|
||||
tag = elem.tag
|
||||
if not isinstance(tag, str):
|
||||
return ""
|
||||
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
||||
|
||||
|
||||
def element_label(elem: ET.Element) -> str:
|
||||
"""Return a compact element label for validation messages."""
|
||||
tag = local_name(elem)
|
||||
elem_id = (elem.get("id") or "").strip()
|
||||
return f'<{tag} id="{elem_id}">' if elem_id else f"<{tag}>"
|
||||
+38
-8246
File diff suppressed because it is too large
Load Diff
+243
-60
@@ -29,6 +29,7 @@ from pptx_transitions import (
|
||||
normalize_transition_effect_request,
|
||||
validate_seconds,
|
||||
)
|
||||
from slide_roster import discover_slide_svgs
|
||||
|
||||
from .drawingml.utils import SVG_NS
|
||||
from .pptx_package.narration import AUDIO_CONTENT_TYPES
|
||||
@@ -45,6 +46,18 @@ _INHERITANCE_SENSITIVE_ANIMATION_FIELDS = frozenset({
|
||||
'decelerate',
|
||||
'bounce_end',
|
||||
})
|
||||
_GROUP_EFFECT_FIELDS = frozenset({
|
||||
'effect',
|
||||
'effect_options',
|
||||
'duration',
|
||||
'delay',
|
||||
'order',
|
||||
'trigger',
|
||||
'trigger_shape',
|
||||
*ANIMATION_TIMING_OPTION_FIELDS,
|
||||
'after_effect',
|
||||
'sound',
|
||||
})
|
||||
_CHROME_ID_TOKENS = frozenset({
|
||||
'background', 'bg',
|
||||
'decoration', 'decorations', 'decor',
|
||||
@@ -186,7 +199,7 @@ def scan_project_targets(
|
||||
svg_dir = project_path / 'svg_output'
|
||||
if not svg_dir.is_dir():
|
||||
return targets_by_slide, [f'svg_output directory not found: {svg_dir}']
|
||||
svg_files = sorted(svg_dir.glob('*.svg'))
|
||||
svg_files = discover_slide_svgs(svg_dir)
|
||||
|
||||
for svg_path in svg_files:
|
||||
targets, anonymous = scan_svg_targets(svg_path)
|
||||
@@ -258,6 +271,45 @@ def resolve_slide_animation_config(
|
||||
return resolved
|
||||
|
||||
|
||||
def animation_group_effect_entries(
|
||||
group_cfg: dict[str, Any],
|
||||
*,
|
||||
path: str,
|
||||
) -> tuple[tuple[str, dict[str, Any]], ...]:
|
||||
"""Expand one legacy group block or one ordered multi-effect envelope."""
|
||||
if 'effects' not in group_cfg:
|
||||
return ((path, group_cfg),)
|
||||
|
||||
extra_fields = sorted(set(group_cfg) - {'effects'})
|
||||
if extra_fields:
|
||||
rendered = ', '.join(repr(field) for field in extra_fields)
|
||||
raise ValueError(
|
||||
f'animations.json {path} cannot combine "effects" with '
|
||||
f'other group-level field(s): {rendered}'
|
||||
)
|
||||
effects = group_cfg['effects']
|
||||
if not isinstance(effects, list):
|
||||
raise ValueError(f'animations.json {path}.effects must be an array')
|
||||
if not effects:
|
||||
raise ValueError(
|
||||
f'animations.json {path}.effects must contain at least one effect'
|
||||
)
|
||||
|
||||
entries: list[tuple[str, dict[str, Any]]] = []
|
||||
for index, effect_cfg in enumerate(effects):
|
||||
effect_path = f'{path}.effects[{index}]'
|
||||
if not isinstance(effect_cfg, dict):
|
||||
raise ValueError(
|
||||
f'animations.json {effect_path} must be an object'
|
||||
)
|
||||
if 'effect' not in effect_cfg:
|
||||
raise ValueError(
|
||||
f'animations.json {effect_path}.effect is required'
|
||||
)
|
||||
entries.append((effect_path, effect_cfg))
|
||||
return tuple(entries)
|
||||
|
||||
|
||||
def _animation_parameter_errors(
|
||||
value: dict[str, Any],
|
||||
label: str,
|
||||
@@ -860,74 +912,134 @@ def _animation_group_errors(
|
||||
|
||||
errors: list[str] = []
|
||||
for group_id, group_cfg in groups.items():
|
||||
label = f'group "{slide_name}/{group_id}"'
|
||||
path = (
|
||||
f'slides[{json.dumps(str(slide_name), ensure_ascii=False)}]'
|
||||
f'.groups[{json.dumps(str(group_id), ensure_ascii=False)}]'
|
||||
)
|
||||
if not isinstance(group_cfg, dict):
|
||||
errors.append(f'animations.json {label} must be an object')
|
||||
errors.append(f'animations.json {path} must be an object')
|
||||
continue
|
||||
|
||||
if 'effects' not in group_cfg:
|
||||
errors.extend(
|
||||
_unknown_field_errors(
|
||||
_animation_effect_entry_errors(
|
||||
group_cfg,
|
||||
frozenset({
|
||||
'effect',
|
||||
'effect_options',
|
||||
'duration',
|
||||
'delay',
|
||||
'order',
|
||||
'trigger_shape',
|
||||
*ANIMATION_TIMING_OPTION_FIELDS,
|
||||
'after_effect',
|
||||
'sound',
|
||||
}),
|
||||
label,
|
||||
path,
|
||||
require_effect=False,
|
||||
target_group_id=str(group_id),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if 'effect' in group_cfg:
|
||||
effect_error = _animation_effect_error(group_cfg['effect'], label)
|
||||
extra_fields = sorted(set(group_cfg) - {'effects'})
|
||||
if extra_fields:
|
||||
rendered = ', '.join(repr(field) for field in extra_fields)
|
||||
errors.append(
|
||||
f'animations.json {path} cannot combine "effects" with '
|
||||
f'other group-level field(s): {rendered}'
|
||||
)
|
||||
effects = group_cfg['effects']
|
||||
if not isinstance(effects, list):
|
||||
errors.append(f'animations.json {path}.effects must be an array')
|
||||
continue
|
||||
if not effects:
|
||||
errors.append(
|
||||
f'animations.json {path}.effects must contain at least one effect'
|
||||
)
|
||||
continue
|
||||
for index, effect_cfg in enumerate(effects):
|
||||
effect_path = f'{path}.effects[{index}]'
|
||||
if not isinstance(effect_cfg, dict):
|
||||
errors.append(
|
||||
f'animations.json {effect_path} must be an object'
|
||||
)
|
||||
continue
|
||||
errors.extend(
|
||||
_animation_effect_entry_errors(
|
||||
effect_cfg,
|
||||
effect_path,
|
||||
require_effect=True,
|
||||
target_group_id=str(group_id),
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _animation_effect_entry_errors(
|
||||
effect_cfg: dict[str, Any],
|
||||
path: str,
|
||||
*,
|
||||
require_effect: bool,
|
||||
target_group_id: str,
|
||||
) -> list[str]:
|
||||
"""Validate one legacy group block or one ``effects[]`` row."""
|
||||
errors = _unknown_field_errors(
|
||||
effect_cfg,
|
||||
_GROUP_EFFECT_FIELDS,
|
||||
path,
|
||||
)
|
||||
if require_effect and 'effect' not in effect_cfg:
|
||||
errors.append(f'animations.json {path}.effect is required')
|
||||
elif 'effect' in effect_cfg:
|
||||
effect_error = _animation_effect_error(effect_cfg['effect'], path)
|
||||
if effect_error:
|
||||
errors.append(effect_error)
|
||||
|
||||
for field, allow_zero in (('duration', False), ('delay', True)):
|
||||
if field not in group_cfg:
|
||||
if field not in effect_cfg:
|
||||
continue
|
||||
try:
|
||||
animation_seconds_to_milliseconds(
|
||||
group_cfg[field],
|
||||
f'animations.json {label} animation {field}',
|
||||
effect_cfg[field],
|
||||
f'animations.json {path}.{field}',
|
||||
allow_zero=allow_zero,
|
||||
)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
if 'order' in group_cfg:
|
||||
order = group_cfg['order']
|
||||
if 'order' in effect_cfg:
|
||||
order = effect_cfg['order']
|
||||
if isinstance(order, bool) or not isinstance(order, int) or order <= 0:
|
||||
errors.append(
|
||||
f'animations.json {label} animation order must be a positive integer: '
|
||||
f'animations.json {path}.order must be a positive integer: '
|
||||
f'{order!r}'
|
||||
)
|
||||
if 'trigger_shape' in group_cfg:
|
||||
trigger_shape = group_cfg['trigger_shape']
|
||||
|
||||
if 'trigger' in effect_cfg:
|
||||
trigger_error = _animation_trigger_error(effect_cfg['trigger'], path)
|
||||
if trigger_error:
|
||||
errors.append(trigger_error)
|
||||
|
||||
if 'trigger_shape' in effect_cfg:
|
||||
trigger_shape = effect_cfg['trigger_shape']
|
||||
if not isinstance(trigger_shape, str) or not trigger_shape.strip():
|
||||
errors.append(
|
||||
f'animations.json {label} trigger_shape must be a '
|
||||
f'animations.json {path}.trigger_shape must be a '
|
||||
f'non-empty group id: {trigger_shape!r}'
|
||||
)
|
||||
elif trigger_shape == str(group_id):
|
||||
elif trigger_shape == target_group_id:
|
||||
errors.append(
|
||||
f'animations.json {label} trigger_shape must reference '
|
||||
f'animations.json {path}.trigger_shape must reference '
|
||||
'a different group'
|
||||
)
|
||||
if group_cfg.get('effect') == 'none':
|
||||
if effect_cfg.get('effect') == 'none':
|
||||
errors.append(
|
||||
f'animations.json {label} trigger_shape cannot be used '
|
||||
f'animations.json {path}.trigger_shape cannot be used '
|
||||
'with effect "none"'
|
||||
)
|
||||
if (
|
||||
'trigger' in effect_cfg
|
||||
and effect_cfg.get('trigger') != 'on-click'
|
||||
):
|
||||
errors.append(
|
||||
f'animations.json {path}.trigger_shape requires '
|
||||
'trigger "on-click" when trigger is explicit'
|
||||
)
|
||||
|
||||
errors.extend(
|
||||
_animation_parameter_errors(
|
||||
group_cfg,
|
||||
label,
|
||||
effect_cfg,
|
||||
path,
|
||||
inherited_effect='auto',
|
||||
)
|
||||
)
|
||||
@@ -969,7 +1081,7 @@ def _bounce_support_error(
|
||||
def _resolved_animation_parameter_errors(config: dict[str, Any]) -> list[str]:
|
||||
"""Validate effective animation parameters after sidecar inheritance."""
|
||||
defaults = config.get('defaults', {})
|
||||
default_animation: dict[str, Any] = {'effect': 'auto'}
|
||||
default_animation: dict[str, Any] = {'effect': 'none'}
|
||||
if isinstance(defaults, dict):
|
||||
value = defaults.get('animation', {})
|
||||
if isinstance(value, dict):
|
||||
@@ -1015,9 +1127,23 @@ def _resolved_animation_parameter_errors(config: dict[str, Any]) -> list[str]:
|
||||
if not isinstance(groups, dict):
|
||||
continue
|
||||
for group_id, group_cfg in groups.items():
|
||||
if (
|
||||
not isinstance(group_cfg, dict)
|
||||
or not _INHERITANCE_SENSITIVE_ANIMATION_FIELDS & set(group_cfg)
|
||||
if not isinstance(group_cfg, dict):
|
||||
continue
|
||||
path = (
|
||||
f'slides[{json.dumps(str(slide_name), ensure_ascii=False)}]'
|
||||
f'.groups[{json.dumps(str(group_id), ensure_ascii=False)}]'
|
||||
)
|
||||
try:
|
||||
effect_entries = animation_group_effect_entries(
|
||||
group_cfg,
|
||||
path=path,
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
for effect_path, effect_cfg in effect_entries:
|
||||
if not (
|
||||
_INHERITANCE_SENSITIVE_ANIMATION_FIELDS
|
||||
& set(effect_cfg)
|
||||
):
|
||||
continue
|
||||
inherited_group_animation = {
|
||||
@@ -1034,18 +1160,18 @@ def _resolved_animation_parameter_errors(config: dict[str, Any]) -> list[str]:
|
||||
}
|
||||
group_animation = resolve_slide_animation_config(
|
||||
inherited_group_animation,
|
||||
group_cfg,
|
||||
effect_cfg,
|
||||
)
|
||||
errors.extend(
|
||||
_animation_parameter_errors(
|
||||
group_animation,
|
||||
f'group "{slide_name}/{group_id}"',
|
||||
inherited_effect='auto',
|
||||
effect_path,
|
||||
inherited_effect='none',
|
||||
)
|
||||
)
|
||||
error = _bounce_support_error(
|
||||
group_animation,
|
||||
f'group "{slide_name}/{group_id}"',
|
||||
effect_path,
|
||||
)
|
||||
if error:
|
||||
errors.append(error)
|
||||
@@ -1076,10 +1202,22 @@ def _declared_animation_sounds(
|
||||
if not isinstance(groups, dict):
|
||||
continue
|
||||
for group_id, group_cfg in groups.items():
|
||||
if isinstance(group_cfg, dict) and 'sound' in group_cfg:
|
||||
sounds.append(
|
||||
(f'group "{slide_name}/{group_id}"', group_cfg['sound'])
|
||||
if not isinstance(group_cfg, dict):
|
||||
continue
|
||||
path = (
|
||||
f'slides[{json.dumps(str(slide_name), ensure_ascii=False)}]'
|
||||
f'.groups[{json.dumps(str(group_id), ensure_ascii=False)}]'
|
||||
)
|
||||
try:
|
||||
effect_entries = animation_group_effect_entries(
|
||||
group_cfg,
|
||||
path=path,
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
for effect_path, effect_cfg in effect_entries:
|
||||
if 'sound' in effect_cfg:
|
||||
sounds.append((effect_path, effect_cfg['sound']))
|
||||
return tuple(sounds)
|
||||
|
||||
|
||||
@@ -1159,6 +1297,15 @@ def validate_animation_config(
|
||||
for target in slide_targets
|
||||
if target.group_id not in ambiguous_ids
|
||||
}
|
||||
default_animation: dict[str, Any] = {'effect': 'none'}
|
||||
defaults = config.get('defaults', {})
|
||||
if isinstance(defaults, dict):
|
||||
animation_value = defaults.get('animation', {})
|
||||
if isinstance(animation_value, dict):
|
||||
default_animation = resolve_slide_animation_config(
|
||||
default_animation,
|
||||
animation_value,
|
||||
)
|
||||
slides = config.get('slides', {})
|
||||
if not isinstance(slides, dict):
|
||||
return list(dict.fromkeys(warnings))
|
||||
@@ -1169,6 +1316,13 @@ def validate_animation_config(
|
||||
if not isinstance(slide_cfg, dict):
|
||||
continue
|
||||
|
||||
slide_animation = default_animation
|
||||
animation_value = slide_cfg.get('animation', {})
|
||||
if isinstance(animation_value, dict):
|
||||
slide_animation = resolve_slide_animation_config(
|
||||
default_animation,
|
||||
animation_value,
|
||||
)
|
||||
slide_targets = targets_by_slide.get(slide_name, [])
|
||||
duplicate_ids = duplicates_by_slide.get(slide_name, ())
|
||||
ambiguous_ids = set(duplicate_ids)
|
||||
@@ -1177,41 +1331,70 @@ def validate_animation_config(
|
||||
if not isinstance(groups, dict):
|
||||
continue
|
||||
for group_id, group_cfg in groups.items():
|
||||
path = (
|
||||
f'slides[{json.dumps(str(slide_name), ensure_ascii=False)}]'
|
||||
f'.groups[{json.dumps(str(group_id), ensure_ascii=False)}]'
|
||||
)
|
||||
if group_id in ambiguous_ids:
|
||||
continue
|
||||
if group_id not in known_groups:
|
||||
warnings.append(
|
||||
f'animations.json references missing group: {slide_name}/{group_id}'
|
||||
f'animations.json {path} references a missing group'
|
||||
)
|
||||
continue
|
||||
target = known_groups[group_id]
|
||||
effect = group_cfg.get('effect') if isinstance(group_cfg, dict) else None
|
||||
if target.structurally_static and effect != 'none':
|
||||
warnings.append(
|
||||
'animations.json references non-animatable structural group: '
|
||||
f'{slide_name}/{group_id}'
|
||||
)
|
||||
if not isinstance(group_cfg, dict):
|
||||
continue
|
||||
trigger_shape = group_cfg.get('trigger_shape')
|
||||
if not isinstance(trigger_shape, str) or not trigger_shape.strip():
|
||||
try:
|
||||
effect_entries = animation_group_effect_entries(
|
||||
group_cfg,
|
||||
path=path,
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
if (
|
||||
target.structurally_static
|
||||
and any(
|
||||
normalize_animation_effect(
|
||||
effect_cfg.get(
|
||||
'effect',
|
||||
slide_animation.get('effect', 'none'),
|
||||
),
|
||||
allow_none=True,
|
||||
allow_modes=True,
|
||||
)
|
||||
is not None
|
||||
for _effect_path, effect_cfg in effect_entries
|
||||
)
|
||||
):
|
||||
warnings.append(
|
||||
f'animations.json {path} references a non-animatable '
|
||||
'structural group'
|
||||
)
|
||||
for effect_path, effect_cfg in effect_entries:
|
||||
trigger_shape = effect_cfg.get('trigger_shape')
|
||||
if (
|
||||
not isinstance(trigger_shape, str)
|
||||
or not trigger_shape.strip()
|
||||
):
|
||||
continue
|
||||
if trigger_shape in ambiguous_ids:
|
||||
warnings.append(
|
||||
'animations.json trigger_shape references ambiguous group: '
|
||||
f'{slide_name}/{trigger_shape}'
|
||||
f'animations.json {effect_path}.trigger_shape '
|
||||
f'references ambiguous group {trigger_shape!r}'
|
||||
)
|
||||
continue
|
||||
trigger_target = known_groups.get(trigger_shape)
|
||||
if trigger_target is None:
|
||||
warnings.append(
|
||||
'animations.json trigger_shape references missing group: '
|
||||
f'{slide_name}/{trigger_shape}'
|
||||
f'animations.json {effect_path}.trigger_shape '
|
||||
f'references missing group {trigger_shape!r}'
|
||||
)
|
||||
elif trigger_target.structurally_static:
|
||||
warnings.append(
|
||||
'animations.json trigger_shape references non-triggerable '
|
||||
f'structural group: {slide_name}/{trigger_shape}'
|
||||
f'animations.json {effect_path}.trigger_shape '
|
||||
f'references non-triggerable structural group '
|
||||
f'{trigger_shape!r}'
|
||||
)
|
||||
|
||||
morph_pairs, morph_errors = _resolve_morph_pairs(
|
||||
@@ -1250,7 +1433,7 @@ def build_scaffold(project_path: Path) -> dict[str, Any]:
|
||||
"""
|
||||
transition_defaults = {'effect': 'fade', 'duration': 0.4}
|
||||
animation_defaults = {
|
||||
'effect': 'auto',
|
||||
'effect': 'none',
|
||||
'duration': 0.4,
|
||||
'stagger': 0.5,
|
||||
'trigger': 'after-previous',
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ class ConvertContext:
|
||||
# to context-safe DrawingML scheme slots while local colors stay concrete.
|
||||
theme_color_spec: ThemeColorSpec | None = None
|
||||
# Canonical BCP-47 content language from spec_lock.md. ``None`` preserves
|
||||
# the legacy per-run script heuristic for older projects and quick tests.
|
||||
# the legacy per-run script heuristic for older projects and lockless quick generation.
|
||||
primary_language: str | None = None
|
||||
|
||||
def next_id(self) -> int:
|
||||
|
||||
+10
-1
@@ -51,6 +51,7 @@ from .utils import (
|
||||
_extract_inheritable_styles,
|
||||
_get_attr,
|
||||
_is_unit_axis_reflection,
|
||||
is_picture_effect_carrier,
|
||||
parse_svg_length,
|
||||
parse_transform_operations,
|
||||
parse_transform_matrix,
|
||||
@@ -860,11 +861,19 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
or elem.get('data-pptx-geometry-kind') == 'custom'
|
||||
)
|
||||
)
|
||||
logical_picture_effect_group = (
|
||||
filter_id is not None
|
||||
and is_picture_effect_carrier(elem)
|
||||
)
|
||||
explicit_native_group = elem.get('data-pptx-object') == 'group'
|
||||
if (
|
||||
len(child_results) == 1
|
||||
and not explicit_native_group
|
||||
and (not should_animate_group or logical_native_shape_group)
|
||||
and (
|
||||
not should_animate_group
|
||||
or logical_native_shape_group
|
||||
or logical_picture_effect_group
|
||||
)
|
||||
):
|
||||
if should_animate_group and elem_id:
|
||||
shape_match = re.search(r'<p:cNvPr id="(\d+)"', child_results[0].xml)
|
||||
|
||||
+16
@@ -4350,6 +4350,13 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
|
||||
# Resolve clip-path → DrawingML geometry
|
||||
clip_geom = _resolve_clip_geometry(elem, ctx, raw_x, raw_y, raw_w, raw_h)
|
||||
effect_xml = ''
|
||||
filter_id = get_effective_filter_id(elem, ctx)
|
||||
if filter_id and filter_id in ctx.defs:
|
||||
effect_xml = build_effect_xml(
|
||||
ctx.defs[filter_id],
|
||||
get_element_opacity(elem, ctx),
|
||||
)
|
||||
|
||||
# Resolve preserveAspectRatio="<align> slice" as DrawingML crop metadata.
|
||||
# Image optimization only downscales the full source image; it never crops
|
||||
@@ -4419,6 +4426,7 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
<a:xfrm{xfrm_attr}><a:off x="{off_x}" y="{off_y}"/>
|
||||
<a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||||
{clip_geom}
|
||||
{effect_xml}
|
||||
</p:spPr>
|
||||
</p:pic>''', bounds_emu=bounds_emu)
|
||||
|
||||
@@ -4987,6 +4995,13 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult:
|
||||
svg_w,
|
||||
svg_h,
|
||||
)
|
||||
effect_xml = ''
|
||||
filter_id = get_effective_filter_id(elem, ctx)
|
||||
if filter_id and filter_id in ctx.defs:
|
||||
effect_xml = build_effect_xml(
|
||||
ctx.defs[filter_id],
|
||||
get_element_opacity(elem, ctx),
|
||||
)
|
||||
blip_xml = _build_image_blip_xml(
|
||||
r_id,
|
||||
get_element_opacity(image_elem, ctx),
|
||||
@@ -5006,5 +5021,6 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult:
|
||||
<a:xfrm{xfrm_attr}><a:off x="{off_x}" y="{off_y}"/>
|
||||
<a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||||
{clip_geom}
|
||||
{effect_xml}
|
||||
</p:spPr>
|
||||
</p:pic>''', bounds_emu=bounds_emu)
|
||||
|
||||
+17
-1
@@ -14,6 +14,7 @@ from .utils import (
|
||||
px_to_emu, _f, _get_attr, parse_svg_length,
|
||||
combine_opacity, parse_inline_style, parse_opacity, parse_stop_style,
|
||||
classify_project_marker_shape,
|
||||
is_project_radial_focus_point,
|
||||
matrix_multiply, parse_svg_color, parse_transform_matrix, resolve_url_id,
|
||||
parse_project_filter_params, project_filter_drawingml_coordinates,
|
||||
parse_project_gradient_ratio, parse_project_linear_gradient_coordinate,
|
||||
@@ -119,10 +120,25 @@ def build_gradient_fill(
|
||||
</a:gradFill>'''
|
||||
|
||||
elif tag == 'radialGradient':
|
||||
focus_x = parse_project_gradient_ratio(
|
||||
grad_elem.get('fx', grad_elem.get('cx', '0.5'))
|
||||
)
|
||||
focus_y = parse_project_gradient_ratio(
|
||||
grad_elem.get('fy', grad_elem.get('cy', '0.5'))
|
||||
)
|
||||
if not is_project_radial_focus_point(focus_x, focus_y):
|
||||
raise ValueError(
|
||||
'Radial gradient effective focus must lie within the '
|
||||
'canonical circle centered at 0.5,0.5 with radius 0.5'
|
||||
)
|
||||
focus_l = quantize_ooxml_unit_ratio(focus_x)
|
||||
focus_t = quantize_ooxml_unit_ratio(focus_y)
|
||||
focus_r = 100000 - focus_l
|
||||
focus_b = 100000 - focus_t
|
||||
return f'''<a:gradFill>
|
||||
<a:gsLst>{gs_list}</a:gsLst>
|
||||
<a:path path="circle">
|
||||
<a:fillToRect l="50000" t="50000" r="50000" b="50000"/>
|
||||
<a:fillToRect l="{focus_l}" t="{focus_t}" r="{focus_r}" b="{focus_b}"/>
|
||||
</a:path>
|
||||
</a:gradFill>'''
|
||||
|
||||
|
||||
+129
-8
@@ -64,14 +64,15 @@ EA_FONTS = {
|
||||
'Source Han Sans JP', 'Source Han Serif JP',
|
||||
'WenQuanYi Micro Hei', 'WenQuanYi Zen Hei',
|
||||
'YouYuan', 'LiSu', 'HuaWenKaiTi',
|
||||
'Songti SC', 'Songti TC',
|
||||
'Heiti TC', 'Kaiti TC', 'Songti SC', 'Songti TC',
|
||||
# Windows 10/11 + Office default / common Simplified Chinese
|
||||
'DengXian', 'DengXian Light', 'DengXian Bold', 'Microsoft YaHei UI',
|
||||
# Office display Chinese (华文 / 方正) — usually title-only, not on every client
|
||||
'STXingkai', 'STLiti', 'STXinwei', 'STHupo', 'STCaiyun',
|
||||
'FZShuTi', 'FZYaoti',
|
||||
# Common Traditional Chinese (Office)
|
||||
'DFKai-SB', 'MingLiU', 'PMingLiU', 'MingLiU-ExtB', 'PMingLiU-ExtB',
|
||||
'DFKai-SB', 'MingLiU', 'PMingLiU', 'MingLiU_HKSCS',
|
||||
'MingLiU-ExtB', 'PMingLiU-ExtB',
|
||||
'Microsoft JhengHei UI',
|
||||
# Japanese fonts (Windows-available)
|
||||
'Yu Gothic', 'Yu Gothic UI', 'Yu Mincho',
|
||||
@@ -88,6 +89,8 @@ FONT_FALLBACK_WIN = {
|
||||
'PingFang SC': 'Microsoft YaHei',
|
||||
'PingFang TC': 'Microsoft JhengHei',
|
||||
'PingFang HK': 'Microsoft JhengHei',
|
||||
'Heiti TC': 'Microsoft JhengHei',
|
||||
'Kaiti TC': 'DFKai-SB',
|
||||
'Hiragino Sans': 'Microsoft YaHei',
|
||||
'Hiragino Sans GB': 'Microsoft YaHei',
|
||||
'Hiragino Mincho ProN': 'SimSun',
|
||||
@@ -98,12 +101,12 @@ FONT_FALLBACK_WIN = {
|
||||
'STXihei': 'Microsoft YaHei',
|
||||
'STZhongsong': 'SimSun',
|
||||
'Songti SC': 'SimSun',
|
||||
'Songti TC': 'SimSun',
|
||||
'Songti TC': 'PMingLiU',
|
||||
'Noto Sans SC': 'Microsoft YaHei',
|
||||
'Noto Sans CJK SC': 'Microsoft YaHei',
|
||||
'Noto Sans TC': 'Microsoft JhengHei',
|
||||
'Noto Serif SC': 'SimSun',
|
||||
'Noto Serif TC': 'SimSun',
|
||||
'Noto Serif TC': 'PMingLiU',
|
||||
# Japanese: keep as-is if user specified (PowerPoint will fallback if uninstalled)
|
||||
# 'Noto Sans JP': → keep as 'Noto Sans JP' (do not map)
|
||||
# 'メイリオ': → keep as 'メイリオ' (Meiryo alias)
|
||||
@@ -111,7 +114,7 @@ FONT_FALLBACK_WIN = {
|
||||
'Source Han Sans SC': 'Microsoft YaHei',
|
||||
'Source Han Sans TC': 'Microsoft JhengHei',
|
||||
'Source Han Serif SC': 'SimSun',
|
||||
'Source Han Serif TC': 'SimSun',
|
||||
'Source Han Serif TC': 'PMingLiU',
|
||||
'Source Han Sans JP': 'Noto Sans JP',
|
||||
'Source Han Serif JP': 'Noto Serif JP',
|
||||
'WenQuanYi Micro Hei': 'Microsoft YaHei',
|
||||
@@ -153,8 +156,11 @@ _SERIF_LATIN = {
|
||||
# target-specific; keep these examples aligned with strategist.md §g.
|
||||
PPT_SAFE_FONTS = frozenset({
|
||||
'microsoft yahei', 'simhei', 'simsun', 'kaiti', 'fangsong',
|
||||
'dengxian', 'microsoft jhenghei',
|
||||
'dengxian',
|
||||
'microsoft jhenghei', 'microsoft jhenghei ui', 'pmingliu', 'mingliu',
|
||||
'mingliu_hkscs', 'dfkai-sb',
|
||||
'pingfang sc', 'heiti sc', 'songti sc', 'stsong',
|
||||
'pingfang tc', 'pingfang hk', 'heiti tc', 'songti tc', 'kaiti tc',
|
||||
'yu gothic', 'yu gothic ui', 'yu mincho',
|
||||
'meiryo', 'meiryo ui',
|
||||
'ms gothic', 'ms mincho', 'ms pgothic', 'ms pmincho', 'ms ui gothic',
|
||||
@@ -224,6 +230,7 @@ PROJECT_GRADIENT_TAGS = frozenset({'linearGradient', 'radialGradient'})
|
||||
# PPTX angle projection can overshoot a unit box by at most ~0.1036.
|
||||
PROJECT_LINEAR_GRADIENT_COORDINATE_MIN = -0.105
|
||||
PROJECT_LINEAR_GRADIENT_COORDINATE_MAX = 1.105
|
||||
PROJECT_RADIAL_FOCUS_TOLERANCE = 0.00001
|
||||
PROJECT_FILTER_PRIMITIVES = frozenset({
|
||||
'feDropShadow',
|
||||
'feGaussianBlur',
|
||||
@@ -239,7 +246,13 @@ PROJECT_FILTER_EFFECT_PRIMITIVES = frozenset({
|
||||
'feDropShadow',
|
||||
'feGaussianBlur',
|
||||
})
|
||||
PROJECT_FILTER_PUBLIC_TARGETS = frozenset({'rect', 'circle', 'path', 'text'})
|
||||
PROJECT_FILTER_PUBLIC_TARGETS = frozenset({
|
||||
'rect',
|
||||
'circle',
|
||||
'image',
|
||||
'path',
|
||||
'text',
|
||||
})
|
||||
_PROJECT_MARKER_NUMBER_TOKEN = (
|
||||
r'[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?'
|
||||
)
|
||||
@@ -2301,6 +2314,16 @@ def parse_project_linear_gradient_coordinate(raw: str) -> float:
|
||||
return number
|
||||
|
||||
|
||||
def is_project_radial_focus_point(focus_x: float, focus_y: float) -> bool:
|
||||
"""Return whether a focus lies inside the canonical SVG radial circle."""
|
||||
if not math.isfinite(focus_x) or not math.isfinite(focus_y):
|
||||
return False
|
||||
return (
|
||||
(focus_x - 0.5) ** 2 + (focus_y - 0.5) ** 2
|
||||
<= 0.25 + PROJECT_RADIAL_FOCUS_TOLERANCE
|
||||
)
|
||||
|
||||
|
||||
def project_gradient_errors(root: ET.Element) -> list[str]:
|
||||
"""Validate the normalized native gradient authoring interface."""
|
||||
errors: set[str] = set()
|
||||
@@ -2370,6 +2393,7 @@ def project_gradient_errors(root: ET.Element) -> list[str]:
|
||||
'point; use different x1/y1 and x2/y2 coordinates'
|
||||
)
|
||||
else:
|
||||
radial_coordinates: dict[str, float] = {}
|
||||
for coordinate_name in ('cx', 'cy', 'r', 'fx', 'fy'):
|
||||
raw_coordinate = gradient.get(coordinate_name)
|
||||
if raw_coordinate is None:
|
||||
@@ -2383,8 +2407,34 @@ def project_gradient_errors(root: ET.Element) -> list[str]:
|
||||
f'got {raw_coordinate!r}'
|
||||
)
|
||||
continue
|
||||
radial_coordinates[coordinate_name] = coordinate
|
||||
if coordinate_name == 'r' and coordinate <= 0:
|
||||
errors.add(f'{label} r must be greater than 0')
|
||||
focus_x_name = (
|
||||
'fx' if gradient.get('fx') is not None else 'cx'
|
||||
)
|
||||
focus_y_name = (
|
||||
'fy' if gradient.get('fy') is not None else 'cy'
|
||||
)
|
||||
focus_is_valid = (
|
||||
(
|
||||
gradient.get(focus_x_name) is None
|
||||
or focus_x_name in radial_coordinates
|
||||
)
|
||||
and (
|
||||
gradient.get(focus_y_name) is None
|
||||
or focus_y_name in radial_coordinates
|
||||
)
|
||||
)
|
||||
if focus_is_valid:
|
||||
focus_x = radial_coordinates.get(focus_x_name, 0.5)
|
||||
focus_y = radial_coordinates.get(focus_y_name, 0.5)
|
||||
if not is_project_radial_focus_point(focus_x, focus_y):
|
||||
errors.add(
|
||||
f'{label} effective focus (fx/fy, otherwise cx/cy) '
|
||||
'must lie within the canonical circle centered at '
|
||||
f'0.5,0.5 with radius 0.5; got ({focus_x}, {focus_y})'
|
||||
)
|
||||
|
||||
stops: list[ET.Element] = []
|
||||
for child in list(gradient):
|
||||
@@ -2626,10 +2676,17 @@ def project_filter_errors(root: ET.Element) -> list[str]:
|
||||
if (
|
||||
tag not in PROJECT_FILTER_PUBLIC_TARGETS
|
||||
and not _is_imported_preset_preview_filter_target(elem, parents)
|
||||
and not is_picture_effect_carrier(elem)
|
||||
):
|
||||
errors.add(
|
||||
f'{label} cannot use filter; supported native targets are '
|
||||
'rect, circle, path, and text'
|
||||
'rect, circle, image, path, text, and an exact single clipped-'
|
||||
'image carrier group'
|
||||
)
|
||||
if tag == 'image' and elem.get('clip-path') is not None:
|
||||
errors.add(
|
||||
f'{label} cannot combine filter and clip-path on the same '
|
||||
'image; put the filter on an exact single-image outer <g>'
|
||||
)
|
||||
match = re.fullmatch(r'url\(#([^)]+)\)', raw_filter.strip())
|
||||
if match is None:
|
||||
@@ -2844,6 +2901,70 @@ def _is_imported_preset_preview_filter_target(
|
||||
)
|
||||
|
||||
|
||||
def is_picture_effect_carrier(elem: ET.Element) -> bool:
|
||||
"""Recognize one effect carrier around exactly one clipped picture."""
|
||||
if (
|
||||
_svg_element_tag(elem) != 'g'
|
||||
or elem.get('data-pptx-object') == 'group'
|
||||
or re.fullmatch(
|
||||
r'url\(#([^)]+)\)',
|
||||
(elem.get('filter') or '').strip(),
|
||||
) is None
|
||||
or elem.get('data-pptx-layer') not in {None, 'master', 'layout'}
|
||||
or any(
|
||||
elem.get(attribute) is not None
|
||||
for attribute in (
|
||||
'data-pptx-placeholder',
|
||||
'data-pptx-binding',
|
||||
'data-pptx-replace-with',
|
||||
'data-pptx-native',
|
||||
)
|
||||
)
|
||||
):
|
||||
return False
|
||||
children = [
|
||||
child for child in elem
|
||||
if _svg_element_tag(child) not in PROJECT_NON_VISUAL_DEFINITION_CHILD_TAGS
|
||||
]
|
||||
if len(children) != 1:
|
||||
return False
|
||||
picture = children[0]
|
||||
if picture.get('filter') is not None:
|
||||
return False
|
||||
owner_kind = elem.get('data-pptx-object')
|
||||
if _svg_element_tag(picture) == 'image':
|
||||
if resolve_url_id(picture.get('clip-path', '')) is None:
|
||||
return False
|
||||
if owner_kind is None:
|
||||
return True
|
||||
shape_id = elem.get('data-pptx-shape-id')
|
||||
return (
|
||||
owner_kind == 'picture'
|
||||
and shape_id is not None
|
||||
and picture.get('data-pptx-object') == 'picture'
|
||||
and picture.get('data-pptx-shape-id') == shape_id
|
||||
)
|
||||
if (
|
||||
_svg_element_tag(picture) != 'svg'
|
||||
or owner_kind != 'picture'
|
||||
or picture.get('data-pptx-object') != 'picture'
|
||||
or picture.get('viewBox') is None
|
||||
or picture.get('preserveAspectRatio') != 'none'
|
||||
):
|
||||
return False
|
||||
shape_id = elem.get('data-pptx-shape-id')
|
||||
if (
|
||||
shape_id is None
|
||||
or picture.get('data-pptx-shape-id') != shape_id
|
||||
):
|
||||
return False
|
||||
crop_children = list(picture)
|
||||
return (
|
||||
len(crop_children) == 1
|
||||
and _svg_element_tag(crop_children[0]) == 'image'
|
||||
)
|
||||
|
||||
|
||||
def parse_hex_color(color_str: str) -> str | None:
|
||||
"""Parse SVG color values to ``RRGGBB``, ignoring any alpha channel."""
|
||||
if color_str and color_str.strip().lower() == 'transparent':
|
||||
|
||||
+109
-41
@@ -58,6 +58,7 @@ from language_tags import normalize_language_tag
|
||||
|
||||
from ..animation_config import (
|
||||
MorphPair,
|
||||
animation_group_effect_entries,
|
||||
resolve_morph_pairs,
|
||||
resolve_slide_animation_config,
|
||||
)
|
||||
@@ -3752,7 +3753,7 @@ def _prepare_flat_structure(
|
||||
text_style_status = (
|
||||
f"{master_count} master text style(s)"
|
||||
if master_text_style_spec is not None
|
||||
else "stock text defaults retained (diagnostic caller)"
|
||||
else "stock text defaults retained (no theme contract)"
|
||||
)
|
||||
print(f" Flat theme: {theme_count} theme part(s), {text_style_status}")
|
||||
|
||||
@@ -4124,6 +4125,7 @@ def _slide_animation_settings(
|
||||
|
||||
def _build_sequence_targets(
|
||||
anim_targets: list[tuple[int, str]],
|
||||
slide_name: str,
|
||||
slide_cfg: dict[str, Any],
|
||||
animation: str | None,
|
||||
animation_cfg: dict[str, Any],
|
||||
@@ -4139,15 +4141,23 @@ def _build_sequence_targets(
|
||||
shape_ids_by_group = {
|
||||
svg_id: sid for sid, svg_id in anim_targets
|
||||
}
|
||||
ordered: list[tuple[int, int, str, dict[str, Any]]] = []
|
||||
ordered: list[tuple[int, int, int, str, str, dict[str, Any]]] = []
|
||||
for idx, (sid, svg_id) in enumerate(anim_targets):
|
||||
group_value = groups_cfg.get(svg_id, {})
|
||||
if not isinstance(group_value, dict):
|
||||
raise ValueError(
|
||||
f'animations.json group "{svg_id}" must be an object'
|
||||
)
|
||||
group_cfg = group_value
|
||||
raw_effect = group_cfg.get('effect')
|
||||
group_path = (
|
||||
f'slides[{json.dumps(slide_name, ensure_ascii=False)}]'
|
||||
f'.groups[{json.dumps(svg_id, ensure_ascii=False)}]'
|
||||
)
|
||||
effect_entries = animation_group_effect_entries(
|
||||
group_value,
|
||||
path=group_path,
|
||||
)
|
||||
for effect_idx, (effect_path, effect_cfg) in enumerate(effect_entries):
|
||||
raw_effect = effect_cfg.get('effect')
|
||||
if raw_effect is not None:
|
||||
normalized_effect = normalize_animation_effect(
|
||||
raw_effect,
|
||||
@@ -4156,27 +4166,49 @@ def _build_sequence_targets(
|
||||
)
|
||||
else:
|
||||
normalized_effect = None
|
||||
if 'effect' in group_cfg and normalized_effect is None:
|
||||
if 'effect' in effect_cfg and normalized_effect is None:
|
||||
continue
|
||||
if animation is None and normalized_effect is None:
|
||||
continue
|
||||
order_value = group_cfg.get('order')
|
||||
order_value = effect_cfg.get('order')
|
||||
order = order_value if order_value is not None else idx + 1
|
||||
if isinstance(order, bool) or not isinstance(order, int) or order <= 0:
|
||||
if (
|
||||
isinstance(order, bool)
|
||||
or not isinstance(order, int)
|
||||
or order <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
f'animations.json group "{svg_id}" order must be a positive integer'
|
||||
f'animations.json {effect_path}.order must be '
|
||||
'a positive integer'
|
||||
)
|
||||
effect_entry = dict(effect_cfg)
|
||||
effect_entry['_shape_id'] = sid
|
||||
effect_entry['_effect'] = normalized_effect
|
||||
effect_entry['_effect_raw'] = raw_effect
|
||||
ordered.append(
|
||||
(
|
||||
order,
|
||||
idx,
|
||||
effect_idx,
|
||||
svg_id,
|
||||
effect_path,
|
||||
effect_entry,
|
||||
)
|
||||
)
|
||||
group_entry = dict(group_cfg)
|
||||
group_entry['_shape_id'] = sid
|
||||
group_entry['_effect'] = normalized_effect
|
||||
group_entry['_effect_raw'] = raw_effect
|
||||
ordered.append((order, idx, svg_id, group_entry))
|
||||
|
||||
ordered.sort(key=lambda item: (item[0], item[1]))
|
||||
ordered.sort(key=lambda item: (item[0], item[1], item[2]))
|
||||
|
||||
seq_targets: list[dict[str, Any]] = []
|
||||
resolved_group_modes: list[str | None] = []
|
||||
for seq_idx, (_order, _original_idx, _svg_id, group_cfg) in enumerate(ordered):
|
||||
main_sequence_count = 0
|
||||
for seq_idx, (
|
||||
_order,
|
||||
_original_idx,
|
||||
_effect_idx,
|
||||
_svg_id,
|
||||
effect_path,
|
||||
group_cfg,
|
||||
) in enumerate(ordered):
|
||||
shape_id = int(group_cfg['_shape_id'])
|
||||
raw_effect = group_cfg.get('_effect')
|
||||
resolved_group_modes.append(
|
||||
@@ -4211,22 +4243,39 @@ def _build_sequence_targets(
|
||||
)
|
||||
item_duration = validate_seconds(
|
||||
group_cfg.get('duration', duration),
|
||||
f'animation duration for group "{_svg_id}"',
|
||||
f'animations.json {effect_path}.duration',
|
||||
allow_zero=False,
|
||||
)
|
||||
trigger_shape = group_cfg.get('trigger_shape')
|
||||
raw_trigger = group_cfg.get(
|
||||
'trigger',
|
||||
animation_cfg.get('trigger', 'after-previous'),
|
||||
)
|
||||
resolved_trigger = normalize_animation_trigger(raw_trigger)
|
||||
if trigger_shape is not None:
|
||||
if 'trigger' in group_cfg and resolved_trigger != 'on-click':
|
||||
raise ValueError(
|
||||
f'animations.json {effect_path}.trigger_shape requires '
|
||||
'trigger "on-click" when trigger is explicit'
|
||||
)
|
||||
resolved_trigger = 'on-click'
|
||||
default_delay = (
|
||||
0
|
||||
if group_cfg.get('trigger_shape') is not None or seq_idx == 0
|
||||
else stagger
|
||||
stagger
|
||||
if (
|
||||
trigger_shape is None
|
||||
and resolved_trigger == 'after-previous'
|
||||
and main_sequence_count > 0
|
||||
)
|
||||
else 0
|
||||
)
|
||||
delay_seconds = validate_seconds(
|
||||
group_cfg.get('delay', default_delay),
|
||||
f'animation delay for group "{_svg_id}"',
|
||||
f'animations.json {effect_path}.delay',
|
||||
allow_zero=True,
|
||||
)
|
||||
delay_ms = animation_seconds_to_milliseconds(
|
||||
delay_seconds,
|
||||
f'animation delay for group "{_svg_id}"',
|
||||
f'animations.json {effect_path}.delay',
|
||||
allow_zero=True,
|
||||
)
|
||||
inherited_fields = {
|
||||
@@ -4255,27 +4304,29 @@ def _build_sequence_targets(
|
||||
'effect': effect,
|
||||
'effect_options': effect_options,
|
||||
'duration': item_duration,
|
||||
'trigger': resolved_trigger,
|
||||
}
|
||||
trigger_shape = group_cfg.get('trigger_shape')
|
||||
if trigger_shape is not None:
|
||||
if not isinstance(trigger_shape, str) or not trigger_shape.strip():
|
||||
raise ValueError(
|
||||
f'animations.json group "{_svg_id}" trigger_shape must '
|
||||
f'animations.json {effect_path}.trigger_shape must '
|
||||
'be a non-empty group id'
|
||||
)
|
||||
trigger_shape_id = shape_ids_by_group.get(trigger_shape)
|
||||
if trigger_shape_id is None:
|
||||
raise ValueError(
|
||||
f'animations.json group "{_svg_id}" trigger_shape '
|
||||
f'animations.json {effect_path}.trigger_shape '
|
||||
f'references a missing or non-triggerable group: '
|
||||
f'{trigger_shape}'
|
||||
)
|
||||
if trigger_shape_id == shape_id:
|
||||
raise ValueError(
|
||||
f'animations.json group "{_svg_id}" trigger_shape must '
|
||||
f'animations.json {effect_path}.trigger_shape must '
|
||||
'reference a different group'
|
||||
)
|
||||
target_entry['trigger_shape_id'] = trigger_shape_id
|
||||
else:
|
||||
main_sequence_count += 1
|
||||
target_entry.update(inherited_fields)
|
||||
if 'sound' in target_entry:
|
||||
target_entry['_sound_path'] = target_entry.pop('sound')
|
||||
@@ -5138,25 +5189,41 @@ def create_pptx_with_native_svg(
|
||||
animation_cli_overrides.get('animation', False)
|
||||
and animation is None
|
||||
)
|
||||
explicit_animation_groups = (
|
||||
frozenset({
|
||||
str(group_id)
|
||||
for group_id, group_cfg in groups_value.items()
|
||||
if isinstance(group_cfg, dict)
|
||||
and group_cfg.get('effect') != 'none'
|
||||
explicit_group_ids: set[str] = set()
|
||||
trigger_group_ids: set[str] = set()
|
||||
if not animation_hard_disabled:
|
||||
for group_id, group_cfg in groups_value.items():
|
||||
if not isinstance(group_cfg, dict):
|
||||
continue
|
||||
group_path = (
|
||||
f'slides['
|
||||
f'{json.dumps(svg_path.stem, ensure_ascii=False)}'
|
||||
f'].groups['
|
||||
f'{json.dumps(str(group_id), ensure_ascii=False)}'
|
||||
f']'
|
||||
)
|
||||
effect_entries = animation_group_effect_entries(
|
||||
group_cfg,
|
||||
path=group_path,
|
||||
)
|
||||
if any(
|
||||
effect_cfg.get('effect') != 'none'
|
||||
and (
|
||||
slide_animation is not None
|
||||
or 'effect' in group_cfg
|
||||
or 'effect' in effect_cfg
|
||||
)
|
||||
} | {
|
||||
group_cfg['trigger_shape']
|
||||
for group_cfg in groups_value.values()
|
||||
if isinstance(group_cfg, dict)
|
||||
and isinstance(group_cfg.get('trigger_shape'), str)
|
||||
and group_cfg['trigger_shape'].strip()
|
||||
})
|
||||
if not animation_hard_disabled
|
||||
else frozenset()
|
||||
for _effect_path, effect_cfg in effect_entries
|
||||
):
|
||||
explicit_group_ids.add(str(group_id))
|
||||
for _effect_path, effect_cfg in effect_entries:
|
||||
trigger_shape = effect_cfg.get('trigger_shape')
|
||||
if (
|
||||
isinstance(trigger_shape, str)
|
||||
and trigger_shape.strip()
|
||||
):
|
||||
trigger_group_ids.add(trigger_shape)
|
||||
explicit_animation_groups = frozenset(
|
||||
explicit_group_ids | trigger_group_ids
|
||||
)
|
||||
converter_group_overrides = (
|
||||
explicit_animation_groups
|
||||
@@ -5247,6 +5314,7 @@ def create_pptx_with_native_svg(
|
||||
):
|
||||
seq_targets, mixed_count = _build_sequence_targets(
|
||||
anim_targets,
|
||||
svg_path.stem,
|
||||
slide_cfg,
|
||||
slide_animation,
|
||||
slide_animation_cfg,
|
||||
|
||||
+140
-158
@@ -79,6 +79,7 @@ from .template_structure import (
|
||||
template_prototype_errors,
|
||||
)
|
||||
from ..animation_config import (
|
||||
animation_group_effect_entries,
|
||||
load_animation_config,
|
||||
validate_animation_config,
|
||||
validate_animation_config_errors,
|
||||
@@ -311,6 +312,38 @@ def _quality_report_context(
|
||||
}
|
||||
|
||||
|
||||
def _quality_gate_status(
|
||||
quality: dict[str, object],
|
||||
) -> tuple[str, int]:
|
||||
"""Return final-gate status and introduced-warning count."""
|
||||
categories = quality.get('categories')
|
||||
blocking_count = None
|
||||
introduced_warning_count = 0
|
||||
if isinstance(categories, dict):
|
||||
blocking = categories.get('blocking')
|
||||
if isinstance(blocking, dict):
|
||||
blocking_count = blocking.get('count')
|
||||
introduced = categories.get('introduced')
|
||||
if (
|
||||
isinstance(introduced, dict)
|
||||
and isinstance(introduced.get('count'), int)
|
||||
):
|
||||
introduced_warning_count = int(introduced['count'])
|
||||
if quality.get('status') != 'loaded':
|
||||
return str(quality.get('status') or 'not-provided'), introduced_warning_count
|
||||
if quality.get('stage') != 'final':
|
||||
return 'non-final', introduced_warning_count
|
||||
if not isinstance(blocking_count, int):
|
||||
return 'unverified', introduced_warning_count
|
||||
if blocking_count > 0:
|
||||
return 'failed', introduced_warning_count
|
||||
if quality.get('source_match') == 'mismatch':
|
||||
return 'stale', introduced_warning_count
|
||||
if quality.get('source_match') != 'passed':
|
||||
return 'unverified', introduced_warning_count
|
||||
return 'passed', introduced_warning_count
|
||||
|
||||
|
||||
def _postflight_warning_summaries(
|
||||
*,
|
||||
quality_gate: str,
|
||||
@@ -367,30 +400,7 @@ def _write_postflight_report(
|
||||
source_audit = _source_resource_audit(svg_files)
|
||||
source_fingerprint = _svg_source_fingerprint(svg_files)
|
||||
quality = _quality_report_context(project_path, source_fingerprint)
|
||||
quality_categories = quality.get('categories')
|
||||
blocking_count = None
|
||||
introduced_warning_count = 0
|
||||
if isinstance(quality_categories, dict):
|
||||
blocking = quality_categories.get('blocking')
|
||||
if isinstance(blocking, dict):
|
||||
blocking_count = blocking.get('count')
|
||||
introduced = quality_categories.get('introduced')
|
||||
if isinstance(introduced, dict) and isinstance(introduced.get('count'), int):
|
||||
introduced_warning_count = int(introduced['count'])
|
||||
if quality.get('status') != 'loaded':
|
||||
quality_gate = str(quality.get('status') or 'not-provided')
|
||||
elif quality.get('stage') != 'final':
|
||||
quality_gate = 'non-final'
|
||||
elif not isinstance(blocking_count, int):
|
||||
quality_gate = 'unverified'
|
||||
elif blocking_count > 0:
|
||||
quality_gate = 'failed'
|
||||
elif quality.get('source_match') == 'mismatch':
|
||||
quality_gate = 'stale'
|
||||
elif quality.get('source_match') != 'passed':
|
||||
quality_gate = 'unverified'
|
||||
else:
|
||||
quality_gate = 'passed'
|
||||
quality_gate, introduced_warning_count = _quality_gate_status(quality)
|
||||
unresolved_tokens = source_audit['unresolved_template_tokens']
|
||||
external_image_count = source_audit['images']['external']
|
||||
generic_only_font_stacks = source_audit['fonts']['generic_only_stacks']
|
||||
@@ -551,30 +561,6 @@ def _print_postflight_receipt(receipt: _PostflightReceipt) -> None:
|
||||
print(f' [REPORT] {receipt.report_path}')
|
||||
|
||||
|
||||
def _validate_quick_test_output(
|
||||
output_path: Path,
|
||||
*,
|
||||
expected_slide_count: int,
|
||||
) -> dict[str, object]:
|
||||
"""Validate a quick-test PPTX without writing a report sidecar."""
|
||||
try:
|
||||
package = _package_part_counts(output_path)
|
||||
except (OSError, zipfile.BadZipFile) as exc:
|
||||
raise PptxPostflightValidationError(
|
||||
f"quick-test PPTX is not a readable ZIP package: {exc}"
|
||||
) from exc
|
||||
if package['zip_integrity'] != 'passed':
|
||||
raise PptxPostflightValidationError(
|
||||
f"quick-test PPTX ZIP integrity failed at {package['corrupt_member']}"
|
||||
)
|
||||
if package['slides'] != expected_slide_count:
|
||||
raise PptxPostflightValidationError(
|
||||
"Quick-test Slide count does not match authored SVG count: "
|
||||
f"{package['slides']} != {expected_slide_count}"
|
||||
)
|
||||
return package
|
||||
|
||||
|
||||
def _declared_pptx_structure_mode(project_path: Path) -> str | None:
|
||||
"""Return the explicitly locked SVG export mode, if the lock declares one."""
|
||||
lock_path = project_path / 'spec_lock.md'
|
||||
@@ -743,36 +729,50 @@ def _recorded_narration_on_click_slides(
|
||||
slide_animation = animation
|
||||
if not animation_cli_overrides.get('animation') and 'effect' in anim_cfg:
|
||||
slide_animation = normalize_animation_effect(anim_cfg.get('effect'))
|
||||
groups_cfg = _as_dict(slide_cfg.get('groups'))
|
||||
has_explicit_animation = any(
|
||||
isinstance(group_cfg, dict)
|
||||
and 'effect' in group_cfg
|
||||
and normalize_animation_effect(group_cfg.get('effect')) is not None
|
||||
for group_cfg in groups_cfg.values()
|
||||
)
|
||||
if slide_animation is None and not has_explicit_animation:
|
||||
continue
|
||||
has_interactive_animation = any(
|
||||
isinstance(group_cfg, dict)
|
||||
and isinstance(group_cfg.get('trigger_shape'), str)
|
||||
and bool(group_cfg['trigger_shape'].strip())
|
||||
and (
|
||||
(
|
||||
'effect' in group_cfg
|
||||
and normalize_animation_effect(group_cfg.get('effect')) is not None
|
||||
)
|
||||
or ('effect' not in group_cfg and slide_animation is not None)
|
||||
)
|
||||
for group_cfg in groups_cfg.values()
|
||||
)
|
||||
if has_interactive_animation:
|
||||
blocked.append(svg_path.stem)
|
||||
continue
|
||||
|
||||
slide_trigger = animation_trigger
|
||||
if not animation_cli_overrides.get('animation_trigger') and anim_cfg.get('trigger'):
|
||||
if (
|
||||
not animation_cli_overrides.get('animation_trigger')
|
||||
and anim_cfg.get('trigger')
|
||||
):
|
||||
slide_trigger = normalize_animation_trigger(anim_cfg.get('trigger'))
|
||||
if slide_trigger == 'on-click':
|
||||
|
||||
groups_cfg = _as_dict(slide_cfg.get('groups'))
|
||||
has_interactive_animation = False
|
||||
for group_id, group_cfg in groups_cfg.items():
|
||||
if not isinstance(group_cfg, dict):
|
||||
continue
|
||||
group_path = (
|
||||
f'slides[{json.dumps(svg_path.stem, ensure_ascii=False)}]'
|
||||
f'.groups[{json.dumps(str(group_id), ensure_ascii=False)}]'
|
||||
)
|
||||
for _effect_path, effect_cfg in animation_group_effect_entries(
|
||||
group_cfg,
|
||||
path=group_path,
|
||||
):
|
||||
row_effect = (
|
||||
normalize_animation_effect(effect_cfg.get('effect'))
|
||||
if 'effect' in effect_cfg
|
||||
else slide_animation
|
||||
)
|
||||
if row_effect is None:
|
||||
continue
|
||||
row_trigger = (
|
||||
normalize_animation_trigger(effect_cfg.get('trigger'))
|
||||
if effect_cfg.get('trigger')
|
||||
else slide_trigger
|
||||
)
|
||||
if effect_cfg.get('trigger_shape') is not None:
|
||||
has_interactive_animation = True
|
||||
break
|
||||
if row_trigger == 'on-click':
|
||||
has_interactive_animation = True
|
||||
break
|
||||
if has_interactive_animation:
|
||||
break
|
||||
|
||||
if has_interactive_animation or (
|
||||
slide_animation is not None and slide_trigger == 'on-click'
|
||||
):
|
||||
blocked.append(svg_path.stem)
|
||||
return blocked
|
||||
|
||||
@@ -812,7 +812,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
Examples:
|
||||
%(prog)s examples/ppt169_demo # Default: native pptx -> exports/, svg_output -> backup/<ts>/
|
||||
%(prog)s examples/ppt169_demo -o out.pptx # Explicit path (no backup/)
|
||||
%(prog)s projects/_smoke_quick --quick-test # Test-only: svg_output/ -> PPTX, no sidecars
|
||||
%(prog)s projects/quick_generate_demo --quick-generate # Lockless flat export with normal postflight
|
||||
|
||||
# Disable transition / change transition effect
|
||||
%(prog)s examples/ppt169_demo -t none
|
||||
@@ -849,16 +849,18 @@ Per-element object animation (-a/--animation, native shapes mode):
|
||||
after-previous (default) cascade on slide entry;
|
||||
gap = --animation-stagger seconds
|
||||
mixed (compatible mode name) cycles a larger 16-preset canonical
|
||||
PowerPoint pool by group order; random samples from the same pool.
|
||||
Use "-a none" to disable
|
||||
element builds explicitly.
|
||||
PowerPoint entrance pool by group order; random samples from the
|
||||
same entrance pool. Use explicit canonical keys for emphasis,
|
||||
motion-path, or exit duties. Use "-a none" to disable element
|
||||
builds explicitly.
|
||||
|
||||
Speaker notes (enabled by default):
|
||||
Speaker notes:
|
||||
- Automatically reads Markdown notes files from the notes/ directory
|
||||
- Supports two naming conventions:
|
||||
1. Match by filename (recommended): 01_cover.md corresponds to 01_cover.svg
|
||||
2. Match by index: slide01.md corresponds to the 1st SVG (backward compatible)
|
||||
- Use --no-notes to disable
|
||||
- Enabled by default outside Quick Generate; use --no-notes to disable
|
||||
- Disabled by default in Quick Generate; use --with-notes to enable
|
||||
|
||||
Recorded narration:
|
||||
%(prog)s examples/ppt169_demo --recorded-narration audio \\
|
||||
@@ -890,14 +892,13 @@ Recorded narration:
|
||||
help='Require SVG canvases to match this registered format')
|
||||
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
|
||||
parser.add_argument(
|
||||
'--quick-test',
|
||||
'--quick-generate',
|
||||
action='store_true',
|
||||
help=(
|
||||
'Test-only direct export of a small fixed SVG roster from '
|
||||
'svg_output/. Infer one consistent canvas from the SVGs, use a flat '
|
||||
'package with converter defaults, and skip spec_lock.md, notes, '
|
||||
'animations, backup, conversion trace, and validation report '
|
||||
'artifacts.'
|
||||
'Export a Quick Generate SVG roster from svg_output/ without '
|
||||
'spec_lock.md. Require a matching final quality report, infer one '
|
||||
'consistent canvas, use a flat package with converter defaults, '
|
||||
'and support normal export capabilities.'
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1032,9 +1033,10 @@ Recorded narration:
|
||||
'(map effect from group id — image-like ids cycle a '
|
||||
'richer canonical pool for visual variation, fallback '
|
||||
'cycles entrance_fade/entrance_wipe/entrance_fly/'
|
||||
'entrance_zoom), "mixed" (canonical 16-preset pool), or '
|
||||
'"random". Legacy short names remain accepted only for '
|
||||
'compatibility.')
|
||||
'entrance_zoom), "mixed" (canonical 16-preset entrance '
|
||||
'pool), or "random" (the same entrance pool). Use '
|
||||
'explicit keys for emphasis/path/exit. Legacy short '
|
||||
'names remain accepted only for compatibility.')
|
||||
parser.add_argument('--animation-duration', type=positive_float, default=None,
|
||||
help='Per-element object-animation duration in seconds '
|
||||
'(default: 0.4; instantaneous native presets keep their '
|
||||
@@ -1070,8 +1072,17 @@ Recorded narration:
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument('--no-notes', action='store_true',
|
||||
help='Disable speaker notes embedding (enabled by default)')
|
||||
notes_mode = parser.add_mutually_exclusive_group()
|
||||
notes_mode.add_argument(
|
||||
'--with-notes',
|
||||
action='store_true',
|
||||
help='Embed speaker notes. Required to opt in during Quick Generate.',
|
||||
)
|
||||
notes_mode.add_argument(
|
||||
'--no-notes',
|
||||
action='store_true',
|
||||
help='Disable speaker notes embedding (enabled by default outside Quick Generate)',
|
||||
)
|
||||
parser.add_argument('--narration-audio-dir', type=str, default=None,
|
||||
help='Low-level audio embedding from this directory; allows partial matches. '
|
||||
'Default-flow exports get the _narrated name suffix.')
|
||||
@@ -1122,47 +1133,21 @@ Recorded narration:
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.quick_test:
|
||||
if args.quick_generate:
|
||||
conflicts: list[str] = []
|
||||
if args.source not in {None, 'output'}:
|
||||
conflicts.append('--source must be omitted or output')
|
||||
if args.pptx_structure not in {None, 'flat'}:
|
||||
conflicts.append('--pptx-structure must be omitted or flat')
|
||||
if args.conversion_trace is not None:
|
||||
conflicts.append('--conversion-trace')
|
||||
if args.native_objects:
|
||||
conflicts.append('--native-charts-and-tables')
|
||||
if args.animation_config:
|
||||
conflicts.append('--animation-config')
|
||||
if args.recorded_narration:
|
||||
conflicts.append('--recorded-narration')
|
||||
if args.narration_audio_dir:
|
||||
conflicts.append('--narration-audio-dir')
|
||||
if args.use_narration_timings:
|
||||
conflicts.append('--use-narration-timings')
|
||||
if args.auto_advance is not None:
|
||||
conflicts.append('--auto-advance')
|
||||
if args.transition is not None or args.transition_duration is not None:
|
||||
conflicts.append('transition overrides')
|
||||
if any(
|
||||
value is not None
|
||||
for value in (
|
||||
args.animation,
|
||||
args.animation_duration,
|
||||
args.animation_trigger,
|
||||
args.animation_stagger,
|
||||
)
|
||||
):
|
||||
conflicts.append('animation overrides')
|
||||
if conflicts:
|
||||
print(
|
||||
"Error: --quick-test cannot be combined with: "
|
||||
"Error: --quick-generate cannot be combined with: "
|
||||
+ ", ".join(conflicts),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if not args.with_notes:
|
||||
args.no_notes = True
|
||||
args.no_animations = True
|
||||
args.pptx_structure = 'flat'
|
||||
|
||||
project_path = Path(args.project_path)
|
||||
@@ -1174,7 +1159,7 @@ Recorded narration:
|
||||
native_structure_contract = None
|
||||
pptx_structure = args.pptx_structure
|
||||
lock_path = project_path / 'spec_lock.md'
|
||||
if not args.quick_test and not lock_path.is_file():
|
||||
if not args.quick_generate and not lock_path.is_file():
|
||||
print(
|
||||
"Error: spec_lock.md is required for release SVG export",
|
||||
file=sys.stderr,
|
||||
@@ -1182,11 +1167,11 @@ Recorded narration:
|
||||
return 1
|
||||
declared_structure_mode = (
|
||||
None
|
||||
if args.quick_test
|
||||
if args.quick_generate
|
||||
else _declared_pptx_structure_mode(project_path)
|
||||
)
|
||||
primary_language = None
|
||||
if not args.quick_test:
|
||||
if not args.quick_generate:
|
||||
try:
|
||||
primary_language = _declared_primary_language(project_path)
|
||||
except LanguageTagError as exc:
|
||||
@@ -1245,7 +1230,7 @@ Recorded narration:
|
||||
theme_font_spec = None
|
||||
master_text_style_spec = None
|
||||
theme_color_spec = None
|
||||
if pptx_structure in {'flat', 'structured'} and not args.quick_test:
|
||||
if pptx_structure in {'flat', 'structured'} and not args.quick_generate:
|
||||
try:
|
||||
theme_font_spec = load_theme_font_spec(project_path)
|
||||
master_text_style_spec = load_master_text_style_spec(project_path)
|
||||
@@ -1287,10 +1272,10 @@ Recorded narration:
|
||||
canvas_format = args.format
|
||||
expected_viewbox = (
|
||||
None
|
||||
if args.quick_test
|
||||
if args.quick_generate
|
||||
else _declared_canvas_viewbox(project_path)
|
||||
)
|
||||
if expected_viewbox is None and not args.quick_test:
|
||||
if expected_viewbox is None and not args.quick_generate:
|
||||
print(
|
||||
"Error: spec_lock.md must contain canvas.viewBox for release export",
|
||||
file=sys.stderr,
|
||||
@@ -1303,11 +1288,17 @@ Recorded narration:
|
||||
native_files, native_source_dir = find_svg_files(
|
||||
project_path,
|
||||
native_source,
|
||||
allow_fallback=args.source is None,
|
||||
allow_fallback=args.source is None and not args.quick_generate,
|
||||
)
|
||||
ref_files = native_files
|
||||
if not native_files:
|
||||
if args.source is not None:
|
||||
if args.quick_generate:
|
||||
print(
|
||||
"Error: No SVG files found for --quick-generate in: "
|
||||
f"{project_path / 'svg_output'}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif args.source is not None:
|
||||
requested_dir = project_path / native_source_dir
|
||||
print(
|
||||
"Error: No SVG files found in explicitly requested source: "
|
||||
@@ -1318,6 +1309,23 @@ Recorded narration:
|
||||
print("Error: No SVG files found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.quick_generate:
|
||||
source_fingerprint = _svg_source_fingerprint(native_files)
|
||||
quality = _quality_report_context(project_path, source_fingerprint)
|
||||
quality_gate, _ = _quality_gate_status(quality)
|
||||
if quality_gate != 'passed':
|
||||
print(
|
||||
"Error: --quick-generate requires a passing final SVG quality "
|
||||
f"report for the current svg_output/; found {quality_gate}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"Run: python3 skills/ppt-master/scripts/svg_quality_checker.py "
|
||||
f'"{project_path}" --quick-generate --stage final --json',
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Compatibility kwargs remain until the builder's old baseline-specific
|
||||
# parameters are removed. Structured export never activates either path.
|
||||
structured_baseline = False
|
||||
@@ -1432,8 +1440,7 @@ Recorded narration:
|
||||
native_tag = "_native_charts_tables" if args.native_objects else ""
|
||||
narrated_tag = "_narrated" if (args.recorded_narration or args.narration_audio_dir) else ""
|
||||
native_path = exports_dir / f"{project_name}_{timestamp}{native_tag}{narrated_tag}.pptx"
|
||||
# Preserve the authored svg_output/ beside every default-flow export.
|
||||
if not args.quick_test:
|
||||
# Preserve the authored svg_output/ beside every default-path export.
|
||||
backup_dir = project_path / "backup" / timestamp
|
||||
|
||||
native_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1841,7 +1848,7 @@ Recorded narration:
|
||||
# are still stamped at export; only the authored fields stay blank.
|
||||
doc_metadata = None
|
||||
metadata_path = project_path / 'metadata.json'
|
||||
if metadata_path.is_file() and not args.quick_test:
|
||||
if metadata_path.is_file():
|
||||
try:
|
||||
loaded = json.loads(metadata_path.read_text(encoding='utf-8'))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
@@ -1966,31 +1973,6 @@ Recorded narration:
|
||||
print(f" [info] svg_output/ not found, backup skipped")
|
||||
|
||||
if success:
|
||||
if args.quick_test:
|
||||
try:
|
||||
package = _validate_quick_test_output(
|
||||
native_path,
|
||||
expected_slide_count=len(native_files),
|
||||
)
|
||||
except PptxPostflightValidationError as exc:
|
||||
print(
|
||||
"Error: quick-test PPTX failed in-memory validation and "
|
||||
f"must not be used: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f" Invalid output remains at: {native_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if verbose:
|
||||
print(
|
||||
" [QUICK-TEST] "
|
||||
f"status=passed slides={package['slides']} "
|
||||
"sidecars=none"
|
||||
)
|
||||
print(f" [PPTX] {native_path}")
|
||||
return 0
|
||||
try:
|
||||
receipt = _write_postflight_report(
|
||||
output_path=native_path,
|
||||
|
||||
+3
-1
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from slide_roster import discover_slide_svgs
|
||||
|
||||
|
||||
class NotesFileReadError(RuntimeError):
|
||||
"""Report a matched notes file that cannot be decoded or read."""
|
||||
@@ -52,7 +54,7 @@ def find_svg_files(
|
||||
else:
|
||||
return [], ''
|
||||
|
||||
return sorted(svg_dir.glob('*.svg')), dir_name
|
||||
return discover_slide_svgs(svg_dir), dir_name
|
||||
|
||||
|
||||
def find_notes_files(
|
||||
|
||||
+12
-4
@@ -33,6 +33,7 @@ from pptx_to_svg.preset_authoring import (
|
||||
)
|
||||
|
||||
from ..drawingml.utils import (
|
||||
is_picture_effect_carrier,
|
||||
parse_project_geometry_length,
|
||||
project_geometry_length_errors,
|
||||
)
|
||||
@@ -1237,15 +1238,21 @@ def _validate_placeholder_carrier(
|
||||
f"{svg_path.name}: {element_id} placeholder '{placeholder}' must be "
|
||||
"carried by one direct <text> child"
|
||||
)
|
||||
if placeholder == "picture" and tag not in {"image", "svg"}:
|
||||
picture_carrier = (
|
||||
tag in {"image", "svg"}
|
||||
or (tag == "g" and is_picture_effect_carrier(carrier))
|
||||
)
|
||||
if placeholder == "picture" and not picture_carrier:
|
||||
raise TemplateStructureError(
|
||||
f"{svg_path.name}: {element_id} picture placeholder must be declared "
|
||||
"with one direct <image> or crop <svg> carrier"
|
||||
"with one direct <image>, crop <svg>, or exact clipped-picture "
|
||||
"effect carrier"
|
||||
)
|
||||
if placeholder == "media" and tag not in {"image", "svg"}:
|
||||
if placeholder == "media" and not picture_carrier:
|
||||
raise TemplateStructureError(
|
||||
f"{svg_path.name}: {element_id} media placeholder must be declared "
|
||||
"with one direct <image> or crop <svg> carrier"
|
||||
"with one direct <image>, crop <svg>, or exact clipped-picture "
|
||||
"effect carrier"
|
||||
)
|
||||
if (
|
||||
placeholder == "object"
|
||||
@@ -1490,6 +1497,7 @@ def parse_template_slide(
|
||||
and layer in {"master", "layout"}
|
||||
and tag == "g"
|
||||
and not _is_authored_preset_atom(elem)
|
||||
and not is_picture_effect_carrier(elem)
|
||||
):
|
||||
raise TemplateStructureError(
|
||||
f"{svg_path.name}: {element_id or tag} is a <g> on the {layer} "
|
||||
|
||||
+24
-7
@@ -2,8 +2,8 @@
|
||||
"""
|
||||
PPT Master - Shape Boolean Core
|
||||
|
||||
Resolve closed SVG operands into SVG-root-coordinate paths and apply
|
||||
PowerPoint-compatible merge-shapes operations without mutating the source SVG.
|
||||
Resolve closed SVG or text-outline operands into SVG-root-coordinate paths and
|
||||
apply PowerPoint-compatible merge-shapes operations without mutating the source.
|
||||
Callers insert returned paths at the original z-order under the final semantic
|
||||
or structured parent, never under the old transformed ancestor.
|
||||
|
||||
@@ -19,7 +19,7 @@ Examples:
|
||||
)
|
||||
|
||||
Dependencies:
|
||||
skia-pathops and local PPT Master modules
|
||||
skia-pathops, local PPT Master modules, and uharfbuzz for text operands
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -117,6 +117,7 @@ def render_boolean_svg_fragments(
|
||||
source_ids: Sequence[str],
|
||||
output_id: str,
|
||||
style: Mapping[str, str] | None = None,
|
||||
font_dirs: Sequence[str | Path] = (),
|
||||
) -> str:
|
||||
"""Return canonical SVG path fragments for one merge-shapes operation.
|
||||
|
||||
@@ -172,7 +173,12 @@ def render_boolean_svg_fragments(
|
||||
|
||||
pathops = _load_pathops()
|
||||
operands = [
|
||||
_element_to_pathops(element, parents, pathops)
|
||||
_element_to_pathops(
|
||||
element,
|
||||
parents,
|
||||
pathops,
|
||||
font_dirs=font_dirs,
|
||||
)
|
||||
for element in selected
|
||||
]
|
||||
inherited_style = _materialize_baked_stroke_style(
|
||||
@@ -315,6 +321,8 @@ def _element_to_pathops(
|
||||
element: ET.Element,
|
||||
parents: Mapping[ET.Element, ET.Element],
|
||||
pathops: Any,
|
||||
*,
|
||||
font_dirs: Sequence[str | Path] = (),
|
||||
) -> Any:
|
||||
chain = _element_chain(element, parents)
|
||||
_validate_source_location(element, chain)
|
||||
@@ -326,11 +334,20 @@ def _element_to_pathops(
|
||||
elif tag in _BOOLEAN_TAGS:
|
||||
commands = _shape_commands(element)
|
||||
commands = transform_path_commands(commands, matrix)
|
||||
elif tag == "text":
|
||||
from .text_outline import text_element_to_path_commands
|
||||
|
||||
commands = text_element_to_path_commands(
|
||||
element,
|
||||
parents,
|
||||
font_dirs=font_dirs,
|
||||
)
|
||||
commands = transform_path_commands(commands, matrix)
|
||||
else:
|
||||
supported = ", ".join(sorted(_BOOLEAN_TAGS))
|
||||
supported = ", ".join((*sorted(_BOOLEAN_TAGS), "text"))
|
||||
raise ValueError(
|
||||
f"Shape Boolean source {_element_label(element)} must be a closed "
|
||||
f"{supported}, or a compact authored preset group"
|
||||
f"Shape Boolean source {_element_label(element)} must be a "
|
||||
f"supported {supported}, or a compact authored preset group"
|
||||
)
|
||||
|
||||
result = _simplify_path(_commands_to_pathops(commands, pathops), pathops)
|
||||
|
||||
+1121
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
from console_encoding import configure_utf8_stdio
|
||||
from slide_roster import discover_slide_svgs
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
@@ -122,7 +123,7 @@ def find_svg_files(project_path: Path) -> list[Path]:
|
||||
project_path: Project directory path
|
||||
|
||||
Returns:
|
||||
List of SVG files (sorted by filename)
|
||||
List of SVG files in numeric filename order
|
||||
"""
|
||||
svg_dir = project_path / 'svg_output'
|
||||
|
||||
@@ -130,7 +131,7 @@ def find_svg_files(project_path: Path) -> list[Path]:
|
||||
print(f"Error: {svg_dir} directory does not exist")
|
||||
return []
|
||||
|
||||
return sorted(svg_dir.glob('*.svg'))
|
||||
return discover_slide_svgs(svg_dir)
|
||||
|
||||
|
||||
def parse_total_md(
|
||||
|
||||
@@ -33,7 +33,7 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from console_encoding import configure_utf8_stdio
|
||||
from project_specs import parse_spec_lock as parse_lock
|
||||
from project_management.project_specs import parse_spec_lock as parse_lock
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from console_encoding import configure_utf8_stdio
|
||||
from server_common import lock_pid, process_alive, read_lock
|
||||
from slide_roster import discover_slide_svgs
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
@@ -197,7 +199,7 @@ def discover_pages(project_path: Path, requested: list[str] | None) -> list[str]
|
||||
svg_dir = project_path / 'svg_output'
|
||||
if not svg_dir.is_dir():
|
||||
raise FileNotFoundError(f'no svg_output/ in {project_path}')
|
||||
all_svgs = sorted(p.name for p in svg_dir.glob('*.svg'))
|
||||
all_svgs = [path.name for path in discover_slide_svgs(svg_dir)]
|
||||
if not requested:
|
||||
return all_svgs
|
||||
selected: list[str] = []
|
||||
@@ -209,15 +211,53 @@ def discover_pages(project_path: Path, requested: list[str] | None) -> list[str]
|
||||
return selected
|
||||
|
||||
|
||||
def check_server(server_url: str) -> None:
|
||||
"""Probe server liveness via /api/slides. Raises RuntimeError if down."""
|
||||
url = f"{server_url.rstrip('/')}/api/slides"
|
||||
def discover_server_url(project_path: Path) -> str:
|
||||
"""Return the live-preview URL recorded for one project."""
|
||||
lock_paths = (
|
||||
project_path / 'live_preview' / 'lock.json',
|
||||
project_path / '.live_preview.lock',
|
||||
)
|
||||
for lock_path in lock_paths:
|
||||
lock = read_lock(lock_path)
|
||||
if not lock or not process_alive(lock_pid(lock)):
|
||||
continue
|
||||
try:
|
||||
port = int(lock.get('port', 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
port = 0
|
||||
if 1 <= port <= 65535:
|
||||
return f'http://127.0.0.1:{port}'
|
||||
raise RuntimeError(
|
||||
f'no running live-preview server recorded for project: {project_path}'
|
||||
)
|
||||
|
||||
|
||||
def check_server(server_url: str, project_path: Path) -> None:
|
||||
"""Require a live-preview server that belongs to the target project."""
|
||||
url = f"{server_url.rstrip('/')}/api/health"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=3.0) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f'{url} returned HTTP {resp.status}')
|
||||
except urllib.error.URLError as e:
|
||||
data = json.load(resp)
|
||||
except (urllib.error.URLError, OSError, ValueError) as e:
|
||||
raise RuntimeError(f'live-preview server not reachable at {server_url}: {e}')
|
||||
expected_project = str(project_path)
|
||||
expected_svg_output = str((project_path / 'svg_output').resolve())
|
||||
service = data.get('service') if isinstance(data, dict) else None
|
||||
legacy_live_preview = (
|
||||
service is None
|
||||
and isinstance(data, dict)
|
||||
and data.get('svg_output') == expected_svg_output
|
||||
)
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or data.get('project') != expected_project
|
||||
or (service != 'live_preview' and not legacy_live_preview)
|
||||
):
|
||||
raise RuntimeError(
|
||||
f'URL does not belong to this project live preview: {server_url}'
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -231,8 +271,8 @@ def main() -> int:
|
||||
"Accepts '02', '02_three_steps', or '02_three_steps.svg'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--server-url', default='http://localhost:5050',
|
||||
help='Live-preview server URL (default: http://localhost:5050)',
|
||||
'--server-url', default=None,
|
||||
help='Explicit live-preview URL (default: discover it from the project lock)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--lock-timeout', type=float, default=30.0,
|
||||
@@ -257,7 +297,8 @@ def main() -> int:
|
||||
return 3
|
||||
|
||||
try:
|
||||
check_server(args.server_url)
|
||||
server_url = args.server_url or discover_server_url(project_path)
|
||||
check_server(server_url, project_path)
|
||||
except RuntimeError as e:
|
||||
_safe_print(str(e))
|
||||
_safe_print(
|
||||
@@ -277,7 +318,7 @@ def main() -> int:
|
||||
|
||||
with file_lock(lock_path, timeout=args.lock_timeout):
|
||||
try:
|
||||
records = render_pages(args.server_url, pages, preview_dir)
|
||||
records = render_pages(server_url, pages, preview_dir)
|
||||
except Exception as e: # noqa: BLE001 — browser launch failure
|
||||
_safe_print(f'browser session failed: {type(e).__name__}: {e}')
|
||||
_safe_print(
|
||||
@@ -293,7 +334,7 @@ def main() -> int:
|
||||
|
||||
summary = {
|
||||
'project': str(project_path),
|
||||
'server_url': args.server_url,
|
||||
'server_url': server_url,
|
||||
'rendered': sum(1 for r in records if r['ok']),
|
||||
'failed': sum(1 for r in records if not r['ok']),
|
||||
'all_background': sum(1 for r in records if r.get('all_background')),
|
||||
|
||||
+1
-1
@@ -171,7 +171,7 @@ Use the §VII table only when at least one real catalog reference is selected. A
|
||||
|
||||
For every independent data chart or pure text-grid table, add `- **Native-ready**: yes|no` to its §IX Slide block. Choose `yes` only when the confirmed requirement or artifact afterlife benefits from an editable native data object; otherwise use `no`. Conceptual visualizations and incidental sparklines, KPI trends, or insets omit this field and remain ordinary SVG.
|
||||
|
||||
In §VIII, author every planned or explicitly required resource from the confirmed source boundary. Write one concise, non-empty `Layout pattern` suggestion in ordinary language; optionally cite stable ids from the layout library when they help recall a technique. Set `Crop Policy` to `adaptive` or `no-crop`; set `Acquire Via` to `ai`, `web`, `user`, `formula`, `placeholder`, or `slice`. Preserve unresolved required assets as `Pending` or `Needs-Manual` instead of dropping or reclassifying them.
|
||||
In §VIII, author every planned or explicitly required resource from the confirmed source boundary. Write one concise, non-empty `Layout pattern` suggestion in ordinary language; optionally cite hierarchical ids from the layout library when they help recall a technique. Set `Crop Policy` to `adaptive` or `no-crop`; set `Acquire Via` to `ai`, `web`, `user`, `formula`, `placeholder`, or `slice`. Preserve unresolved required assets as `Pending` or `Needs-Manual` instead of dropping or reclassifying them.
|
||||
|
||||
§VIII `Layout pattern` is a per-resource preference. When a page uses several images, repeats one image in multiple views, or combines an image with native overlays, describe the page-level relationship and participating resources in §IX `Layout` / `Images`; do not duplicate an unchanged resource row merely to encode animation sequencing.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SVG Icon Library
|
||||
|
||||
This directory provides **11,600+ high-quality SVG icons** across five libraries that can be directly embedded into SVG files generated by PPT Master. When the Strategist selects bundled generic icons, it chooses at most one primary library from the four stylistic libraries; the brand-logo library (`simple-icons`) may be selected alone or alongside it.
|
||||
This directory provides **11,600+ high-quality SVG icons** across five libraries that can be directly embedded into SVG files generated by PPT Master. Default Strategist or the Quick Generate main agent chooses at most one primary library from the four stylistic libraries; the brand-logo library (`simple-icons`) may be selected alone or alongside it.
|
||||
|
||||
## Libraries
|
||||
|
||||
@@ -16,7 +16,7 @@ This directory provides **11,600+ high-quality SVG icons** across five libraries
|
||||
|
||||
## Per-project icons folder
|
||||
|
||||
This directory is the **global library**. At selection time the Strategist copies the chosen icons into the deck's own `<project>/icons/<lib>/` with `icon_sync.py`:
|
||||
This directory is the **global library**. The active resource owner copies chosen icons into the deck's own `<project>/icons/<lib>/` with `icon_sync.py` before SVG authoring:
|
||||
|
||||
```bash
|
||||
python3 skills/ppt-master/scripts/icon_sync.py <project_path> tabler-outline/home tabler-outline/bulb simple-icons/github
|
||||
@@ -97,7 +97,7 @@ Do not load a full index or enumerate broad keyword families. Re-pick from the n
|
||||
> 1. **Geometry**: straight lines (`chunk-filled`) vs. curves (`tabler-filled` / `phosphor-duotone`) vs. open strokes (`tabler-outline`)
|
||||
> 2. **Visual weight**: heavy solid (`chunk-filled`) → medium solid (`tabler-filled`) → medium layered (`phosphor-duotone`) → light stroke (`tabler-outline`)
|
||||
|
||||
**One primary bundled stylistic library per Strategist selection.** Pick one of `chunk-filled` / `tabler-filled` / `tabler-outline` / `phosphor-duotone` for generic icons (home, chart, users, etc.). If it lacks an exact icon, find the closest available alternative within that library instead of selecting from another bundled stylistic library. This is a catalog-selection rule, not a prohibition on combining assets that already exist in the project's `icons/` directory.
|
||||
**One primary bundled stylistic library per deck selection.** Pick one of `chunk-filled` / `tabler-filled` / `tabler-outline` / `phosphor-duotone` for generic icons (home, chart, users, etc.). If it lacks an exact icon, find the closest available alternative within that library instead of selecting from another bundled stylistic library. This is a catalog-selection rule, not a prohibition on combining assets that already exist in the project's `icons/` directory.
|
||||
|
||||
**Brand-logo exception (`simple-icons`).** `simple-icons` is **not a stylistic library** and does not participate in the "one library" rule. Its job is brand recognition — Slack's purple, GitHub's cat, AWS's color — which is intentionally heterogeneous. Use it **alone or alongside** the chosen stylistic library, but **only** for actual company / product / service brand marks. Do **not** reach for it as a substitute when the chosen stylistic library lacks a generic icon.
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ New locks always write `title_family` and `body_family`, even when their values
|
||||
- `icons.library` records the primary stylistic library selected from `chunk-filled`, `tabler-filled`, `tabler-outline`, or `phosphor-duotone`, or `none` when no generic bundled icons are selected. Selected `simple-icons/*` brand marks may appear alone or alongside it in `inventory` without becoming a stylistic library. The inventory records planned bundled choices, while every SVG already under `<project_path>/icons/` remains valid prepared execution material.
|
||||
- `objective` grammar: one concise sentence preserving the deck goal and audience success condition.
|
||||
- `image_rendering` grammar: one catalog id, or `custom` with `image_rendering_behavior`.
|
||||
- `images`: `- <key>: <path> | source=<via> | pattern=<layout> | crop=<adaptive|no-crop>`; e.g. `- p04: images/a.png | source=user | pattern=full-height image beside the evidence | crop=no-crop`. Use the canonical `images/<filename>` path; `source` and `crop` exactly project §VIII, while `pattern` preserves its non-empty normalized free-form suggestion and any optional catalog ids. The pattern remains a recommendation for Executor recall, not a geometry or realization lock. Omit unplaced sheets.
|
||||
- `images`: `- <key>: <path> | source=<via> | pattern=<layout> | crop=<adaptive|no-crop>`; e.g. `- p04: images/a.png | source=user | pattern=full-height image beside the evidence | crop=no-crop`. Use the canonical `images/<filename>` path; `source` and `crop` exactly project §VIII, while `pattern` preserves its non-empty normalized free-form suggestion and any optional hierarchical catalog ids. The pattern remains a recommendation for Executor recall, not a geometry or realization lock. Omit unplaced sheets.
|
||||
- Custom reference grammar: comma-separated exact catalog ids with no duplicates. Reference fields are valid only for `custom`; omit them for a genuinely novel direction.
|
||||
- `stroke_width` grammar: `1.5`, `2`, or `3`; present only for `tabler-outline`.
|
||||
- `page_rhythm` grammar: `P` + at least two digits (`P01`, `P100`) followed by `anchor|dense|breathing`.
|
||||
|
||||
@@ -550,7 +550,7 @@ paint comes from the confirmed brief and template `design_spec.md`. After
|
||||
inserting the complete helper group, add only the registered structural
|
||||
attributes required by its Master/Layout or object-slot role; geometry and
|
||||
paint changes require a new helper render. When actual `standard` / `fidelity`
|
||||
construction needs a Boolean result over closed shapes, Template_Designer
|
||||
construction needs a Boolean result over supported shape/text operands, Template_Designer
|
||||
decides whether to use `shape_boolean_svg.py` under
|
||||
[`native-shape-authoring.md`](../references/native-shape-authoring.md) §6; a
|
||||
brief/reference suggestion does not lock the operation. `mirror` preserves the expanded
|
||||
|
||||
@@ -15,17 +15,21 @@ description: Generate PPTX route authority for source intake, planning, SVG auth
|
||||
- `preset_shape_svg.py` and `shape_boolean_svg.py` may provide only their documented stdout fragment(s) after the main agent chooses the object's role, operands, paint, and z-order; neither helper chooses layout or writes a page.
|
||||
- Gate checklists are internal verification, not user-facing output. On success, continue automatically and emit at most one compact status line when useful; on failure, report only the blocking items and required recovery.
|
||||
|
||||
### Quick Test Profile Short Circuit
|
||||
### Quick Generate Profile Short Circuit
|
||||
|
||||
When the user explicitly requests quick/fast mode for a disposable test with a
|
||||
small fixed roster of self-contained slides, load and follow
|
||||
[`quick-test.md`](./profiles/quick-test.md). That profile owns the complete
|
||||
test-only sequence and skips this route's Steps 1–7.
|
||||
For an explicit quick/fast, skip-strategy, or direct-SVG request, follow
|
||||
[`quick-generate.md`](./profiles/quick-generate.md). It runs applicable source
|
||||
conversion/research and project-local resource preparation, lets the current
|
||||
agent decide content/visual/resource details in active context, then
|
||||
hand-authors SVG, runs one lockless final checker, and exports the final PPTX.
|
||||
It skips Strategist, Confirm UI, Design Spec/lock, the first-page gate, and
|
||||
`finalize_svg.py`.
|
||||
|
||||
**Hard rule — no implicit downgrade**: page count alone never selects quick
|
||||
test. Normal delivery, source conversion, factual research, template use,
|
||||
external assets, native data objects, notes, animation, narration, and reusable
|
||||
output remain on the default pipeline below.
|
||||
**Hard rule — no implicit downgrade or page cap**: page count neither selects
|
||||
nor blocks quick generation. Source preparation, images, icons, formulas, and
|
||||
their manifests remain valid. All exporter capabilities remain available when
|
||||
requested or agent-selected; use their existing prerequisites. Structured
|
||||
template reuse still requires the default lock-backed pipeline.
|
||||
|
||||
### SVG Page-Design Boundary
|
||||
|
||||
@@ -34,7 +38,7 @@ output remain on the default pipeline below.
|
||||
| Any route that authors or regenerates slide visuals through SVG | `svg_output/` is the complete page-design source: every visible text, image, shape, chart/table fallback, and layout element that should appear on the exported slide is present in that page SVG or referenced by it. |
|
||||
| Templates, `design_spec.md`, and `spec_lock.md` | Authoring/control inputs. They guide SVG creation but MUST NOT supply visible slide content that is absent from the completed SVG during export. |
|
||||
| Semantic SVG markers | Minimal rendering-neutral compiler hints used only after existing Layout/Layer/Placeholder/Native metadata has been considered. They never replace native SVG geometry, text, styles, grouping, or asset references. |
|
||||
| `svg_final/` | Mandatory derived, self-contained SVG visual preview in the default pipeline. It may be opened directly or inserted into PowerPoint as an SVG picture, but it is not a supported PPTX source and carries no manual Convert-to-Shape compatibility contract. Quick-test skips it. |
|
||||
| `svg_final/` | Mandatory derived, self-contained SVG visual preview in the default pipeline. It may be opened directly or inserted into PowerPoint as an SVG picture, but it is not a supported PPTX source and carries no manual Convert-to-Shape compatibility contract. Quick-generate skips it. |
|
||||
| SVG-to-PPTX export | The only supported generated-PPTX route reads `svg_output/` and maps its content through the project converter to DrawingML/native objects. It compiles only the selected route's explicit structure contract: `flat` keeps represented content Slide-local, while `structured` may place explicitly scoped content in Master/Layout/Slide parts. It MUST NOT infer structure, upgrade `flat`, or invent new visible page content. |
|
||||
| Native PPTX routes and presentation-behavior stages | Remain outside SVG page-design closure. `template-fill-pptx`, `native-enhance-pptx`, animations, transitions, speaker notes, narration, and package relationships are not required to round-trip through SVG. |
|
||||
|
||||
@@ -172,11 +176,11 @@ Read references/strategist.md
|
||||
| Deterministic trigger | Additional Strategist reference |
|
||||
|---|---|
|
||||
| Step 3 installed an explicit Brand/Layout/Deck workspace | `references/strategist-template.md` |
|
||||
| The core's proposed Stage 2 `image_usage` contains a source other than `none`, the user supplied an explicit non-`none` image constraint, or formula-worthy content activates formula planning | `references/strategist-image.md` before authoring image renderings, production detail, formula resources, or §VIII |
|
||||
| The core's proposed Stage 2 `image_usage` contains a source other than `none`, the user supplied an explicit non-`none` image constraint, or formula-worthy content activates formula planning | `references/strategist-image.md` + `references/image-layout-spec.md` + `references/image-layout-patterns.md` before authoring image renderings, production detail, formula resources, or §VIII |
|
||||
|
||||
The core first chooses the proposed Stage 2 source ids. Load the image module before writing Stage 2 whenever that proposal is non-`none`; after confirmation, keep it active only for confirmed non-`none` sources or an active formula plan. A confirmed `none` path with no formula work writes no image rows. Bare template names and style language do not load the template module.
|
||||
Core chooses Stage-2 sources. Load it before Stage 2 for non-`none`, or after confirmation if `none` changes; do not backfill candidates. Retain for confirmed non-`none` or formulas; otherwise write no image rows. Bare template/style names do not load the template module.
|
||||
|
||||
> ⚠️ **Mandatory artifact gates**: after final confirmation, author the complete `design_spec.md` from `templates/design_spec_reference.md`. After Gate 1, run enabled refinement and wait for approval; then author `spec_lock.md` from its reference, the approved Design Spec, and context. Create each new artifact once—no placeholder scaffold; `scaffold-*` commands remain manual-only. Schemas validate structure; semantic fidelity remains mandatory.
|
||||
> ⚠️ **Mandatory artifact gates**: after final confirmation, author complete `design_spec.md` from `${SKILL_DIR}/templates/design_spec_reference.md`. After Gate 1 and any refinement approval, author `spec_lock.md` from `${SKILL_DIR}/templates/spec_lock_reference.md` plus approved Design Spec/context. Author each new artifact once without placeholders or `scaffold-*` (manual-only). Schema validity does not prove semantic fidelity.
|
||||
|
||||
**Artifact ownership**: fact-channel and source/derived artifact boundaries are defined in [`references/artifact-ownership.md`](../references/artifact-ownership.md). This Step uses those ownership rules; it does not redefine them.
|
||||
|
||||
@@ -186,9 +190,23 @@ The core first chooses the proposed Stage 2 source ids. Load the image module be
|
||||
|
||||
**Confirmation orchestration**: field meaning and recommendation logic belong to the active Strategist modules; [`confirm_ui.md`](../scripts/docs/confirm_ui.md) owns the JSON schema, server lifecycle, staged-result contract, port behavior, and equivalent chat fallback.
|
||||
|
||||
⛔ **BLOCKING**: Unless explicitly delegated, final confirmation is the single always-on user gate. An enabled `refine_spec` adds the one conditional chat gate after Design Spec Gate 1. Keep Stage 1/2 handoffs in one turn; after each wait, author the next stage without chat. Author each stage once; submitted values—including blanks or unusual overrides—are authoritative.
|
||||
⛔ **BLOCKING**: Unless explicitly delegated, the three-stage Strategist confirmation is the single always-on user gate. An enabled `refine_spec` adds the one conditional chat gate after Design Spec Gate 1. In the UI branch, keep Stage 1/2 handoffs in one turn and author the next stage after each wait. In the chat branch, wait for an explicit user response at each stage. Author each stage once; submitted values—including blanks or unusual overrides—are authoritative.
|
||||
|
||||
**Confirmation ownership and surface**: Only the user confirms. Fresh Stage 1 launches, posts the required chat handoff, then waits. Use chat on explicit chat-only/delegation, explicit handoff confirmation/revision, or launch failure/timeout plus one `result.json` re-check. Chat tools do not replace the default launch. The agent may write recommendations, operate the server, and read state, but MUST NOT call `/api/confirm`, automate submission, synthesize a payload, or write/replace `result.json`. Delegation applies only to this run: show the complete three-stage summary and never fabricate UI results. Silence confirms nothing.
|
||||
**Confirmation ownership and surface**: Only the user confirms. Before any
|
||||
server command, apply `confirm_ui.md`'s surface decision to this run's most
|
||||
recent explicit surface instruction and retain that branch as the owner
|
||||
specifies. A natural-language request or agreement to personally confirm in
|
||||
chat, or to avoid the page, selects the chat branch without a magic keyword;
|
||||
skip `--daemon`, every `--wait-only`, and UI `result.json`. Explicit delegation
|
||||
is a separate higher-priority branch. With no surface instruction, fresh Stage
|
||||
1 uses the default UI branch: launch, post the required chat handoff, then wait.
|
||||
A chat-question tool alone does not replace that default. The agent may write
|
||||
recommendations, operate the server, and read state, but MUST NOT call
|
||||
`/api/confirm`, automate submission, synthesize a payload, or write/replace
|
||||
`result.json`. Delegation applies only to this run: show the complete
|
||||
three-stage summary and never fabricate UI results. Silence confirms nothing.
|
||||
|
||||
**UI branch files and completion evidence:**
|
||||
|
||||
| Stage file (the active unconfirmed stage may be overwritten) | Strategist writes | Completion evidence |
|
||||
|---|---|---|
|
||||
@@ -198,7 +216,9 @@ The core first chooses the proposed Stage 2 source ids. Load the image module be
|
||||
|
||||
If the user rejects the current recommendation before confirming it, regenerate by overwriting that same stage file and have the page refresh; do not create revision-suffixed files. This never authorizes one stage file to carry another stage's payload.
|
||||
|
||||
1. Create `confirm_ui/recommendations.stage1.json`, then run in order:
|
||||
**UI branch only** — create `confirm_ui/recommendations.stage1.json`, then:
|
||||
|
||||
1. Run in order:
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --daemon
|
||||
@@ -206,13 +226,13 @@ If the user rejects the current recommendation before confirming it, regenerate
|
||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only --wait-stage stage1
|
||||
```
|
||||
|
||||
2. Read the Stage 1 result. Derive proposed image sources in core and load `strategist-image.md` before constructing Stage 2 when its trigger fires; apply `strategist-template.md` when active. Create `confirm_ui/recommendations.stage2.json` without changing Stage 1, then wait:
|
||||
2. Read Stage 1. Derive proposed image sources, load the triggered image-planning bundle above, and apply `strategist-template.md` when active. Create `confirm_ui/recommendations.stage2.json` without changing Stage 1, then wait:
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only --wait-stage stage2
|
||||
```
|
||||
|
||||
3. Read the Stage 2 result, create `confirm_ui/recommendations.stage3.json` without changing either earlier stage, then perform the final blocking wait:
|
||||
3. Read Stage 2; load the image bundle if it newly confirms non-`none`. Create `confirm_ui/recommendations.stage3.json` without changing prior stages and wait:
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --wait-only
|
||||
@@ -226,7 +246,18 @@ If the user rejects the current recommendation before confirming it, regenerate
|
||||
python3 ${SKILL_DIR}/scripts/confirm_ui/server.py <project_path> --shutdown
|
||||
```
|
||||
|
||||
If the user opted out of the page but did not delegate confirmation, skip launch and run the same three stages in chat with explicit user responses. If the user explicitly delegated confirmation, consolidate the same three stages into one AI-authored summary and proceed without `result.json`. Otherwise use the always-on Stage-1 chat handoff; it keeps the current contract and direct-chat fallback visible without replacing UI confirmation.
|
||||
If the user selects chat any time after the UI server launches, immediately
|
||||
apply `confirm_ui.md`'s in-run switch procedure. Continue the unresolved current
|
||||
stage and all remaining stages in chat; do not enter UI interruption recovery
|
||||
or relaunch the server.
|
||||
|
||||
**Chat branch** — run the same three stages in chat with explicit user
|
||||
responses, retaining one visible cumulative confirmation summary as the
|
||||
equivalent final state; do not create or require a UI result. If the user
|
||||
explicitly delegated confirmation, consolidate the same three stages into one
|
||||
AI-authored summary. Otherwise the no-selection UI branch uses the always-on
|
||||
Stage-1 chat handoff, which keeps direct-chat fallback visible without replacing
|
||||
UI confirmation.
|
||||
|
||||
⛔ **GATE — final state → Design Spec → conditional review → lock.** Consume every present final value once into the complete, audited `design_spec.md` under [`strategist.md`](../references/strategist.md) §6.2. Preserve each owning semantic type and all production, typography, image-source, and `image_notes` obligations; acceptance never turns a Reference/Permission into a Literal. Do not reopen `result.json`.
|
||||
|
||||
@@ -245,7 +276,7 @@ For the normal/default `continuous` path, print no split-mode reminder and proce
|
||||
|
||||
**Mandatory — spec-refinement note** (not another Confirm UI stage): after confirmation details and any split-mode line, append one localized 💡 line offering review of the complete Design Spec before the lock; any part may be revised in chat until explicit approval. Default OFF; only explicit chat opt-in or `refine_spec: true` runs [`refine-spec`](stages/refine-spec.md) after Gate 1. Confirm UI records the toggle; chat fallback prints the same line.
|
||||
|
||||
**Formula policy**: Stage 3 confirms `mixed`, `render-all`, or `text-only`. When the confirmed policy requires rendering formula-worthy content, load [`strategist-image.md`](../references/strategist-image.md) even if `image_usage` is `none`, and follow its formula-resource contract before filling the planning artifacts. `text-only` creates no formula image rows.
|
||||
**Formula policy**: Stage 3 confirms `mixed`, `render-all`, or `text-only`. When rendering is required, load the image-planning bundle even if `image_usage` is `none`, then follow [`strategist-image.md`](../references/strategist-image.md)'s formula-resource contract. `text-only` creates no formula image rows.
|
||||
|
||||
**Proactive production decisions**: Stage 3 records
|
||||
`proactive_speaker_notes`, `proactive_custom_animations`, and
|
||||
@@ -282,10 +313,10 @@ python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images
|
||||
|
||||
For a new project, use the reference-first whole-document sequence:
|
||||
|
||||
1. Read `templates/design_spec_reference.md`; compose I–X from retained confirmation, analysis, and context; create `<project_path>/design_spec.md` once without placeholders/examples.
|
||||
1. Read `${SKILL_DIR}/templates/design_spec_reference.md`; create complete I–X `<project_path>/design_spec.md` once from retained confirmation, analysis, and context, without placeholders/examples.
|
||||
2. Audit it field by field against retained confirmation; Gate 1 must pass.
|
||||
3. If enabled, run [`refine-spec`](stages/refine-spec.md) on that file until explicit approval; touch no lock.
|
||||
4. From `templates/spec_lock_reference.md`, the approved Design Spec, and context, create the new lock once or resynchronize stale derived state. Do not reopen `result.json` or make a new design choice.
|
||||
4. Read `${SKILL_DIR}/templates/spec_lock_reference.md`; create or resynchronize the lock once from approved Design Spec and context. Never reopen `result.json` or make a new design choice.
|
||||
5. Compare lock anchors/routing to the Design Spec; run `python3 ${SKILL_DIR}/scripts/project_manager.py validate <project_path>`.
|
||||
|
||||
Final state → initial Design Spec mismatch, approved Design Spec/context → lock mismatch, or an unapplied revision blocks despite schema validity. `validate` does not prove fidelity. Repair from retained confirmation before refinement; during it, preserve unaffected values and apply explicit revisions. After approval, derive the lock from that Design Spec/context. Resume/refine edits existing files, never scaffolds. Fresh recovery alone may reread persisted final evidence once.
|
||||
@@ -348,7 +379,7 @@ Workflow:
|
||||
- [ ] **Next**: open a fresh chat window and input `继续生成 projects/<project_name>` to enter the execution session via the [`resume-execute`](stages/resume-execute.md) stage.
|
||||
```
|
||||
|
||||
> On acquisition failure, follow [image-base.md](../references/image-base.md) §5 without halting. Web rows continue through materially different query/provider/license/URL strategies; after exhaustion, mark `Needs-Manual`, report, and continue.
|
||||
> On acquisition failure, follow [image-base.md](../references/image-base.md) §6 without halting. Web rows continue through materially different query/provider/license/URL strategies; after exhaustion, mark `Needs-Manual`, report, and continue.
|
||||
|
||||
---
|
||||
|
||||
@@ -368,22 +399,22 @@ Read the execution references for this deck's locked `mode` + `visual_style` (fr
|
||||
```
|
||||
Read references/executor-base.md # REQUIRED: flat/shared execution core
|
||||
Read references/shared-standards-core.md # REQUIRED: SVG compatibility core
|
||||
Read references/svg-effects.md # REQUIRED: advanced visual effects and construction vocabulary
|
||||
Read references/native-shape-authoring.md # REQUIRED: native-shape selection and Boolean construction
|
||||
Read references/semantic-svg.md # REQUIRED: semantic metadata boundary
|
||||
Read references/modes/<resolved-id>.md # one preset id, or each `mode_references` id
|
||||
Read references/visual-styles/<resolved-id>.md # one preset id, or each `visual_style_references` id
|
||||
```
|
||||
|
||||
> Read only the five always-on references above plus the conditionally triggered modules below. A preset reads its one locked file. For `mode: custom` or `visual_style: custom`, read every exact file named by the optional `mode_references` / `visual_style_references`, then synthesize those sources under the corresponding behavior. If the reference field is absent, the direction is genuinely novel: read no preset file and follow the behavior directly. Never infer adjacent references or glob `modes/` / `visual-styles/`.
|
||||
> Read only the always-on references above plus the conditionally triggered modules below. A preset reads its one locked file. For `mode: custom` or `visual_style: custom`, read every exact file named by the optional `mode_references` / `visual_style_references`, then synthesize those sources under the corresponding behavior. If the reference field is absent, the direction is genuinely novel: read no preset file and follow the behavior directly. Never infer adjacent references or glob `modes/` / `visual-styles/`.
|
||||
|
||||
| Deterministic trigger | Additional references |
|
||||
|---|---|
|
||||
| `pptx_structure.mode: structured` | `executor-structured.md` + `pptx-structure-interface.md` |
|
||||
| Any data chart/table, including mini or inset charts and sparklines | `executor-chart.md` |
|
||||
| Preset pattern or supported native chart/table | `native-data-interface.md` before drawing |
|
||||
| `spec_lock.md images` / §VIII has an image/formula row, or the template has bundled images | `executor-image.md` + `image-layout-spec.md` + `svg-image-embedding.md`; add `image-layout-patterns.md` only for a cited `#<id>` or optional composition recall |
|
||||
| `spec_lock.md images` / §VIII has an image/formula row, or the template has bundled images | `executor-image.md` + `image-layout-spec.md` + `image-layout-patterns.md` + `svg-image-embedding.md` |
|
||||
| At least one placed image has `Status: Sourced` | `executor-web-image.md` after the image branch |
|
||||
| The locked style/current page calls for noncanonical or alpha paint, dash/cap/join, tracking/decoration/outline, gradient/filter/glow/shadow, path/transform/clipping, or another constructed effect | `svg-effects.md` before authoring that value or effect |
|
||||
| §IX contains `Native shape suggestion`, or current page construction calls for a literal PowerPoint stock shape, a stock Connector contour, an explicit Merge Shapes operation or result, or a shape-built dimensional form (cylinder/pedestal, layered diagram, reflection, ground plane), or Executor is about to hand-author a freeform not already required by data geometry or the locked organic / hand-drawn style | `native-shape-authoring.md` before selecting or materializing that geometry. This trigger is image-independent: a text-only, data-only, or icon-only page reaches it the same way |
|
||||
| All SVG pages and SVG quality gates are complete, and the effective Speaker Notes outcome in `design_spec.md §I` is enabled | `executor-notes.md` before generating speaker notes |
|
||||
|
||||
No branch is loaded by analogy. Evaluate these triggers from `spec_lock.md`, §VII/§VIII, the selected style, and the current page plan.
|
||||
@@ -394,8 +425,8 @@ No branch is loaded by analogy. Evaluate these triggers from `spec_lock.md`, §V
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live --daemon
|
||||
```
|
||||
- Start it immediately when Executor begins; `svg_output/` may be empty. Editor opens at the launch-log URL such as `http://127.0.0.1:5050`; if another project already holds it, the launcher **auto-advances to the next free port** — read the actual URL from the launch log and report that.
|
||||
- Treat the launch URL as a checkpoint value: before writing the first SVG, either report the actual URL from the launcher or state the launch failure explicitly. Do not silently continue while claiming preview is available.
|
||||
- Start when Executor begins; `svg_output/` may be empty. Default: first free port from `5050`; `--port N`: strict bind. Read the actual URL from output or `<project_path>/live_preview/lock.json`.
|
||||
- Before the first SVG, report that URL or the launch failure; never claim an unavailable preview.
|
||||
- Run it as a long-running side process/session; do not wait for it to exit before generating SVG pages. Do not wait for user confirmation after startup.
|
||||
- **Service must keep running** until one of: (a) the user clicks **Exit preview** in the browser, or (b) the user explicitly asks in chat to stop it. Generation continues even if the user closes the editor.
|
||||
- **Do NOT read or apply submitted annotations during generation.** Users may annotate at any time, but Executor proceeds without touching them. The window to apply annotations opens only after Step 7 completes — see [`workflows/stages/live-preview.md`](stages/live-preview.md).
|
||||
@@ -412,7 +443,7 @@ python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live --daemon
|
||||
|
||||
**Visual Construction Phase**: generate SVG pages sequentially, one at a time, in one continuous pass → `<project_path>/svg_output/`
|
||||
|
||||
Each completed SVG MUST be a standalone, complete representation of that slide's visible design. Template SVGs and locked planning artifacts may guide construction, but export must not reach back to them to add visible objects omitted from `svg_output/`. Speaker notes, animation, narration, transitions, and direct native-PPTX workflows remain separately owned artifacts/capabilities. Treat §IX `Native shape suggestion` as a candidate, not a command: inspect the actual page construction, then choose the highest-level faithful construction in this order — editable basic primitive, exact Office preset, Merge Shapes Boolean result, and only then a necessary freeform. Load and apply [`native-shape-authoring.md`](../references/native-shape-authoring.md) before materializing an adopted native treatment. Diagram relationships follow the same Shape-first order; do not infer a preset from contour similarity.
|
||||
Each completed SVG MUST be a standalone, complete representation of that slide's visible design. Template SVGs and locked planning artifacts may guide construction, but export must not reach back to them to add visible objects omitted from `svg_output/`. Speaker notes, animation, narration, transitions, and direct native-PPTX workflows remain separately owned artifacts/capabilities. Treat §IX `Native shape suggestion` as a candidate, not a command: inspect the actual page construction, then choose the highest-level faithful construction in this order — editable basic primitive, exact Office preset, Merge Shapes Boolean result, and only then a necessary freeform. Apply [`native-shape-authoring.md`](../references/native-shape-authoring.md) before materializing an adopted native treatment. Diagram relationships follow the same Shape-first order; do not infer a preset from contour similarity.
|
||||
|
||||
**Motion-ready image composition**: Only when an explicit user motion
|
||||
instruction, the effective Custom Animations outcome in `design_spec.md §I` is
|
||||
|
||||
+8
-3
@@ -16,7 +16,8 @@ Global stop/continue rules for all four top-level routes, plus concrete failure
|
||||
|---|---:|---|---|---|
|
||||
| Confirm UI launch failure | No | Re-check `confirm_ui/result.json` once, then use chat fallback | No | [`generate-pptx`](../generate-pptx.md) Step 4 chat confirmation |
|
||||
| Confirm UI wait timeout | No, if no final result yet | Re-check `result.json` once; keep server cleanup mandatory | Only if user still wants the page | Step 4 same stage or chat fallback |
|
||||
| Confirm UI Stage 1 completed then interrupted | Yes until Stage 2 is written/confirmed | Read existing Stage 1 `result.json`, create `recommendations.stage2.json` without changing Stage 1, then `--wait-only --wait-stage stage2` | Usually no | Step 4 Stage 2 write/wait |
|
||||
| User explicitly switches from Confirm UI to chat during any stage | Yes until the unresolved current stage is confirmed | Follow [`confirm_ui.md`](../../scripts/docs/confirm_ui.md)'s in-run switch, retain persisted confirmed stages, then continue the current and remaining stages in chat; never relaunch UI | Confirm in chat unless explicitly delegated | Step 4 current chat stage |
|
||||
| Confirm UI Stage 1 completed then unexpectedly interrupted while UI remains selected | Yes until Stage 2 is written/confirmed | Read existing Stage 1 `result.json`, create `recommendations.stage2.json` without changing Stage 1, then `--wait-only --wait-stage stage2` | Usually no | Step 4 Stage 2 write/wait |
|
||||
| Missing final confirmation | Yes | None | User must confirm or change the values | Step 4 final confirmation |
|
||||
| Final confirmed value or a later explicit user override is missing, changed, substituted, or weakened in `design_spec.md` | Yes | Repair from the retained final-confirmation object plus any newer explicit instruction; only a fresh recovery turn with no retained state reads persisted final evidence once | Only when the effective value genuinely cannot be honored | Step 4 Gate 1 — confirmation fidelity |
|
||||
| `spec_lock.md` changes confirmed identity or omits a required execution anchor/routing decision | Yes | Re-author the affected lock rows from the completed Design Spec and current context; do not enumerate page-local literals | No unless the Design Spec itself is incomplete | Step 4 Gate 2 — lock context fidelity |
|
||||
@@ -72,10 +73,14 @@ outcomes/provenance only in Design Spec §I, never the lock.
|
||||
|
||||
Here, **final confirmation evidence** means either the explicit final confirmation in the current chat or `<project>/confirm_ui/result.json` with `status: confirmed` and `stage: final`. Planning artifacts alone do not prove that the user confirmed their values. After that gate, a newer explicit user instruction may update only its effective production outcome and provenance in the durable Design Spec; resume from the owning step without reopening Confirm UI.
|
||||
|
||||
The UI wait resume entries below apply only while UI remains the selected
|
||||
surface. A newer explicit chat-surface instruction follows `confirm_ui.md`'s
|
||||
in-run switch and resumes the unresolved stage in chat without relaunching UI.
|
||||
|
||||
| Last good state | Resume from |
|
||||
|---|---|
|
||||
| Stage 1 confirmation exists, Stage 2 missing | Create `recommendations.stage2.json` without changing Stage 1, then `confirm_ui/server.py <project> --wait-only --wait-stage stage2` |
|
||||
| Stage 2 confirmation exists, final confirmation missing | Resume [`generate-pptx`](../generate-pptx.md) Step 4 confirmation orchestration at Stage 3: derive production mechanics from the confirmed solution, create `recommendations.stage3.json` without changing earlier stages, then perform the final wait. |
|
||||
| Stage 1 confirmation exists, Stage 2 missing, and UI remains selected | Create `recommendations.stage2.json` without changing Stage 1, then `confirm_ui/server.py <project> --wait-only --wait-stage stage2` |
|
||||
| Stage 2 confirmation exists, final confirmation missing, and UI remains selected | Resume [`generate-pptx`](../generate-pptx.md) Step 4 confirmation orchestration at Stage 3: derive production mechanics from the confirmed solution, create `recommendations.stage3.json` without changing earlier stages, then perform the final wait. |
|
||||
| Final confirmation evidence exists; `design_spec.md` is missing, with or without a surviving `spec_lock.md` | Return to Generate Step 4 and [`strategist.md`](../../references/strategist.md) §6.2; read final evidence once into the fresh context, read [`design_spec_reference.md`](../../templates/design_spec_reference.md), author the complete `design_spec.md` from scratch using that state plus source analysis, and pass Gate 1. Then read [`spec_lock_reference.md`](../../templates/spec_lock_reference.md) and re-author the complete `spec_lock.md` from the audited Design Spec plus current context, replacing any orphan lock. Never reconstruct the Design Spec from an orphan lock or retain orphan-lock choices as authority. |
|
||||
| Final confirmation evidence exists; `design_spec.md` exists and `spec_lock.md` missing | Return to Generate Step 4; in this fresh recovery context read final evidence once to audit the existing Design Spec, then read [`spec_lock_reference.md`](../../templates/spec_lock_reference.md) and author the complete lock from the audited Design Spec plus current context. |
|
||||
| Final confirmation evidence and both planning artifacts exist, but Gate 1 fails | In a fresh recovery context read final evidence once, repair `design_spec.md`, then re-author every affected lock row. Do not reopen recommendations or infer a replacement from the current lock. |
|
||||
|
||||
@@ -20,6 +20,7 @@ Maintainer-only inventory for adding, moving, or removing workflow documents. Ru
|
||||
| ID | Class | Path | Parent / lifecycle slot |
|
||||
|---|---|---|---|
|
||||
| `beautify-pptx` | Generation profile | [`profiles/beautify-pptx.md`](./profiles/beautify-pptx.md) | Generate PPTX |
|
||||
| `quick-generate` | Generation profile | [`profiles/quick-generate.md`](./profiles/quick-generate.md) | Generate PPTX direct SVG-to-PPTX short circuit |
|
||||
| `apply-template-workspace` | Template-input stage | [`stages/apply-template-workspace.md`](./stages/apply-template-workspace.md) | Generate Step 3 |
|
||||
| `create-brand` | Template child workflow | [`create-template/create-brand.md`](./create-template/create-brand.md) | Create Template |
|
||||
| `create-layout` | Template child workflow | [`create-template/create-layout.md`](./create-template/create-layout.md) | Create Template |
|
||||
|
||||
+23
-10
@@ -139,15 +139,15 @@ If `images/image_manifest.json` does not exist because the source deck has no ex
|
||||
|
||||
## 5. Beautify Plan — Recommend & Confirm
|
||||
|
||||
⛔ **BLOCKING**: the scope is not hard-coded — same spirit as the Strategist confirmation stage. Recommend each item below from what the deck actually contains (the Step 4 inventory), present the plan, and **wait for the user to confirm or adjust** before writing any spec. Chat is the canonical channel; the confirm UI below is the visual convenience surface over it for the palette + typography review (its result is honored identically to a chat reply).
|
||||
⛔ **BLOCKING**: the scope is not hard-coded — same spirit as the Strategist confirmation stage. Recommend each item below from what the deck actually contains (the Step 4 inventory), present the plan, and **wait for the user to confirm or adjust** before writing any spec. Use Generate Step 4's selected surface for the full visual confirmation; keep the structural-scope decisions in chat. Values confirmed through either channel are honored identically.
|
||||
|
||||
This step has two halves:
|
||||
- **Visual re-confirm via the confirm UI** — the **full** Step 4 confirm page (below), seeded from the source so every targeted-confirmation field (canvas, mode, visual style, palette, icons, typography incl. body baseline, image strategy, generation mode) is **pre-filled with the inherited / source-derived default and left editable**. Beautify *recommends* keeping the source's identity, but never removes the user's place to override any field — you may choose not to change a value, but you must not deny the place to change it. This is also where the deck's text size is confirmed: `<stem>.identity.json` now carries size hints — `observed.sizes_pt` (the point sizes the deck actually renders at) and `theme.sizes` (the declared placeholder defaults) — so the `body_size` recommendation **follows the source's own font size** rather than a blind canvas default; the user still confirms or overrides it here.
|
||||
- **Visual re-confirm via the selected confirmation surface** — the **full** Step 4 field set (below), seeded from the source so every targeted-confirmation field (canvas, mode, visual style, palette, icons, typography incl. body baseline, image strategy, generation mode) is **pre-filled with the inherited / source-derived default and left editable**. Beautify *recommends* keeping the source's identity, but never removes the user's place to override any field — you may choose not to change a value, but you must not deny the place to change it. This is also where the deck's text size is confirmed: `<stem>.identity.json` now carries size hints — `observed.sizes_pt` (the point sizes the deck actually renders at) and `theme.sizes` (the declared placeholder defaults) — so the `body_size` recommendation **follows the source's own font size** rather than a blind canvas default; the user still confirms or overrides it here.
|
||||
- **Structural scope** — the inventory-driven list decisions below (ignored, reuse, needs-confirmation, verification level) stay in **chat**; they have no confirm-UI widget.
|
||||
|
||||
| Plan item | Recommend from | Default lean |
|
||||
|---|---|---|
|
||||
| Identity source | `<stem>.identity.json` `theme` vs `observed` | present **both as color / typography candidates in the confirm UI** so the user picks the one that looks right (theme first when the deck is theme-driven; observed first when slides override heavily) — recommend a default ordering and say why |
|
||||
| Identity source | `<stem>.identity.json` `theme` vs `observed` | present **both as color / typography candidates in the selected confirmation surface** so the user picks the one that looks right (theme first when the deck is theme-driven; observed first when slides override heavily) — recommend a default ordering and say why |
|
||||
| Preserve scope | inventory `text_blocks` / `images` / `charts` / `tables` / `diagrams` | all text verbatim; data values and SmartArt relationships frozen; pictures reused |
|
||||
| Ignored | inventory `ignored` | name them so the user sees what drops (hidden / master-only text / image crop / rotation) |
|
||||
| Needs confirmation | inventory `needs_confirmation` | flag complex charts + overcrowded pages explicitly; ask how to handle |
|
||||
@@ -163,9 +163,19 @@ This step has two halves:
|
||||
| Paste-back into the original | regenerated elements share the inherited palette + fonts, so they **blend visually** when pasted. v1 does **not** guarantee a seamless coordinate-level drop-in (slide coordinates, master placeholders, font availability are the original deck's, not ours) |
|
||||
| Complex charts / merged-cell tables | best-effort from the captured data; combo / dual-axis / waterfall lose the un-captured plots — flagged for the user |
|
||||
|
||||
**Visual re-confirm — full confirm UI seeded from the source**:
|
||||
**Visual re-confirm — full confirmation seeded from the source**:
|
||||
|
||||
Use the three `<project_path>/confirm_ui/recommendations.stageN.json` files at the same staged handoffs as [`generate-pptx`](../generate-pptx.md) Step 4 and launch the same confirm server. The active, unconfirmed stage may be overwritten for a requested regeneration; normal progression leaves confirmed earlier stages intact. Do **not** hide fields: seed **every** targeted-confirmation field with the inherited / source-derived default so the user sees the recommendation and keeps the place to change it. Schema → [`scripts/docs/confirm_ui.md`](../../scripts/docs/confirm_ui.md).
|
||||
Apply [`generate-pptx`](../generate-pptx.md) Step 4's surface decision first. In
|
||||
the default UI branch, use the three
|
||||
`<project_path>/confirm_ui/recommendations.stageN.json` files at the same staged
|
||||
handoffs and launch the same confirm server. In the chat branch, present the
|
||||
same three stages and fields without launching the server or requiring
|
||||
`result.json`. The active, unconfirmed UI stage may be overwritten for a
|
||||
requested regeneration; normal progression leaves confirmed earlier stages
|
||||
intact. Do **not** hide fields: seed **every** targeted-confirmation field with
|
||||
the inherited / source-derived default so the user sees the recommendation and
|
||||
keeps the place to change it. Schema →
|
||||
[`scripts/docs/confirm_ui.md`](../../scripts/docs/confirm_ui.md).
|
||||
|
||||
The typography rows below show the non-English shape; omit `english` for an English source.
|
||||
|
||||
@@ -202,15 +212,18 @@ The typography rows below show the non-English shape; omit `english` for an Engl
|
||||
|
||||
- **Recommend keep, allow override**: pre-fill the open communication contract from the source's apparent audience and purpose, preserving composite purposes in prose; also pre-fill canvas / mode / visual style / icons / image strategy with the source-faithful default (canvas = Step 3 format, mode = `briefing`, image_usage = `provided`). The purpose examples are hints, never a `primary_job` selector. Beautify's only true non-choices are frozen text and strict 1:1 page count (changing either means routing to the main pipeline). Seed `content_divergence` to verbatim preservation with `locked: true`; the Confirm UI renders it read-only and the server restores the locked value on every staged submit. A request to reshape wording or page structure routes to the main pipeline instead of weakening this profile.
|
||||
- **Our recommendation is the pre-selected default = the source replica**: for color and typography, author **several candidates** like the from-scratch flow. The pre-selected default (`selected: 0`, the first card) is what beautify recommends — the candidate that **best replicates the source deck's style** (the truest reading of `theme` / `observed`). Replicate-by-default.
|
||||
- **Judge the other alternatives exactly as the from-scratch flow does — fonts as much as colors**: don't invent a beautify-specific rule. Author each non-replica candidate with the **same content-driven judgment the Strategist uses when generating from scratch** (color §e, typography §g), applied to the material this project provides — the source document's content and subject, the company's own theme colors, and any brand signal. Pick the palette **and** the font pairing by what fits *this* deck's content; fonts are chosen by content fit, not just defaulted to a safe face. Reach **≥3 candidates total with distinct heading/body combinations** (PPT-safe stacks; the same creative-choice rule used elsewhere) so a user who departs from the replica still lands on a considered, content-fitting direction. `primary` always follows the source deck's main language; include `english` only when that language is not English.
|
||||
- **`body_size` is the load-bearing field, and the replica follows the source's own size**: seed the replica candidate's `body_size` from the source's actual body size — take the dominant `observed.sizes_pt` value (the most frequent run-level size, the **body proxy**) and **convert it to px (`× 4/3`)** before seeding, since the system is px-only and the source measures in pt: a source 20pt body becomes `26.67`px, so the replica renders at the source's true size (seeding the bare `20` as px would shrink it ~25% — the pt-as-px trap). Whichever source value you land on below (observed mode, or `theme.sizes.body`) gets the same `× 4/3` conversion. The confirm page (and chat fallback) then writes that px to `result.json` (`body_size`) **directly — no further conversion, no `body_size_pt` provenance** (pt never enters the contract). The "most frequent = body" read is a proxy, not a guarantee — `observed.sizes_pt` counts every explicit run size (titles, captions, footnotes, chart/label text included, no placeholder-type resolution), so a deck dense with small labels can let a caption size outrank true body; cross-check the proxy against the page's actual body blocks and the sanity range below before trusting it, and prefer the size the body paragraphs visibly render at over the raw mode when the two disagree. Fall back to `theme.sizes.body` (the declared placeholder size) when `observed.sizes_pt` is empty, and to a PPT consumption-mode baseline (`text` 20 / `balanced` 24 / `presentation` 32 px — one fixed value per mode) only when neither is present. Note `theme.sizes.body` is the master `bodyStyle` **level-1 declared default** — a coarse value that commonly **over-reads** the real body density (decks often render body at a deeper outline level or override it smaller), so when you land on this fallback treat it as an upper-ish guess and run it through the sanity check below, never as a precise body size. `theme.sizes.body_levels` and `layout_sizes_pt` are **reference context, not extra fallback tiers**: consult them to judge a saner body value when the deck is theme-driven (`observed` empty) — e.g. a deeper `body_levels` entry or a `layout_sizes_pt` hint may read truer than level-1 — but do not auto-seed from them; the seed chain stays `observed → theme.sizes.body → consumption-mode baseline`, and a theme-driven deck whose body size genuinely can't be pinned cleanly is exactly the case the sanity check is for. The canvas hint stays a **sanity range**, not the seed: if the source's own size lands far outside it (a dense source doc reads tiny on a projection canvas), surface that to the user rather than silently snapping — the replica recommendation is the source's size, the user confirms or overrides. Non-replica alternatives may use the consumption-mode baseline. This is what prevents the deck from exporting at an unintentionally small size while still honoring the source.
|
||||
- **Judge the other alternatives exactly as the from-scratch flow does — fonts as much as colors**: don't invent a beautify-specific rule. Author each non-replica candidate with the **same content-driven judgment the Strategist uses when generating from scratch** (color §e, typography §g), applied to the material this project provides — the source document's content and subject, the company's own theme colors, and any brand signal. Pick the palette **and** the font pairing by what fits *this* deck's content; fonts are chosen by content fit, not just defaulted to a safe face. Reach **≥3 meaningful candidates total**; reasonable font repetition is non-blocking, so never manufacture a different pairing just to satisfy a quota. `primary` always follows the source deck's main language; include `english` only when that language is not English.
|
||||
- **`body_size` is the load-bearing field, and the replica follows the source's own size**: seed the replica candidate's `body_size` from the source's actual body size — take the dominant `observed.sizes_pt` value (the most frequent run-level size, the **body proxy**) and **convert it to px (`× 4/3`)** before seeding, since the system is px-only and the source measures in pt: a source 20pt body becomes `26.67`px, so the replica renders at the source's true size (seeding the bare `20` as px would shrink it ~25% — the pt-as-px trap). Whichever source value you land on below (observed mode, or `theme.sizes.body`) gets the same `× 4/3` conversion. The confirm page writes that px to `result.json` (`body_size`); the chat branch retains the same px in its visible final summary. Neither path performs another conversion or adds `body_size_pt` provenance (pt never enters the contract). The "most frequent = body" read is a proxy, not a guarantee — `observed.sizes_pt` counts every explicit run size (titles, captions, footnotes, chart/label text included, no placeholder-type resolution), so a deck dense with small labels can let a caption size outrank true body; cross-check the proxy against the page's actual body blocks and the sanity range below before trusting it, and prefer the size the body paragraphs visibly render at over the raw mode when the two disagree. Fall back to `theme.sizes.body` (the declared placeholder size) when `observed.sizes_pt` is empty, and to a PPT consumption-mode baseline (`text` 20 / `balanced` 24 / `presentation` 32 px — one fixed value per mode) only when neither is present. Note `theme.sizes.body` is the master `bodyStyle` **level-1 declared default** — a coarse value that commonly **over-reads** the real body density (decks often render body at a deeper outline level or override it smaller), so when you land on this fallback treat it as an upper-ish guess and run it through the sanity check below, never as a precise body size. `theme.sizes.body_levels` and `layout_sizes_pt` are **reference context, not extra fallback tiers**: consult them to judge a saner body value when the deck is theme-driven (`observed` empty) — e.g. a deeper `body_levels` entry or a `layout_sizes_pt` hint may read truer than level-1 — but do not auto-seed from them; the seed chain stays `observed → theme.sizes.body → consumption-mode baseline`, and a theme-driven deck whose body size genuinely can't be pinned cleanly is exactly the case the sanity check is for. The canvas hint stays a **sanity range**, not the seed: if the source's own size lands far outside it (a dense source doc reads tiny on a projection canvas), surface that to the user rather than silently snapping — the replica recommendation is the source's size, the user confirms or overrides. Non-replica alternatives may use the consumption-mode baseline. This is what prevents the deck from exporting at an unintentionally small size while still honoring the source.
|
||||
|
||||
Run Generate Step 4's confirmation orchestration unchanged, including its
|
||||
pre-wait Stage-1 chat handoff.
|
||||
pre-launch surface decision and the UI branch's pre-wait Stage-1 chat handoff.
|
||||
|
||||
After the final wait returns, read `<project_path>/confirm_ui/result.json` exactly once and retain the complete confirmed object through Design Spec authoring. Always run `--shutdown` on exit (page-confirm or chat-fallback) so port 5050 is free for Step 6 live preview.
|
||||
In the UI branch, after the final wait returns, read
|
||||
`<project_path>/confirm_ui/result.json` exactly once. In the chat or delegated
|
||||
branch, retain the visible final summary instead and require no UI result. After
|
||||
any launched UI path, run `--shutdown` before Step 6; do not assume `5050`.
|
||||
|
||||
On confirmation, enter [`generate-pptx`](../generate-pptx.md) Step 4 as Strategist with the plan pre-resolved. The two beautify invariants always hold: the content-faithful clause ([`strategist.md`](../../references/strategist.md) §d Layer 1) and page count = source slide count (strict 1:1). Write the retained confirmed object completely into `design_spec.md` — `mode` (recommended `briefing`), canvas, `visual_style`, color (e) + typography (g) incl. `body_size` (the reviewed values; skip both recommendation flows) — honoring whatever the user kept or overrode. Do not reopen `result.json` afterward. §VII contains only `Page | Template | Usage` rows for selected catalog references; unmatched chart/table plans stay in their §IX page blocks. §VIII contains source pictures for re-layout.
|
||||
On confirmation, enter [`generate-pptx`](../generate-pptx.md) Step 4 as Strategist with the plan pre-resolved. The two beautify invariants always hold: the content-faithful clause ([`strategist.md`](../../references/strategist.md) §d Layer 1) and page count = source slide count (strict 1:1). Write the retained final confirmation state completely into `design_spec.md` — `mode` (recommended `briefing`), canvas, `visual_style`, color (e) + typography (g) incl. `body_size` (the reviewed values; skip both recommendation flows) — honoring whatever the user kept or overrode. Do not reopen UI evidence afterward. §VII contains only `Page | Template | Usage` rows for selected catalog references; unmatched chart/table plans stay in their §IX page blocks. §VIII contains source pictures for re-layout.
|
||||
|
||||
**Hard rule — §IX is verbatim and 1:1**: each source slide becomes exactly one page, in source order, its text transcribed word-for-word from `sources/<stem>.md`. Do not merge, split, drop, or rewrite. Complete and audit `design_spec.md` first, then author `spec_lock.md` from that Design Spec plus the source/page/template context per `strategist.md` §6 before handing off to the Executor.
|
||||
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
---
|
||||
description: Generate profile for agent-decided source and resource preparation, direct SVG authoring, and final PPTX delivery without Strategist or confirmation artifacts.
|
||||
---
|
||||
|
||||
# Quick Generate Profile
|
||||
|
||||
> Generate-PPTX profile, not a top-level route. It removes the separate
|
||||
> Strategist and confirmation phase; it does not remove the facts, resources,
|
||||
> or export capabilities needed to build the final deck.
|
||||
|
||||
**Trigger**: the user explicitly requests quick/fast generation, asks to skip
|
||||
strategy/confirmation, or directs the agent to proceed to SVG and export.
|
||||
Page count alone never activates or blocks this profile.
|
||||
|
||||
---
|
||||
|
||||
## 1. Profile Boundary
|
||||
|
||||
| Concern | Quick Generate contract |
|
||||
|---|---|
|
||||
| Interaction | The current main agent decides content, design, resources, and implementation without Strategist, Confirm UI, or approval stops |
|
||||
| Inputs | Any supported Generate input; convert/import sources and run bounded factual research when the input requires them |
|
||||
| Resources | Prepare every project-local image, icon, formula, and required provenance/manifest artifact before the referencing SVG is authored |
|
||||
| Planning artifacts | Do not create `design_spec.md`, `spec_lock.md`, confirmation payloads, or a second persisted strategy |
|
||||
| Delivery | Hand-author the resolved SVG roster, run one lockless final checker, skip `finalize_svg.py`, and export the final native PPTX through `--quick-generate` |
|
||||
|
||||
**Hard rule — speed removes interaction, not material**: all ordinary source,
|
||||
research, resource-preparation, analysis, and export capabilities remain
|
||||
available when needed; the missing planning contract relaxes design constraints
|
||||
only.
|
||||
|
||||
Explicit user facts, wording, choices, exclusions, and permission boundaries
|
||||
still win. For every unspecified routine choice, decide directly and continue;
|
||||
do not ask the user to approve a strategy or implementation detail.
|
||||
|
||||
After entry, continue through selected work, the final checker, and export.
|
||||
Pause only for user interruption or an unresolved hard prerequisite.
|
||||
|
||||
**Default — optional production behavior (may override when useful)**: Speaker
|
||||
notes, custom object animations, and narration start off. The current agent may
|
||||
enable any ordinary capability when the request or deck benefits; use its
|
||||
normal inputs, flags, and prerequisites without asking for approval. Quick
|
||||
never creates or reads a Design Spec or lock to enable it.
|
||||
|
||||
**Mandatory — discover motion before deciding whether to load it**: scan this
|
||||
compact gate once; do not load the full execution reference when the defaults
|
||||
already fit.
|
||||
|
||||
| Signal | Action |
|
||||
|---|---|
|
||||
| The same semantic object or scene continues across adjacent pages | Load [`animations.md`](../../references/animations.md) before SVG authoring; prepare both visible endpoints and use its Morph contract |
|
||||
| Page- or object-specific reveal, renewed emphasis, meaningful movement, or same-page removal clarifies the message | Load [`animations.md`](../../references/animations.md) before SVG authoring; preserve the required units/states, then run [`customize-animations`](../stages/customize-animations.md) after the final checker |
|
||||
| One deck-wide entrance policy supplies all required staged reveal | Load [`animations.md`](../../references/animations.md) before export and use an exporter flag such as `-a auto`; do not run the custom stage |
|
||||
| A directional/section boundary benefits from a non-default transition | Load [`animations.md`](../../references/animations.md) before export and select from its §3 playbook |
|
||||
| No earlier signal applies | Keep `fade` transitions and object animation `none`; do not load the motion reference |
|
||||
|
||||
This gate activates capability discovery, not motion coverage. Keep the
|
||||
defaults when no row supplies a concrete communication job. When several
|
||||
signals apply, perform every required action and use the earliest required load
|
||||
point; a before-authoring signal always overrides a before-export-only timing.
|
||||
|
||||
---
|
||||
|
||||
## 2. Source and Resource Preparation
|
||||
|
||||
Run [`generate-pptx.md`](../generate-pptx.md) Step 1 when applicable. Initialize
|
||||
the minimal workspace with:
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/project_manager.py init <project_name> \
|
||||
--format <format> --quick-generate
|
||||
```
|
||||
|
||||
It creates only `svg_output/` and no root README. Add capability inputs only
|
||||
when triggered; checker/exporter create `validation/`, `exports/`, and the
|
||||
default-path `backup/`. With source files, continue with Step 2
|
||||
`import-sources`; it creates the triggered input directories. Never scaffold a
|
||||
Design Spec or lock. Use a new path, or verify that an existing path's
|
||||
`svg_output/` is empty; Quick ignores any existing `design_spec.md` or
|
||||
`spec_lock.md`.
|
||||
|
||||
Before writing P01, resolve in active context:
|
||||
|
||||
- the slide roster, canvas, visual direction, palette, typography, and wording;
|
||||
- when useful, one transient deck-level visual motif with an identity or
|
||||
communication job, a recognizable invariant, and planned variation across
|
||||
applicable page roles; omit it when restraint serves the deck better;
|
||||
- a transient resource roster with page, filename, purpose, visual intent,
|
||||
acquisition path, crop behavior, and status. For an image/formula, include
|
||||
its page relationship plus any subject position, focus, quiet region, or
|
||||
overlay-safety cue that must exist before SVG authoring;
|
||||
- the implementation path for each resource. An explicit user path wins;
|
||||
otherwise choose the registered automatic/default path without another
|
||||
interaction.
|
||||
|
||||
Prepare only the resource paths that the roster triggers:
|
||||
|
||||
| Resource | Required preparation |
|
||||
|---|---|
|
||||
| Supplied/extracted image | Copy the selected file into `images/`; preserve its factual/provenance context and use the measured file rather than an invented substitute |
|
||||
| Bundled/custom icon | Follow the [icon library contract](../../templates/icons/README.md), resolve the selected SVG under project `icons/`, and use `icon_sync.py` for bundled icons |
|
||||
| Formula | Follow the [`latex_render.py` contract](../../scripts/docs/image.md), write `images/formula_manifest.json`, run the renderer, and keep the rendered PNG under `images/` |
|
||||
| AI image | Follow `image-base.md` + `image-generator.md`; keep `image_prompts.json` and its human-readable sidecar |
|
||||
| Web image | Follow `image-base.md` + `image-searcher.md`; keep query/status data and `image_sources.json`, including any required on-slide attribution |
|
||||
| Illustration slice | Generate or obtain the parent sheet, run `slice_images.py`, and place only the resulting element files |
|
||||
|
||||
After image resources change, run `analyze_images.py` so
|
||||
`analysis/image_analysis.csv` reflects the files that SVG authoring will use.
|
||||
Operational manifests and provenance are resource truth, not a hidden design
|
||||
strategy.
|
||||
|
||||
Every required resource must reach a usable terminal state before the
|
||||
referencing page is authored. A required `Needs-Manual` resource blocks Quick
|
||||
delivery even when an unverified candidate file exists. After a manual supply
|
||||
or replacement, validate the file/provenance and reconcile the row to
|
||||
`Generated`, `Sourced`, or `Rendered`; do not use file presence as a bypass or
|
||||
silently replace it with unrelated material.
|
||||
|
||||
---
|
||||
|
||||
## 3. Direct SVG Authoring
|
||||
|
||||
Always read
|
||||
[`shared-standards-core.md`](../../references/shared-standards-core.md),
|
||||
[`svg-effects.md`](../../references/svg-effects.md), and
|
||||
[`native-shape-authoring.md`](../../references/native-shape-authoring.md). Do
|
||||
not load `executor-base.md`: its persisted-plan prerequisites do not apply to
|
||||
this profile. For any image/formula, always read
|
||||
[`executor-image.md`](../../references/executor-image.md),
|
||||
[`image-layout-spec.md`](../../references/image-layout-spec.md),
|
||||
[`image-layout-patterns.md`](../../references/image-layout-patterns.md), and
|
||||
[`svg-image-embedding.md`](../../references/svg-image-embedding.md); add
|
||||
[`executor-web-image.md`](../../references/executor-web-image.md) for a sourced
|
||||
web image. Load [`canvas-formats.md`](../../references/canvas-formats.md) only
|
||||
for a non-default canvas.
|
||||
|
||||
Use one zero-padded filename width sized for the resolved roster, such as
|
||||
`01_cover.svg` through `12_end.svg` or `001_cover.svg` through `120_end.svg`.
|
||||
Never reuse pages from another run: the exporter publishes every SVG discovered
|
||||
under `svg_output/`.
|
||||
|
||||
**Canvas**: unless the user specifies another canvas, use `ppt169` with
|
||||
`viewBox="0 0 1280 720"`. For another requested registered format, load
|
||||
[`canvas-formats.md`](../../references/canvas-formats.md) and use its exact
|
||||
viewBox. The first SVG establishes the export canvas; every remaining page must
|
||||
match it exactly.
|
||||
|
||||
**Structure**: author flat, Slide-local SVG only. Include the complete visible
|
||||
page and all resource references in each SVG; set one root
|
||||
`data-pptx-page-role` from `cover`, `toc`, `section`, `content`, or `ending`,
|
||||
and omit Master/Layout/layer/placeholder metadata.
|
||||
|
||||
**Typography**: name an installed concrete font family in the SVG; do not depend
|
||||
on a lock or generated font asset.
|
||||
|
||||
**Generation pacing**: the current main agent hand-writes the SVG roster in
|
||||
order. Use P01 as the visual anchor and continue directly through the remaining
|
||||
pages without a first-page checker or confirmation stop. When a motif was
|
||||
resolved, reuse it selectively and vary scale, crop, density, position, or
|
||||
content interaction instead of cloning one ornament. Keep this choice only in
|
||||
active context; create no planning artifact or approval stop. After the complete
|
||||
roster exists, run the one final checker below. Apply other supporting tools and
|
||||
stages only when their capability is actually needed.
|
||||
|
||||
---
|
||||
|
||||
## 4. Export
|
||||
|
||||
After every page and required referenced resource exists, run the lockless
|
||||
final SVG check:
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> \
|
||||
--quick-generate --stage final --json
|
||||
```
|
||||
|
||||
Fix every blocking error and rerun the same command. Then export:
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path> --quick-generate
|
||||
```
|
||||
|
||||
`--quick-generate` reads `svg_output/` as the page source and resolves the
|
||||
project-local assets referenced by those SVGs. It infers one consistent canvas,
|
||||
uses a lockless flat PowerPoint package, and does not force-disable ordinary
|
||||
export options. Notes, custom object animation, and narration remain off unless
|
||||
selected by the agent. Do not run `finalize_svg.py`.
|
||||
|
||||
The exporter requires a passing `final` report whose SVG fingerprint matches
|
||||
the current `svg_output/`; missing, blocking, non-final, or stale reports stop
|
||||
before PPTX creation. The default output path retains ordinary backup and
|
||||
postflight behavior. An explicit `-o <path>.pptx` keeps the ordinary no-backup
|
||||
behavior. On failure, repair the owning SVG, resource, or optional capability
|
||||
input, rerun the final checker, then export again; do not create a Design Spec
|
||||
or lock.
|
||||
|
||||
```markdown
|
||||
## ✅ Quick Generate Complete
|
||||
|
||||
- [x] All required source/resource preparation is complete
|
||||
- [x] Resolved SVG pages and their project-local references exist
|
||||
- [x] The lockless final SVG quality report passes and matches the current SVGs
|
||||
- [x] Every selected optional export capability completed
|
||||
- [x] One native PPTX exists under `exports/` or the explicit output path
|
||||
- [x] No Strategist, confirmation, Design Spec, or lock artifact was created
|
||||
- [ ] **Next**: Report the PPTX path
|
||||
```
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
description: Test-only Generate profile for authoring a few SVG slides and exporting one PPTX without normal planning or sidecar artifacts.
|
||||
---
|
||||
|
||||
# Quick Test Profile
|
||||
|
||||
> Generate-PPTX profile, not a top-level route. Use it only for disposable
|
||||
> converter/layout tests; normal presentation delivery stays on
|
||||
> [`generate-pptx.md`](../generate-pptx.md) Steps 1–7.
|
||||
|
||||
**Trigger**: the user explicitly requests quick/fast test mode, identifies the
|
||||
deck as a test, and asks for a small fixed slide roster. A small page count alone
|
||||
never activates this profile.
|
||||
|
||||
---
|
||||
|
||||
## 1. Eligibility
|
||||
|
||||
| Condition | Required state |
|
||||
|---|---|
|
||||
| Intent | Explicit disposable test, not a presentation delivery |
|
||||
| Page roster | Small, fixed, and named or directly inferable |
|
||||
| Content | Supplied in chat and sufficient to draw without conversion or research |
|
||||
| Visual inputs | Plain SVG geometry/text, or already supplied self-contained data; no asset acquisition |
|
||||
| Output | Only authored SVG pages and one native PPTX |
|
||||
|
||||
**Missing eligibility** → use the normal Generate pipeline. Do not ask the user
|
||||
to weaken a normal delivery request so it can enter this profile.
|
||||
|
||||
**Hard rule — explicit scope only**: this profile never activates for a factual
|
||||
deck, a template-backed deck, source-file conversion, native charts/tables,
|
||||
external images/icons/fonts, speaker notes, animation, narration, visual
|
||||
review, or any reusable deliverable.
|
||||
|
||||
---
|
||||
|
||||
## 2. Minimal Authoring Contract
|
||||
|
||||
Read only [`shared-standards-core.md`](../../references/shared-standards-core.md).
|
||||
Load one of its conditional modules only when the user's exact SVG test needs
|
||||
that registered feature; otherwise keep the SVG surface to solid paint, basic
|
||||
geometry, text, and semantic groups.
|
||||
|
||||
Create only:
|
||||
|
||||
```text
|
||||
<project_path>/
|
||||
├── svg_output/
|
||||
│ └── <ordered-page>.svg
|
||||
└── exports/
|
||||
└── <project_name>_<timestamp>.pptx
|
||||
```
|
||||
|
||||
**Hard rule — no normal-pipeline artifacts**: do not run source conversion,
|
||||
`project_manager.py init`, topic research, template application, Strategist,
|
||||
Confirm UI, image/icon acquisition, Live Preview, SVG quality checker,
|
||||
speaker-note generation, `finalize_svg.py`, chart verification, animation,
|
||||
narration, or any supporting stage. Do not create `design_spec.md`,
|
||||
`spec_lock.md`, `sources/`, `analysis/`, `images/`, `icons/`, `templates/`,
|
||||
`confirm_ui/`, `notes/`, `svg_final/`, `validation/`, `backup/`, or metadata
|
||||
sidecars.
|
||||
|
||||
**Canvas**: write `viewBox="0 0 W H"` on every page. The first SVG establishes
|
||||
the test canvas; every remaining page must match it exactly.
|
||||
|
||||
**Structure**: author flat, Slide-local SVG only. Include the complete visible
|
||||
page in each SVG; set one root `data-pptx-page-role` from `cover`, `toc`,
|
||||
`section`, `content`, or `ending`, and omit Master/Layout/layer/placeholder
|
||||
metadata.
|
||||
|
||||
**Typography**: name an installed concrete font family in the SVG; do not depend
|
||||
on a lock or generated font asset.
|
||||
|
||||
**Generation pacing**: the current main agent hand-writes the fixed SVG roster
|
||||
in order. Skip the normal first-page and final checker gates.
|
||||
|
||||
---
|
||||
|
||||
## 3. Direct Export
|
||||
|
||||
Run one export command after every requested SVG exists:
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path> --quick-test
|
||||
```
|
||||
|
||||
`--quick-test` reads only `svg_output/`, infers one consistent canvas, uses a
|
||||
flat PowerPoint package with converter defaults, disables notes and motion,
|
||||
skips lock/theme sidecars, and writes no backup, conversion trace, or validation
|
||||
report. An explicit `-o <path>.pptx` may replace the default `exports/`
|
||||
destination without changing the artifact boundary.
|
||||
|
||||
**Validation**: success requires `[QUICK-TEST] status=passed`, the authored SVG
|
||||
count to equal the published Slide count, and the PPTX to pass in-memory ZIP
|
||||
integrity. On failure, repair the owning SVG and rerun this command; do not
|
||||
create planning or validation artifacts.
|
||||
|
||||
```markdown
|
||||
## ✅ Quick Test Complete
|
||||
|
||||
- [x] Requested SVG pages exist under `svg_output/`
|
||||
- [x] One native PPTX exists under `exports/` or the explicit output path
|
||||
- [x] No normal-pipeline artifacts were created
|
||||
- [ ] **Next**: Report the PPTX path
|
||||
```
|
||||
@@ -30,7 +30,7 @@ route selection. After selection, the route authority owns execution.
|
||||
|
||||
| Route | Request shape | Authority | Preconditions | Mutation model | Output contract |
|
||||
|---|---|---|---|---|---|
|
||||
| Generate PPTX | Create a new presentation; regenerate an existing deck visually; use source material or a topic; optionally apply an explicit template workspace | [`generate-pptx`](./generate-pptx.md) | Source facts exist or the topic-research stage can gather them; explicit `quick-test` supplies self-contained test content | Author new SVG pages and export a new PPTX | Default pipeline: project with `design_spec.md`, `spec_lock.md`, `svg_output/`, `validation/`, and `exports/`; explicit `quick-test`: only `svg_output/` plus one PPTX |
|
||||
| Generate PPTX | Create a new presentation; regenerate an existing deck visually; use source material or a topic; optionally apply an explicit template workspace | [`generate-pptx`](./generate-pptx.md) | Source facts exist or research can gather them; explicit quick intent activates its profile | Author new SVG pages and export a new PPTX | Default: spec, lock, SVG, validation, and PPTX; Quick: optional source/resource artifacts, no spec/lock, SVG, and one PPTX |
|
||||
| Create Template | Create a reusable brand/layout/deck template from one or more PPTX/SVG files, images/PDFs, direct or file-based text, documents/websites, brand assets, or a mixed reference bundle | [`create-template`](./create-template.md) | A reusable-template request exists; reference material is optional, and project scope additionally requires an initialized target project | Author a new portable workspace; never modify any reference file in place | Workspace with required `templates/`, optional `images/` / `icons/`, and optional review `exports/` |
|
||||
| Fill Native PPTX | Use a raw PPTX's native slide shells and replace/fill content | [`template-fill-pptx`](./template-fill-pptx.md) | Source PPTX plus new material/topic | Clone and patch PPTX through OOXML; no SVG pipeline | New filled PPTX in project `exports/` |
|
||||
| Enhance Native PPTX | Keep a finished PPTX's visible slides stable while adding notes, audio, timings, or transitions | [`native-enhance-pptx`](./native-enhance-pptx.md) | Finished source PPTX exists | Append/update scoped OOXML parts; no slide regeneration | New enhanced PPTX in project `exports/` |
|
||||
@@ -41,9 +41,9 @@ route selection. After selection, the route authority owns execution.
|
||||
|
||||
| Request condition | Generate-route behavior |
|
||||
|---|---|
|
||||
| Explicit disposable test intent + explicit quick/fast mode + small fixed roster of self-contained slides | Activate [`quick-test`](./profiles/quick-test.md) inside Generate PPTX; author SVG pages and run the test-only direct exporter without entering normal Steps 1–7 |
|
||||
| Topic only, or supplied sources leave planning-critical factual gaps | Run [`topic-research`](./stages/topic-research.md) inside [`generate-pptx`](./generate-pptx.md) Step 1: immediately for topic-only input, or after conversion and reading for source-backed input; research only the identified gaps, then continue Step 2 |
|
||||
| Existing PPTX may be split, merged, dropped, reordered, or re-outlined | Treat the PPTX as source content through [`generate-pptx`](./generate-pptx.md) Step 1 and its PPTX intake; use the default Generate pipeline |
|
||||
| Explicit quick/fast, skip-strategy, or direct SVG-to-PPTX intent | Activate [`quick-generate`](./profiles/quick-generate.md): prepare sources/resources as needed, let the current agent decide without interaction, omit Strategist/confirmation/spec/lock, hand-author SVG, run the lockless final checker, and export the final PPTX |
|
||||
| Topic only, or supplied sources leave planning-critical factual gaps | Run [`topic-research`](./stages/topic-research.md) inside the selected Generate profile's source preparation: immediately for topic-only input, or after conversion and reading for source-backed input; research only the identified gaps |
|
||||
| Existing PPTX may be split, merged, dropped, reordered, or re-outlined | Treat the PPTX as source content through [`generate-pptx`](./generate-pptx.md) Step 1 and its PPTX intake; continue the default pipeline unless explicit Quick Generate intent selected that profile |
|
||||
| Existing PPTX must preserve wording, page count, and page order 1:1 | Activate the [`beautify-pptx`](./profiles/beautify-pptx.md) profile inside the main pipeline |
|
||||
| Explicit current brand/layout/deck workspace root | Enter [`generate-pptx`](./generate-pptx.md) Step 3 and conditionally load [`apply-template-workspace`](./stages/apply-template-workspace.md); consume the workspace root, never only its inner `templates/` directory |
|
||||
| Split-mode project resumes in a fresh chat | Run [`resume-execute`](./stages/resume-execute.md) inside the active Generate route |
|
||||
@@ -51,18 +51,19 @@ route selection. After selection, the route authority owns execution.
|
||||
| User explicitly requests spec refinement | Run [`refine-spec`](./stages/refine-spec.md) after Design Spec Gate 1 and before lock Gate 2 |
|
||||
| Data charts exist | Run [`verify-charts`](./stages/verify-charts.md) before export |
|
||||
| User explicitly requests visual review | Run [`visual-review`](./stages/visual-review.md) before post-processing |
|
||||
| User requests preview, selection, or annotation application | Run [`live-preview`](./stages/live-preview.md) at the stage defined there |
|
||||
| User requests preview, selection, or annotation application | Use the default Generate pipeline and run [`live-preview`](./stages/live-preview.md) at the stage defined there; explicit Quick + preview intent falls back to default rather than dropping preview |
|
||||
| User requests page transitions, auto-advance, or deck-wide animation settings without page-specific motion planning or an existing `animations.json` | Load [`animations`](../references/animations.md) and apply its export-level contract |
|
||||
| `<project_path>/animations.json` already exists, the user explicitly requests per-slide/object-level animation control, or the effective Custom Animations outcome in `design_spec.md §I` is enabled | Run [`customize-animations`](./stages/customize-animations.md) after the final SVG quality gate and any enabled speaker-note pass, before Generate Step 7. A §IX `Motion suggestion` informs an active pass but never triggers it alone |
|
||||
| Generate PPTX receives an explicit narration request or has effective Narration Audio enabled in `design_spec.md §I`; Enhance Native PPTX has a confirmed `audio.enabled: true` module | Run [`generate-audio`](./stages/generate-audio.md) after the owning route's notes/export readiness; Generate audio implies effective Speaker Notes enabled |
|
||||
|
||||
**Hard rule — profile, not fifth route**: The 1:1 beautify behavior uses the same Strategist → Executor → SVG export lifecycle as Generate PPTX. It changes content/page invariants; it does not define a separate artifact lifecycle.
|
||||
|
||||
**Hard rule — test profile, not a release shortcut**: `quick-test` stays inside
|
||||
Generate PPTX but owns a test-only SVG → PPTX short circuit. A small page count
|
||||
alone never activates it, and any reusable, factual, source-backed,
|
||||
template-backed, asset-dependent, or package-behavior request stays on the
|
||||
normal Generate pipeline.
|
||||
**Hard rule — direct-generation profile, not a fifth route**: `quick-generate`
|
||||
stays inside Generate PPTX but owns an explicit SVG → PPTX short circuit. Page
|
||||
count alone never activates or blocks it. Conversion, bounded research, and
|
||||
project-local resources remain available. Package capabilities may be requested
|
||||
or agent-selected. Structured template reuse requires the default lock-backed
|
||||
Generate pipeline.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user