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:
@@ -33,8 +33,8 @@
|
||||
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "3b5df7547964f0cb3424de74cff55b69039250d3",
|
||||
"syncedAt": "2026-07-27T16:00:00Z"
|
||||
"commit": "4857a2c5ef989794751a0f66b8545a4a49566286",
|
||||
"syncedAt": "2026-07-28T15:59:58Z"
|
||||
},
|
||||
{
|
||||
"id": "caveman",
|
||||
@@ -60,8 +60,8 @@
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "bf906bb8aeebc64d374afb54497b822d587ac6d7",
|
||||
"syncedAt": "2026-07-27T16:00:00Z"
|
||||
"commit": "47c7f92dbc4dd22a29982986458787000c4e7bc1",
|
||||
"syncedAt": "2026-07-28T15:59:58Z"
|
||||
},
|
||||
{
|
||||
"id": "frontend-slides",
|
||||
@@ -96,8 +96,8 @@
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "5260a14f8467e52d7fc945c5efead3e2090f0f86",
|
||||
"syncedAt": "2026-07-27T16:00:00Z"
|
||||
"commit": "cbb6bf9917efb18d787e510fc41a5e9e388bbdf8",
|
||||
"syncedAt": "2026-07-28T15:59:58Z"
|
||||
},
|
||||
{
|
||||
"id": "next-skills",
|
||||
@@ -105,8 +105,8 @@
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"adapter": "skill-collection",
|
||||
"commit": "1f65c7646eb57660e4eb38899b8197346d0d93c1",
|
||||
"syncedAt": "2026-07-27T16:00:00Z"
|
||||
"commit": "ad618bf13fbc4be57d6b6136a20547af50708eb2",
|
||||
"syncedAt": "2026-07-28T15:59:58Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-07-27T16:02:46Z"
|
||||
"syncedAt": "2026-07-28T16:02:23Z"
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "next-skills",
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"commit": "1f65c7646eb57660e4eb38899b8197346d0d93c1",
|
||||
"commit": "ad618bf13fbc4be57d6b6136a20547af50708eb2",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-07-27T16:00:00Z"
|
||||
"syncedAt": "2026-07-28T15:59:58Z"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
English | [中文](./README_CN.md)
|
||||
|
||||
<details open>
|
||||
<summary>This project is kept free and open source with the support of <a href="https://www.kimi.com/code/?aff=ppt-master">Kimi</a>, <a href="https://www.packyapi.com/register?aff=ppt-master">PackyCode</a>, <a href="https://apikey.fun/register?aff=PPT-MASTER">APIKEY.FUN</a>, <a href="https://runapi.co/register?aff=WMLJ">RunAPI</a>, <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624">YouYun ZhiSuan</a> and other sponsors.</summary>
|
||||
<summary>This project is kept free and open source with the support of <a href="https://www.kimi.com/code/?aff=ppt-master">Kimi</a>, <a href="https://www.packyapi.ai/register?aff=ppt-master">PackyCode</a>, <a href="https://apikey.fun/register?aff=PPT-MASTER">APIKEY.FUN</a>, <a href="https://runapi.co/register?aff=WMLJ">RunAPI</a>, <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY-git_pptmaster0624">YouYun ZhiSuan</a> and other sponsors.</summary>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.kimi.com/code/?aff=ppt-master"><img src="https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png" alt="Kimi" width="100%"></a>
|
||||
@@ -27,8 +27,8 @@ Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring this
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=ppt-master"><img src="docs/assets/sponsors/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our project users: register using <a href="https://www.packyapi.com/register?aff=ppt-master">this link</a> and enter the promo code <strong>ppt-master</strong> during recharge to get 10% off.</td>
|
||||
<td width="180"><a href="https://www.packyapi.ai/register?aff=ppt-master"><img src="docs/assets/sponsors/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our project users: register using <a href="https://www.packyapi.ai/register?aff=ppt-master">this link</a> and enter the promo code <strong>ppt-master</strong> during recharge to get 10% off.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikey.fun/register?aff=PPT-MASTER"><img src="docs/assets/sponsors/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
@@ -235,7 +235,7 @@ Never used one of these? Don't worry — in this project they play exactly one r
|
||||
|
||||
> **Model recommendation**: for the best results, use **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)** (or Claude) to drive the pipeline, paired with AI image generation — **`gpt-image-2`** (OpenAI) or **`gemini-3.1-flash-image`** (Google). Kimi Code, the project sponsor, is a great pick for pay-as-you-go access.
|
||||
|
||||
**🔑 Want to use Claude / GPT / Gemini but don't have access yet?** Project sponsors **[PackyCode](https://www.packyapi.com/register?aff=ppt-master)**, **[APIKEY.FUN](https://apikey.fun/register?aff=PPT-MASTER)** and **[RunAPI](https://runapi.co/register?aff=WMLJ)** offer pay-as-you-go access to Claude, GPT, Gemini and more — no subscription required, with exclusive discounts for our users (details at the top of this page).
|
||||
**🔑 Want to use Claude / GPT / Gemini but don't have access yet?** Project sponsors **[PackyCode](https://www.packyapi.ai/register?aff=ppt-master)**, **[APIKEY.FUN](https://apikey.fun/register?aff=PPT-MASTER)** and **[RunAPI](https://runapi.co/register?aff=WMLJ)** offer pay-as-you-go access to Claude, GPT, Gemini and more — no subscription required, with exclusive discounts for our users (details at the top of this page).
|
||||
|
||||
**🔀 Juggling several providers?** Once you hold keys from more than one of them, [cc-switch](https://github.com/farion1231/cc-switch) — a cross-platform desktop app — lets you one-click switch API providers for Claude Code, Codex, Gemini CLI and more, no manual config editing.
|
||||
|
||||
@@ -399,7 +399,7 @@ PPT Master is currently built and maintained primarily by me. Every new template
|
||||
|
||||
<a href="https://www.kimi.com/code/?aff=ppt-master"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/assets/sponsors/kimi-dark.svg"><img src="docs/assets/sponsors/kimi-light.svg" alt="Kimi" height="40" /></picture></a>
|
||||
|
||||
<a href="https://www.packyapi.com/register?aff=ppt-master"><img src="docs/assets/sponsors/packycode.png" alt="PackyCode" height="40" /></a>
|
||||
<a href="https://www.packyapi.ai/register?aff=ppt-master"><img src="docs/assets/sponsors/packycode.png" alt="PackyCode" height="40" /></a>
|
||||
|
||||
<a href="https://apikey.fun/register?aff=PPT-MASTER"><img src="docs/assets/sponsors/apikey-fun.png" alt="APIKEY.FUN" height="40" /></a>
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "5260a14f8467e52d7fc945c5efead3e2090f0f86",
|
||||
"commit": "cbb6bf9917efb18d787e510fc41a5e9e388bbdf8",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/ppt-master",
|
||||
"syncedAt": "2026-07-27T16:00:00Z"
|
||||
"syncedAt": "2026-07-28T15:59:58Z"
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring PPT M
|
||||
|
||||
### PackyCode
|
||||
|
||||
<a href="https://www.packyapi.com/register?aff=ppt-master"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/packycode.png" alt="PackyCode" width="150"></a>
|
||||
<a href="https://www.packyapi.ai/register?aff=ppt-master"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/packycode.png" alt="PackyCode" width="150"></a>
|
||||
|
||||
[PackyCode](https://www.packyapi.com/register?aff=ppt-master) provides relay access to Claude Code, Codex, Gemini, and other services. Register through the dedicated link and enter the promo code **`ppt-master`** during recharge to receive 10% off.
|
||||
[PackyCode](https://www.packyapi.ai/register?aff=ppt-master) provides relay access to Claude Code, Codex, Gemini, and other services. Register through the dedicated link and enter the promo code **`ppt-master`** during recharge to receive 10% off.
|
||||
|
||||
### APIKEY.FUN
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ PPT Master 始终免费开源。以下赞助方共同支持项目的持续维护
|
||||
|
||||
### PackyCode
|
||||
|
||||
<a href="https://www.packyapi.com/register?aff=ppt-master"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/packycode.png" alt="PackyCode" width="150"></a>
|
||||
<a href="https://www.packyapi.ai/register?aff=ppt-master"><img src="https://raw.githubusercontent.com/hugohe3/ppt-master/main/docs/assets/sponsors/packycode.png" alt="PackyCode" width="150"></a>
|
||||
|
||||
[PackyCode](https://www.packyapi.com/register?aff=ppt-master) 提供 Claude Code、Codex、Gemini 等服务的中转接入。通过专属链接注册,并在充值时填写优惠码 **`ppt-master`**,即可享受 9 折优惠。
|
||||
[PackyCode](https://www.packyapi.ai/register?aff=ppt-master) 提供 Claude Code、Codex、Gemini 等服务的中转接入。通过专属链接注册,并在充值时填写优惠码 **`ppt-master`**,即可享受 9 折优惠。
|
||||
|
||||
### APIKEY.FUN
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Page Transitions & Per-Element Animations
|
||||
|
||||
Execution contract for generated-PPTX **page transitions** and **per-element
|
||||
object animations**. This file owns defaults, sidecar semantics, anchor
|
||||
selection, validation, and package read-back.
|
||||
object animations**, including deterministic Morph object pairing. This file
|
||||
owns defaults, sidecar semantics, anchor selection, validation, and package
|
||||
read-back.
|
||||
|
||||
## Capability Menu — Open Here
|
||||
|
||||
@@ -13,19 +14,19 @@ before the page plan is frozen, not only when a deck is already exported.
|
||||
| What the deck needs | Reach for | Decided at |
|
||||
|---|---|---|
|
||||
| Reveal content in step with the narration | Per-element object animation — `-a auto` deck-wide, or an `animations.json` sidecar for specific order, effects, timing, and triggers | Post-processing; §2, §4, [`customize-animations`](../workflows/stages/customize-animations.md) |
|
||||
| A continuous action — slide-in, flip, camera push-in, progressive reveal, camera pan | **Morph: author the action as two static pages plus `-t morph`.** There is no keyframe timeline anywhere in this pipeline; the difference between two ordinary editable slides *is* the animation | **Page authoring (Step 6)** — §3.1 |
|
||||
| A continuous action — slide-in, flip, camera push-in, progressive reveal, camera pan | **Morph: author the action as two static pages, then select Morph and add explicit pairs when identity must be deterministic.** There is no keyframe timeline anywhere in this pipeline; the difference between two ordinary editable slides *is* the animation | **Page authoring (Step 6), then motion post-processing** — §2.1, §3.1 |
|
||||
| A static full-bleed page that should stop looking frozen | One slow `path_*` motion on the background group only, `with-previous`, 4–10 s | Post-processing; §4.1, one sidecar entry |
|
||||
| Carousel, counting numerals, parallax depth, click-to-reveal flip card | Four recurring recipes assembled from the mechanisms above | §4.2 — the carousel and odometer both need paired pages |
|
||||
| Kiosk or unattended playback | `--auto-advance <seconds>`, optionally with `-t none` | Export; §3 |
|
||||
| Nothing should move | `-t none`, and leave per-element animation at its default `none` | Export; §1 |
|
||||
|
||||
**Hard rule — morph is an authoring decision, not an export flag**: `-t morph`
|
||||
tweens only objects it can match across consecutive slides, and matching is by
|
||||
object identity — same image filename, same group `id`, unchanged container
|
||||
dimensions. A deck that reaches export without paired pages cannot gain morph
|
||||
motion by adding the flag; it degrades silently to a cross-fade. Resolve this
|
||||
while `svg_output/` is still being authored, or accept that the sequence stays
|
||||
static.
|
||||
**Hard rule — Morph geometry is an authoring decision; pairing is a later
|
||||
execution decision**: export cannot invent the two visible endpoint states.
|
||||
Author both consecutive pages while `svg_output/` is still being built. For
|
||||
deterministic identity, expose each endpoint as a compatible direct-root group
|
||||
and declare the pair in `animations.json` (§2.1); the source and destination ids
|
||||
and geometry may differ. `-t morph` without explicit pairs leaves matching to
|
||||
PowerPoint's heuristic and is not proof that the intended objects will tween.
|
||||
|
||||
**Reference — not a constraint**: per-element animation stays off by default
|
||||
(§1). Auto-firing element builds on every page are an unsolicited "AI deck"
|
||||
@@ -48,11 +49,15 @@ To regenerate a deck with different settings, rerun `svg_to_pptx.py` against the
|
||||
|
||||
Per-element animation is off by default. To enable it deck-wide, pass `-a auto` at export (no config needed). When a deck instead needs specific object timing — for example title first, chart second, annotation last — use the optional `animations.json` sidecar. The SVG remains the visual source; the custom stage may rewrite its grouping hierarchy, ids, and bounds to create better semantic anchors without changing visible output, while the sidecar controls PPTX animation behavior.
|
||||
|
||||
Run the [`customize-animations`](../workflows/stages/customize-animations.md) post-processing stage when the user asks to tune animation order, effects, timing, or object-level reveals.
|
||||
Run the [`customize-animations`](../workflows/stages/customize-animations.md)
|
||||
post-processing stage when Design Spec §IX contains `Motion suggestion`, or
|
||||
when the project already carries `animations.json`, or when the user asks to
|
||||
tune animation order, effects, timing, or object-level reveals.
|
||||
|
||||
**Hard rule — semantic anchors before sidecar**: derive reveal units from page
|
||||
meaning and narration, then regroup coarse/fragmented Slide-local content
|
||||
without changing its appearance. Only post-regroup top-level ids are valid.
|
||||
**Hard rule — semantic anchors before object-targeted sidecar entries**: when
|
||||
object animation is in scope, derive reveal units from page meaning and
|
||||
narration, then regroup coarse/fragmented Slide-local content without changing
|
||||
its appearance. Only post-regroup top-level ids are valid object targets.
|
||||
|
||||
```bash
|
||||
# Inspect the real anchors after the semantic regrouping pass
|
||||
@@ -114,7 +119,8 @@ Rules:
|
||||
| `direction` | Directional Fly/Crawl/Wipe/Peek/Strips/Split/Stretch/Zoom and related entrance/exit effects |
|
||||
| `amount` | Wheel spokes (`1`, `2`, `3`, `4`, `8`), emphasis Spin degrees, or Transparency ratio |
|
||||
| `color` | Color-capable emphasis effects; `#RRGGBB` or `theme:<scheme-color>` |
|
||||
| `font_name`, `size` | Change Font and Grow/Shrink |
|
||||
| `font_name` | Change Font; required for `emphasis_change_font`; one installed PowerPoint face, not a CSS list |
|
||||
| `size` | Grow/Shrink |
|
||||
| `relative` | Motion paths (`true` = shape-relative, `false` = fixed slide path) |
|
||||
- Any animation/group block may set `repeat_count` or `repeat_duration`
|
||||
(mutually exclusive), `auto_reverse`, `rewind`, `accelerate`, `decelerate`,
|
||||
@@ -138,11 +144,80 @@ Rules:
|
||||
- Unknown effects, modes, or triggers and invalid numeric/order fields fail validation; no fallback effect is substituted.
|
||||
|
||||
**Inheritance**: the sidecar is optional. Sparse legacy slides inherit
|
||||
`defaults.transition` / `defaults.animation`, then CLI resolution; explicit CLI
|
||||
flags win. Groups inherit the resolved slide duration, timing modifiers,
|
||||
after-effect, and sound. `effect_options` remains coupled to an explicit effect;
|
||||
`trigger_shape` is never inherited; omitted `order`/`delay` use exporter
|
||||
defaults. New authoring writes complete slide blocks.
|
||||
`defaults.transition` / `defaults.animation`, then CLI resolution. Explicit CLI
|
||||
flags override the corresponding sidecar default/slide fields; explicit group
|
||||
overrides remain unless `-a none` hard-disables all object motion. Groups
|
||||
inherit the resolved slide duration, timing modifiers, after-effect, and sound.
|
||||
`effect_options` remains coupled to an explicit effect; `trigger_shape` is
|
||||
never inherited; omitted `order`/`delay` use exporter defaults. New authoring
|
||||
writes complete slide blocks.
|
||||
|
||||
### 2.1 Deterministic Morph Object Pairing
|
||||
|
||||
When one semantic object continues across two adjacent slides, the destination
|
||||
slide may declare explicit forced-Morph pairs. This is separate from `groups`:
|
||||
Morph owns cross-slide identity, while `groups` owns Animation Pane rows.
|
||||
The generated names follow Microsoft's
|
||||
[forced object-matching convention](https://support.microsoft.com/en-us/powerpoint/morph-transition-tips-and-tricks).
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"defaults": {
|
||||
"transition": { "effect": "fade", "duration": 0.4 },
|
||||
"animation": { "effect": "none", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" }
|
||||
},
|
||||
"slides": {
|
||||
"01_overview": {
|
||||
"transition": { "effect": "fade", "duration": 0.4 },
|
||||
"animation": { "effect": "none", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" }
|
||||
},
|
||||
"02_detail": {
|
||||
"transition": {
|
||||
"effect": "morph",
|
||||
"effect_options": { "morph_by": "object" },
|
||||
"duration": 0.8
|
||||
},
|
||||
"animation": { "effect": "none", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" },
|
||||
"morph": {
|
||||
"from": "01_overview",
|
||||
"pairs": {
|
||||
"hero-image": {
|
||||
"from": "hero-overview",
|
||||
"to": "hero-detail"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `morph` belongs to the destination slide. `morph.from` must be the
|
||||
immediately preceding SVG stem in export order.
|
||||
- `animation_config.py scaffold` never guesses cross-slide identity. Add pairs
|
||||
from the semantic motion plan after inspecting the final direct-root ids.
|
||||
- Each `pairs` key is a stable identity; its `from` and `to` values are unique
|
||||
direct-root `<g id>` values on the source and destination slides. Supply the
|
||||
key without `!!`; export writes the PowerPoint Selection Pane name
|
||||
`!!<key>` on both objects.
|
||||
- A destination with explicit pairs must explicitly set `effect: morph`.
|
||||
`morph_by` may be omitted for its `object` default or set to `object`;
|
||||
`word`/`character` are rejected. A CLI transition override that changes the
|
||||
resolved effect fails export.
|
||||
- A middle slide may continue the same object into another Morph transition,
|
||||
but the same group must retain the same key. One key cannot name two objects
|
||||
on one slide, and one object cannot carry two keys. Every `!!` key shared by
|
||||
two adjacent Morph pages must be declared in that destination's `pairs`;
|
||||
undeclared forced matches are rejected.
|
||||
- Explicit pairing can coexist with in-slide object animation and remains
|
||||
active when `-a none` disables Animation Pane rows. `--no-animations`
|
||||
disables the sidecar and all page/object motion.
|
||||
- The exporter resolves both group ids to final Slide-local PowerPoint shapes,
|
||||
writes names only after Master/Layout processing, then reopens the package
|
||||
and verifies adjacency, Morph by object, one name per slide, and matching
|
||||
OOXML object types. Missing, structural, moved, ambiguous, or mismatched
|
||||
targets fail instead of falling back to automatic Morph matching.
|
||||
|
||||
---
|
||||
|
||||
@@ -193,7 +268,7 @@ Flags:
|
||||
|
||||
### 3.1 Morph — author an action as the difference between two pages
|
||||
|
||||
Morph tweens objects it can match across consecutive slides. That makes it a general mechanism, not just a transition: **any continuous action can be authored as two static pages plus `-t morph`**, with no keyframe timeline anywhere. Duplicate the page, change one property on one object, and PowerPoint interpolates the rest.
|
||||
Morph tweens objects it can match across consecutive slides. That makes it a general mechanism, not just a transition: **any continuous action can be authored as two static pages plus a Morph transition**, with no keyframe timeline anywhere. Duplicate the page, change one property on one object, and PowerPoint interpolates the rest. Use §2.1 explicit pairs when the match must be deterministic.
|
||||
|
||||
| Change between the two pages | Reads as |
|
||||
|---|---|
|
||||
@@ -205,11 +280,22 @@ Morph tweens objects it can match across consecutive slides. That makes it a gen
|
||||
|
||||
Chain three or more pages to build a sequence — extend, hold, retract — where each page is still an ordinary editable slide.
|
||||
|
||||
**Hard rule — matching is by object identity**: keep the same image filename, the same group `id`, and container dimensions that do not change between the pages. Rename the file or resize the frame and morph silently degrades to a cross-fade with none of the motion. This is the most common reason a morph sequence "does nothing".
|
||||
**Hard rule — matching needs compatible object identity, not identical SVG
|
||||
geometry**: for generated decks, prefer §2.1 deterministic pairs. The source
|
||||
and destination direct-root group ids may differ, and position, size, crop, or
|
||||
other visible state is expected to change; both endpoints must still resolve to
|
||||
one compatible top-level PowerPoint object kind. Automatic Morph without pairs
|
||||
is heuristic and may cross-fade instead of tweening.
|
||||
|
||||
**Give text somewhere to come from.** Morph tweens objects present on both pages; text that only exists on the second page can only fade in. The standard fix, used in essentially every morph-driven deck: place the *next* page's copy on the current page just outside the canvas (below), and the *previous* page's copy just outside the opposite edge (above). Each block then slides through the frame instead of blinking, and the deck reads as one continuous surface being scrolled. Objects parked outside the canvas are not rendered but must still exist on both pages with the same identity.
|
||||
**Give text somewhere to come from.** Morph tweens objects present on both pages; text that only exists on the second page can only fade in. The standard fix is to place the *next* page's copy on the current page just outside the canvas (below), and the *previous* page's copy just outside the opposite edge (above). Each block then slides through the frame instead of blinking, and the deck reads as one continuous surface being scrolled. Objects parked outside the canvas are not rendered but must still exist on both pages and be explicitly paired when deterministic identity matters.
|
||||
|
||||
**When morph refuses to match**: PowerPoint pairs objects of the same kind first, so two different shape types, or a shape and a picture, will cross-fade instead of tweening. Authored decks force the pairing by giving both objects an identical custom shape name. The exporter does read a per-object name — `data-pptx-shape-name` on the wrapper — but that attribute is currently specified as **importer metadata** for mirror/preserve packages ([`svg-effects.md`](./svg-effects.md) §6.6), not as an authoring control for generated pages. Until that contract is widened, keep morph pairs the same object kind with matching geometry and `id`, and do not introduce the attribute on generated pages to force a match.
|
||||
**When Morph refuses to match**: PowerPoint pairs compatible object kinds; a
|
||||
shape and a picture will cross-fade instead of tweening. For generated pages,
|
||||
declare the identity through the destination slide's `morph` block (§2.1).
|
||||
The exporter writes the shared `!!<key>` name after structure processing and
|
||||
reads the package back. Do not author `data-pptx-shape-name` for this purpose;
|
||||
that attribute remains importer metadata for mirror/preserve packages
|
||||
([`svg-effects.md`](./svg-effects.md) §6.6).
|
||||
|
||||
**Not supported — Slide Zoom / Summary Zoom.** Click-to-jump navigation built on PowerPoint's Zoom objects (the "click a portrait, zoom into that section" pattern) has no exporter path. Build click-driven navigation with `trigger_shape` on ordinary object animations instead, or with plain hyperlinks.
|
||||
|
||||
@@ -301,9 +387,9 @@ It pairs naturally with a fixed foreground: with image-layout-patterns `#90`, th
|
||||
Four combinations that recur constantly in authored decks. Each is built from
|
||||
mechanisms already defined above — none needs a new capability.
|
||||
|
||||
**Carousel** (morph, §3.1) — hold a fixed row of card frames and rotate the *content* through them: on each page every image advances one position, so the card at centre changes while the frames stay put. Morph then slides the images between frames and the row appears to scroll. Requires identical frame geometry and `id`s on every page; only the image assignments change. Scales to any number of images with one page each.
|
||||
**Carousel** (Morph, §2.1 and §3.1) — hold a fixed row of card frames and rotate the *content* through them: on each page every image advances one position, so the card at centre changes while the frames stay put. Explicitly pair each moving content unit across adjacent pages; the fixed frames stay static and need no pair. Scales to any number of images with one page each.
|
||||
|
||||
**Odometer / counting numerals** (morph or motion path) — build a vertical strip of digits 0–9 and show one through a fixed window: a masked opening, or a background-filled rectangle above and below ([`image-layout-patterns.md`](./image-layout-patterns.md) `#95`). Shift the strip so the target digit lands in the window, then either morph between two pages or run a `path_up` motion on the strip. Give each digit column a 0.1 s stagger so they settle in sequence rather than in lockstep.
|
||||
**Odometer / counting numerals** (morph or motion path) — build a vertical strip of digits 0–9 and show one through a fixed window formed by background-filled rectangles above and below ([`image-layout-patterns.md`](./image-layout-patterns.md) `#95`). Shift the strip so the target digit lands in the window, then either morph between two pages or run a `path_up` motion on the strip. Give each digit column a 0.1 s stagger so they settle in sequence rather than in lockstep.
|
||||
|
||||
**Parallax depth** (morph) — move a background layer a *short* distance and a foreground layer a longer one between two pages. The differing travel is read as depth. Keep both layers' z-order identical on both pages; a layer that changes stacking between pages breaks the tween and the transition jumps.
|
||||
|
||||
@@ -350,6 +436,9 @@ Generated export reads each slide's timing tree back and checks row count/order,
|
||||
trigger, trigger shape, shape target, preset class, resolved effect tuple, native behavior
|
||||
signature, duration, and timeline offset. Package validation then checks root
|
||||
timing placement, unique and valid `p:cTn` ids, and every `p:spTgt` reference.
|
||||
Deterministic Morph additionally checks the final adjacent slide parts for the
|
||||
requested `!!` names, one-to-one uniqueness, compatible object types, and a
|
||||
real Morph-by-object transition on the destination.
|
||||
The writer does not emit `p:bldP` for groups or pictures. Direct-PPTX preserve
|
||||
mode tolerates unchanged legacy group/picture `p:bldP` rows from earlier PPT
|
||||
Master exports; new generated packages remain strict.
|
||||
|
||||
@@ -10,7 +10,7 @@ Global artifact ownership rules for PPT Master projects.
|
||||
|
||||
| Artifact | Owner | Role | Read/write contract |
|
||||
|---|---|---|---|
|
||||
| `sources/` content-type files | Content contract | Main pipeline factual/text origin for tables, chart data values, SmartArt node wording, and presentation content | Strategist reads content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`), judges by content, and resolves approved on-slide wording into §IX. Executor opens source passages only for explicit verification/resolution; do not replace values with PPTX geometry JSON in the main pipeline. |
|
||||
| `sources/` content-type files | Content contract | Main pipeline factual/text origin for tables, chart data values, SmartArt node wording, and presentation content | Strategist reads content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`), judges by content, and resolves approved semantic content plus complete preferred on-slide wording into §IX. Executor opens source passages only for explicit verification/resolution; do not replace values with PPTX geometry JSON in the main pipeline. |
|
||||
| `sources/*.facts.json` | Fact provenance contract | Stable external `fact_id` → claim/source mapping created by topic research | Strategist cites IDs in §IX; Executor resolves them for visible footnotes / natural notes attribution. Scenario data never enters this file. |
|
||||
| `sources/` converted-source originals | Source archive | Imported source files that have a converted content contract (`.pdf` / `.pptx` / `.docx` / `.xlsx` / `.html` / `.epub` / `.tex` / `.rst` / `.ipynb` / `.typ`, etc.) and source-adjacent extracted assets | Read via the converted `<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 |
|
||||
@@ -43,7 +43,7 @@ Global artifact ownership rules for PPT Master projects.
|
||||
| `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 |
|
||||
| `animations.json` | Optional animation config | Object-level animation sidecar | Created only by explicit animation workflow/request |
|
||||
| `animations.json` | Optional animation config | Page-transition and object-animation sidecar | Created only when the conditional animation workflow activates; normal export never creates it |
|
||||
|
||||
---
|
||||
|
||||
@@ -51,7 +51,7 @@ Global artifact ownership rules for PPT Master projects.
|
||||
|
||||
| Invariant | Rule |
|
||||
|---|---|
|
||||
| Content authority | Content-type files in `sources/` (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) own the factual/text origin for main-pipeline content, tables, chart values, and SmartArt node wording; Strategist resolves approved on-slide wording into §IX. Executor renders §IX and opens sources only for explicit verification/resolution, never to draft a second outline. `slide_library.json` does not own content values. |
|
||||
| Content authority | Content-type files in `sources/` (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) own the factual/text origin for main-pipeline content, tables, chart values, and SmartArt node wording; Strategist resolves approved semantic content plus complete preferred on-slide wording into §IX. Executor realizes §IX under [`executor-base.md`](./executor-base.md) §2.1's content-vs-expression contract and opens sources only for explicit verification/resolution, never to draft a second outline. `slide_library.json` does not own content values. |
|
||||
| Sources read policy | In `sources/`, read content-type files (`.md` / `.markdown` / `.txt` / `.csv` / `.tsv` / `.json` / `.jsonl` / `.yaml` / `.yml`) and judge by content — a `.json` / `.csv` may be core content or just data. Exclude known sidecars: `*.conversion_profile.json` and `*_files/image_manifest.json`. `analysis/` facts (`source_profile.json`, `<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. |
|
||||
|
||||
@@ -35,12 +35,12 @@ Always-loaded Executor authority for flat SVG page authoring and behavior shared
|
||||
| Elevation | shadow, glow, explicit highlights |
|
||||
| Image integration | scrim, vignette, brand wash, clipping, faux glass |
|
||||
| Line / type | dash/cap/join, markers, gradient stroke; tracking, outline, alpha/gradient text |
|
||||
| Space / constructed style | transform/reuse, curves/arcs, hand-drawn, ink/Riso, halftone, isometric, paper cut |
|
||||
| Space / constructed style | transform/reuse, hand-drawn, ink/Riso, halftone, isometric, paper cut; custom curves/arcs only when meaning or the locked style requires them |
|
||||
| Continuous action across pages | Paired pages that differ in one property, exported with morph |
|
||||
|
||||
**Hard rule — discovery does not expand compatibility**: Follow `svg-effects.md` syntax and fallbacks; unsupported blur, blend, mask, dense texture, or skew remains baked/alternative-only.
|
||||
|
||||
**Default — resolve cross-page motion here, while pages are still being authored (may override when the deck has no continuous action to express)**: object animation and page transitions are post-processing decisions, but morph is not. It tweens only objects it can match across consecutive slides — same image filename, same group `id`, unchanged container dimensions — so a sequence that should read as one continuous action (slide-in, flip, camera push-in, progressive reveal, camera pan) must be authored as paired pages in `svg_output/` now. A deck that reaches export without them cannot gain the motion by adding a flag; it degrades silently to a cross-fade. Adding pages is a §IX roster change and returns to Strategist for Design Spec repair first.
|
||||
**Default — resolve cross-page geometry here, while pages are still being authored (may override when the deck has no continuous action to express)**: object effects, page transitions, and Morph pair keys are post-processing decisions, but the two visible endpoint states are not. A sequence that should read as one continuous action (slide-in, flip, camera push-in, progressive reveal, camera pan) must be authored as consecutive pages in `svg_output/` now. Give each continuing endpoint a compatible direct-root group; source and destination ids or geometry may differ because the later motion stage can bind them explicitly through `animations.json`. A deck that reaches export without both states cannot gain the motion by adding a flag. Adding pages is a §IX roster change and returns to Strategist for Design Spec repair first.
|
||||
|
||||
---
|
||||
|
||||
@@ -66,7 +66,9 @@ Consume stdout directly; stop on non-zero exit. The projection is derived, not a
|
||||
|
||||
**Hard rule — exact page roster**: `design_spec.md §IX` is the ordered queue: one final slide per entry, with the same id/order. The UI range no longer applies. Never add, drop, merge, split, or reorder; repair/reconfirm the Design Spec first.
|
||||
|
||||
**Hard rule — selection vs realization**: use Strategist-selected content, resources/paths, chart/layout keys, core fonts, palette anchors, icons, and crop boundaries. Adapt realization, never selection, except sparse local font/color garnish allowed below. Missing or unresolved material stops execution and returns to Strategist-owned acquisition/failure recovery; never search, generate, download, sync, invent, or substitute it. Selection changes require upstream repair.
|
||||
**Hard rule — binding selection vs realization**: use Strategist-selected semantic content, resources/paths, chart keys, template/layout routing keys, core fonts, palette anchors, icons, and crop boundaries. Adapt realization, never those binding selections, except sparse local font/color garnish allowed below. A §VIII preferred image pattern is not a template/layout routing key; [`executor-image.md`](./executor-image.md) owns its realization freedom. Missing or unresolved material stops execution and returns to Strategist-owned acquisition/failure recovery; never search, generate, download, sync, invent, or substitute it. Binding selection changes require upstream repair.
|
||||
|
||||
**Hard rule — content vs expression**: `design_spec.md §IX` owns each page's semantic content and supplies complete preferred wording and block texture; those expression choices are not verbatim requirements unless explicitly literal. Executor may paraphrase, condense repetition, regroup or reorder material within the same page, and switch among prose, bullets, keywords, labels, or visual annotation when fit or readability benefits. The result must remain information-equivalent: preserve the `Core message`, `Audience move`, and every substantive claim, fact, data value, proper name, qualifier or caveat, relationship, key argument or evidence, and literal requirement. Never add a claim, move content across pages, or drop information to make the layout fit; return an unfit or underspecified block for Design Spec repair.
|
||||
|
||||
Use named lock roles literally when that role applies, and use optional `Template Application` from the retained Design Spec. Choose contextual page-local values from the Design Spec, style, content, and current composition rather than forcing every object into a lock row. A page-context delta overrides neither facts nor constraints. Deprecated `page-context --bundle` is a compatibility no-op.
|
||||
|
||||
@@ -82,12 +84,12 @@ Use named lock roles literally when that role applies, and use optional `Templat
|
||||
| `balanced` | Keep the primary claim and its evidence on the page; let notes add interpretation and transitions. Mix prose, structured evidence, and necessary lists according to their semantic relationship. |
|
||||
| `presentation` | Make one claim and one dominant visual expression legible at projection distance. Keep visible copy concise; put explanation and transitions in notes instead of creating paragraph dumps or compressed bullet prose. |
|
||||
|
||||
§IX and sourced facts remain authoritative. Use its wording when it works; adapt it when presentation benefits while preserving intent, necessary content, and explicit literal requirements. Never drop or invent facts to force a mode. When the authored texture materially conflicts with the lock, render the least-destructive faithful composition and surface `warning: P<NN> content texture conflicts with consumption_mode <value>` as an upstream outline issue; do not encode this subjective judgment in the checker.
|
||||
Apply the content-vs-expression contract above within the selected reading mode. Never drop or invent facts to force a mode. When the authored texture materially conflicts with the lock, render the least-destructive faithful composition and surface `warning: P<NN> content texture conflicts with consumption_mode <value>` as an upstream outline issue; do not encode this subjective judgment in the checker.
|
||||
|
||||
**Per-block expression**: render each `design_spec.md §IX Content` block in its written texture — a full-sentence block as wrapped prose, a fragment/label block as bullets/keywords. **Never split a full-sentence block into a bullet list** — splitting loses the information that the block was continuous reasoning, not a set of parallel points; not because a bullet lays out easier, and not because an inherited template slot is shaped as a list. If a block carries no clear texture, infer the mode from its wording and the page layout.
|
||||
**Default — authored texture (may override when information-equivalent)**: start from each `design_spec.md §IX Content` block's written texture because it is the Strategist's recommended expression. Keep prose when its continuity carries causal, argumentative, narrative, qualification, or emphasis relationships; use bullets or keywords when the material is genuinely parallel or ordered, or another information-equivalent structure is clearer. Never convert solely because a list is easier to lay out or an inherited template exposes a list slot.
|
||||
|
||||
- **Hard rule — one paragraph, one text frame**: use one `<text>` per prose paragraph, never one sibling `<text>` per visual line. Keep the first line as direct text; each later wrap is a direct `<tspan>` that repeats the parent `x`, keeps its effective font size, and uses one positive relative `dy`. An all-`<tspan>` form may start with `dy="0"`. Choose consistent positive line spacing from the typeface, size, density, and reading distance; no fixed ratio overrides legibility or the selected style.
|
||||
- **Template precedence**: when an inherited template slot is a bullet list but the §IX block is prose, the prose wins — widen or reflow the container to hold the paragraph, or drop that card; do not pour the sentence back into the list slot.
|
||||
- **Template precedence**: an inherited slot never overrides the content relationship. If faithful expression needs prose, widen or reflow the container, or drop that card; never convert solely to fill a list slot.
|
||||
- **Mode precedence**: the locked mode shapes voice / register, not §IX's authored titles or page order. When a `§IX` title is a user-authored topic label, keep it — do not upgrade it to an assertion just because the mode (e.g. `pyramid`) favors them; mode title-tendencies apply only to AI-drafted titles.
|
||||
|
||||
> Note: block-level phrasing, applied *within* the page's `page_rhythm` density (below), not against it.
|
||||
@@ -143,55 +145,80 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P<NN>`
|
||||
- **Fact provenance**: when a §IX page lists `Fact IDs`, resolve each ID from `sources/*.facts.json` and keep the claim/value unchanged. Render a compact source footnote using the source name and a short URL/domain when space permits; state the attribution naturally in speaker notes. When §IX says `Data class: scenario`, place a visible localized `Scenario data` / `情景数据` label adjacent to the affected KPI/chart and state naturally in notes that the number is illustrative. Never attach an external fact ID to scenario data or let an unlabeled invented KPI look factual.
|
||||
- **Default — stage each page with the style's composition geometry (may override when the content genuinely calls for a plain grid)**: an SVG page is a canvas, not a DOM. Before defaulting to stacked rounded-rect cards or uniform equal columns, pick one page-scale move from the locked visual style's §1 `Composition geometry` (a bleed shape, diagonal split, oversized numeral, orbit rings, …) to stage the page's primary zone. Card grids are one option among many, not the house layout.
|
||||
- **Containers are structural**: cards and grids express grouping, hierarchy, or capacity, not a house style. Preserve meaningful template frames; restyle radius, fill, stroke, and depth from the active Design Spec and `spec_lock.md`. Chart-catalog adaptation is owned by [`executor-chart.md`](./executor-chart.md); preview effects never override project styling or structural roles.
|
||||
- **Reference — prefer semantic geometry over preset stacks**: for relationships such as ascending, converging, breaking through, or stacking, consider one page-specific polygon/path that expresses the relationship before stacking generic arrows. This does not override §3.0 when one literal stock shape is the semantic object.
|
||||
- **Reference — prefer semantic geometry over preset stacks**: for relationships such as ascending, converging, breaking through, or stacking, first seek a basic primitive, one exact preset, or a clear Boolean result. Only when none can faithfully express the relationship should one page-specific polygon/path replace a stack of generic arrows.
|
||||
- **Reference — create depth with restraint**: use rhythm, spacing, typography, accent bars, and subtle tints before shadows. Reserve lift for a few genuinely floating elements; keep peer grids, dividers, and body containers flat.
|
||||
- **Phased generation** (recommended):
|
||||
1. **Visual Construction Phase**: generate all SVG pages sequentially for visual consistency. Use layout judgment for chart marks during the draft. **MUST embed plot-area markers** per [`executor-chart.md`](./executor-chart.md) §2.1 on every §IX-planned data-chart page — coordinate calibration is a post-generation step (see [`verify-charts`](../workflows/stages/verify-charts.md)) that depends on these markers — and **native object metadata** per [`executor-chart.md`](./executor-chart.md) §2.2 on every planned native-ready object. **Reach for native presets** per §3.0 as you draw each page: a block arrow, chevron, banner/ribbon, callout, standard flowchart node, or star is authored through `preset_shape_svg.py` at draw time — decided by the object's intent as you create it, never by scanning finished paths, and never committed to a bare `<path>`/`<polygon>` when a preset expresses it (a gradient fill/stroke or a pattern fill is the one paint exception — keep those ordinary SVG). **First-page gate (Mandatory)**: after completing the first page, run `python3 scripts/svg_quality_checker.py <project_path> --stage first-page --json` without output filtering. Review the whole P01 issue set, make one consolidated edit pass for every error and any selected warnings, then perform one verification rerun. If it still fails, treat that complete output as the next batch; never check between individual fixes. After it passes, draw P02 through the last page without checker calls.
|
||||
2. **Quality Check Gate**: only after every planned SVG exists, run `python3 scripts/svg_quality_checker.py <project_path> --stage final --json` on `svg_output/` without `tail` / `head` / `grep` filtering. One run already reports all pages. Review its complete issue set, fix every `error` plus any selected advisory warnings in one consolidated edit pass, then perform one verification rerun. If it still fails, its complete output begins the next batch cycle; never use checker calls to discover or fix one next issue at a time. Every `warning` is advisory: it never sends the page back for required modification, never authorizes automatic rewriting of compatible user syntax, and needs no acknowledgement/disposition line. Recommendation warnings describe the generated-SVG default; fidelity/quality warnings may be surfaced when material, while the existing input remains releasable. Prototype-identical diagnostics are recorded as `inherited`, source conversion losses as `source-import`, changed/new advisories as `introduced`, and release failures as `blocking` in `validation/svg_quality_report.json`. If release truly depends on a condition, it belongs in `errors`. On success, use the exit status and terminal summary; do not open or `cat` the complete JSON into model context. If terminal output is truncated on failure, read only the relevant issue arrays from the report written by that same run. Do NOT defer error handling to after `finalize_svg.py` — finalize rewrites SVG and masks some violations.
|
||||
3. **Logic Construction Phase**: after SVGs pass the quality check, batch-generate speaker notes for narrative continuity.
|
||||
|
||||
### 3.0 Native Preset Shape Selection
|
||||
### 3.0 Native Shape Selection
|
||||
|
||||
**Reach for a native preset whenever one expresses a complete object — this is
|
||||
the default, not the exception.** Block arrows, chevrons, banners / ribbons,
|
||||
callouts, flowchart nodes, stars, and other Office symbols should be **authored
|
||||
as presets** via `preset_shape_svg.py`, not drawn as plain `<path>`s or faked
|
||||
with rectangles: presets are what give the slide real PowerPoint shapes with
|
||||
adjustment handles and the designed, non-flat-card look. When a page calls for
|
||||
one of these, use the preset. Apply the decision gate in
|
||||
[`native-shape-authoring.md`](./native-shape-authoring.md) to pick the right
|
||||
shape and to keep only the exceptions below as ordinary SVG.
|
||||
**Use the highest-level native construction that faithfully expresses the
|
||||
object.** Basic primitives already export as editable PowerPoint shapes. For
|
||||
anything beyond them, an exact Office preset is the default; when no single
|
||||
preset suffices but closed operands can express the result, materialize a
|
||||
Merge Shapes Boolean result. Hand-authored freeform geometry is the final
|
||||
fallback, not the first drawing convenience. Block arrows, chevrons, banners /
|
||||
ribbons, callouts, flowchart nodes, stars, and other Office symbols should be
|
||||
**authored as presets** via `preset_shape_svg.py`, not redrawn as plain
|
||||
`<path>`s or faked with rectangles. Apply the decision gate in
|
||||
[`native-shape-authoring.md`](./native-shape-authoring.md) before drawing the
|
||||
object.
|
||||
|
||||
§IX `Native shape suggestion` records a semantic opportunity, not a literal
|
||||
tool command. Decide from the actual page construction whether a basic
|
||||
primitive, preset, Boolean result, or necessary freeform best realizes it; a
|
||||
different implementation is valid when it preserves the intended object and
|
||||
content.
|
||||
|
||||
| Decision | Action |
|
||||
|---|---|
|
||||
| Plain rect / symmetric round rect / circle / ellipse | Keep the ordinary SVG primitive; it is already natively editable. |
|
||||
| Straight relationship / divider / leader | Use `<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. |
|
||||
| Stock shape that needs a gradient fill/stroke or a pattern fill | Keep ordinary SVG — the helper paints `none` or a solid HEX on both fill and stroke only ([`native-shape-authoring.md`](./native-shape-authoring.md) §5). |
|
||||
| Page-specific, compound, organic, branded, icon, or data geometry | Keep ordinary SVG path/polygon geometry. |
|
||||
| Similar-looking contour only | Never guess; keep ordinary SVG. |
|
||||
| Page-specific freeform, organic, branded, icon, data geometry, or relationship contour that primitives, one preset, and Boolean materialization cannot faithfully express | Keep ordinary SVG path/polygon geometry. |
|
||||
| Similar-looking contour only | Never infer a preset; continue to the Boolean gate, then use freeform only if no faithful construction exists. |
|
||||
|
||||
This automatic decision applies only before drawing a new object. Do not scan
|
||||
existing SVG, classify path contours, or upgrade ordinary SVG during export.
|
||||
**Hard rule — freeform is the last construction tier**: before hand-authoring a
|
||||
stock-looking `<path>` / `<polygon>`, complete the primitive → exact preset →
|
||||
Boolean-result decision order above. A freeform is permitted only when those
|
||||
tiers cannot faithfully express the object; avoiding a helper or drawing the
|
||||
browser-visible contour faster is not a valid exception. Data-defined geometry
|
||||
and a genuinely locked organic / hand-drawn contour satisfy the exception by
|
||||
semantics, not by convenience.
|
||||
|
||||
This decision applies only while drawing a new object. A suggestion never
|
||||
triggers retrospective scanning, contour classification, or automatic
|
||||
upgrading of ordinary SVG during export.
|
||||
|
||||
**Hard rule**: do not hand-write `data-pptx-authoring`, `data-pptx-prst`,
|
||||
`data-pptx-frame`, adjustment metadata, or registry paths. The helper generates
|
||||
one compact atomic `<g>` from the shared 187-shape registry, with semantic
|
||||
metadata and base paint written once. Rerun the helper when geometry or paint
|
||||
changes; never edit one of its direct paths.
|
||||
`data-pptx-frame`, adjustment metadata, or registry paths. The preset helper
|
||||
generates one compact atomic `<g>` from the shared 187-shape registry, with
|
||||
semantic metadata and base paint written once. Rerun that helper when geometry
|
||||
or paint changes; never edit one of its direct paths.
|
||||
|
||||
For chart-template and diagram authoring, thin relationships use ordinary
|
||||
`<line>` / supported open `<path>` geometry with registered arrow markers;
|
||||
solid directional blocks use ordinary `shape` presets such as `rightArrow` or
|
||||
`chevron`. Do not select a connector-family preset merely because two nodes are
|
||||
related, and never hand-add endpoint/site metadata. Connector-family presets
|
||||
remain available only for an explicit request for a standalone unconnected
|
||||
`p:cxnSp`; imported Connector topology stays under the preserve/mirror contract.
|
||||
`actionButton*` presets provide visual geometry only, not actions or hyperlinks.
|
||||
**Default — relationship geometry**: use `<line>` for a straight relationship.
|
||||
When a relationship genuinely needs a bend or curve and a stock Connector
|
||||
contour fits, prefer the matching `bentConnector*` / `curvedConnector*` preset
|
||||
over a hand-authored SVG Bézier. Use an open freeform path only when a straight
|
||||
line and the native Connector families cannot faithfully express the required
|
||||
route, data geometry, or locked hand-drawn / organic style. A directional solid
|
||||
object remains an ordinary `shape` preset such as `rightArrow` or `chevron`.
|
||||
|
||||
**Hard rule — narrow helper scope**: the helper prints one shape fragment to
|
||||
stdout. It does not write a page or choose layout. Read the fragment and insert
|
||||
it through the normal `apply_patch` page edit; never redirect, loop, or batch it
|
||||
into `svg_output/`.
|
||||
Authored Connector presets export as unconnected `p:cxnSp` objects: they do not
|
||||
bind to node sites or follow moved nodes. Never hand-add endpoint/site metadata
|
||||
or claim attachment semantics. Imported Connector topology stays under the
|
||||
preserve/mirror contract. `actionButton*` presets provide visual geometry only,
|
||||
not actions or hyperlinks.
|
||||
|
||||
**Hard rule — narrow helper scope**: Both helpers print only their documented
|
||||
stdout fragment(s); neither writes a page or chooses layout. Read every returned
|
||||
fragment and insert it through the normal `apply_patch` page edit; never
|
||||
redirect, loop, or batch helper output into `svg_output/`.
|
||||
|
||||
|
||||
### SVG File Naming Convention
|
||||
@@ -254,11 +281,13 @@ test -f "<project_path>/icons/<lib>/<name>.svg"
|
||||
|
||||
## 5. Font Usage
|
||||
|
||||
Structural typography anchors come from `spec_lock.md typography`. Use an exact `<role>_family` when declared; title roles otherwise use `title_family`, and body/support roles otherwise use `body_family`. `font_family` is the legacy/default fallback, not a reason to erase role differences. Sparse accent families follow §2.1; all structural text uses selected families. LaTeX formulas rendered by Strategist are PNG images, not a `code_family` role.
|
||||
Typography comes from `spec_lock.md`: `<role>_family` wins; otherwise titles use `title_family`, body/support `body_family`, then legacy `font_family`. Sparse accents follow §2.1. LaTeX renders stay PNG, not `code_family`.
|
||||
|
||||
**Default — font-family inheritance (may override where needed)**: Put the common stack on root `<svg>`; matching descendants omit it. Override at the nearest clear `<g>`, `<text>`, or `<tspan>`. Change placement, never lock selection.
|
||||
|
||||
**Missing required field — `typography.font_family`** → stop and return to Generate Step 4 / [`strategist.md`](strategist.md) §6.2 to repair `spec_lock.md`; do not infer a stack from `design_spec.md`.
|
||||
|
||||
**Hard rule**: every SVG `font-family` stack MUST resolve to a pre-installed exported Latin / EA typeface; use the Strategist §g safe set for locked roles and §2.1 for sparse display exceptions. PPTX has no runtime fallback — missing fonts degrade to Calibri.
|
||||
**Hard rule**: every SVG `font-family` stack MUST resolve to target-installed/approved Latin and EA faces. PPTX writes one face per script; CSS tails affect preview only, and fonts are not embedded. Missing-face substitution is viewer-selected—not guaranteed Calibri or a later stack entry.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Handle images by their status in the Design Spec's Image Resource List. Status e
|
||||
| **Existing** | User-provided | Reference images directly from `../images/` directory |
|
||||
| **Generated** | Generated by Image_Generator | Reference images directly from `../images/` directory |
|
||||
| **Sourced** | Web-acquired by Image_Searcher | Reference from `../images/`. **Read [`image_sources.json`](image-searcher.md) to decide attribution** — load [`executor-web-image.md`](./executor-web-image.md). |
|
||||
| **Rendered** | Deterministic formula PNG | Reference from `../images/`; use `preserveAspectRatio="xMidYMid meet"` |
|
||||
| **Rendered** | Deterministic formula PNG | Reference from `../images/`; use a legal anchor with `meet` (centered default: `xMidYMid meet`) |
|
||||
| **Needs-Manual** | Acquisition failed and file is absent | Use dashed border placeholder unless the expected file exists; the Step 7 readiness gate swaps placeholders for real files before export |
|
||||
| **Placeholder** | Not yet prepared | Use dashed border placeholder |
|
||||
|
||||
@@ -23,12 +23,31 @@ Handle images by their status in the Design Spec's Image Resource List. Status e
|
||||
|
||||
**Template-bundled images**: [`apply-template-workspace.md`](../workflows/stages/apply-template-workspace.md) copies them into project `images/`. Outside `mirror`, reference `../images/<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/`.
|
||||
|
||||
**Mandatory — selected pattern, flexible realization**: Read [`image-layout-patterns.md`](./image-layout-patterns.md) once when this branch loads, then resolve every primary/modifier id in each active §VIII/lock row to its exact catalog entry. Preserve the selected semantic composition. Adapt geometry, ratio, placement, spacing, and hierarchy for the actual page; never replace the pattern, role, file/source, must-use, or crop policy downstream. If another pattern is needed, return upstream to update the Design Spec. Only explicit user/template preservation locks exact geometry. Avoid generic left/right repetition.
|
||||
**Reference — preferred pattern, flexible realization**: Read [`image-layout-patterns.md`](./image-layout-patterns.md) once and resolve every active §VIII/lock pattern id. Adapt its geometry or composition when the page communicates better, while preserving resource role, source, must-use status, crop policy, content, and explicit user/template constraints. Pattern-only changes need no upstream rewrite. Avoid generic left/right repetition.
|
||||
|
||||
**Reference — motion-ready image layering, not a constraint**: For adopted §IX or an explicit focus, comparison, evidence, reveal-order, or cross-page requirement, decide during SVG authoring whether the final composition needs separate visible units. Keep ordinary stable framing/background static and wrap each independently revealed or continuing Slide-local unit in a descriptive direct-root `<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.
|
||||
|
||||
**Hard rule — visible-layer timing**: Any crop, lens, scrim, comparison, evidence, or annotation layer required by an adopted motion plan MUST already exist in the final SVG without violating structural contracts. The later stage may regroup ordinary Slide-local content visual-equivalently, but cannot invent or modify missing visible content. If no legal existing unit can serve a non-binding suggestion, simplify it to available units, a page transition, or `none`; an explicit requirement that cannot be represented follows failure recovery.
|
||||
|
||||
**No semantic re-reading**: §VIII owns image identity, purpose, and focus / crop constraints. Executor uses its `Reference` plus regenerated dimensions; it never opens source images to rediscover subjects, substitute assets, or invent focus. For `adaptive` without reliable focus, use `meet`; missing or contradictory required constraints return upstream.
|
||||
|
||||
**Placeholder**: Dashed border `<rect stroke-dasharray="8,4" .../>` + description text
|
||||
|
||||
**Crop policy**: read the §VIII row and matching lock projection. `crop=no-crop` (or a legacy trailing `| no-crop`) requires a native-ratio container and `preserveAspectRatio="xMidYMid meet"`. `crop=adaptive` permits but never requires cropping; choose `meet` or focal-safe `slice` from purpose, ratio, focus, and container. A missing or conflicting projection returns upstream instead of being inferred during execution.
|
||||
**Crop policy**: read the §VIII row and matching lock projection. On every slide that uses a `crop=no-crop` source (or a legacy trailing `| no-crop`), retain one visible complete instance using one of the nine legal anchors with `meet`, never `none`, and no `clip-path`, `mask`, clipping overflow, or nested `<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**: for binding use or pattern
|
||||
`#100`, reuse one exact `href` without slice assets. Give every
|
||||
independent/Morph object a stable
|
||||
page-unique id and a distinct nested crop wrapper under
|
||||
[`svg-effects.md`](./svg-effects.md) §6.5. Plain rectangles need no crop marker;
|
||||
shaped frames put `data-pptx-crop="1"` on the wrapper and a matching
|
||||
`userSpaceOnUse` clip on its inner `<image>`, never the wrapper. Repeated crops
|
||||
or SVG/PPT visual drift fail. Derive every wrapper `viewBox` from one shared
|
||||
source-to-page transform over the union of the visible containers. Never run
|
||||
`cover` / focal cropping independently per container: different container
|
||||
positions and heights must change the source-unit `x`, `y`, `width`, and
|
||||
`height` by the same union-relative mapping, so the gaps remove pixels without
|
||||
rescaling the scene. A compound clip on one `<image>` is pattern `#82`, not a
|
||||
substitute when the objects must remain independently editable or Morphable.
|
||||
|
||||
**Formula images — declared-inference fallback for a missing `no-crop` flag**: rows with `Acquire Via: formula` or `Type: Latex Formula` MUST be treated as no-crop. For a rendered file, use dimensions in this order: current `analysis/image_analysis.csv`, `design_spec.md §VIII`, then `images/formula_manifest.json`. For a `Needs-Manual` row, size the dashed placeholder from the planned dimensions in §VIII, then the manifest; the Step 7 readiness gate re-analyzes the supplied file and reconciles the container before export. Do not normalize all formulas to one height unless the spec explicitly states that layout choice.
|
||||
|
||||
@@ -18,8 +18,8 @@ Whenever the slide uses an image with `Status: Sourced`, look up the correspondi
|
||||
|
||||
The credit text is **not** rendered by post-processing or export — it must be present in the SVG you produce. The shape of the credit element (size, position, color, multi-image source line, hero gradient overlay) is specified in [image-searcher.md §7](./image-searcher.md). Do not invent a different style.
|
||||
|
||||
Use `attribution_text` from the manifest entry as the **starting point**, then compress for the small-text constraint (drop URL, drop filename, keep "via Provider / License"). For CC0/PD images that landed in the `attribution-required` tier only because of upstream metadata quirks (rare), credits are still safe to render.
|
||||
Use `attribution_text` from the manifest entry as the **starting point**, then compress for the small-text constraint: drop URL and filename, but retain that image's author and CC BY / CC BY-SA license so the quality checker can bind the credit to the referenced asset. For CC0/PD images that landed in the `attribution-required` tier only because of upstream metadata quirks (rare), credits are still safe to render.
|
||||
|
||||
`svg_quality_checker.py` treats missing CC BY / CC BY-SA inline attribution as an **error**. Fix the offending SVG before post-processing.
|
||||
`svg_quality_checker.py` treats a missing image-specific author + license credit as an **error**; one generic CC token does not cover multiple files. An unreadable/missing manifest or missing per-file provenance is also blocking. Fix the manifest or SVG before post-processing.
|
||||
|
||||
**The manifest is the single source of truth for credits.** Do not duplicate license info into speaker notes or any other artifact.
|
||||
|
||||
@@ -23,7 +23,7 @@ Defined in `design_spec.md §VIII`. Status enum: see [`svg-image-embedding.md`](
|
||||
|
||||
| Filename | Dimensions | Purpose / Type | Layout pattern | Crop Policy | Acquire Via | Status | Reference |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `<planned file>` | `<planned size>` | `<planned role>` | `<Strategist selection>` | `adaptive` / `no-crop` | `ai` / `web` / `slice` | Pending | `<acquisition brief>` |
|
||||
| `<planned file>` | `<planned size>` | `<planned role>` | `<Strategist 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.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ AI images exist to serve the deck's communication goal. Pick whatever combinatio
|
||||
| `text_policy` | Use |
|
||||
|---|---|
|
||||
| `none` | No text inside the image |
|
||||
| `embedded` | Image contains text as part of the artwork — decorative lettering, designed title, hand-lettered keywords, infographic labels, anything the page needs |
|
||||
| `embedded` | Image contains stable text as part of the artwork — decorative lettering, artistic wordmarks, hand-lettered keywords, or figure-internal labels |
|
||||
|
||||
**Hard rule — only what's actually hard**:
|
||||
|
||||
@@ -46,7 +46,7 @@ Every AI image uses one deck-wide rendering, the deck's stable color anchors/sem
|
||||
|---|---|---|
|
||||
| **Rendering** | Visual style family (vector / sketch-notes / 3d-isometric / corporate-photo / …) | Once per deck — every AI image in the deck shares one rendering |
|
||||
| **Deck colors** | Core background / primary / accent / secondary-accent / text anchors from `spec_lock.md colors`, interpreted with the Design Spec and per-image context; these are not reconfirmed | Anchored after Stage 2 |
|
||||
| **Type** | What the image's internal composition skeleton looks like — geometric layout of a local infographic block (infographic / flowchart / framework / matrix / cycle / funnel / pyramid / comparison / timeline / map / scene). Only applies to `page_role: local`; for `page_role: hero_page`, describe composition with §4.1 primitives instead of picking a type. | Per image |
|
||||
| **Type** | What a local structural infographic's internal skeleton looks like (infographic / flowchart / framework / matrix / cycle / funnel / pyramid / comparison / timeline / map / scene). A local single-subject or portrait image may omit type and use §4.1 A/B prose; `hero_page` always omits type. | Per image |
|
||||
|
||||
> Rendering decides *how the image is drawn* (line quality, texture, depth). Color instructions begin from the deck roles: background / secondary background usually dominate, primary carries main forms, and accents stay scarce. Adjust proportions and derive coherent lighting/material/tint transitions for the image context; do not replace the deck's identity with an unrelated image-only palette.
|
||||
|
||||
@@ -122,12 +122,12 @@ For each `Acquire Via: ai` row in `design_spec.md §VIII`:
|
||||
|
||||
1. **Determine `page_role`** — Strategist's explicit value wins; a blank or omitted value resolves to `local`. `hero_page` must be explicit.
|
||||
2. **Determine `text_policy`** — Strategist's value wins when set. **Declared-inference fallback for a blank or omitted value**: pick `none` or `embedded` from the row's `Purpose`, `Reference`, and page intent based on whether in-image text serves the page. Long body / data / lists stay in SVG.
|
||||
3. **Determine type** — only when the resolved `page_role` is `local` (the image sits as a region block on an SVG page). Match the row's `Purpose` against the `_index.md` auto-selection table (methodology visualization → `framework`; process steps → `flowchart`; SWOT/Eisenhower → `matrix`; PDCA / flywheel → `cycle`; etc.). `Purpose` is authoritative for picking among the 11 internal-composition types. **When the resolved `page_role` is `hero_page`, skip type selection** and describe composition directly using §4.1 primitives (single-subject / portrait / typographic / atmospheric).
|
||||
4. `read_file references/image-type-templates/<type>.md` (only if not already read — types are commonly reused across images in one deck)
|
||||
3. **Determine type or free composition** — an Illustration Sheet omits manifest `type` and follows §4.3's grid composition. For another local structural infographic, match `Purpose` against the `_index.md` table and choose one of the 11 types. For a local single-subject / portrait image, omit type and describe §4.1 A/B inside its actual region. For `hero_page`, omit type and use §4.1 A/B/C/D/E.
|
||||
4. `read_file references/image-type-templates/<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 type's structural layout (from Step 3)
|
||||
- The selected type's structural layout, or the no-type composition prose (from Step 3)
|
||||
- The image's specific `Reference` intent (from `design_spec.md §VIII`)
|
||||
- The container sizing guidance from the type file (so the model knows it's painting a local block, not a full canvas)
|
||||
- The hard rules from §5 below (HEX-not-as-text, simplified figures, text policy)
|
||||
@@ -147,9 +147,9 @@ Every assembled prompt follows this paragraph structure. **Write prose, not tag
|
||||
```
|
||||
[Rendering style paragraph — 80-120 words from the chosen rendering file].
|
||||
[Deck color behavior — state the core anchors and any context-justified tonal treatment, e.g. "secondary background #F8F9FA provides the breathing field, primary #1E3A5F carries main forms, accent #D4AF37 marks one emphasis; subtle lighter/darker material transitions remain in the same visual family"].
|
||||
[Type-specific composition — from the chosen type file, e.g. "central hub node with four radiating satellite nodes connected by clean lines"].
|
||||
[Composition — from the chosen type file or §4.1 no-type prose].
|
||||
[Image-specific subject — translated from the row's Reference intent into concrete visual nouns].
|
||||
[Container note — "composed as a {W}x{H}px image for {page_role} use"; add composition cues only when the page actually needs them. SVG-overlay-reservation cues ("leave the lower band calm — SVG title overlays it", "keep the right third calmer for SVG text") are valid **only** when `page_role: hero_page` (SVG sits on top of the image). For `page_role: local`, the image sits inside a region block and the SVG layer never overlays its interior — never reserve overlay space in a local prompt].
|
||||
[Container note — "composed as a {W}x{H}px image for {page_role} use"; add composition cues only when the page actually needs them. SVG-overlay-reservation cues ("leave the lower band calm — SVG title overlays it", "keep the right third calmer for SVG text") are valid when `page_role: hero_page`, or when §VIII `Reference` / §IX `Layout` explicitly plans native labels, hotspots, lenses, or other SVG overlays inside a `local` image region. Otherwise a `local` image is a self-contained region block and reserves no interior overlay space].
|
||||
[Hard rules — see §5].
|
||||
```
|
||||
|
||||
@@ -163,33 +163,33 @@ Every assembled prompt follows this paragraph structure. **Write prose, not tag
|
||||
|
||||
This produces generic, model-average output. The model is not weighting your tags — write **one coherent visual scene** instead.
|
||||
|
||||
### 4.1 Hero-page composition primitives
|
||||
### 4.1 No-type composition primitives
|
||||
|
||||
When `page_role: hero_page` (the image is the page's main voice — cover, chapter divider, mood transition, signature stat, closing quote), the image's internal composition does not need its own structural `type` (matrix / cycle / framework etc. are for *local* infographic blocks). Instead, describe the composition directly in the prompt using one of the four primitives below.
|
||||
Use these when no structural type applies. A/B can describe either a hero image or a local single-subject / portrait region; scale their framing to the actual container. C/D are hero-page compositions, and E is the explicit escape hatch. Local structural infographics still use the 11 type templates.
|
||||
|
||||
**Primitive A — single dominant subject (product / object / concept hero)**
|
||||
|
||||
> One dominant subject occupying 60-70% of the canvas, positioned with intent (centered, rule-of-thirds offset, or slight left/right). Supporting context <30% of canvas weight. Generous negative space — at least 15% padding on the subject's "open" side. No second-place subject competing.
|
||||
> Start with one dominant subject as the clear focal point, positioned with intent (centered, rule-of-thirds offset, or slight left/right). Scale it to command the container while keeping supporting context subordinate. Leave a deliberate open side when the page composition needs breathing room or an overlay; no fixed padding is implied. No second-place subject competing.
|
||||
|
||||
Use for: product reveal, concept introduction, chapter title visual, brand statement.
|
||||
Use for: product reveal, concept introduction, chapter-opener visual, brand statement, or a local single-object region.
|
||||
|
||||
**Primitive B — single human subject (portrait)**
|
||||
|
||||
> One person, frontal or three-quarter turn, head + upper body. Subject occupies 50-65% of canvas height, centered or rule-of-thirds offset. Eyes at the upper-third horizontal line. Background neutral, minimal, or softly blurred. No competing foreground objects. At least 15% padding above the crown.
|
||||
> One person, frontal or three-quarter turn, head + upper body. Start with the face as the clear focal point, centered or rule-of-thirds offset, with eyes near the upper-third horizontal line. Background neutral, minimal, or softly blurred. Keep comfortable headroom and no competing foreground objects; adjust framing to the container rather than enforcing fixed padding.
|
||||
|
||||
Use for: founder profile, speaker bio, testimonial page, executive intro. Pair with `rendering: corporate-photo` for photographic realism; otherwise the §5.2 simplified-figures rule applies.
|
||||
Use for: founder profile, speaker bio, testimonial page, or executive intro, including a local bio region. Pair with `rendering: corporate-photo` for photographic realism; otherwise the §5.2 simplified-figures rule applies.
|
||||
|
||||
**Primitive C — typographic hero (the text *is* the image)**
|
||||
|
||||
> The image's central content is one large text element — a short headline, big number, or single word — rendered as art, occupying 40-60% of canvas height. Minimal supporting visual (small icon, geometric anchor, accent line) at <25% weight. At least 20% padding around the text.
|
||||
> The image's central content is one large text element — a short headline, big number, or single word — rendered as art and carrying dominant visual weight. Keep any supporting visual (small icon, geometric anchor, accent line) clearly subordinate. Give the letterforms enough breathing room for readability, adjusting scale and spacing to the actual text and container.
|
||||
|
||||
Use with `text_policy: embedded`. Must obey the §5.3 rule — text that is part of the artwork and stable can be embedded; copy that must stay exact or editable goes to SVG overlay (switch to Primitive D).
|
||||
|
||||
**Primitive D — atmospheric backdrop (no subject)**
|
||||
|
||||
> Atmospheric field with no dominant subject — gradients, subtle patterns, or restrained color blocks. Small geometric anchor optional, placed in a corner or along an edge, never centered. The center 60-70% of the canvas must stay calm to receive SVG title/text overlay.
|
||||
> Atmospheric field with no dominant subject — gradients, subtle patterns, or restrained color blocks. A small geometric anchor may sit in a corner or along an edge. Arrange visual activity around the SVG overlay region named by the page plan so that region stays calm enough for its title or text; its position and extent follow the composition rather than a fixed percentage.
|
||||
|
||||
**Applies to `page_role: hero_page` only.** The "calm center for SVG overlay" contract is the defining feature of this primitive — and it only holds when SVG actually sits on top of the image. `page_role: local` images live inside a region block; the SVG layer never overlays their interior, so Primitive D is not a valid choice for local. Local schematic / scene / chart images use the §3 type templates instead.
|
||||
**Applies to `page_role: hero_page` only.** The "calm center for SVG overlay" contract defines this primitive. A `local` image uses §3 type templates or §4.1 A/B instead; when §VIII / §IX explicitly plans native overlays inside that region, its prompt may reserve only the named focal/quiet area without turning the whole asset into Primitive D.
|
||||
|
||||
Use for: cover background, chapter divider background, breathing-page background, any page where the SVG layer carries the words and the image only sets tone.
|
||||
|
||||
@@ -211,21 +211,21 @@ Example opening for a triptych hero:
|
||||
|
||||
**Fewshot examples per primitive** (one each, deck-context placeholders intact):
|
||||
|
||||
> **A — 3d-isometric + tech-neon product reveal, text_policy: none, 600×600**
|
||||
> **A — 3d-isometric + deck-color product reveal, text_policy: none, 600×600**
|
||||
>
|
||||
> 3D isometric illustration in true 30°/30°/30° projection. One dominant product-form subject — a stylized device or sleek tech object — occupies the center of the canvas at roughly 65% of the area. The subject is rendered in primary electric blue `#0EA5E9` on its lit faces, with 15% darker tonal shift on shadowed faces. A subtle 8%-opacity outer glow halo surrounds the subject. Small supporting context: three thin connecting lines in accent vivid cyan `#06B6D4` arcing from the subject toward the canvas edges (suggesting connectivity), and a soft 8% drop shadow grounding the subject. Background is deep secondary navy `#0A0E27` (about 30% of canvas, including shadowed plane). The subject is clearly the singular focal element. Composed as a 600×600 hero block with 15% padding around the subject. NO text, letters, numbers, or labels anywhere. Color values are rendering guidance only.
|
||||
> 3D isometric illustration in true 30°/30°/30° projection. One dominant product-form subject — a stylized device or sleek tech object — commands the center of the canvas. The subject is rendered in primary electric blue `#0EA5E9` on its lit faces, with 15% darker tonal shift on shadowed faces. A subtle 8%-opacity outer glow halo surrounds the subject. Small supporting context: three thin connecting lines in accent vivid cyan `#06B6D4` arcing from the subject toward the canvas edges (suggesting connectivity), and a soft 8% drop shadow grounding the subject. Background is deep secondary navy `#0A0E27`, including the shadowed plane. The subject is clearly the singular focal element, with deliberate breathing room around it. Composed as a 600×600 hero block. NO text, letters, numbers, or labels anywhere. Color values are rendering guidance only.
|
||||
|
||||
> **B — corporate-photo + cool-corporate executive headshot, text_policy: none, 600×800**
|
||||
> **B — corporate-photo + deck-color executive headshot, text_policy: none, 600×800**
|
||||
>
|
||||
> Editorial corporate portrait photograph of one professional executive. The person is centered slightly left of canvas center, photographed from chest-up at eye level, looking confidently toward the camera with a relaxed natural expression — not posed-stiff, not over-smiling. Professionally attired in a contemporary business setting (a tailored blazer, neutral palette clothing). Soft natural light from the upper left, gentle shadow on the right side of the face. Diverse, professionally attired subject, photorealistically rendered, contemporary styling. Background is a softly out-of-focus office context — secondary light gray `#F8F9FA` wall with a subtle hint of primary deep navy `#1E3A5F` in a blurred architectural element. Color grading is cool-corporate — restrained, professional. Shallow depth of field — subject sharp, background gently blurred. Subject's eyes positioned at the upper-third horizontal line. Composed as a 600×800 bio portrait with 10% padding. NO text, name tags, or captions in the image. Color values are rendering guidance only.
|
||||
> Editorial corporate portrait photograph of one professional executive. The person is centered slightly left of canvas center, photographed from chest-up at eye level, looking confidently toward the camera with a relaxed natural expression — not posed-stiff, not over-smiling. Professionally attired in a contemporary business setting (a tailored blazer, neutral palette clothing). Soft natural light from the upper left, gentle shadow on the right side of the face. Diverse, professionally attired subject, photorealistically rendered, contemporary styling. Background is a softly out-of-focus office context — secondary light gray `#F8F9FA` wall with a subtle hint of primary deep navy `#1E3A5F` in a blurred architectural element. Color grading is restrained and professional. Shallow depth of field — subject sharp, background gently blurred. Subject's eyes positioned near the upper-third horizontal line, with comfortable headroom. Composed as a 600×800 bio portrait. NO text, name tags, or captions in the image. Color values are rendering guidance only.
|
||||
|
||||
> **C — ink-notes + mono-ink big-number stat, text_policy: embedded, 800×500**
|
||||
> **C — ink-notes + deck-color big-number stat, text_policy: embedded, 800×500**
|
||||
>
|
||||
> Professional hand-drawn visual-note style on pure white background. The image's central content is the hand-lettered number "100x" — rendered in bold confident ink strokes occupying about 50% of the canvas height, centered with deliberate slight wobble characteristic of hand-lettering. Text is in English/Latin characters only. Beneath the number, a thin hand-drawn underline in ink. To the side of the number, one small hand-drawn doodle decoration — a star or upward arrow — adds visual rhythm. Accent coral `#E8655A` (from the deck's accent) appears only as a tiny emphasis dot, totaling under 4% of canvas. Background is pure white `#FFFFFF`. Composed as an 800×500 typographic hero block with 20% padding around the number. No other text or labels in the image — just the "100x" headline and the small doodle.
|
||||
> Professional hand-drawn visual-note style on pure white background. The image's central content is the hand-lettered number "100x" — rendered in bold confident ink strokes as the dominant element, centered with deliberate slight wobble characteristic of hand-lettering. Beneath the number, a thin hand-drawn underline in ink. To the side of the number, one small hand-drawn doodle decoration — a star or upward arrow — adds visual rhythm. Accent coral `#E8655A` (from the deck's accent) appears only as a tiny emphasis dot, totaling under 4% of the canvas. Background is pure white `#FFFFFF`. Composed as an 800×500 typographic hero block with enough breathing room for the letterforms to read clearly. No other text or labels in the image — just the "100x" headline and the small doodle.
|
||||
|
||||
> **D — vector-illustration + cool-corporate cover background, text_policy: none, 1280×720**
|
||||
> **D — vector-illustration + deck-color cover background, text_policy: none, 1280×720**
|
||||
>
|
||||
> Clean flat vector illustration backdrop. Atmospheric composition with no central subject — bold geometric shapes arranged along the canvas edges to leave the center calm. Primary deep navy `#1E3A5F` forms a confident diagonal block across the lower-left third; secondary light gray `#F8F9FA` occupies the upper two-thirds as breathing space; accent gold `#D4AF37` appears only as one thin geometric line near the lower right corner (under 5% of canvas). Crisp 2px outlines, no gradients, single 8% soft drop shadow under the navy block. The central 60% of the canvas is deliberately calm and unbusy — designed to receive a slide title overlaid in SVG. Composed as a 1280×720 full-bleed PPT background. NO text, letters, numbers, signs, watermarks, or written symbols anywhere in the image. Color values are rendering guidance only — do not display HEX codes or color names as text. Simplified geometric shapes only.
|
||||
> Clean flat vector illustration backdrop. Atmospheric composition with no central subject — bold geometric shapes arranged along the canvas edges to leave the planned central title field calm. Primary deep navy `#1E3A5F` forms a confident diagonal block across the lower-left area; secondary light gray `#F8F9FA` provides the breathing field; accent gold `#D4AF37` appears only as one thin geometric line near the lower right corner, under 5% of the canvas. Crisp 2px outlines, no gradients, a single 8% soft drop shadow under the navy block. The intended SVG title region is deliberately calm and unbusy. Composed as a 1280×720 full-bleed PPT background. NO text, letters, numbers, signs, watermarks, or written symbols anywhere in the image. Color values are rendering guidance only — do not display HEX codes or color names as text. Simplified geometric shapes only.
|
||||
|
||||
### 4.2 Prompt depth — expand for subject-domain accuracy
|
||||
|
||||
@@ -279,9 +279,9 @@ If one deck needs mixed shapes, create separate sheets per shape family unless o
|
||||
**Resource contract — the sheet and its elements are different row kinds.** A sliced element can only be placed if it exists as a resource the Executor is allowed to reference (`spec_lock.md images`). So §VIII carries two row kinds (planning authority: [`strategist-image.md`](./strategist-image.md)):
|
||||
|
||||
- **Sheet row** — `Acquire Via: ai`, `Type: Illustration Sheet`, the intent prompt, named as the slice source with its intended cell shape and placement purpose (`Reference: landscape footer-vignette spot set`). It is generated in Step 5 but **never placed on a slide** — keep it **out of** `spec_lock.md images`. Image_Generator resolves the exact `aspect_ratio`, grid, and slice command from this intent.
|
||||
- **Element rows** — one per used element, `Acquire Via: slice`, filename matching a `--names` output, `Reference` naming the parent sheet + cell/element. These **are** placed — list every one in `spec_lock.md images`, normally with `crop=no-crop` (a tight-trimmed transparent spot should be fit, not cover-cropped). Their dimensions are filled in after slicing (Step 5 re-runs `analyze_images.py`). Each row must already carry a Strategist-selected decorative-cutout Layout pattern, never a boxed container — see Placement below.
|
||||
- **Element rows** — one per used element, `Acquire Via: slice`, filename matching a `--names` output, `Reference` naming the parent sheet + cell/element. These **are** placed — list every one in `spec_lock.md images`, normally with `crop=no-crop` (a tight-trimmed transparent spot should be fit, not cover-cropped). Their dimensions are filled in after slicing (Step 5 re-runs `analyze_images.py`). Each row must already carry a Strategist-recommended decorative-cutout Layout pattern rather than a boxed-container recommendation; Executor owns the actual placement — see Placement below.
|
||||
|
||||
For traceability, add optional `slice_grid` and `slice_names` fields to the sheet item in `image_prompts.json` after choosing the geometry. `image_gen.py` ignores unknown item fields but preserves them in the manifest, so these fields document the exact command that must be used for slicing.
|
||||
For traceability, add optional `slice_grid` and `slice_names` fields to the sheet item in `image_prompts.json` after choosing the geometry. `image_gen.py` validates, preserves, and displays these metadata fields; it does not run the separate slicing command.
|
||||
|
||||
**Slice** with [`slice_images.py`](../scripts/slice_images.py) — cells are cut row-major into individual files in `images/`. With `--alpha` they are **transparent cutout stickers** (image-layout-patterns `#63`), not rectangular content images. Recommended flags: `--names` (semantic per-cell filenames matching the element rows; the count **must** equal `rows*cols`), `--trim` (tight-crop each cell so imprecise placement inside a cell doesn't leave lopsided margins), `--alpha` (knock the flat background out to transparency so an element drops onto any slide color):
|
||||
|
||||
@@ -296,7 +296,7 @@ python3 scripts/slice_images.py <project>/images/illus_sheet.png --grid 2x3 \
|
||||
2. **Clean grid, or it cuts ugly.** The model will not place every element perfectly; force a clear grid with gutters, and generate **a few sheets** (re-roll the same prompt) to pick the cleanest-laid-out one before slicing. State the exact row/column structure and cell shape so the model does not invent a square matrix. `--trim` absorbs the rest.
|
||||
3. **Generate only as large as needed.** Each cell is a fraction of the sheet. Pick the smallest sheet size that keeps each sliced cell at least **1.5-2x** the intended display size. `1K` is usually enough for small 80-160px decorative spots; use `2K` for medium 180-320px placements; reserve `4K` for large, cropped, or potentially enlarged elements.
|
||||
|
||||
**Placement — these are decorative accessories, not boxed pictures.** Strategist selects each element row's pattern from the decorative-cutout family in [`image-layout-patterns.md`](./image-layout-patterns.md): `#63` sticker/cutout, `#4` bleed off the canvas edge, `#58` corner fragment, `#66` fade into the background, `#69` slight editorial rotation, or `#49` asymmetric cluster. Executor realizes that choice through margin position, off-edge treatment, overlap, scale, and angle rather than reserving a tidy tile. Anchor most pages on one primary element and let the rest stay small ([primary-per-page](./strategist-image.md)).
|
||||
**Placement — these are decorative accessories, not boxed pictures.** Strategist recommends each element row's pattern from the decorative-cutout family in [`image-layout-patterns.md`](./image-layout-patterns.md): `#63` sticker/cutout, `#4` bleed off the canvas edge, `#58` corner fragment, `#66` fade into the background, `#69` slight editorial rotation, or `#49` asymmetric cluster. Executor uses that recall to choose the actual unboxed composition through margin position, off-edge treatment, overlap, scale, angle, or another suitable decorative placement while preserving the resource role and crop/content constraints. Anchor most pages on one primary element and let the rest stay small ([primary-per-page](./strategist-image.md)).
|
||||
|
||||
**Through-line — one family, many roles.** A spot sheet pays off more when the same motif family also drives the cover and section dividers. A large cover / divider anchor is not a giant sheet cell—generate it as its own `hero_page` image sharing the sheet's `deck_rendering`, `color_scheme`, and subject world. Plan this only when the deck leans into illustration, never as a quota.
|
||||
|
||||
@@ -328,12 +328,12 @@ Every AI-image page carries text in two layers:
|
||||
|
||||
| Layer | Owned by | Examples |
|
||||
|---|---|---|
|
||||
| Layer 1 (image-owned) | the prompt — baked into the raster | figure-internal annotations (axis labels, A / B / C markers, units, scale bars, panel labels); architecture / schematic module names, node labels, signal-path identifiers; hero typographic or decorative lettering that *is* the visual |
|
||||
| Layer 2 (SVG-owned) | `<text>` overlay — fully editable | page-level chrome (title, navigation, footer, body bullets, conclusion callout); readable copy, captions |
|
||||
| Layer 1 (image-owned) | the prompt — baked into the raster | figure-internal annotations (axis labels, A / B / C markers, units, scale bars, panel labels); architecture / schematic module names, node labels, signal-path identifiers; stable artistic lettering that *is* the visual |
|
||||
| Layer 2 (SVG-owned) | `<text>` overlay — fully editable | authoritative deck/page/chapter titles; navigation, footer, body bullets, conclusion callout; readable copy, captions |
|
||||
|
||||
`text_policy` controls only Layer 1. AI judges per image; no global default bias.
|
||||
|
||||
**When `embedded` is the right call — positive triggers** (any one match flips the row from a `none` starting point to `embedded`; the editability rule at the tail of §5.3 still has final say):
|
||||
**When `embedded` is the right call — positive triggers** (any one match supports `embedded`; the editability rule at the tail of §5.3 still has final say):
|
||||
|
||||
| Trigger | Typical Layer 1 text |
|
||||
|---|---|
|
||||
@@ -348,9 +348,9 @@ Defaulting an entire `ai` resource list to `none` because "SVG can always overla
|
||||
| `text_policy` | Prompt cue |
|
||||
|---|---|
|
||||
| `none` | "NO text of any kind anywhere in the image — no letters, numbers, signs, watermarks, labels, or written symbols." |
|
||||
| `embedded` | Describe the Layer 1 text directly inside the visual scene: the word(s), how they're rendered, and the artistic treatment. |
|
||||
| `embedded` | Describe the stable Layer 1 lettering directly inside the visual scene: the exact character(s), how they are rendered, and the artistic treatment. |
|
||||
|
||||
**Hard rule — cross-cutting**: Layer 2 chrome stays SVG regardless of `text_policy`. Never bake the deck title, navigation, footer, body bullets, or conclusion callout into the image, even when `embedded`.
|
||||
**Hard rule — cross-cutting**: Authoritative titles and Layer 2 chrome stay SVG regardless of `text_policy`. Bake title-like wording only when the approved plan explicitly treats those exact characters as stable artistic lettering that is part of the artwork rather than editable deck/page/chapter copy. Navigation, footer, body bullets, captions, and conclusion callouts always stay SVG.
|
||||
|
||||
**Forbidden — text that may be reworded**: any word that may later change belongs in Layer 2, not Layer 1. Layer 1 is for stable visual identifiers and designed lettering that is part of the image itself.
|
||||
|
||||
@@ -358,7 +358,7 @@ Defaulting an entire `ai` resource list to `none` because "SVG can always overla
|
||||
|
||||
The font for in-image text is a free natural-language description, not an enum. Pick whatever serves the image: blackletter for a heritage cover, hand-brushed for a manifesto poster, retro chrome 3D for Y2K, art-deco display for a luxury hero, ribbon script for a bookstore zine — any artistic treatment the image earns.
|
||||
|
||||
The table below is **a reference for the one case where you want the in-image lettering to read as the same typographic family as the SVG body** (e.g. a clean editorial deck where the cover title in the image should feel like the body Helvetica, not a surprise blackletter). Use it as a starting point, not a constraint.
|
||||
The table below is **a reference for the one case where stable in-image lettering should read as the same typographic family as the SVG body** (e.g. an artistic cover wordmark should feel like the body Helvetica, not a surprise blackletter). Use it as a starting point, not a constraint.
|
||||
|
||||
| `spec_lock typography.font_family` contains | Optional descriptor if you want to echo the SVG body |
|
||||
|---|---|
|
||||
@@ -371,11 +371,11 @@ The table below is **a reference for the one case where you want the in-image le
|
||||
**When to ignore the table**:
|
||||
|
||||
- Decorative / background lettering, posters, large mood words → describe the artistic treatment freely
|
||||
- Cover hero title that wants its own visual identity (blackletter, retro chrome, art-deco display, brushed script) → describe freely
|
||||
- Stable artistic cover lettering that wants its own visual identity (blackletter, retro chrome, art-deco display, brushed script) → describe freely
|
||||
- Sketch-notes / ink-notes / hand-drawn renderings where the lettering is part of the rendering itself → describe freely
|
||||
- Any case where rendering already implies a font character (e.g. `vintage-poster` implies period display lettering) → trust the rendering, no need to echo SVG body
|
||||
|
||||
**When to use the table**: a designed title (cover main title, chapter heading) on a deck whose visual identity is grounded in the SVG body typography, and where a surprise font choice would feel out of place.
|
||||
**When to use the table**: stable artistic lettering on a deck whose visual identity is grounded in the SVG body typography, and where a surprise font choice would feel out of place.
|
||||
|
||||
**In-image text vs SVG text — decide by editability, not by model capability**
|
||||
|
||||
@@ -383,8 +383,8 @@ Layer 1 text is rasterized into the artwork — once generated it cannot be edit
|
||||
|
||||
| Text | Layer |
|
||||
|---|---|
|
||||
| Part of the artwork and stable — decorative lettering, designed title, hand-lettered keyword, figure-internal identifiers (axis labels, panel letters, units) | Layer 1 (image) OK |
|
||||
| Page chrome, body copy, captions, data values — anything that must stay exact, searchable, or may be reworded | Layer 2 (SVG) |
|
||||
| Part of the artwork and stable — decorative lettering, artistic wordmark, hand-lettered keyword, figure-internal identifiers (axis labels, panel letters, units) | Layer 1 (image) OK |
|
||||
| Authoritative titles, page chrome, body copy, captions, data values — anything that must stay exact, searchable, editable, or may be reworded | Layer 2 (SVG) |
|
||||
|
||||
Generation is non-deterministic on every backend, but **do not pre-judge by script or length** — never push text to SVG, shorten a headline, or downgrade `embedded` to `none` on the assumption that a particular script or a long string "won't render". Decide where text lives by the editability rule above, not by guessed rendering ability. Name the exact characters to bake literally in the prompt; do not re-read the generated image to verify them.
|
||||
|
||||
@@ -449,23 +449,25 @@ Write `project/images/image_prompts.json` with this shape:
|
||||
| `deck_rendering` | yes | Step 2 lock | Single rendering name shared by all items in this deck |
|
||||
| `color_scheme` | yes | `spec_lock.md colors` | Core deck color anchors shared by every item; prompts may add contextual tonal behavior, but no separate image palette |
|
||||
| `items[].filename` | yes | `§VIII` resource list | Output filename with extension |
|
||||
| `items[].type` | conditional | Step 3 per-image (only when `page_role: local`) | One of 11 internal-composition types: `infographic`, `flowchart`, `framework`, `matrix`, `cycle`, `funnel`, `pyramid`, `comparison`, `timeline`, `map`, `scene`. **Omit `type` entirely when `page_role: hero_page`** — the composition comes from §4.1 primitives written directly into the prompt, not from a type file. |
|
||||
| `items[].type` | conditional | Step 3 per-image | One of 11 internal-composition types for a local structural infographic. Omit it for `hero_page`, an Illustration Sheet, and a local single-subject / portrait composition authored with §4.1 A/B prose. |
|
||||
| `items[].page_role` | yes | Step 3 per-image | `local` (default — region block on SVG page) or `hero_page` (image is page's main voice; SVG overlay minimal or empty) |
|
||||
| `items[].text_policy` | yes | Step 3 per-image | `none` (image carries no text — explicit visual rule) or `embedded` (image contains decorative lettering, designed title, hand-lettered keywords, or stable visual identifiers like axis labels / subplot letters / unit symbols). AI judges per image; no global default bias — see §5.3. |
|
||||
| `items[].text_policy` | yes | Step 3 per-image | `none` (image carries no text — explicit visual rule) or `embedded` (image contains stable artistic lettering, hand-lettered keywords, or visual identifiers like axis labels / subplot letters / unit symbols). AI judges per image; no global default bias — see §5.3. |
|
||||
| `items[].aspect_ratio` | yes | Container sizing | Passed to `image_gen.py --aspect_ratio` |
|
||||
| `items[].prompt` | yes | §4 assembly | The full assembled paragraph |
|
||||
| `items[].image_size` | no | Container sizing | `512px` / `1K` / `2K` / `4K` |
|
||||
| `items[].model` | no | Per-item execution override | Backend model for this item; otherwise the CLI/backend default wins |
|
||||
| `items[].alt_text` | no | Accessibility | Short caption |
|
||||
| `items[].slice_grid` | no | §4.3 sheet geometry | Illustration sheet only; exact `RxC` grid to pass to `slice_images.py --grid` |
|
||||
| `items[].slice_names` | no | §4.3 sheet geometry | Illustration sheet only; semantic filenames to pass to `slice_images.py --names` |
|
||||
| `items[].slice_grid` | paired optional | §4.3 sheet geometry | Illustration sheet only; exact `RxC` grid to pass to `slice_images.py --grid`; requires `slice_names` |
|
||||
| `items[].slice_names` | paired optional | §4.3 sheet geometry | Illustration sheet only; comma-separated safe PNG basenames to pass to `slice_images.py --names`; requires exactly `rows*cols` unique outputs |
|
||||
| `items[].status` | yes | CLI manages | `Pending` initially; CLI updates to `Generated` / `Failed` / `Needs-Manual` |
|
||||
|
||||
> **Back-compat for legacy `type` values**: existing manifests using `background` / `hero` / `portrait` / `typography` (the four removed pseudo-types) remain readable. Read them as: `background` → `page_role: hero_page` + no type; `hero` → `page_role: hero_page` + no type (use §4.1 Primitive A in prompt); `portrait` → `page_role: local` + no type (use §4.1 Primitive B); `typography` → `page_role: hero_page` + `text_policy: embedded` + no type (use §4.1 Primitive C). New manifests should follow the rule above (omit `type` when `page_role: hero_page`).
|
||||
> **Back-compat for legacy `type` values**: existing manifests using `background` / `hero` / `portrait` / `typography` (the four removed pseudo-types) remain readable. Read them as: `background` → `page_role: hero_page` + no type; `hero` → `page_role: hero_page` + no type (use §4.1 Primitive A in prompt); `portrait` → `page_role: local` + no type (use §4.1 Primitive B); `typography` → `page_role: hero_page` + `text_policy: embedded` + no type (use §4.1 Primitive C). New manifests omit `type` for hero pages and local single-subject / portrait prose.
|
||||
>
|
||||
> **Existing manifest compatibility**:
|
||||
>
|
||||
> - **Fixed compatibility defaults**: a missing `page_role` resolves to `local`; a missing `text_policy` resolves to `none`. Emit one aggregate legacy-compatibility warning per manifest.
|
||||
> - **Declared replay procedure**: an existing manifest may lack `deck_rendering`, or an existing local item may lack `type`, because `items[].prompt` is already assembled. Leave that metadata absent, execute the existing prompt verbatim, and do not reconstruct either value. This exception applies only to replaying an existing manifest; new manifests must satisfy the field table above. A `hero_page` item still omits `type` intentionally.
|
||||
> - **Declared replay procedure**: an existing manifest may lack `deck_rendering`, or an existing local item may lack `type`, because `items[].prompt` is already assembled. Leave that metadata absent, execute the existing prompt verbatim, and do not reconstruct either value. New manifests follow the field table; hero pages and local single-subject / portrait prose omit `type` intentionally.
|
||||
> - A legacy non-empty `deck_style_anchor` string or object remains readable for replay and sidecar display but never overrides a current `deck_rendering`.
|
||||
> - A legacy `deck_palette` field may remain but cannot override `color_scheme`. Read legacy `page_role: full_page` as `hero_page`.
|
||||
|
||||
---
|
||||
@@ -484,13 +486,13 @@ C (AI-generated) supports three implementation modes sharing one `image_prompts.
|
||||
| `IMAGE_BACKEND` not configured (or Path A fails) AND host has a native image tool | **Path B**: Host-native tool | Agent invokes the host's image capability; outputs land at `project/images/<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 — the effective choice is `auto` (explicitly confirmed or defaulted) or absent — use the automatic A → B → C chain:
|
||||
**Selection logic — declared-procedure fallback when no path is confirmed**: the confirmed user choice wins. When neither channel confirmed a specific path, Generate Step 4 records the effective choice as `auto`; that explicit durable value uses the automatic A → B → C chain. A missing/blank/unknown project value is not an implicit API authorization:
|
||||
|
||||
0. **Confirmed override (wins)** — honor `AI Image Acquisition Path` from `design_spec.md §I`. Generate Step 4 already consumed the final confirmation into that durable artifact; do not reopen `result.json` here. If the recorded choice is set and not `auto`, honor it directly, **even when it contradicts `IMAGE_BACKEND`**:
|
||||
- `api` → **Path A** (`image_gen.py --manifest`).
|
||||
- `host-native` → **Path B** (host's native image tool) — skip A and do **not** run `image_gen.py --manifest`, *even if `IMAGE_BACKEND` is configured*.
|
||||
- `manual` → **Offline Manual** (write prompts, render the Markdown sidecar, hand off; do **not** run `image_gen.py --manifest`).
|
||||
If an explicitly chosen path is unavailable or still fails after its retry, mark the affected row `Needs-Manual`; do not switch to another automated provider. Only when the Design Spec records `auto` or no specific path applies does the automatic chain decide. A legacy project missing this Design Spec row returns to Step 4 recovery to consume persisted confirmation once and record it; Image_Generator does not inspect the confirmation channel itself.
|
||||
If an explicitly chosen path is unavailable or still fails after its retry, mark the affected row `Needs-Manual`; do not switch to another automated provider. Only when the Design Spec records `auto` does the automatic chain decide. A legacy project missing this Design Spec row returns to Step 4 recovery to consume persisted confirmation once and record it; Image_Generator does not inspect the confirmation channel itself.
|
||||
1. **Try Path A** — if `IMAGE_BACKEND` is configured (env or `.env`), run `image_gen.py --manifest`. If it fails twice in a row, fall to Path B.
|
||||
2. **Try Path B** — if `IMAGE_BACKEND` was not configured (A skipped), or A failed, and the host has a native image tool (Codex / Antigravity / Claude Code / similar), the agent invokes the host's image capability directly.
|
||||
3. **Fall to C (Offline Manual)** — if B is also unavailable (no host-native tool) or fails, write prompts to `images/image_prompts.json` and hand off to the user.
|
||||
@@ -507,7 +509,7 @@ python3 scripts/image_gen.py \
|
||||
--output project/images
|
||||
```
|
||||
|
||||
The CLI iterates `items[]` with adaptive concurrency, writes `status` back per item, and is **idempotent**: re-running only re-processes entries whose status is `Pending` or `Failed`.
|
||||
The CLI validates the file behind every `Generated` row before skipping it, iterates retryable rows with bounded adaptive concurrency, and atomically writes each status. A missing/corrupt generated file returns to `Failed`; persistent rate limits finish this run as retryable `Failed` instead of looping forever.
|
||||
|
||||
**Parameters**:
|
||||
|
||||
@@ -621,7 +623,7 @@ When an existing AI Resource List row omits `Reference` or contains a blank `Ref
|
||||
| Purpose | A reasonable starting point |
|
||||
|---------|-----------------------------|
|
||||
| Cover | `page_role: hero_page` + §4.1 Primitive A (single-subject) or D (atmospheric); choose `text_policy` by what the cover should communicate |
|
||||
| Chapter divider | `page_role: hero_page` + Primitive D (atmospheric) or A (single-subject); often `text_policy: embedded` with a designed chapter title |
|
||||
| Chapter divider | `page_role: hero_page` + Primitive D (atmospheric) or A (single-subject); keep the authoritative chapter title in SVG, with `embedded` reserved for separate stable artistic lettering |
|
||||
| Methodology / framework illustration | `type: framework`, `page_role: local` |
|
||||
| Process / workflow illustration | `type: flowchart`, `page_role: local` |
|
||||
| Before/After or two-option page | `type: comparison`, `page_role: local` |
|
||||
|
||||
+65
-31
@@ -4,7 +4,7 @@ A vocabulary registry of ways images can be placed on a slide. The point of this
|
||||
|
||||
Every entry has a name plus a short technical hint. Common techniques get a single line. Less obvious or easily forgotten techniques get a short paragraph — not a full tutorial, but enough that a model unfamiliar with the project can implement it without guessing. This is a registry, not a teaching document; no use-case prescriptions, no decision tables.
|
||||
|
||||
> **Numbers are stable identifiers, not sequence.** The file is split into **Part 1 — Primary Structures** (#1–#19, #38–#56, #73–#81, #88, #92–#94) and **Part 2 — Modifier Layers** (#20–#37, #57–#72, #82–#87, #89–#91, #95–#99). Numbers jump within each Part because Primary structures were grouped first; existing references to `#38`, `#48`, etc. anywhere in the project still resolve correctly. **High-Yield Patterns** below is a router over those same numbers, not a third part — read it first, then jump to the entry it names.
|
||||
> **Numbers are stable identifiers, not sequence.** The file is split into **Part 1 — Primary Structures** (#1–#19, #38–#56, #73–#81, #88, #92–#94) and **Part 2 — Modifier Layers** (#20–#37, #57–#72, #82–#87, #89–#91, #95–#100). Numbers jump within each Part because Primary structures were grouped first; existing references to `#38`, `#48`, etc. anywhere in the project still resolve correctly. **High-Yield Patterns** below is a router over those same numbers, not a third part — read it first, then jump to the entry it names.
|
||||
|
||||
---
|
||||
|
||||
@@ -16,7 +16,7 @@ Almost every pattern below is an instance of one underlying split:
|
||||
|
||||
This is the single most underused move in image-heavy decks. The default reflex is to place image and text in adjacent rectangles. The far more powerful move — especially for content-rich pages — is to let the image **be the canvas** (often full-bleed) and draw native vector elements (annotation cards, flow nodes, KPI tiles, leader lines, network diagrams, dashboards) directly on top.
|
||||
|
||||
Anything that must be editable, numerically accurate, contain Chinese, or be styled to the deck's exact palette belongs in the SVG layer regardless of what the image looks like underneath.
|
||||
Anything that must remain editable, numerically or semantically exact, or styled to the deck's exact typography belongs in the SVG layer regardless of what the image looks like underneath. Script alone never decides ownership.
|
||||
|
||||
---
|
||||
|
||||
@@ -31,7 +31,8 @@ The patterns below are what separate a deck that looks designed from a deck that
|
||||
| One ordinary photo must carry a cover or a chapter divider | `#90` scrim with shapes cut out + `#86` contour echo | Three elements turn a stock image into a designed page; the cut contour is where the page's character comes from |
|
||||
| The supplied image does not fit the canvas | `#89` same image twice — sharp cutout over a receded copy | Subject at full fidelity in any aspect ratio; no stretching, no letterbox bars, no second asset |
|
||||
| Several peer images belong to one frame | `#92` split tiling — one parent cut into interlocking cells | Edges interlock exactly; the group still reads as one object |
|
||||
| One image should appear inside several detached containers | `#82` one image shattered across separated shapes | Merge-Shapes look; the photo runs continuously behind the gaps |
|
||||
| One image should span detached containers as one edit object | `#82` one image shattered across separated shapes | Merge-Shapes look; the photo runs continuously behind the gaps |
|
||||
| Those same-source containers must remain independently editable or animated | `#100` same-source addressable crops | Several native picture objects share one source coordinate system without slice assets |
|
||||
| A panel needs a real opening onto what is behind it | `#83` panel with a hole punched through it | True subtraction — survives a gradient, a texture, or a second image behind the panel |
|
||||
| A photo row needs depth without 3D | `#94` embracing arc row, or `#93` containers arrayed along a curve | A perspective wall reproduced in 2D from scale + vertical offset alone |
|
||||
| A flat scrim reads as a sheet of paint over the photo | `#98` grid scrim with per-cell opacity | The overlay reads as panelled glass or a contact sheet, felt rather than drawn |
|
||||
@@ -39,9 +40,13 @@ The patterns below are what separate a deck that looks designed from a deck that
|
||||
| Text needs legibility but a solid scrim would kill the photo | `#97` frosted-glass panel | The photo's colour and composition stay visible through the panel |
|
||||
| An image grid looks like a stock template | `#88` non-rectangular tessellation with 1–3 cells left empty | The empty cells are where the title and body copy live |
|
||||
| A subject should escape its container | `#85` subject breaking out + `#96` | Depth with no shadow at all |
|
||||
| One place should be recognized across consecutive pages | `#87` one image panned across pages | The deck reads as one continuous scene; with `-t morph` the flip becomes a camera pan |
|
||||
| One place should be recognized across consecutive pages | `#87` one image panned across pages | The deck reads as one continuous scene; `-t morph` uses heuristic matching, while explicit `morph.pairs` makes the camera pan deterministic |
|
||||
|
||||
**Hard rule — registration is what makes this family work**: in `#82`, `#85`, `#87`, `#89`, `#96`, and `#97`, the image stays anchored to the *union* of its containers, or the two copies stay in exact register. A few pixels of drift reads as a printing error, and giving each container its own image collapses the page into an ordinary tile grid. `#84` is the one pattern that breaks registration on purpose, and it only reads as a decision because the others establish the expectation.
|
||||
**Mandatory**: Pair a modifier-only router result with a content-appropriate Part 1 Primary as the page bones before §VIII. The pairing makes the recommendation complete; it does not lock Executor geometry or create a usage quota.
|
||||
|
||||
**Hard rule — registration is what makes this family work**: in `#82`, `#85`, `#87`, `#89`, `#96`, `#97`, and `#100`, the image stays anchored to the *union* of its containers, or the copies share one source coordinate system. A few pixels of drift reads as a printing error. `#84` alone breaks registration on purpose.
|
||||
|
||||
**Prepared-asset gate**: select `#96` only when a registered cutout PNG already exists, `#97` only when its blurred crop exists, and `#99` only when its desaturated copy exists. If not, keep the original asset and fall back to a native-shape treatment such as `#30` / `#29`; do not invent an image-processing step during execution.
|
||||
|
||||
**Skip-detection signal** — if every page's `Layout pattern` resolves to a bare `#2` / `#3` / `#5` / `#6` with no Modifier id, this table was not consulted. Re-open it before finalizing `design_spec.md §VIII`.
|
||||
|
||||
@@ -109,15 +114,15 @@ Pick one or more of these as the page's bones. Cross-primary combinations are en
|
||||
|
||||
This is the family that opens up the largest design space and the one AI is most likely to skip. The shared pattern: image fills the slide (or a large region), native SVG elements are layered on top to carry the actual information. None of the overlay elements need to be generated by the image model — they are vector primitives you draw yourself.
|
||||
|
||||
38. **Background image + annotation cards with bezier leader lines** — full-bleed `<image>` + 2–4 small info cards (`<rect rx>` + icon + title + one-line text) placed in the image's calm regions. From each card, draw a bezier `<path>` ending in a `marker-end` arrow that points to the specific object in the image being annotated. Card text and leader lines are editable; image is the scene.
|
||||
38. **Background image + annotation cards with Shape-first leaders** — full-bleed `<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, draw a dashed `<path>` route that traces a workflow through the scene, with numbered `<circle>` nodes at each stop. Each node = number + icon + label. The flow is fully editable; the image is atmosphere.
|
||||
39. **Background image + flow nodes drawn over the scene** — the image is a real or rendered scene (workshop, control room, landscape). On top, connect numbered `<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; arcs via `<path d="…A…">`. Looks like a live dashboard floating above the scene.
|
||||
42. **Background image + glassmorphism UI panels** — image is the visual world; on top, draw UI elements (semi-transparent panels, progress arcs, status badges, indicators). Panels use `fill-opacity="0.6–0.8"` + thin light-color strokes; use exact native `arc` / `blockArc` presets when they fit, and custom `A` geometry only for data-defined arcs they cannot express. Looks like a live dashboard floating above the scene.
|
||||
|
||||
43. **Background image + native data chart on top** — AI image generation cannot produce accurate data charts. Solution: use an AI-generated dashboard image as **visual reference only** (clearly labeled as such in a caption), and draw the actual chart with native SVG primitives (`<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.
|
||||
|
||||
@@ -154,9 +159,11 @@ This is the family that opens up the largest design space and the one AI is most
|
||||
| Wave band + vertical bars | Rhythmic strip |
|
||||
| Trapezoid + slanted bars | Perspective row |
|
||||
|
||||
**Authoring**: compute each cell's contour and write it as its own `<path>` clip — the geometry is deterministic, so derive the cells rather than eyeballing them. `shape_boolean_svg.py fragment` returns exactly these interlocking regions as separately addressable paths. Give every cell the same stroke (2px, background color) so the cuts read as designed seams.
|
||||
**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.
|
||||
|
||||
**Choosing between #92 and #82**: same construction, opposite content rule. One image across all cells (#82) says "these fragments are one thing"; a different image per cell (#92) says "these are peers, cut from one frame". Mixing them destroys both readings. Distinct from #50 / #51, where cells are independent rectangles that never shared a parent.
|
||||
**Choosing between #92 and #82 / #100**: different images per cell (#92)
|
||||
are peers. One registered source means one edit object (#82) or independent
|
||||
same-source objects (#100).
|
||||
|
||||
52–53. **Filmstrip / stack** — a sequence of `<image>` with thin consistent gaps: horizontal, equal height and varying widths (**#52**), or vertical, aligned by width with shared annotations down one side (**#53**).
|
||||
|
||||
@@ -202,27 +209,45 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
|
||||
|
||||
20–23. **Basic shape crops** — `<clipPath>` holding one shape, referenced by `<image clip-path="url(#id)"/>`: `<circle>` (**#20**), `<rect rx ry>` (**#21**, `rx` sets roundness), `<ellipse>` (**#22**), `<polygon points>` (**#23**, keep every vertex inside the image's display rect). #24 supersedes all four whenever the contour is curved or organic.
|
||||
|
||||
24. **Custom path crop (blob, arrow, leaf, silhouette)** — `<clipPath><path d="…"/></clipPath>`; allows any curved or organic shape. PowerPoint export translates this to `custGeom` and survives roundtrip.
|
||||
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)** — a *single* `<image>` clipped by **one `<path>` whose `d` contains several disjoint subpaths** (`M … Z M … Z`), so one photo appears inside several detached containers — staggered rounded slices, a gapped 2×2 grid, a rotated cross. This is the SVG equivalent of PowerPoint's Merge Shapes 结合 → 相交, and export maps it to one picture with `custGeom`.
|
||||
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).
|
||||
|
||||
**Geometry**: write each container as its own subpath in one `d`. A rounded rect is `M x+r,y H x+w-r A r,r 0 0 1 x+w,y+r V y+h-r A r,r 0 0 1 x+w-r,y+h H x+r A r,r 0 0 1 x,y+h-r V y+r A r,r 0 0 1 x+r,y Z`; repeat per container, all in the same `<path>`. Keep the subpaths disjoint so no winding rule is ever needed.
|
||||
100. **Same-source addressable crops** — repeat one exact `href` in independent
|
||||
nested crop wrappers with different source-unit `viewBox` values. They export
|
||||
as separate native picture objects for editing and Morph while assembling one
|
||||
registered scene without slice assets. Follow
|
||||
[`executor-image.md`](./executor-image.md) §1. Unlike #82 this yields several
|
||||
pictures; unlike #84 registration remains exact.
|
||||
|
||||
**The one thing that makes or breaks it — registration**: place the `<image>` over the *union bounding box* of every subpath (not one image per shape), sized with `preserveAspectRatio="xMidYMid slice"`. The photo then runs continuously *behind* the containers and the gaps read as cuts through one scene. Give each container a different image and it instantly collapses into an ordinary tile grid (#50 / #51) — the continuity is the entire design, not the shapes.
|
||||
|
||||
Distinct from #24 (one connected contour) and #47–#56 (every cell its own image). For non-trivial contours take the `d` from `shape_boolean_svg.py union` / `combine` (see [`native-shape-authoring.md`](./native-shape-authoring.md)) instead of deriving it by hand. Clip-shape constraints — one direct shape child, no `fill-rule` / `clip-rule`, `<image>` targets only — are owned by [`shared-standards-core.md`](./shared-standards-core.md) §1.2.
|
||||
**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`. `shape_boolean_svg.py subtract` emits this contour directly.
|
||||
**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. `shape_boolean_svg.py fragment` returns each atomic region as a separately addressable path when the pieces must be individually positioned.
|
||||
Keep the displacement small and consistent in direction; large or random offsets stop reading as a decision and start reading as a rendering bug. The `fragment` operation of `shape_boolean_svg.py render` returns each atomic region as a separately addressable path when the pieces must be individually positioned; follow [`native-shape-authoring.md`](./native-shape-authoring.md) §6.
|
||||
|
||||
85. **Subject breaking out of its container** — the subject sits half inside a card / grid cell / color panel and half outside its boundary. Two `<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.
|
||||
|
||||
@@ -230,17 +255,22 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
|
||||
|
||||
26. **Triptych baked into a single wide image** — one wide `<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 & Masking Treatments
|
||||
## 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(...)"`.
|
||||
|
||||
> **Crop displacement (HARD rule for text over images).** `preserveAspectRatio="xMidYMid slice"` center-crops whatever the source aspect ratio does not cover — when source and display aspects differ, the subject can land under the text column even if the prompt asked for it on the "focal side". Before layering text on a slice-cropped image: estimate the crop from the aspect-ratio difference, and keep the **entire text column on the scrim's opaque plateau** — text must never start inside a gradient's transition zone. When the subject position is unverified, fall back to an opaque treatment (`#30` at high opacity, or a solid panel) instead of a two-stop scrim (`#29`).
|
||||
|
||||
27. **Linear gradient mask for text legibility** — `<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.
|
||||
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 `#000` / `#fff` around 0.4 for uniform darkening or lightening, the simplest scrim there is (**#30**), or a deck color at 0.15–0.25 to pull a foreign-looking photo toward the palette without regenerating it (**#31**).
|
||||
30–31. **Flat overlay wash** — one `<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.
|
||||
|
||||
@@ -281,7 +311,7 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
|
||||
|
||||
The panel must stay in register with the base photo; a frosted panel showing a *different* part of the scene is the classic tell. Pair with #95 when the panel should also carry a floating edge.
|
||||
|
||||
33. **Spotlight mask — clear region surrounded by darkness** — cover the canvas with `<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.
|
||||
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.
|
||||
|
||||
@@ -301,13 +331,13 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
|
||||
|
||||
## Special Techniques
|
||||
|
||||
62. **Same image, two references — full view + zoom-callout** — reference the same image file twice in two `<image>` elements: one shows the full scene at normal size; the second uses `clipPath` (circle or rectangle) plus a larger display size to "zoom into" a sub-region. Connect them with a bezier `<path>` ending in `marker-end`; ring the zoom with a `<circle stroke>` so it reads as a magnifying lens. No special asset needed — the zoom effect comes from same-source-different-display.
|
||||
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, designed title, hand-lettered keyword. Prompt with explicit text content — name the exact characters literally. Use for text that is part of the artwork and will not change. Anything that must be correct or editable goes in the SVG `<text>` layer (#65).
|
||||
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.
|
||||
|
||||
@@ -321,15 +351,17 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
|
||||
|
||||
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. **Image-to-image transition / merge** — two `<image>` elements with overlapping regions, one or both with gradient masks (from group C) creating a soft blend between them.
|
||||
72. **Baked-alpha image-to-image blend** — a genuinely soft blend between two images requires a precomposited bitmap or source images with baked alpha. An ordinary gradient overlay can conceal the join only when both images fade through the same solid bridge color; it is not a per-pixel mask and cannot blend arbitrary imagery.
|
||||
|
||||
95. **Shape filled with the page background itself** — the most-used trick in real decks and the one that has no obvious SVG name. A shape is painted not with a color but with *the page's own background, sampled at the shape's own position*, so it becomes invisible against the page while still being a real object that can be moved, animated, or given an edge.
|
||||
95. **Shape filled with the page background itself** — the most-used trick in real decks and the one that has no obvious SVG name. A shape is painted not with a color but with *the page's own background, sampled at the shape's own position*, so it becomes invisible against the page while still being a real object that can carry an edge treatment.
|
||||
|
||||
**SVG form**: give the shape the same `<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.
|
||||
- **Invisible objects that still animate** — a background-filled bar can wipe, slide, or morph across the page to reveal or conceal content, while never being visible itself.
|
||||
- **A stationary conceal/reveal patch** — it can cover one fixed region while independent content enters or leaves above or below it.
|
||||
- **Edge-only forms** — the shape disappears but its stroke, glow, or shadow remains, giving a floating outline that appears cut into the page.
|
||||
|
||||
Distinct from #83 (a panel with a real hole) and #90 (a scrim with cuts): those remove paint, this one *impersonates* the background. Reach for it when the thing above must stay a solid object.
|
||||
@@ -352,7 +384,7 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
|
||||
|
||||
87. **One image panned across consecutive pages** — a single wide image referenced by 2–4 consecutive slides, each showing a different horizontal segment (same `<image>` file and container geometry per page, only `x` shifts). Static on its own, it makes the deck read as one continuous scene; the audience recognizes the place before reading a word.
|
||||
|
||||
**To make it actually move, the pages must be morph-compatible**: keep the same image file, the same container size, and the same group `id` on every participating page, then export with `-t morph` ([`animations.md`](./animations.md)). Morph then treats it as one object and slides it — the flip becomes a camera pan. Change the filename or the container dimensions between pages and morph stops matching the object, silently degrading to a cross-fade with none of the effect. Nothing else in the deck needs to know about this; it is a page-authoring decision plus one export flag.
|
||||
**Motion contract**: keep the same image file and compatible direct-root group/container geometry on every participating page. Exporting with `-t morph` alone leaves object matching to PowerPoint's heuristic; stable ids and compatible geometry improve the chance of a camera pan but do not prove it. When the pan must be deterministic, run the custom motion stage and declare the adjacent objects in `animations.json` `morph.pairs` ([`animations.md`](./animations.md) §2.1); the pair may bind different source/destination ids while preserving compatible object kinds. Changing the file or endpoint geometry still changes the visual action and may reduce an unpaired Morph to a cross-fade.
|
||||
|
||||
---
|
||||
|
||||
@@ -360,7 +392,9 @@ Stack any of these freely on top of a Primary structure. Multiple Modifiers per
|
||||
|
||||
A page is built by layering. Pick one or more **Primary Structures** (Part 1) as the page's bones, then add any number of **Modifier Layers** (Part 2) for finish. Both stack — the question on each page is "is the next layer still earning its place", not "have I exceeded a quota".
|
||||
|
||||
**Cross-primary combinations are encouraged.** A side-by-side comparison (#48) where each side is annotated with bezier-leader cards (#38) is one page, not a violation. A 3×3 grid (#9) whose center cell is upgraded to an image-as-canvas with KPI overlay (#40) reads as one composition. The old reflex "one primary per page" tends to under-use the catalog — combine when the page asks for it.
|
||||
**Cross-primary combinations are encouraged.** A side-by-side comparison (#48) where each side is annotated with Shape-first leader cards (#38) is one page, not a violation. A 3×3 grid (#9) whose center cell is upgraded to an image-as-canvas with KPI overlay (#40) reads as one composition. The old reflex "one primary per page" tends to under-use the catalog — combine when the page asks for it.
|
||||
|
||||
**Reference — motion-aware layer vocabulary, not a constraint**: When focus, comparison, evidence, or reveal order serves the page, the Image-as-Canvas + Native Overlay and Multi-Image Compositions families may expose independently meaningful visible units. `#62` can separate full view from same-source detail; `#63` can isolate a cutout foreground; `#74` / `#77` / `#78` / `#80` can separate image-led navigation or evidence units. These are composition layers, not effect assignments, and no pattern owes animation. `#72` is a static image blend in the fully revealed page, not a PowerPoint page transition.
|
||||
|
||||
**Modifier stacking pattern that works in practice** — observed on real content pages combining one Primary with four Modifiers:
|
||||
|
||||
@@ -385,13 +419,13 @@ Combine freely. The "AI-default" failure mode is the opposite: defaulting to bar
|
||||
| Benefits with one dominant proof image | `#80` |
|
||||
| Light promotional page without photos | `#81` |
|
||||
|
||||
**Reach for the boolean-geometry family (#82–#99) before adding another photo to the page.** Routing, the registration invariant, and the skip-detection signal are in **High-Yield Patterns** at the top of this file.
|
||||
**Reach for the boolean-geometry family (#82–#100) before adding another photo to the page.** Routing, the registration invariant, and the skip-detection signal are in **High-Yield Patterns** at the top of this file.
|
||||
|
||||
**Cross-page through-line (recurring motif).** The patterns above are per-page, but a deck reads as *designed* when one illustration motif family recurs across pages—a cover anchor, section dividers repeating the motif (`#75`), and small `#63` spots threaded through the body. Keep one family (shared rendering / locked deck colors / subject world), vary scale and placement, and never turn recurrence into a quota.
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
- Long body copy, data points, numeric labels, and Chinese text always go in the SVG layer — never baked into the image.
|
||||
- Page chrome, body copy, captions, and data values that must remain exact or editable stay in SVG. Stable figure-internal identifiers, axis/unit labels, panel markers, or lettering that is deliberately part of the artwork may be image-owned under `text_policy: embedded`, regardless of script or length.
|
||||
- Project-wide SVG compatibility rules start at [`shared-standards-core.md`](./shared-standards-core.md),
|
||||
whose routing table names each conditional owner. This catalog neither
|
||||
restates nor relaxes that contract; each pattern records only its
|
||||
|
||||
+22
-20
@@ -2,9 +2,9 @@
|
||||
|
||||
# Image Layout Specification
|
||||
|
||||
Sizing reference for side-by-side or multi-image pages. Use only after Strategist selects the composition; this file never selects layout or crop policy.
|
||||
Sizing reference for side-by-side or multi-image pages. Use after Strategist proposes a preferred composition; this file never locks layout or crop policy.
|
||||
|
||||
**Selected pattern, flexible geometry**: Let original aspect ratio inform the container. A `no-crop` asset displays completely; an `adaptive` asset may use `meet` or a focal-safe `slice`. Rework geometry within the selected pattern when either mode produces weak hierarchy, unsafe cropping, or excessive dead space; changing the pattern requires an upstream Design Spec update.
|
||||
**Preferred pattern, Executor-owned realization**: Let original aspect ratio inform the container. Every slide using a `no-crop` asset keeps one complete visible instance; a same-slide same-source detail crop may supplement it. An `adaptive` asset may use `meet` or a focal-safe `slice`. Rework geometry or choose another composition when the recommendation produces weak hierarchy, unsafe cropping, excessive dead space, or a poorer communication result. Preserve binding resource/content/crop constraints; a pattern-only change needs no upstream update.
|
||||
|
||||
> **Scope**: The ratio tables and formulas are calculation aids for a selected side-by-side or multi-image plan. Hero, background, accent, and other compositions stay outside this file. Layout never overrides the `no-crop` boundary owned by [`strategist-image.md`](./strategist-image.md) and [`executor-image.md`](./executor-image.md).
|
||||
|
||||
@@ -13,13 +13,13 @@ Sizing reference for side-by-side or multi-image pages. Use only after Strategis
|
||||
## Layout Decision Flow
|
||||
|
||||
```
|
||||
1. Read the selected narrative intent, hierarchy, and primary/modifier ids from Strategist's plan.
|
||||
2. If the selected pattern is not side-by-side or multi-image, this spec does not apply.
|
||||
1. Read the narrative intent, hierarchy, and preferred primary/modifier ids from Strategist's plan.
|
||||
2. If the preferred or Executor-selected pattern is not side-by-side or multi-image, this spec does not apply.
|
||||
3. Read the asset's `no-crop` boundary and original dimensions; calculate ratio (width/height).
|
||||
4. Use the tables as candidate structures, not an automatic selector.
|
||||
5. Calculate the image/text rectangles, then choose `meet` or focal-safe `slice` within the crop boundary.
|
||||
6. Revise geometry within the selected ids when the result weakens hierarchy, legibility, or required image content.
|
||||
7. Return upstream for a different pattern, resource, role, or crop boundary; Executor never rewrites selection.
|
||||
6. Revise geometry or choose another composition when the result weakens hierarchy, legibility, or required image content.
|
||||
7. Return upstream only for a different resource, role, must-use decision, crop boundary, or another binding constraint; Executor owns pattern-only realization changes.
|
||||
```
|
||||
|
||||
**When to run**: after `analyze_images.py` has produced current dimensions and a side-by-side or multi-image composition is under consideration. Skip this sizing reference for other page structures.
|
||||
@@ -63,8 +63,8 @@ Image height = W / R = 1160 / R px
|
||||
Text area height = H - image height - gap(20px)
|
||||
|
||||
Review: if the remaining text area cannot carry the planned copy legibly,
|
||||
rebalance the rectangles within the selected pattern; otherwise return upstream
|
||||
for a Design Spec pattern update.
|
||||
rebalance the rectangles or choose another composition while preserving binding
|
||||
resource/content/crop constraints.
|
||||
```
|
||||
|
||||
### Left-Right Layout Calculation
|
||||
@@ -83,7 +83,7 @@ Image height = image width / R
|
||||
Text area width = W - image width - gap(20px)
|
||||
```
|
||||
|
||||
**Review**: if the remaining text area cannot carry the planned copy legibly, rebalance the image/text rectangles within the selected pattern; otherwise return upstream for a Design Spec pattern update.
|
||||
**Review**: if the remaining text area cannot carry the planned copy legibly, rebalance the image/text rectangles or choose another composition while preserving binding resource/content/crop constraints.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,7 +108,7 @@ Image: 773x560 (left), Text area: 367x560 (right) → 7:3 left-right
|
||||
```
|
||||
Original: 1820x1040, R=1.75
|
||||
Strategist compares top-bottom: image height=663, text area=-43 ❌
|
||||
Strategist selects left-right: image 780x446 (left), text area 360x600 (right) → 7:3 left-right
|
||||
Strategist recommends left-right: image 780x446 (left), text area 360x600 (right) → 7:3 left-right
|
||||
```
|
||||
|
||||
---
|
||||
@@ -166,7 +166,7 @@ Image positions:
|
||||
(60, 390) 570x290 (650, 390) 570x290
|
||||
```
|
||||
|
||||
> Multi-image slides: decide `meet` or focal-safe `slice` per asset. Keep `no-crop` images complete; do not force every image into the same scaling mode merely for grid uniformity.
|
||||
> Multi-image slides: decide `meet` or focal-safe `slice` per asset. On every slide using a `no-crop` source, keep one complete instance; a same-slide same-source detail crop may supplement it. Do not force every image into the same scaling mode merely for grid uniformity.
|
||||
|
||||
---
|
||||
|
||||
@@ -176,8 +176,8 @@ Image positions:
|
||||
|-----------|-----------------|
|
||||
| Proportion does not reflect information weight | Rebalance image and text rectangles |
|
||||
| Container conflicts with the native ratio | Change the container, choose `meet`, or use a focal-safe crop |
|
||||
| Required pixels, labels, identity, or evidence would be cropped | Use `preserveAspectRatio="xMidYMid meet"` and recompose around the complete image |
|
||||
| Text area cannot carry the planned copy legibly | Increase its area within the selected pattern; otherwise return upstream |
|
||||
| Required pixels, labels, identity, or evidence would be cropped | Use a legal anchor with `meet` and recompose around the complete image |
|
||||
| Text area cannot carry the planned copy legibly | Increase its area or choose another composition while preserving binding constraints |
|
||||
|
||||
---
|
||||
|
||||
@@ -188,10 +188,10 @@ This spec only defines layout calculation. Write computed fields into the Image
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `Ratio` | Original image width / height |
|
||||
| `Layout pattern` | Strategist-selected catalog pattern; semantic composition fixed, geometry flexible |
|
||||
| `Crop Policy` | `no-crop` protects complete pixels; `adaptive` lets Executor choose `meet` or focal-safe `slice` |
|
||||
| `Layout pattern` | Strategist-recommended catalog pattern; preferred composition, Executor-owned realization |
|
||||
| `Crop Policy` | `no-crop` requires one complete instance; `adaptive` lets Executor choose `meet` or focal-safe `slice` |
|
||||
| `Reference` | Optional calculated image/text rectangles, focal notes, and composition intent |
|
||||
| `spec_lock.md images` value | `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-crop>` |
|
||||
| `spec_lock.md images` value | `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-crop>`; source/crop exactly project §VIII, while pattern preserves its ordered catalog ids (or normalized custom prose) as a recommendation, not a geometry/realization lock |
|
||||
|
||||
For SVG `<image>` syntax, path rules, `preserveAspectRatio`, external refs, and Base64 embedding: see [`svg-image-embedding.md`](svg-image-embedding.md).
|
||||
|
||||
@@ -205,6 +205,8 @@ Complete display (`no-crop` assets such as data charts):
|
||||
preserveAspectRatio="xMidYMid meet"/>
|
||||
```
|
||||
|
||||
**Hard rule — no-crop placement**: On every slide using the source, retain one visible complete instance with one of the nine legal anchors plus `meet`, never `none`, and no `clip-path`, `mask`, clipping overflow, or nested `<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
|
||||
@@ -218,12 +220,12 @@ Crop-to-fill (an `adaptive` asset with a verified focal-safe crop):
|
||||
## Automation Tool
|
||||
|
||||
```bash
|
||||
python3 scripts/analyze_images.py <project_path>/images # Default: PPT 16:9
|
||||
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` selects target format (default `ppt169`). The tool computes a top-bottom / left-right candidate, image display area, and text area from the formulas above. Treat its output as planning input; record the composition actually selected for the page.
|
||||
`--canvas` explicitly overrides the project-derived format; `ppt169` is only the fallback. The tool computes a top-bottom / left-right candidate, image display area, and text area from the formulas above. Treat its output as planning input; record the composition actually selected for the page.
|
||||
|
||||
---
|
||||
|
||||
@@ -231,5 +233,5 @@ python3 scripts/analyze_images.py <project_path>/images --canvas xiaohongshu #
|
||||
|
||||
| Role | Responsibility |
|
||||
|------|---------------|
|
||||
| **Strategist** | Run `analyze_images.py`, select the catalog pattern/resources, and record the crop boundary |
|
||||
| **Executor** | Realize the selected pattern for the actual asset/page while preserving its ids, role, source, must-use, and `no-crop` constraints |
|
||||
| **Strategist** | Run `analyze_images.py`, recommend a catalog pattern, select resources, and record the crop boundary |
|
||||
| **Executor** | Choose the actual composition for the asset/page while preserving role, source, must-use, content, and `no-crop` constraints |
|
||||
|
||||
+3
-3
@@ -20,13 +20,13 @@ Organic earthy illustration with botanical and natural motifs. Soft curves, orga
|
||||
|
||||
## 3. Using the deck's HEX values
|
||||
|
||||
nature has a strong tendency toward **earth-toned natural palette**. When the deck's HEX aligns (warm-earth, nature-organic palette), use directly:
|
||||
nature has a strong tendency toward earth-toned material cues, but the deck's locked color roles remain authoritative:
|
||||
|
||||
- Primary HEX: dominant nature color (deep green, deep earth brown)
|
||||
- Secondary HEX: light atmospheric tone (cream, soft sky)
|
||||
- Accent HEX: a small natural pop (a flower, a fruit, a leaf vein)
|
||||
|
||||
If the deck's HEX is cool / corporate, nature may be the wrong rendering — consult the compatibility matrix.
|
||||
If the deck's anchors are cool or corporate, keep their semantic roles and translate the organic material/lighting into that family. If this destroys the intended natural character, choose a different rendering during planning; never invent an image-only palette or alter the deck colors during execution.
|
||||
|
||||
---
|
||||
|
||||
@@ -34,4 +34,4 @@ If the deck's HEX is cool / corporate, nature may be the wrong rendering — con
|
||||
|
||||
**Snippet A — half-page sustainability scene, text_policy: none**
|
||||
|
||||
> Organic illustration style with botanical motifs. A soft natural scene of a single tree on a gentle hill, with curving organic forms and earthy color palette. Tree foliage in primary deep forest green `#166534` with subtle leaf-vein texture at 12% opacity. Tree trunk in warm earth brown using a darker shade. Hill in soft secondary cream `#FEF3C7` with very gentle curve. Sky in pale soft blue suggesting morning. A small accent of warm yellow `#D4AF37` flowers at the base of the tree. No hard outlines — forms defined by color contrast and soft organic edges. Composed as a 600×800 half-page block with 12% inner padding. NO text or labels. Color values are rendering guidance only.
|
||||
> Organic illustration style with botanical motifs. A soft natural scene of a single tree on a gentle hill, with curving organic forms and earthy color palette. Tree foliage in primary deep forest green `#166534` with subtle leaf-vein texture at 12% opacity. Tree trunk in warm earth brown using a darker shade. Hill in soft secondary cream `#FEF3C7` with very gentle curve. Sky in pale soft blue suggesting morning. A small accent of warm yellow `#D4AF37` flowers at the base of the tree. No hard outlines — forms defined by color contrast and soft organic edges. Composed as a 600×800 half-page block with 12% inner padding. NO text or labels. Color values are rendering guidance only.
|
||||
|
||||
@@ -48,16 +48,14 @@ Strict: provider chain, license filter = cc0,pdm,pexels,pixabay
|
||||
|
||||
| Provider | Config | Strength |
|
||||
|---|---|---|
|
||||
| Openverse | zero-config | fallback aggregator: Wikimedia + Flickr + museums + rawpixel |
|
||||
| Wikimedia Commons | zero-config | educational, scientific, geographic, historical |
|
||||
| Pexels | recommended: `PEXELS_API_KEY` (free, [signup](https://www.pexels.com/api/)) | modern stock photography, people, workplace, lifestyle |
|
||||
| Pixabay | recommended: `PIXABAY_API_KEY` (free, [signup](https://pixabay.com/api/docs/)) | broad type coverage including photos and illustrations |
|
||||
| Openverse | zero-config | fallback aggregator: Wikimedia + Flickr + museums + rawpixel |
|
||||
| Wikimedia Commons | zero-config | educational, scientific, geographic, historical |
|
||||
|
||||
Default chain (when `--provider` is unset):
|
||||
|
||||
```
|
||||
openverse → wikimedia → pexels (if PEXELS_API_KEY set) → pixabay (if PIXABAY_API_KEY set)
|
||||
```
|
||||
`pexels` (when keyed) → `pixabay` (when keyed) → `openverse` → `wikimedia`.
|
||||
|
||||
Keyed providers without an API key are silently skipped — not an error.
|
||||
|
||||
@@ -67,23 +65,16 @@ Keyed providers without an API key are silently skipped — not an error.
|
||||
|
||||
## 4. Intent → Query Translation
|
||||
|
||||
Web image APIs match keywords against image metadata, not semantic embeddings. `simplify_query` automatically:
|
||||
Keep two layers distinct:
|
||||
|
||||
1. Strips HEX color codes (`#1E3A5F`) and parentheticals (`(corporate vibe)`)
|
||||
2. Drops hard-noise words: brand names, generic filler
|
||||
3. Drops soft-noise words (`ai`, `tech`, `platform`, `professional`, `editorial`, `photo`, `background`) — only when concrete nouns remain
|
||||
4. Caps at 4 words
|
||||
5. **Fail-open**: if filtering empties the query, return the original
|
||||
|
||||
Then `build_query_progression` tries: original → simplified (4 words) → simplified (3 words). First non-empty hit wins.
|
||||
|
||||
**Per-row web Reference grammar**:
|
||||
|
||||
| Segment | Rule |
|
||||
| Layer | Owner and grammar |
|
||||
|---|---|
|
||||
| Subject | Use 1-2 concrete nouns only: `offshore wind farm`, `Xiamen skyline`, `boardroom meeting` |
|
||||
| Quality cues | **DO NOT ADD QUALITY CUES** like `professional editorial photography` or `clean composition`. These APIs use exact keyword matching; adding long adjectives will result in 0 matches. |
|
||||
| Language | For Chinese landmarks: use precise Chinese names (e.g., `磁器口古镇`) if specifically targeting `--provider wikimedia`. For general stock providers (Pexels/Pixabay), use simple English nouns (e.g., `Chongqing Jiefangbei`); do NOT use complex Chinese sentences or overly long English descriptive strings which fail on these platforms. |
|
||||
| Design Spec §VIII `Reference` | Strategist's complete visual intent: exact subject, desired view/mood, focal or quiet region, and crop-safety constraints. Positive quality cues are valid here. |
|
||||
| `image_queries.json.items[].query` / positional query | Image_Searcher's provider keyword string: 1–4 concrete entity or identity words only; omit mood, quality, composition, HEX, and negative wording. |
|
||||
|
||||
Web APIs match metadata, not semantic intent. For ad-hoc long positional input, `simplify_query` strips noise and caps the fallback at four words, but a pipeline manifest should already contain the short provider query. For Chinese landmarks, use the precise Chinese name with Wikimedia; for stock providers, use short English identity terms.
|
||||
|
||||
Image_Searcher consumes the locked Reference and never rewrites `design_spec.md` or `spec_lock.md`. A candidate either satisfies that existing subject/focal/crop intent, or the role re-queries once and then marks `Needs-Manual`.
|
||||
|
||||
When the subject is an exact entity (landmark / person / company / product / venue), write `required_terms` at the same time you write the row's `query`. Use one required group per identity anchor and `|` for aliases / translations, e.g. `["Chongqing|重庆", "Jiefangbei|解放碑|Liberation Monument"]`. This keeps the query short for provider search while preventing metadata-ranked wrong entities from being accepted.
|
||||
|
||||
@@ -93,11 +84,11 @@ Do **not** loosen `required_terms` to generic category words just to improve cov
|
||||
|
||||
> Note: Keyword APIs search negative words literally.
|
||||
|
||||
| ✅ Good Reference (intent) | ❌ Avoid |
|
||||
| §VIII Reference (intent) | Provider query |
|
||||
|---|---|
|
||||
| "Offshore wind farm at dusk, aerial view, professional editorial photography" | "professional editorial photography background" |
|
||||
| "Diverse engineering team collaborating around a laptop, modern office, natural light" | "use Openverse, search 'team'" |
|
||||
| "Sunlit forest path in autumn, clean composition, high-resolution photography" | "Hero image, dramatic lighting" |
|
||||
| "Offshore wind farm at dusk, aerial view, quiet sky on the left for safe crop" | `offshore wind farm` |
|
||||
| "Diverse engineering team around a laptop, modern office, natural light" | `engineering team laptop` |
|
||||
| "Chongqing Jiefangbei monument, full structure visible, landscape frame" | `Chongqing Jiefangbei monument` |
|
||||
|
||||
---
|
||||
|
||||
@@ -120,13 +111,14 @@ python3 scripts/image_search.py "<query>" \
|
||||
| `--slide` | no | `""` | Slide ID from resource list (recorded in manifest) |
|
||||
| `--purpose` | no | `""` | `background` / `hero` / `side` / `accent` |
|
||||
| `--orientation` | no | `any` | `any` / `landscape` / `portrait` / `square` |
|
||||
| `--min-width / --min-height` | no | `1200 / 800` | Actual downloaded-pixel floors; `--from-url` honors explicit lower overrides |
|
||||
| `--provider` | no | (chain) | Pin one provider |
|
||||
| `--strict-no-attribution` | no | off | Restrict to no-attribution licenses; refuse CC BY / CC BY-SA |
|
||||
| `--require-terms` | no | — | Entity-safety gate for exact subjects. Repeatable; comma separates required groups; `A|B` means aliases within one group. Example: `--require-terms Chongqing --require-terms "Jiefangbei|Liberation Monument"` |
|
||||
| `--manifest` | no | (default) | Override manifest path |
|
||||
| `--save-candidates` | no | off | Escalation only: also keep a review pool in `candidates/<stem>/`. Default downloads just the best match (+ a review copy) |
|
||||
| `--max-candidates` | no | `4` | Pool size when `--save-candidates` is set |
|
||||
| `--promote` | no | — | Promote a reviewed candidate to the target filename, e.g. `--promote candidate_03.jpg --filename team.jpg -o <dir>` |
|
||||
| `--promote` | no | — | Human-selected candidate override; low resolution warns but does not block promotion |
|
||||
| `--from-url` | no | — | Manual replace: download a user-supplied image URL into `--filename` (recorded `license_tier: manual`); works without a multimodal model |
|
||||
|
||||
### Batch mode (≥ 2 web rows) — preferred
|
||||
@@ -162,7 +154,7 @@ Use `required_terms` for **exact-entity images**: landmarks, people, companies,
|
||||
|
||||
For less-covered local attractions, keep the strict identity gate rather than progressively deleting location anchors or replacing proper names with category words. If strict metadata cannot prove the entity, mark the row `Needs-Manual` and use the manual URL path when the user supplies a confirmed source.
|
||||
|
||||
The runner searches all `Pending` / `Failed` rows concurrently, appends each success to `image_sources.json` (the credit source of truth, idempotent on `filename`), and writes status back into `image_queries.json` — `Sourced` on success, `Needs-Manual` when the full provider/stage chain is exhausted. Status is saved after each completion, so an interrupted run preserves finished rows; re-running skips terminal rows. A single `web` row may still use single-query mode above.
|
||||
The runner first revalidates every `Sourced` row against its readable file, requested dimensions, and `image_sources.json` entry; drift returns that row to `Failed`. It then searches all `Pending` / `Failed` rows concurrently, appends each success to the provenance manifest, and writes status back into `image_queries.json`: `Sourced` on success, retryable `Failed` on provider/download errors, and terminal `Needs-Manual` only after a clean provider/stage exhaustion. Status is saved after each completion. A single `web` row may still use single-query mode above.
|
||||
|
||||
**Pacing**: free providers (Wikimedia/Openverse) are rate-sensitive, so batch concurrency defaults to a modest **3** (`--concurrency N`, or `IMAGE_SEARCH_CONCURRENCY` env). Use `--concurrency 1` to restore strict one-at-a-time pacing. Single-query mode is one request at a time by nature.
|
||||
|
||||
@@ -180,12 +172,12 @@ Do not tune this into a visual taste engine. The scorer prevents obvious metadat
|
||||
|
||||
### Suitability review — with or without a multimodal model
|
||||
|
||||
A metadata-ranked top hit is *downloadable and token-relevant*, not necessarily *visually suitable* — `score_candidate` never sees pixels. So a web best match should be reviewed before it is trusted, by whichever reviewer is available:
|
||||
A metadata-ranked top hit is *downloadable and token-relevant*, not necessarily *visually suitable* — `score_candidate` never sees pixels. Review it against the locked §VIII Reference and Crop Policy before it is trusted:
|
||||
|
||||
- **Multimodal model**: each download writes a downscaled review copy to `images/.review/<stem>.jpg` (the placed asset stays full-resolution; the bounded copy just keeps a very large original safe and quick to open). Read it and judge fit — subject, mood, quality, not just topic overlap.
|
||||
- **Multimodal model**: each download writes a downscaled review copy to `images/.review/<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.
|
||||
|
||||
For exact-entity rows, suitability has two gates: `required_terms` first enforces metadata identity, then the `.review` image confirms the pixels actually show the right subject well enough. A row can still be rejected after passing `required_terms` if the visual is weak, cropped badly, or only tangentially shows the subject.
|
||||
For exact-entity rows, suitability has two gates: `required_terms` first enforces metadata identity, then the `.review` image confirms the pixels actually show the right subject and satisfy the locked focal/crop intent. Passing metadata never authorizes changing that intent downstream.
|
||||
|
||||
Never treat a generic `required_terms` pass as acceptance. For example, matching `Ground Fissure` can return an unrelated transit station named Yunlong, and matching `stone pillar` can return a different scenic area. If the proper name / geography cannot be retained, stop at `Needs-Manual`.
|
||||
|
||||
@@ -266,10 +258,10 @@ Every successful download appends or replaces one entry keyed on `filename`:
|
||||
| `metadata_dimensions` | Present only when upstream-claimed size differs from the saved file (preview vs original). Informational only. |
|
||||
| `license_tier` | Drives Executor's attribution decision: `no-attribution` / `attribution-required` for provider-sourced images, or `manual` for a user-supplied `--from-url` replacement (embed only; rights/credit are the user's responsibility). |
|
||||
| `attribution_required` | Boolean alias of `license_tier == "attribution-required"`. |
|
||||
| `attribution_text` | Pre-rendered canonical credit string. **Use as-is; do not regenerate.** |
|
||||
| `attribution_text` | Canonical credit source. Preserve its author/provider/license facts; compress only through §7's visual grammar rather than inventing or dropping identity. |
|
||||
| `stage` | `all` by default, or `no-attribution-only` when strict mode is used. |
|
||||
|
||||
> Manifest is **idempotent on `filename`**. Rerunning the CLI replaces that entry; other entries are preserved.
|
||||
> Manifest is **idempotent on `filename`** and written atomically. Rerunning replaces that entry while preserving all others. An existing unreadable/non-object manifest blocks the write instead of being overwritten as fresh state.
|
||||
|
||||
---
|
||||
|
||||
@@ -324,9 +316,10 @@ Extends [`image-base.md`](./image-base.md) §6.
|
||||
| No candidates from any provider in either stage | Mark row `Needs-Manual`. Suggest: shorter query, drop `--strict-no-attribution`, or set keyed provider's API key. |
|
||||
| Single candidate fails to download (HTTP 403/404) | Dispatcher auto-falls through to the next ranked candidate. No user action. |
|
||||
| All candidates from one provider fail | Dispatcher moves to the next provider in the chain. |
|
||||
| Provider/network failure remains after dispatch | Mark row `Failed`; a later batch run retries it. |
|
||||
| Keyed provider has no API key | Silently skipped. Not an error. |
|
||||
|
||||
CLI exit: `0` on success, `1` only when no acceptable image was found across the entire dispatch matrix.
|
||||
CLI exit: `0` when all attempted rows resolve; `1` while any row remains `Failed` or `Needs-Manual`.
|
||||
|
||||
---
|
||||
|
||||
@@ -334,7 +327,7 @@ CLI exit: `0` on success, `1` only when no acceptable image was found across the
|
||||
|
||||
Reference field is **intent description**, not a query. See [`image-base.md`](./image-base.md) §8 for the rule.
|
||||
|
||||
If the description is verbose, that's fine — `simplify_query` handles it.
|
||||
Keep it intact as the acceptance contract. Derive a separate 1–4 word provider query; do not pass the Reference verbatim or rewrite it after search.
|
||||
|
||||
---
|
||||
|
||||
@@ -350,7 +343,7 @@ Executor reads `image_sources.json` per slide that uses a Sourced image. For eac
|
||||
|
||||
Executor does not interpret raw license strings — `license_tier` is sufficient.
|
||||
|
||||
`svg_quality_checker.py` verifies this handoff before post-processing: if an attribution-required image is referenced without visible `CC BY` / `CC BY-SA` credit text, the SVG fails the quality gate.
|
||||
`svg_quality_checker.py` verifies this handoff before post-processing: a referenced attribution-required image needs its own visible author + CC BY / CC BY-SA credit; one generic deck-level CC token cannot satisfy several images.
|
||||
|
||||
---
|
||||
|
||||
@@ -359,8 +352,8 @@ Executor does not interpret raw license strings — `license_tier` is sufficient
|
||||
In addition to the shared checkpoint in [`image-base.md`](./image-base.md) §10:
|
||||
|
||||
- [ ] Every web row has a downloaded file at `project/images/<filename>` OR is marked `Needs-Manual`
|
||||
- [ ] Each `Sourced` web image was reviewed for fit — a multimodal model via its `images/.review/<stem>.jpg` copy, otherwise handed to the user via `source_page_url`; a poor fit was re-queried, replaced with `--from-url`, escalated via `--save-candidates` + `--promote`, or marked `Needs-Manual`
|
||||
- [ ] Each `Sourced` web image was reviewed against the locked Reference/Crop Policy — a multimodal model via `images/.review/<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` row has a manifest entry with valid `license_tier` and non-empty `attribution_text` (except `manual` `--from-url` rows, which carry no `attribution_text`)
|
||||
- [ ] Any `attribution-required` image has visible inline credit text in the corresponding SVG
|
||||
- [ ] Any `attribution-required` image has visible author + license credit in every SVG that references it
|
||||
- [ ] `metadata_dimensions` warnings surfaced when downloaded preview is much smaller than upstream-claimed size
|
||||
- [ ] `Needs-Manual` rows include the failure reason
|
||||
|
||||
+6
-5
@@ -9,10 +9,10 @@ A **type** describes the **internal geometric composition skeleton** of a local
|
||||
**Type *is not***:
|
||||
|
||||
- *not* "what this image is for in the PPT page" — that's `page_role` (`local` vs `hero_page`, see [`image-generator.md`](../image-generator.md) §1)
|
||||
- *not* "what subject occupies the image" — single subject, single person, big number, no subject: these are all expressible through §4.1 hero-page primitives or natural-language prompt description, not through types
|
||||
- *not* "what subject occupies the image" — single subject, single person, big number, no subject: these are all expressible through §4.1 no-type primitives or natural-language prompt description, not through types
|
||||
- *not* a high-level asset category — the row's `Purpose` + `Reference` columns in `design_spec.md §VIII` already carry that, no separate vocabulary needed
|
||||
|
||||
**When to skip type entirely** — when `page_role: hero_page` (the image is the page's main voice: cover, chapter divider, mood transition, signature stat, closing quote), do **not** pick a type. Instead describe the composition directly using the four primitives in [`image-generator.md`](../image-generator.md) §4.1 (single-subject / portrait / typographic / atmospheric). The 11 types below are for local infographic blocks only.
|
||||
**When to skip type entirely** — every `hero_page` omits type and uses the prose primitives in [`image-generator.md`](../image-generator.md) §4.1. A local single-subject or single-person region also omits type and uses Primitive A/B sized to that region. The 11 types below are for local structural infographic blocks only.
|
||||
|
||||
---
|
||||
|
||||
@@ -53,7 +53,8 @@ For each row in `design_spec.md §VIII Image Resource List` where `page_role: lo
|
||||
| History / evolution / roadmap / timeline | `timeline` |
|
||||
| Offices / market presence / regions / supply chain / geography | `map` |
|
||||
| Team / lifestyle / story / scenario / case (group, with environment) | `scene` |
|
||||
| Cover / chapter divider / mood transition / big number / hero quote / single-subject hero / single-person headshot | **No type — use `page_role: hero_page` + [`image-generator.md`](../image-generator.md) §4.1 primitives** |
|
||||
| Cover / chapter divider / mood transition / big number / hero quote / single-subject hero | **No type — use `page_role: hero_page` + [`image-generator.md`](../image-generator.md) §4.1 primitives** |
|
||||
| Local single object / single-person headshot / bio portrait | **No type — keep `page_role: local`; use §4.1 Primitive A/B for the region** |
|
||||
|
||||
`text_policy` and `page_role` are decided per image — see each type file's variants section and the page's communication goal.
|
||||
|
||||
@@ -83,8 +84,8 @@ For `page_role: hero_page` images, default container is the slide canvas (e.g. 1
|
||||
|
||||
## 4. How to use
|
||||
|
||||
1. For each `page_role: local` row in the Image Resource List, pick the type using the auto-selection table above.
|
||||
2. For each `page_role: hero_page` row, **skip type selection** — go straight to [`image-generator.md`](../image-generator.md) §4.1 primitives.
|
||||
1. For each local structural infographic row, pick the type using the table above.
|
||||
2. For each `hero_page` or local single-subject / portrait row, skip type selection and use the applicable [`image-generator.md`](../image-generator.md) §4.1 prose.
|
||||
3. `read_file image-type-templates/<type>.md` — only the types actually used in this deck. Most decks use 2-4 types; load each at most once.
|
||||
4. Apply the type's composition skeleton alongside the locked deck-wide rendering and deck color roles.
|
||||
|
||||
|
||||
+2
-2
@@ -39,7 +39,7 @@ Sample fragment:
|
||||
|
||||
### 3.2 `text_policy: embedded`
|
||||
|
||||
Self-contained cycle diagram with step names typeset into the artwork. Keep step names to single English words ("PLAN", "DO", "CHECK", "ACT") in a font family echoing the deck's body typography.
|
||||
Self-contained cycle diagram with step names typeset into the artwork. Keep each name concise and stable, use the wording and script required by the content, and echo the deck's body typography.
|
||||
|
||||
---
|
||||
|
||||
@@ -47,4 +47,4 @@ Self-contained cycle diagram with step names typeset into the artwork. Keep step
|
||||
|
||||
**Snippet A — vector-illustration + cool-corporate PDCA cycle, text_policy: none, 700×700**
|
||||
|
||||
> Clean flat vector illustration of a closed-loop process cycle. Four step nodes arranged in a perfect circle on the canvas perimeter — at 12 o'clock, 3 o'clock, 6 o'clock, and 9 o'clock positions. Each node is a rounded square (about 130×130px equivalent) filled with primary deep navy `#1E3A5F`, with one simple white iconic symbol centered inside — a clipboard (plan), a hand (do), a magnifying glass (check), a gear (act). Curved directional arrows in accent gold `#D4AF37` connect each node to the next going clockwise, closing the loop. The center of the circle is calm — secondary light gray `#F8F9FA` field with one small accent gold dot at the exact center as the cycle's anchor. Crisp 2px outlines, soft 8% drop shadow under each node. Composed as a 700×700 half-page cycle block with 15% padding. NO text, letters, or step labels anywhere — SVG will overlay all step names. Color values are rendering guidance only.
|
||||
> Clean flat vector illustration of a closed-loop process cycle. Four step nodes arranged in a perfect circle on the canvas perimeter — at 12 o'clock, 3 o'clock, 6 o'clock, and 9 o'clock positions. Each node is a rounded square (about 130×130px equivalent) filled with primary deep navy `#1E3A5F`, with one simple white iconic symbol centered inside — a clipboard (plan), a hand (do), a magnifying glass (check), a gear (act). Curved directional arrows in accent gold `#D4AF37` connect each node to the next going clockwise, closing the loop. The center of the circle is calm — secondary light gray `#F8F9FA` field with one small accent gold dot at the exact center as the cycle's anchor. Crisp 2px outlines, soft 8% drop shadow under each node. Composed as a 700×700 half-page cycle block with 15% padding. NO text, letters, or step labels anywhere — SVG will overlay all step names. Color values are rendering guidance only.
|
||||
|
||||
+2
-2
@@ -65,7 +65,7 @@ Sample fragment to add to the prompt:
|
||||
|
||||
### `text_policy: embedded`
|
||||
|
||||
A short keyword (1-2 English words) appears inside or beside each satellite / cell / layer.
|
||||
A concise, stable keyword in the required script appears inside or beside each satellite / cell / layer.
|
||||
|
||||
Sample fragment:
|
||||
|
||||
@@ -77,4 +77,4 @@ Sample fragment:
|
||||
|
||||
**Snippet A — vector-illustration + cool-corporate, hub-and-spokes, `text_policy: none`, 700×700 half-page**
|
||||
|
||||
> Clean flat vector illustration with bold geometric shapes and confident solid fills. Crisp 2px outlines, no gradients, single 8% soft drop shadow under elevated elements. The composition is a hub-and-spokes framework: one central rounded-square node in primary deep navy `#1E3A5F` sits at the exact center; four satellite circles in the same navy are evenly distributed around it (top, right, bottom, left), connected to the center by thin straight lines in a darker neutral. Each satellite contains one simple iconic symbol in white fill — a gear, a chart bar, an upward arrow, a chat bubble — chosen for clear recognition at small sizes. Background is calm secondary light gray `#F8F9FA` carrying 65% of the canvas area. Accent gold `#D4AF37` appears only as one thin emphasis ring around the central node — under 5% of canvas area. Composed as a 700×700 half-page block with 16% inner padding on all sides — satellites breathe well within the canvas, no element touches the edge. No text, letters, numbers, or labels anywhere in the image — SVG labels will be added externally. Color values are rendering guidance only — do not display HEX codes or color names as text. Simplified iconic symbols only, no realistic faces.
|
||||
> Clean flat vector illustration with bold geometric shapes and confident solid fills. Crisp 2px outlines, no gradients, single 8% soft drop shadow under elevated elements. The composition is a hub-and-spokes framework: one central rounded-square node in primary deep navy `#1E3A5F` sits at the exact center; four satellite circles in the same navy are evenly distributed around it (top, right, bottom, left), connected to the center by thin straight lines in a darker neutral. Each satellite contains one simple iconic symbol in white fill — a gear, a chart bar, an upward arrow, a chat bubble — chosen for clear recognition at small sizes. Background is calm secondary light gray `#F8F9FA` carrying 65% of the canvas area. Accent gold `#D4AF37` appears only as one thin emphasis ring around the central node — under 5% of canvas area. Composed as a 700×700 half-page block with 16% inner padding on all sides — satellites breathe well within the canvas, no element touches the edge. No text, letters, numbers, or labels anywhere in the image — SVG labels will be added externally. Color values are rendering guidance only — do not display HEX codes or color names as text. Simplified iconic symbols only, no realistic faces.
|
||||
|
||||
+2
-2
@@ -40,7 +40,7 @@ Sample fragment:
|
||||
|
||||
### 3.2 `text_policy: embedded`
|
||||
|
||||
Self-contained funnel diagram where band names are typeset into the artwork. Keep band names to single English words ("AWARE", "LIKE", "BUY", "REFER") in a font family echoing the deck's body typography. High failure risk on 5+ band funnels — stay at 3-4 bands when going embedded.
|
||||
Self-contained funnel diagram where band names are typeset into the artwork. Keep each name concise and stable in the required script and echo the deck's body typography. When exact/editable labels or narrow bands cannot preserve legibility, move those labels to SVG; otherwise embedded lettering remains valid after visual review.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,4 +48,4 @@ Self-contained funnel diagram where band names are typeset into the artwork. Kee
|
||||
|
||||
**Snippet A — vector-illustration + cool-corporate marketing funnel, text_policy: none, 600×800**
|
||||
|
||||
> Clean flat vector illustration of a marketing conversion funnel. Four horizontal bands stacked vertically, each band centered on the vertical axis and each ~20% narrower than the one above. Band 1 (top, widest): primary deep navy `#1E3A5F` solid fill, with a simple white megaphone icon centered on the left. Band 2: secondary lighter navy tint, with a heart icon. Band 3: accent gold `#D4AF37`, with a shopping-cart icon. Band 4 (bottom, narrowest): deeper accent gold, with a star icon. Each band has crisp straight edges and 8% drop shadow beneath. Thin secondary cream `#F8F9FA` dividers separate the bands. Background is calm secondary cream. Composed as a 600×800 portrait funnel block with 12% padding. NO text, letters, numbers, or stage labels anywhere — SVG will overlay all band names. Color values are rendering guidance only.
|
||||
> Clean flat vector illustration of a marketing conversion funnel. Four horizontal bands stacked vertically, each band centered on the vertical axis and each ~20% narrower than the one above. Band 1 (top, widest): primary deep navy `#1E3A5F` solid fill, with a simple white megaphone icon centered on the left. Band 2: secondary lighter navy tint, with a heart icon. Band 3: accent gold `#D4AF37`, with a shopping-cart icon. Band 4 (bottom, narrowest): deeper accent gold, with a star icon. Each band has crisp straight edges and 8% drop shadow beneath. Thin secondary cream `#F8F9FA` dividers separate the bands. Background is calm secondary cream. Composed as a 600×800 portrait funnel block with 12% padding. NO text, letters, numbers, or stage labels anywhere — SVG will overlay all band names. Color values are rendering guidance only.
|
||||
|
||||
+2
-2
@@ -40,7 +40,7 @@ Sample fragment:
|
||||
|
||||
### 3.2 `text_policy: embedded`
|
||||
|
||||
Self-contained reference map where place names are typeset into the design. High failure risk — image models often misspell place names or place them at wrong coordinates. Prefer `none` + SVG overlay unless the design language genuinely requires in-image labels (vintage cartography, atlas-style poster).
|
||||
Self-contained reference map where place names are typeset into the design. Use this when lettering is genuinely part of the visual language (for example vintage cartography or an atlas-style poster) and review spelling/location before use. Labels that must remain exact or editable belong in SVG.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,4 +48,4 @@ Self-contained reference map where place names are typeset into the design. High
|
||||
|
||||
**Snippet A — vector-illustration + cool-corporate world office map, text_policy: none, 1280×720**
|
||||
|
||||
> Clean flat vector illustration of a stylized world map. Simplified continental landmasses (recognizable but not photorealistic) in primary deep navy `#1E3A5F` solid fill, positioned across the canvas with approximate geographic accuracy. Ocean/negative space is secondary light gray `#F8F9FA`. Eight small accent gold `#D4AF37` circular markers placed at meaningful office locations — three in North America, two in Europe, one in East Asia, one in South Asia, one in Australia. Each marker has a thin pulsing-glow effect at 30% opacity (a subtle ring around the dot). Two thin accent gold connection lines (slightly curved, suggesting flight paths) connect three of the markers. Crisp 1.5px outlines on the landmasses. No country borders within continents — landmasses are single-color solid silhouettes. Composed as a 1280×720 full-bleed world map with 12% padding. NO text, country names, or labels anywhere — SVG will overlay all labels. Color values are rendering guidance only.
|
||||
> Clean flat vector illustration of a stylized world map. Simplified continental landmasses (recognizable but not photorealistic) in primary deep navy `#1E3A5F` solid fill, positioned across the canvas with approximate geographic accuracy. Ocean/negative space is secondary light gray `#F8F9FA`. Eight small accent gold `#D4AF37` circular markers placed at meaningful office locations — three in North America, two in Europe, one in East Asia, one in South Asia, one in Australia. Each marker has a thin pulsing-glow effect at 30% opacity (a subtle ring around the dot). Two thin accent gold connection lines (slightly curved, suggesting flight paths) connect three of the markers. Crisp 1.5px outlines on the landmasses. No country borders within continents — landmasses are single-color solid silhouettes. Composed as a 1280×720 full-bleed world map with 12% padding. NO text, country names, or labels anywhere — SVG will overlay all labels. Color values are rendering guidance only.
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@ Sample fragment:
|
||||
|
||||
### 3.2 `text_policy: embedded`
|
||||
|
||||
When the matrix's identity comes from its in-image lettering — a stylized SWOT poster with the four letters as visuals, a designer-matrix where axis labels are typeset into the artwork. Keep labels to single English words ("HIGH", "LOW", "GROW", "HOLD"). Specify the font family in the prompt to echo the deck's body typography.
|
||||
When the matrix's identity comes from its in-image lettering — a stylized SWOT poster with the four letters as visuals, or a designer matrix whose axis labels are part of the artwork. Keep labels concise and stable in the required script. Specify a font family that echoes the deck's body typography; move exact/editable labels to SVG instead.
|
||||
|
||||
---
|
||||
|
||||
@@ -49,4 +49,4 @@ When the matrix's identity comes from its in-image lettering — a stylized SWOT
|
||||
|
||||
**Snippet A — vector-illustration + cool-corporate SWOT matrix, text_policy: none, 800×800**
|
||||
|
||||
> Clean flat vector illustration of a 2×2 strategic matrix. Two perpendicular thin lines in primary deep navy `#1E3A5F` cross at the exact canvas center, dividing the canvas into four equal quadrants. Each quadrant has a subtle background tint: upper-left in pale primary navy at 10% opacity, upper-right in pale accent gold `#D4AF37` at 15% opacity, lower-left in pale gray `#F8F9FA`, lower-right in slightly deeper pale navy at 18% opacity. Each quadrant contains one simple iconic symbol in primary navy, centered within its quadrant — a shield (strength), a lightning bolt (opportunity), a target (weakness), an alert triangle (threat). Each icon occupies about 45% of its quadrant. Small accent gold dots sit at the four outer corners. Composed as an 800×800 reference matrix block with 10% padding. NO text, letters, axis labels, or quadrant names anywhere — SVG will overlay all labels. Color values are rendering guidance only.
|
||||
> Clean flat vector illustration of a 2×2 strategic matrix. Two perpendicular thin lines in primary deep navy `#1E3A5F` cross at the exact canvas center, dividing the canvas into four equal quadrants. Each quadrant has a subtle background tint: upper-left in pale primary navy at 10% opacity, upper-right in pale accent gold `#D4AF37` at 15% opacity, lower-left in pale gray `#F8F9FA`, lower-right in slightly deeper pale navy at 18% opacity. Each quadrant contains one simple iconic symbol in primary navy, centered within its quadrant — a shield (strength), a lightning bolt (opportunity), a target (weakness), an alert triangle (threat). Each icon occupies about 45% of its quadrant. Small accent gold dots sit at the four outer corners. Composed as an 800×800 reference matrix block with 10% padding. NO text, letters, axis labels, or quadrant names anywhere — SVG will overlay all labels. Color values are rendering guidance only.
|
||||
|
||||
+2
-2
@@ -39,7 +39,7 @@ Sample fragment:
|
||||
|
||||
### 3.2 `text_policy: embedded`
|
||||
|
||||
Self-contained pyramid with tier names typeset into the artwork. Keep tier names to single English words in a font family echoing the deck's body typography. High failure risk on 5+ tier pyramids — stay at 3-4 tiers when going embedded.
|
||||
Self-contained pyramid with tier names typeset into the artwork. Keep tier names concise and stable in the required script and echo the deck's body typography. When exact/editable labels or the available tier space cannot be preserved, move those labels to SVG; otherwise embedded lettering remains valid after visual review.
|
||||
|
||||
---
|
||||
|
||||
@@ -47,4 +47,4 @@ Self-contained pyramid with tier names typeset into the artwork. Keep tier names
|
||||
|
||||
**Snippet A — vector-illustration + cool-corporate Maslow pyramid, text_policy: none, 600×800**
|
||||
|
||||
> Clean flat vector illustration of a hierarchy pyramid. Five horizontal stepped tiers stacked vertically, each tier centered on the canvas vertical axis and ~18% narrower than the tier below. From bottom to top: tier 1 (foundation, widest) in deeper primary `#1E3A5F`; tier 2 in primary `#3B5478`; tier 3 in lighter primary `#5A7099`; tier 4 in secondary blue tint `#A8BDDD`; tier 5 (apex, narrowest) in accent gold `#D4AF37`. Each tier has one simple white iconic symbol centered — a brick (physiological), a shield (safety), a heart (belonging), a trophy (esteem), a star (self-actualization). Thin secondary cream `#F8F9FA` dividers separate the tiers. Background secondary cream. Composed as a 600×800 portrait pyramid block with 12% padding. NO text or labels — SVG will overlay tier names. Color values are rendering guidance only.
|
||||
> Clean flat vector illustration of a hierarchy pyramid. Five horizontal stepped tiers stacked vertically, each tier centered on the canvas vertical axis and ~18% narrower than the tier below. From bottom to top: tier 1 (foundation, widest) in deeper primary `#1E3A5F`; tier 2 in primary `#3B5478`; tier 3 in lighter primary `#5A7099`; tier 4 in secondary blue tint `#A8BDDD`; tier 5 (apex, narrowest) in accent gold `#D4AF37`. Each tier has one simple white iconic symbol centered — a brick (physiological), a shield (safety), a heart (belonging), a trophy (esteem), a star (self-actualization). Thin secondary cream `#F8F9FA` dividers separate the tiers. Background secondary cream. Composed as a 600×800 portrait pyramid block with 12% padding. NO text or labels — SVG will overlay tier names. Color values are rendering guidance only.
|
||||
|
||||
+37
-28
@@ -3,27 +3,30 @@
|
||||
# Native Shape Authoring Reference
|
||||
|
||||
Use this reference during Executor SVG construction or project-owned canonical
|
||||
template maintenance when one standard PowerPoint shape can express one
|
||||
complete object or multiple closed shapes require a PowerPoint-style Boolean
|
||||
result. Neither helper writes a page. The preset helper does not create the
|
||||
shape's own `p:txBody`; keep visible text outside the atomic fragment.
|
||||
template maintenance when basic primitives, one standard PowerPoint shape, or
|
||||
multiple closed shapes can express the intended object. Prefer, in order:
|
||||
editable basic primitives, one exact Office preset, then a PowerPoint-style
|
||||
Boolean result. Hand-authored freeform geometry is allowed only when those
|
||||
constructions cannot faithfully express the object. Neither helper writes a
|
||||
page. The preset helper does not create the shape's own `p:txBody`; keep visible
|
||||
text outside the atomic fragment.
|
||||
|
||||
## 1. Selection Gate
|
||||
|
||||
Apply this decision order before drawing a stock geometric object.
|
||||
Apply this decision order before drawing any new geometric contour.
|
||||
|
||||
> This gate is for picking the **right** native shape, not for avoiding presets.
|
||||
> When a page needs an arrow, chevron, callout, banner, flowchart node, or a
|
||||
> literal Office symbol, authoring it as a preset is the **default** — the
|
||||
> ordinary-SVG rows below are deliberate exceptions, not the norm.
|
||||
> This gate is for picking the **highest-level faithful native construction**.
|
||||
> Do not hand-author a freeform merely because an SVG path is convenient.
|
||||
|
||||
| Condition | Action |
|
||||
|---|---|
|
||||
| Plain rectangle, symmetric rounded rectangle, circle, or ellipse | Write the ordinary SVG primitive; the exporter already emits an editable native shape. |
|
||||
| Straight relationship, divider, or leader | Write `<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. |
|
||||
| The visual meaning or contour exceeds one stock shape | Write ordinary `<path>` / `<polygon>` geometry; export keeps it as editable custom geometry. |
|
||||
| The shape only resembles a preset | Keep ordinary SVG; never infer a preset from contour similarity. |
|
||||
| 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. |
|
||||
|
||||
**Hard rule**: `preset_shape_svg.py` is the only authoring entry for
|
||||
@@ -46,10 +49,10 @@ paths or contours, or upgrade ordinary SVG during export.
|
||||
| Visual intent | Candidate presets | Boundary |
|
||||
|---|---|---|
|
||||
| Literal geometric body | `triangle`, `diamond`, `pentagon`, `hexagon`, `octagon`, `star5` | Use only when the named geometry itself is the intent. |
|
||||
| Solid block direction | `rightArrow`, `leftArrow`, `upArrow`, `downArrow`, `leftRightArrow`, `upDownArrow`, `chevron` | Thin relationship geometry remains an ordinary SVG `<line>` / `<path>` with no attachment semantics. |
|
||||
| Solid block direction | `rightArrow`, `leftArrow`, `upArrow`, `downArrow`, `leftRightArrow`, `upDownArrow`, `chevron` | Use `<line>` for a thin straight relationship; do not fake a solid directional object with a stroked path. |
|
||||
| Standard flowchart node | `flowChartProcess`, `flowChartDecision`, `flowChartInputOutput`, `flowChartTerminator`, `flowChartDocument` | Use only for an actual flowchart; ordinary content cards remain cards. |
|
||||
| Explicit standalone connector | `straightConnector1`, `bentConnector*`, `curvedConnector*` | Use only when the user explicitly requests a PowerPoint Connector object. Diagram relationships otherwise stay ordinary SVG line/path shapes with no attachment semantics. |
|
||||
| Stock callout | `wedgeRectCallout`, `wedgeRoundRectCallout`, `wedgeEllipseCallout`, `cloudCallout` | Brand-specific or custom-tail callouts remain free SVG. |
|
||||
| Stock bent / curved relationship contour | `bentConnector*`, `curvedConnector*` | Prefer when the contour fits and endpoint attachment is not required. The authored object is an unconnected native Connector, so moving nodes does not reroute it. |
|
||||
| Stock callout | `wedgeRectCallout`, `wedgeRoundRectCallout`, `wedgeEllipseCallout`, `cloudCallout` | For a brand-specific or custom tail, continue through the Boolean gate; use freeform only if the result still cannot be expressed faithfully. |
|
||||
| Stock ribbon or scroll | `ribbon*`, `ellipseRibbon*`, `verticalScroll`, `horizontalScroll` | Select only when the stock contour is visually acceptable. |
|
||||
| Standalone math symbol | `mathPlus`, `mathMinus`, `mathMultiply`, `mathDivide`, `mathEqual`, `mathNotEqual` | Inline formulas and prose symbols remain text/formula assets. |
|
||||
| Literal Office symbol | `heart`, `sun`, `moon`, `lightningBolt`, `gear6`, `gear9` | Never replace an icon required by `spec_lock.icons`. |
|
||||
@@ -61,13 +64,14 @@ python3 ${SKILL_DIR}/scripts/preset_shape_svg.py list --search arrow
|
||||
python3 ${SKILL_DIR}/scripts/preset_shape_svg.py describe rightArrow
|
||||
```
|
||||
|
||||
**Shape-first diagram rule**: chart-template adaptations use ordinary line/path
|
||||
shapes for thin relationships and ordinary `shape` presets for solid block
|
||||
directions. Connector-family presets are reserved for an explicit request for
|
||||
a standalone PowerPoint Connector; they are not the default for architecture,
|
||||
process, hierarchy, or framework diagrams and do not gain attachment semantics.
|
||||
Existing Connector topology imported from a source PPTX remains owned by the
|
||||
preserve/mirror round-trip contract.
|
||||
**Shape-first diagram rule**: use `<line>` for straight thin relationships;
|
||||
use an exact connector-family preset for a stock bent or curved contour; use a
|
||||
block-arrow / chevron preset for a solid direction. Resort to an open freeform
|
||||
path only when those native constructions cannot faithfully express the
|
||||
relationship, data geometry, or locked hand-drawn / organic style. Newly
|
||||
authored connector-family presets remain unconnected and do not gain attachment
|
||||
semantics. Existing Connector topology imported from a source PPTX remains
|
||||
owned by the preserve/mirror round-trip contract.
|
||||
|
||||
**Forbidden — false native semantics**:
|
||||
|
||||
@@ -95,7 +99,7 @@ python3 ${SKILL_DIR}/scripts/preset_shape_svg.py render rightArrow \
|
||||
--adjust "adj1=val 50000"
|
||||
```
|
||||
|
||||
For an explicitly requested standalone native connector only:
|
||||
For a stock bent / curved contour that does not require endpoint attachment:
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/preset_shape_svg.py render bentConnector3 \
|
||||
@@ -197,9 +201,11 @@ freshness contract.
|
||||
|
||||
## 6. Shape Boolean Materialization
|
||||
|
||||
**Trigger**: The authored design explicitly requires a PowerPoint-style Union,
|
||||
Combine, Fragment, Intersect, or Subtract operation over two or more closed
|
||||
vector shapes.
|
||||
**Trigger**: Current page construction has two or more closed vector operands
|
||||
whose faithful result calls for PowerPoint-style Union, Combine, Fragment,
|
||||
Intersect, or Subtract. A §IX `Native shape suggestion` is a semantic candidate,
|
||||
not a prerequisite or tool command; Executor may adopt, adapt, or decline it
|
||||
from the actual content and explicit user/template constraints.
|
||||
|
||||
```bash
|
||||
python3 ${SKILL_DIR}/scripts/shape_boolean_svg.py render <svg-file> \
|
||||
@@ -213,7 +219,9 @@ python3 ${SKILL_DIR}/scripts/shape_boolean_svg.py render <svg-file> \
|
||||
|---|---|
|
||||
| Sources | Closed `path`, `polygon`, `rect`, `circle`, `ellipse`, or one validated compact authored shape preset. Open ordinary geometry, connectors, ordinary groups, text, images, definitions, and nested SVG viewports fail closed. |
|
||||
| Primary shape | The first `--source` supplies result paint. For `subtract`, all later operands are removed from that primary geometry. Explicit paint flags override only their named channels. |
|
||||
| Coordinates | Ancestor and local transforms are baked into SVG-root coordinates. Insert stdout at the root in the primary operand's z-order; never reinsert it under an original transformed ancestor. |
|
||||
| Coordinates | Ancestor and local transforms are baked into SVG-root coordinate space. Place stdout in the primary operand's z-order with no additional transform; never reinsert it under an original transformed ancestor. Root-coordinate space does not require each result path to be a direct `<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. |
|
||||
| Fragment roles | Fragment paths may share one ordinary Slide-local semantic group, but remain separate shapes and cannot collectively claim one carrier or one Master/Layout atom. Helper output inherits no structural role metadata from its operands; redeclare only the final layer/carrier/role contract. |
|
||||
| Result | `union`, `combine`, `intersect`, and `subtract` emit one ordinary `<path>`. `fragment` emits stable sibling paths named `<id>-1`, `<id>-2`, ... in top/left/bottom/right/area order. |
|
||||
| Winding | Results use explicit nonzero contour direction and never emit `fill-rule`, `clip-rule`, `clip-path`, `mask`, or Merge Shapes metadata. Operands that depend on even-odd fill, clipping, or masking fail closed. |
|
||||
| Preservation | This helper authors new geometry only. Never use it to merge or split mirror/preserve source structure. |
|
||||
@@ -226,8 +234,9 @@ stores the materialized freeform geometry, not replayable operation history.
|
||||
|
||||
**Hard rule — stdout-only replacement**: The helper never writes the source
|
||||
page. In one normal `apply_patch` edit, remove every selected operand and insert
|
||||
every returned path at the SVG root in the primary operand's z-order. Fragment
|
||||
paths remain separate shapes; do not wrap them to claim one structural atom.
|
||||
every returned path in root coordinate space at the primary operand's z-order,
|
||||
using the placement contract above. Fragment paths remain separate shapes; an
|
||||
ordinary semantic group does not turn them into one structural atom.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-4
@@ -171,9 +171,9 @@ diagnostic behavior are indexed in
|
||||
|
||||
### 1.2 Image Clipping (Conditional Contract)
|
||||
|
||||
`clip-path` has a native picture-geometry mapping only on SVG-namespace
|
||||
`<image>` elements (plus the exact imported crop wrapper defined under Images)
|
||||
and only under this contract:
|
||||
`clip-path` maps natively only on SVG `<image>` (including an exact crop
|
||||
wrapper's inner image) under this contract. Legacy imported crops may retain
|
||||
an outer-wrapper clip as compatible input:
|
||||
|
||||
| Concern | Required form |
|
||||
|---|---|
|
||||
@@ -181,7 +181,7 @@ and only under this contract:
|
||||
| Contains exactly one direct SVG-namespace supported shape child | Multiple shapes are not composited |
|
||||
| Shape is one of: `<circle>`, `<ellipse>`, `<rect>` (optional rx/ry), `<path>`, `<polygon>` | These map to DrawingML geometry (preset or custom) |
|
||||
| No `clip-rule` or `fill-rule`, whether direct or in inline `style` | DrawingML picture geometry has no equivalent winding-rule control |
|
||||
| Used only on `<image>` or an exact imported crop wrapper | Shapes, groups, text, and generalized nested SVG targets are **forbidden** |
|
||||
| Used only on `<image>` or a compatible legacy imported crop wrapper | Shapes, groups, text, and generalized nested SVG targets are **forbidden** |
|
||||
|
||||
| SVG clip shape | DrawingML output |
|
||||
|---|---|
|
||||
|
||||
@@ -24,7 +24,7 @@ When proposed sources include `ai`, read [`image-renderings/_index.md`](./image-
|
||||
|
||||
Also write one `custom_candidates.image_strategy` under the Confirm UI contract: localized `name` / `visual` / `mood`, `rendering: custom`, and non-empty localized `behavior` satisfying the catalog grammar. If it combines or borrows existing renderings, name every exact id in the visible proposal and read every corresponding `image-renderings/<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`.
|
||||
|
||||
For specialized or regulated paper-figure subjects, preserve the prompt depth required by [`image-generator.md`](./image-generator.md) §4.2 rather than shortening to a generic brief. Scan the outline for genuine image-led pages, list the proposed hero pages in Stage-2 `image_notes` so the user can retain, edit, or remove them in the same confirmation, then mark only the confirmed pages' AI rows `page_role: hero_page`; local is the default. `text_policy: embedded` is reserved for lettering that must be fused into the artwork; ordinary titles, data, labels, and prose remain editable SVG. Resolve confirmed provided assets through the context-first boundary above before writing §VIII.
|
||||
For specialized or regulated paper-figure subjects, preserve the prompt depth required by [`image-generator.md`](./image-generator.md) §4.2 rather than shortening to a generic brief. Scan the outline for genuine image-led pages, list the proposed hero pages in Stage-2 `image_notes` so the user can retain, edit, or remove them in the same confirmation, then mark only the confirmed pages' AI rows `page_role: hero_page`; local is the default. `text_policy: embedded` is reserved for stable figure-internal identifiers or lettering deliberately fused into the artwork; page titles, editable data values/labels, and prose remain SVG. Resolve confirmed provided assets through the context-first boundary above before writing §VIII.
|
||||
|
||||
## 3. Formula Asset Policy
|
||||
|
||||
@@ -49,13 +49,13 @@ Follow `latex_render.py --help` for the manifest fields. The renderer writes dim
|
||||
|
||||
## 4. Image Resource List
|
||||
|
||||
Add §VIII rows for the image resources actually planned from the confirmed source boundary and for every selected formula; a formula-only plan contains only formula rows. A permitted but unused source needs no row. Author each row's filename, dimensions/ratio, layout pattern, crop policy, purpose/type, acquisition, status, reference, and conditional AI fields as part of the complete Design Spec. `Acquire Via` is `ai`, `web`, `user`, `formula`, `placeholder`, or `slice`; status follows [`svg-image-embedding.md`](./svg-image-embedding.md). When a planned or explicitly required asset is not yet available, retain its row as `Pending` or `Needs-Manual`; never remove the row or change `Acquire Via` to make the Design Spec look complete. After §VIII passes final confirmation, project every placed row into `spec_lock.md images` as `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-crop>` and omit unplaced Illustration Sheets. References describe visual intent: AI uses subject + intent + composition without repeating rendering or HEX; web uses a concrete subject plus a few positive quality descriptors; formula preserves the source LaTeX and placement intent.
|
||||
Add §VIII rows for the image resources actually planned from the confirmed source boundary and for every selected formula; a formula-only plan contains only formula rows. A permitted but unused source needs no row. Author each row's filename, dimensions/ratio, preferred layout pattern, crop policy, purpose/type, acquisition, status, reference, and conditional AI fields as part of the complete Design Spec. `Acquire Via` is `ai`, `web`, `user`, `formula`, `placeholder`, or `slice`; status follows [`svg-image-embedding.md`](./svg-image-embedding.md). When a planned or explicitly required asset is not yet available, retain its row as `Pending` or `Needs-Manual`; never remove the row or change `Acquire Via` to make the Design Spec look complete. After §VIII passes final confirmation, project every placed row into `spec_lock.md images` as `<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 its ordered catalog ids or normalized custom prose while remaining preferred expression, not locked geometry. References describe visual intent: AI uses subject + intent + composition without repeating rendering or HEX; web records exact subject, view/mood, focal/quiet region, and crop safety with positive quality cues; Image_Searcher later derives a separate 1–4 word query without rewriting this locked intent; formula preserves source LaTeX and placement intent.
|
||||
|
||||
**Prepared-user fast path**: For initial imported or user-supplied assets confirmed as `provided`, copy the exact `Filename` basename and derive `Dimensions` / `Ratio` from that row's `Width` / `Height` / `AspectRatio` in the latest `analysis/image_analysis.csv`; drop source-side directories, set `Acquire Via: user` and `Status: Existing`, and decide the remaining §VIII fields normally. Existing §VIII / lock / provenance-manifest records override this inference. Assets declared as `ai`, `web`, `slice`, `formula`, or manual fulfillment retain that provenance and advance through their own status lifecycle after entering `images/`; location never reclassifies them as `user / Existing`.
|
||||
**Prepared-user fast path**: For initial imported or user-supplied assets confirmed as `provided`, copy the exact `Filename` basename and derive `Dimensions` / `Ratio` from that row's EXIF-corrected `Width` / `Height` / native `AspectRatio` in the latest `analysis/image_analysis.csv`; `SourceDisplayRatio` is source-context metadata, not the bitmap crop ratio. Drop source-side directories, set `Acquire Via: user` and `Status: Existing`, and decide the remaining §VIII fields normally. Existing §VIII / lock / provenance-manifest records override this inference. Assets declared as `ai`, `web`, `slice`, `formula`, or manual fulfillment retain that provenance and advance through their own status lifecycle after entering `images/`; location never reclassifies them as `user / Existing`.
|
||||
|
||||
🚧 **GATE — non-formula rows**: read every entry in [`image-layout-patterns.md`](./image-layout-patterns.md), starting from its `High-Yield Patterns` router. Copy one primary `#<id> <name>` plus any modifier names verbatim into each row; no empty, paraphrased, or invented ids.
|
||||
🚧 **GATE — non-formula rows**: start at `High-Yield Patterns` and read [`image-layout-patterns.md`](./image-layout-patterns.md) completely. Each row copies a Part 1 Primary `#<id> <name>` plus any Modifiers verbatim; modifier-only routes add fitting Primary page bones. No blanks, paraphrases, or invented ids.
|
||||
|
||||
**Default — resolve each row against the high-yield router before selecting a plain split or grid (may override when the content genuinely wants one):** the boolean-geometry family costs no extra asset and is the largest single lever on how designed the exported deck looks. A deck whose rows are all bare `#2` / `#3` / `#5` / `#6` with no modifier ids did not consult the catalog; reopen it before finalizing §VIII. Strategist owns that pattern selection; Executor adapts its geometry while retaining the selected primary/modifier semantics, resource role, and explicit constraints. Audit the completed column against page intent: repeated left/right or top/bottom structures are valid when the narrative calls for them, but catalog families and modifiers must remain available without a usage quota.
|
||||
**Default — resolve each row against the high-yield router before selecting a plain split or grid (may override when the content genuinely wants one):** the native boolean-geometry family is the largest single lever on how designed the exported deck looks. Patterns requiring a cutout, blurred crop, or desaturated copy are selectable only when that derived asset is already prepared; otherwise choose a one-asset/native-shape fallback. A deck whose rows are all bare `#2` / `#3` / `#5` / `#6` with no modifier ids did not consult the catalog; reopen it before finalizing §VIII. This is a Strategist recommendation, not a geometry or pattern lock. Executor may adapt or replace it after seeing the actual page while preserving the resource role, file/source, must-use status, crop boundary, content, and explicit user/template constraints; a pattern-only change needs no upstream rewrite. Audit the completed column against page intent: repeated left/right or top/bottom structures are valid when the narrative calls for them, but catalog families and modifiers must remain available without a usage quota.
|
||||
|
||||
Choose narrative intent before dimensions: hero/full-bleed, atmosphere/background, side-by-side, or accent/inline. Portrait and multi-image calculations belong to [`image-layout-spec.md`](./image-layout-spec.md). Write `Crop Policy: no-crop` whenever cropping could remove required pixels, labels, evidence, identity, or edge content; screenshots, charts, certificates/contracts, dense diagrams, logos, product markings, and formulas are common triggers rather than an exhaustive list. Otherwise write `Crop Policy: adaptive`: Executor may use complete display or a focal-safe crop, and the value never commands cropping. Formula rows use `Type: Latex Formula`, `Acquire Via: formula`, `Crop Policy: no-crop`, and `Rendered` or `Needs-Manual`.
|
||||
|
||||
|
||||
@@ -214,9 +214,9 @@ See [`../templates/icons/README.md`](../templates/icons/README.md) for the curre
|
||||
|
||||
- User or active template typography is authoritative. Otherwise ≥3 Stage-2 directions include concord (safe) and contrast (tension); never add a separate font-choice round or pair near-duplicate title/body families.
|
||||
- Every Stage-2 direction carries `heading` / `body` `cjk`, `latin`, `css`, and positive `body_size`; repeat user/template-fixed stacks.
|
||||
- Use PowerPoint-installed exported faces. Safe anchors: CJK `Microsoft YaHei` / `SimHei` / `SimSun` / `FangSong` / `KaiTi`; Latin sans `Arial` / `Calibri` / `Segoe UI` / `Verdana` / `Trebuchet MS`; Latin serif `Times New Roman` / `Georgia` / `Cambria` / `Garamond` / `Book Antiqua`; mono `Consolas` / `Courier New`; display `Impact` / `Arial Black`. Other export-safe families are limited to sparse short display/ornament; structural or recurring use returns upstream.
|
||||
- Keep each stack to four families or fewer. A non-installed brand or web face is legal only when the Design Spec explicitly records the install / embed requirement and a safe substitute.
|
||||
- Avoid splitting roles across near-equivalents such as YaHei↔PingFang, SimSun↔Songti, Arial↔Helvetica↔Segoe UI, or Times New Roman↔Times. A cross-platform counterpart may remain inside one fallback stack.
|
||||
- Use concrete, target-installed PowerPoint faces. **Examples only, never a catalog/default** (verify locale): Chinese `DengXian` / `SimSun`; Japanese `Meiryo` / `Yu Gothic`; Korean `Malgun Gothic` / `Batang`; Latin `Arial` / `Georgia` / `Consolas` / `Impact`.
|
||||
- Keep stacks to four families or fewer. A brand/web face may lead only after user-confirmed target installation/approved install; PPT Master does not embed fonts. Otherwise export a safe face and keep the unavailable face as Design Spec reference.
|
||||
- Avoid near-equivalent role splits such as YaHei↔PingFang, SimSun↔Songti, Arial↔Helvetica↔Segoe UI, or Times New Roman↔Times. Counterparts may aid SVG/browser preview; CSS tails are not deterministic PowerPoint fallbacks.
|
||||
- Choose by locked style and vary the axis instead of defaulting to YaHei/Arial: serif×sans, Kai/FangSong×hei, hei×song, double-serif, display×neutral, same-family weight, or sans+mono. These are recall seeds, not presets.
|
||||
|
||||
**Strategist-owned role extension after confirmation**: Confirm UI keeps the heading/body choice unchanged. While authoring the complete §IX roster and §IV typography plan, scan the actual content for recurring roles that materially need a different family for character or legibility—such as `annotation`, `footer`, `footnote`, `data`, `emphasis`, `quote`, or `code`. Add a lowercase snake_case role and exact stack only when it recurs; inherited roles and one-off garnish stay omitted. The extension must remain coherent with the confirmed heading/body system and locked visual style, and it does not reopen confirmation. Only when an additional family role is added, record one compact `Role rationale` in §IV naming the added role(s) and why; otherwise omit the line.
|
||||
@@ -270,7 +270,21 @@ Formula policy and formula-asset planning are conditional. If the source contain
|
||||
|
||||
The module owns formula policy, AI rendering alternatives, acquisition paths, resource rows, prompt depth, page roles, and placement intent.
|
||||
|
||||
### Visualization Candidate Recall (Non-blocking — Strategist recommends, no user confirmation needed)
|
||||
### Presentation Capability & Visualization Recall (Non-blocking — Strategist recommends, no user confirmation needed)
|
||||
|
||||
**Per-page capability recall**: Before §IX, consider this menu without a usage
|
||||
quota. Use existing fields for semantic intent; omit unused lines and
|
||||
implementation parameters. Executor may adapt/decline the
|
||||
two non-literal suggestions while preserving content and intent; explicit
|
||||
user/template requirements bind.
|
||||
|
||||
| Capability | Opportunity signal | Design Spec handoff |
|
||||
|---|---|---|
|
||||
| Image composition | Image-as-canvas, editorial crop, collage, cutout, or meaningful focus / comparison / evidence units carry the page better than an adjacent rectangle | Propose a permitted source; when selected, load [`strategist-image.md`](./strategist-image.md), record exact §VIII `Layout pattern`, and describe page-level image/overlay relationships in §IX `Layout` / `Images` |
|
||||
| Native paint / overlay | Gradient, translucency, scrim, vignette, or wash supports focus, hierarchy, depth, legibility, or image integration | Record purpose/layering in §IX `Layout`, plus `Images` when imagery participates; no new field or type/stops/opacity/coordinates—Executor chooses realization |
|
||||
| Native shape / Merge Shapes | A literal Office symbol, a stock bent/curved relationship contour, or a compound silhouette, negative-space cutout, overlap-only region, or meaningful fragmentation strengthens the visual idea | Add an optional §IX `Native shape suggestion` with the semantic result plus a candidate preset/Connector family or Boolean operation/operands |
|
||||
| Page transition | A section/state change, spatial continuity, recorded/self-running flow, or the same semantic object changing position, scale, crop, or state across adjacent pages benefits from motion | Add an optional §IX `Motion suggestion` describing the communication job and any continuing object's start/end semantic states; leave effect, ids, pairing names, and timing to Executor |
|
||||
| Object animation | Progressive reveal clarifies sequence, causality, comparison, hierarchy, narration order, full-view → detail, atmosphere → evidence, or hotspot/annotation order | Add an optional §IX `Motion suggestion` describing semantic units/order and any visible image-state relationship; leave group ids, effect, and timing to Executor |
|
||||
|
||||
Review planned pages through two lenses:
|
||||
|
||||
@@ -314,7 +328,17 @@ A failed validation must be corrected with a recalled key. `no-template-match` i
|
||||
| P03 | line_chart | Compare the source metrics over time |
|
||||
```
|
||||
|
||||
**Flag native-preset candidates**: In the affected page's §IX `Layout` / `Visualization`, note when the content calls for a literal stock PowerPoint chevron, block arrow, standard flowchart node, callout, banner, or star. Executor still decides the exact preset under its native-shape branch; this note never creates a §VII row by itself.
|
||||
**Native-geometry candidate detail**: Add `Native shape suggestion` to the
|
||||
affected §IX page when the content calls for a literal stock PowerPoint
|
||||
chevron, block arrow, standard flowchart node, callout, banner, star, or a
|
||||
stock bent/curved Connector contour. Describe a relationship by its semantic
|
||||
route and candidate family, not an exact preset key, endpoint/site metadata, or
|
||||
attachment promise. For a compound silhouette, cutout, common region, or
|
||||
meaningful fragmentation, name the candidate Union / Combine / Fragment /
|
||||
Intersect / Subtract operation, semantic operands, and intended result.
|
||||
Executor still decides the exact basic primitive, preset, Boolean construction,
|
||||
or necessary freeform under its native-shape branch; the recommendation never
|
||||
creates a §VII row or lock field.
|
||||
|
||||
### Speaker Notes Requirements (Default — no discussion needed)
|
||||
|
||||
@@ -380,7 +404,7 @@ Content-outline and speaker-notes strategy follow the deck's locked **mode** —
|
||||
|
||||
**Recommendation signals**: derive the initial reading mode from the confirmed `audience`, `delivery_context`, and `artifact_afterlife`. Asynchronous review, reference, approval, audit, and leave-behind use lean `text`; presenter-led projection, large-room delivery, launch, or classroom explanation lean `presentation`; hybrid review / roadshow use leans `balanced`. When live projection and durable afterlife both matter, recommend `balanced` unless the contract clearly prioritizes one. If the user confirms `presentation`, support afterlife through notes, appendix pages, captions, and visible sources instead of crowding every slide.
|
||||
|
||||
**Per-block expression**: let the semantic relationship choose the form. Causal explanation, argument, interpretation, and narrative continuity use prose. Truly parallel, ordered, or enumerable items may use bullets / numbers. Never create bullets merely because copy is long or a template exposes a list slot. In `presentation`, distill one assertion and move its explanation into notes rather than turning every sentence into a fragment. Source texture remains a secondary cue: an article / transcript / talk leans prose, while a data sheet or inventory may lean structured labels. Write complete, usable phrasing into §IX; do not leave skeletons for Executor. It is preferred wording unless literal preservation applies.
|
||||
**Per-block expression**: let the semantic relationship choose the form. Causal explanation, argument, interpretation, and narrative continuity use prose. Truly parallel, ordered, or enumerable items may use bullets / numbers. Never create bullets merely because copy is long or a template exposes a list slot. In `presentation`, distill one assertion and move its explanation into notes rather than turning every sentence into a fragment. Source texture remains a secondary cue: an article / transcript / talk leans prose, while a data sheet or inventory may lean structured labels. Write complete, usable phrasing into §IX; do not leave skeletons for Executor. It is preferred wording unless literal preservation applies; Executor owns faithful expression adaptation under [`executor-base.md`](./executor-base.md) §2.1's content-vs-expression contract.
|
||||
|
||||
This is what makes the axis meaningful: a `presentation` deck and a `text` deck built from the **same source and communication contract** must differ in page grammar, page count recommendation, per-page text volume, visual burden, layout density, rhythm, and notes—not only in font size. Page count stays the user's call; reading mode informs the recommendation when the user has not fixed one. Record it as **Reading Mode** in `design_spec.md §I` (compatibility key `delivery_purpose`, lock key `consumption_mode`). Separately, `communication_intent` / `audience_outcome` determine what the outline must accomplish, while `delivery_context` and `artifact_afterlife` help select the reading mode and still remain independent constraints after selection. The `page_rhythm` leans are a bias, not a quota. Preservation paths keep source wording and structure verbatim: honor reading mode only in styling and notes, never by rephrasing or re-paginating.
|
||||
|
||||
@@ -391,7 +415,7 @@ This is what makes the axis meaningful: a `presentation` deck and a `text` deck
|
||||
Generate Step 4 owns this sequence. `design_spec.md` is the complete human-readable decision; `spec_lock.md` is its context-selected execution subset/routing contract. Consume `result.json` once into the initial Design Spec and never reopen it for the lock. Refinement edits that same Design Spec; affected user revisions become the latest authority. Never treat the planning files as parallel interpretations.
|
||||
|
||||
1. Use the retained complete final-confirmation state already read once by Generate Step 4, then read `templates/design_spec_reference.md`.
|
||||
2. Compose the whole Design Spec in active context before touching the target path. Create `design_spec.md` once from the schema marker through §X; do not copy a scaffold into the project or patch placeholder fields. Record production mechanics in §I. In §IX, create the complete ordered roster; each entry carries layout, title, core message, **Audience move**, final wording, visualization/image references, sourced `Fact IDs`, and `Data class: scenario` for invented demo data. After Gate 1 plus conditional refine approval, roster ids/count/order and content are authoritative; layout, cover/closing composition, and image/chart patterns remain References unless promoted.
|
||||
2. Compose the whole Design Spec in active context before touching the target path. Create `design_spec.md` once from the schema marker through §X; do not copy a scaffold into the project or patch placeholder fields. Record production mechanics in §I. In §IX, create the complete ordered roster; each entry carries layout, title, core message, **Audience move**, complete preferred wording, applicable capability recommendations, visualization/image references, sourced `Fact IDs`, and `Data class: scenario` for invented demo data. After Gate 1 plus conditional refine approval, roster ids/count/order and semantic content are authoritative; non-literal wording, block texture, layout, cover/closing composition, capability recommendations, and image/chart patterns remain References unless promoted.
|
||||
3. Compare `design_spec.md` against the final confirmation field by field. Repair every omission or deviation before entering an enabled refine-spec review or authoring `spec_lock.md`.
|
||||
4. If enabled, run [`refine-spec`](../workflows/stages/refine-spec.md) after Gate 1; edit only that Design Spec and create no lock before explicit approval.
|
||||
5. Read `templates/spec_lock_reference.md`. From the approved Design Spec plus context, create the lock once or resynchronize stale derived state. Retain identity/refinements, select stable roles/routing, omit unnamed page-local values, and do not reopen evidence. This is implementation judgment, not another recommendation.
|
||||
@@ -413,9 +437,9 @@ Generate Step 4 owns this sequence. `design_spec.md` is the complete human-reada
|
||||
|
||||
⛔ **GATE 2 — lock context fidelity.** After Gate 1 closes, author machine-relevant anchors/routing into `spec_lock.md`. The lock may normalize syntax and add justified recurring roles, but must not change identity, discard a refinement, introduce a direction, or become a field copy/allowlist. On contradiction, return to Gate 1 using retained confirmation by default or the approved revised Design Spec after refinement; fresh recovery reads persisted final evidence once only when active state is absent.
|
||||
|
||||
**Execution lock content**: `spec_lock.md` compactly carries communication, stable color/type anchors, icons, images, page rhythm, chart choices, and route-specific PowerPoint structure. Name every recurring typography role; a planned short non-structural Hero/Display size may stay omitted only while the same value appears at most twice, and its third occurrence requires a named role. Never re-derive a confirmed anchor. New locks keep `font_family` as the body/default compatibility stack and also write explicit `title_family` + `body_family`; every additional recurring Design Spec role projects to `<role>_family`. Collapsing distinct Design Spec stacks into `font_family`, or dropping an extra role, fails Gate 2. Keep core fonts/palette roles stable; page authoring varies treatment and may add sparse local garnish. Project every placed §VIII image's source, pattern, and crop policy; omit unplaced sheets and planning provenance. Free-design, brand-only, and `template_reuse_scope: style` use `pptx_structure.mode: flat`; the template module owns structured mappings. Executor context policy lives in [executor-base.md](executor-base.md) §2.1. Repair from Gate 2's active decision authority, then re-author affected lock rows.
|
||||
**Execution lock content**: `spec_lock.md` compactly carries communication, stable color/type anchors, icons, images, page rhythm, chart choices, and route-specific PowerPoint structure. Name every recurring typography role; a planned short non-structural Hero/Display size may stay omitted only while the same value appears at most twice, and its third occurrence requires a named role. Never re-derive a confirmed anchor. New locks keep `font_family` as the body/default compatibility stack and also write explicit `title_family` + `body_family`; every additional recurring Design Spec role projects to `<role>_family`. Collapsing distinct Design Spec stacks into `font_family`, or dropping an extra role, fails Gate 2. Keep core fonts/palette roles stable; page authoring varies treatment and may add sparse local garnish. Project every placed §VIII image's source, preferred-pattern reference, and crop policy; omit unplaced sheets and planning provenance. Free-design, brand-only, and `template_reuse_scope: style` use `pptx_structure.mode: flat`; the template module owns structured mappings. Executor context policy lives in [executor-base.md](executor-base.md) §2.1. Repair from Gate 2's active decision authority, then re-author affected lock rows.
|
||||
|
||||
**Contextual extension**: derived paint or sparse local font/color garnish may stay in one SVG while non-structural and non-recurring. New base/semantic colors, structural/recurring fonts, resources, or patterns require upstream repair; Executor never reverse-projects a choice as fact. Promote garnish upstream before reuse, read back and validate the affected planning fragments, and never add values to silence a comparison.
|
||||
**Contextual extension**: derived paint or sparse local font/color garnish may stay in one SVG while non-structural and non-recurring. New base/semantic colors, structural/recurring fonts, resources, or recurring cross-page identity patterns require upstream repair; a page-local §VIII preferred image pattern follows [`executor-image.md`](./executor-image.md) and may change during realization. Executor never reverse-projects a local choice as planning fact. Promote recurring garnish upstream before reuse, read back and validate the affected planning fragments, and never add values to silence a comparison.
|
||||
|
||||
- **Communication trace is mandatory**: Keep the full confirmed communication contract in `design_spec.md §I`, then project only `audience`, `objective`, `core_message`, and canonical `consumption_mode` into `spec_lock.md communication`. Write `objective` as one concise execution sentence that preserves both the confirmed `communication_intent` and the success condition in `audience_outcome`; do not copy `delivery_context`, `artifact_afterlife`, dates, provenance, or conflict-resolution commentary into the lock. Before finalizing §IX, check that every named purpose has at least one outline obligation and **every Slide block**, including cover / divider / closing pages, has an `Audience move` that advances the global outcome. A page that advances no purpose or outcome should be merged, rewritten, or cut. `project_manager.py validate` and `svg_quality_checker.py` enforce the compact lock fields and per-page move presence, not their subjective quality.
|
||||
- **Custom behavior is concise and executable**: For confirmed `custom` mode or visual style, project one resolved `mode_behavior` / `visual_style_behavior` sentence or short paragraph. When the direction actually combines or borrows catalog entries, also project the exact, comma-separated `mode_references` / `visual_style_references`; omit the field for a genuinely novel direction and never fabricate a nearby reference. Preserve the confirmed direction, reference locked role names such as `colors.primary` when needed, and omit selection history, contradictions, precedence explanations, or other Design Spec provenance. Executor reads these fields from the retained lock and loads every referenced catalog entry once per valid context.
|
||||
|
||||
@@ -103,8 +103,8 @@ closed parser checks. See
|
||||
|---|---|
|
||||
| Definition | Direct `<linearGradient>` / `<radialGradient>` child of `<defs>` with unique `id` |
|
||||
| Reference | Exact local `url(#id)` |
|
||||
| Stops | Direct `<stop>` children; explicit color; finite offset `0..1` or `0%..100%`; optional stop alpha |
|
||||
| Coordinates | Normalized values / percentages; do not depend on `gradientUnits` user-space geometry |
|
||||
| Stops | ≥2 direct `<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` |
|
||||
| Forbidden | External/quoted refs, `href` inheritance, `gradientTransform`, `spreadMethod`, CSS gradients |
|
||||
|
||||
| Target | Contract and fidelity |
|
||||
@@ -114,16 +114,14 @@ closed parser checks. See
|
||||
| `<text>` / non-positional `<tspan>` | Gradient fill only; no gradient text outline |
|
||||
| `<image>` | No gradient paint; use §6.5 overlays |
|
||||
|
||||
Linear export preserves stops/alpha/direction but reduces coordinates to an
|
||||
angle. Radial export becomes a centered circular gradient and does not preserve
|
||||
`cx/cy/r/fx/fy`. Gradient strokes remain editable, but PPTX-to-SVG re-import may
|
||||
retain only the first stop. Stop alpha and element opacity multiply.
|
||||
PPTX import normalizes compatible gradients and records any property-level
|
||||
degradation without aborting the deck; `--strict` keeps the closed parser
|
||||
contract. See
|
||||
Linear export preserves stops/alpha and reduces direction to an angle;
|
||||
coincident endpoints are invalid. Radial export centers a circular
|
||||
approximation, dropping `cx/cy/r/fx/fy`. Gradient strokes stay editable;
|
||||
reverse import may keep the first stop only. Stop alpha multiplies element opacity.
|
||||
PPTX import normalizes gradients and reports degradation;
|
||||
`--strict` keeps the closed parser contract. See
|
||||
[`conversion.md`](../scripts/docs/conversion.md#import-compatibility-and-recovery-boundary).
|
||||
The quality checker and exporter preflight both validate definition location,
|
||||
references, gradient structure, and paint context from the same closed contract.
|
||||
Checker/exporter preflight share this validation.
|
||||
Gradient-stop colors are contextual paint values. Keep them coherent with the
|
||||
deck anchors and page intent; they are not required to duplicate existing
|
||||
`spec_lock.colors` literals.
|
||||
@@ -280,34 +278,23 @@ unresolved only during template checking; export requires the resolved image.
|
||||
Missing, ambiguous, corrupt, mislabeled, or unsupported sources are errors and
|
||||
must never be dropped or packaged as invalid zero-byte media.
|
||||
|
||||
**Hard rule — nested SVG is an imported crop transport, not a general
|
||||
viewport**: every non-root `<svg>` must be the exact picture-crop wrapper emitted
|
||||
by `pptx_to_svg`. The outer element has explicit registered project-geometry
|
||||
`x`, `y`, positive `width`/`height`, a unit-coordinate `viewBox` made of four
|
||||
ordinary decimal values, and
|
||||
`preserveAspectRatio="none"`; it contains exactly one direct, empty `<image>`
|
||||
with exactly one non-empty `href` or `xlink:href`, `x="0"`, `y="0"`, `width="1"`,
|
||||
`height="1"`, and `preserveAspectRatio="none"`. Its ancestor chain contains
|
||||
only the root SVG and ordinary visual `<g>` wrappers; definitions, text,
|
||||
render-only geometry details, and other non-visual containers cannot own this
|
||||
transport. The outer wrapper may additionally carry `id`, a supported
|
||||
`transform`, registered structure metadata (`data-pptx-layer` or
|
||||
`data-pptx-carrier`), and the importer metadata
|
||||
`data-pptx-frame`, `data-pptx-object`, `data-pptx-shape-id`,
|
||||
`data-pptx-shape-name`, and `data-pptx-shape-scope`. A shape clip is present
|
||||
only when exact `data-pptx-crop="1"` and a registered image-only `clip-path`
|
||||
occur together and the local clip definition resolves. The inner image may
|
||||
add only registered `opacity`. The `viewBox` must quantize without clamping to
|
||||
a DrawingML `srcRect` with a positive visible region: each signed crop value
|
||||
must fit the OOXML percentage integer range `-2147483648..2147483647`, while
|
||||
`l + r < 100000` and
|
||||
`t + b < 100000` preserve a positive visible region. Negative crop values and
|
||||
crop windows extending outside the source unit rectangle are retained exactly,
|
||||
not clamped. `0 0 1 1` is redundant and must be written as a plain `<image>`.
|
||||
Extra visual children, indirect images, character data, unknown attributes,
|
||||
malformed or unrepresentable crop coordinates, and generalized nested
|
||||
viewports are errors. Checker and the converter share this parser so a nested
|
||||
subtree cannot pass validation and then silently disappear during export.
|
||||
**Hard rule — nested SVG is picture-crop transport, not a general viewport**:
|
||||
every non-root `<svg>` is the exact wrapper accepted by the shared crop parser:
|
||||
|
||||
| Part | Required form |
|
||||
|---|---|
|
||||
| Outer | Registered `x`, `y`, positive `width`/`height`; four ordinary-decimal unit coordinates in `viewBox`; `preserveAspectRatio="none"`; `overflow="hidden"` |
|
||||
| Child | Exactly one direct empty `<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` |
|
||||
| Shape crop | Exact outer `data-pptx-crop="1"`; authored wrappers put the registered, locally resolving image-only clip on the inner image, using `userSpaceOnUse` geometry matching the visible `viewBox`; legacy imported outer clips remain compatible |
|
||||
|
||||
The inner image may add only registered `opacity` and that clip. Quantize the
|
||||
`viewBox` without clamping: every signed crop fits
|
||||
`-2147483648..2147483647`, with `l + r < 100000` and `t + b < 100000`.
|
||||
Retain negative/outside-source crops exactly; write redundant `0 0 1 1` as a
|
||||
plain `<image>`. Extra, indirect, or character content; unknown attributes;
|
||||
malformed or unrepresentable crops; and general nested viewports fail. Checker
|
||||
and converter share this parser.
|
||||
|
||||
| Overlay | Construction | Typical stops / alpha |
|
||||
|---|---|---|
|
||||
@@ -564,12 +551,19 @@ least two.
|
||||
bounds reuse its normalized commands rather than a second path grammar.
|
||||
|
||||
Command identity, relative coordinates, shorthand, arc parameters, and original
|
||||
handles are not retained. Geometry needs non-zero bounds. Use a closed cubic
|
||||
path for organic silhouettes, polygon/closed path for ribbons/facets, open path
|
||||
for curved connectors, multi-`M` path for exact linework, and a [`shared-standards-core.md`](./shared-standards-core.md) §1.2 path clip
|
||||
for organic pictures. Filled silhouettes end with `Z`; open paths use
|
||||
`fill="none"`. Do not depend on `fill-rule="evenodd"`; build explicit visible
|
||||
geometry or bake an essential knockout.
|
||||
handles are not retained. Geometry needs non-zero bounds. Before authoring a
|
||||
freeform, apply [`native-shape-authoring.md`](./native-shape-authoring.md):
|
||||
prefer an editable basic primitive, one exact Office preset, or a Boolean
|
||||
materialization. Use a closed cubic path only for an organic silhouette those
|
||||
cannot express, polygon/closed path for unmatched ribbons/facets, and an open
|
||||
path only for a required data curve, custom route, or locked hand-drawn /
|
||||
organic style. Straight relationships use `<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
|
||||
`Z`; open paths use `fill="none"`. Do not depend on
|
||||
`fill-rule="evenodd"`; build explicit visible geometry or bake an essential
|
||||
knockout.
|
||||
For a fixed background, a background-colored overlay is also valid.
|
||||
|
||||
| Rounded rect input | Result |
|
||||
@@ -657,6 +651,8 @@ filled `Native-normalized` arrowhead. Example:
|
||||
browser-filter permissions.
|
||||
|
||||
**Reference — not a constraint**: use them only when they match the locked style.
|
||||
Their curve recipes are explicit exceptions to the Shape-first default above;
|
||||
they do not authorize decorative freeforms in another style.
|
||||
|
||||
| Intent | Construction | Boundary / fidelity |
|
||||
|---|---|---|
|
||||
@@ -744,9 +740,9 @@ subsection; this table only routes scenarios.
|
||||
| Hand/print | Annotation → highlighter/curve; ink wash → layered alpha paths; Riso → offset duplicate | §6.11; no turbulence, true bleed, or blend mode |
|
||||
| Pixel/halftone | Pixel accent → integer rect grid; sparse screen → circles | §6.11; dense screen → §6.12 |
|
||||
| Faceted/layered | Pseudo-3D → 2D facets; paper cut → direct shadow per layer | §6.11; no 3D transform/group composite shadow |
|
||||
| Data/freeform | Series depth → area first + line above; organic card → closed cubic; shaped image → [`shared-standards-core.md`](./shared-standards-core.md) §1.2 path clip | §6.11 / §6.9 |
|
||||
| Data/freeform | Series depth → area first + line above; unmatched organic silhouette → closed cubic; shaped image → [`shared-standards-core.md`](./shared-standards-core.md) §1.2 path clip | §6.11 / §6.9 |
|
||||
| Radial | Donut/gauge → explicit arcs; sunburst → sector per node; position-insensitive ring → shorthand | §6.10; shorthand has 90° preview/native offset |
|
||||
| Arrow | Manual diagonal arrowhead → calculated triangle; ordinary connector → marker | §6.10 / §1.1 |
|
||||
| Arrow | Straight relationship → `<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 |
|
||||
|
||||
---
|
||||
|
||||
+5
-4
@@ -22,10 +22,11 @@ Defined in the Design Specification & Content Outline; each image carries an `Ac
|
||||
| Status | Meaning | Executor Handling |
|
||||
|--------|---------|-------------------|
|
||||
| **Pending** | Acquisition needed (`Acquire Via: ai` / `web`) or derivation needed (`Acquire Via: slice`); not yet attempted | Image Acquisition Phase (Step 5) consumes this; must not remain after Step 5 |
|
||||
| **Failed** | The latest automatic acquisition attempt failed; this is retryable and non-terminal | Step 5 reruns the owning manifest or explicitly resolves the row to `Needs-Manual`; Executor must never treat `Failed` as usable content |
|
||||
| **Generated** | AI-generated file exists at expected path, or sliced element file exists at expected path | Reference from `../images/`; no on-slide credit needed. **Exception**: an `Illustration Sheet` row is only a slice source — it lives in §VIII but never in `spec_lock.md images`, so the Executor never places it |
|
||||
| **Sourced** | Web-sourced file exists at expected path | Reference from `../images/`; check `image_sources.json` for `license_tier` — if `attribution-required`, render an inline credit element on the slide (see [`executor-web-image.md`](./executor-web-image.md) §1 and [`image-searcher.md`](./image-searcher.md) §7 for the visual spec) |
|
||||
| **Rendered** | Deterministic formula PNG exists at expected path (`Acquire Via: formula`) | Reference from `../images/`; use `preserveAspectRatio="xMidYMid meet"` and do not crop |
|
||||
| **Needs-Manual** | Acquisition attempted once + one retry, failed; for `slice`, parent sheet is unavailable | Dashed placeholder unless user has manually supplied the file. For `slice` rows, place the parent sheet and rerun `slice_images.py`; do not hand-place individual element files |
|
||||
| **Rendered** | Deterministic formula PNG exists at expected path (`Acquire Via: formula`) | Reference from `../images/`; use a legal anchor with `meet` for the complete placement (centered default: `xMidYMid meet`) and do not crop |
|
||||
| **Needs-Manual** | Automatic acquisition is unavailable/exhausted or the confirmed path requires manual fulfillment; for `slice`, the parent sheet is unavailable | Dashed placeholder unless the user has supplied the expected file. For `slice` rows, supply the parent sheet and rerun `slice_images.py`; do not hand-place individual element files |
|
||||
| **Existing** | User already has image (`Acquire Via: user`) | Place in `images/`, reference with `<image>` |
|
||||
| **Placeholder** | Intentionally not prepared yet (`Acquire Via: placeholder`) | Dashed border placeholder; replace later |
|
||||
|
||||
@@ -36,8 +37,8 @@ Defined in the Design Specification & Content Outline; each image carries an `Ac
|
||||
```
|
||||
1. Strategist defines image needs → Add image resource list with Acquire Via + Status per row
|
||||
2. Image Acquisition (Step 5):
|
||||
- Pending + ai → Image_Generator runs image_gen.py → Generated
|
||||
- Pending + web → Image_Searcher runs image_search.py → Sourced
|
||||
- Pending / Failed + ai → Image_Generator runs image_gen.py → Generated
|
||||
- Pending / Failed + web → Image_Searcher runs image_search.py → Sourced
|
||||
- Pending + slice → after parent AI sheet is Generated, slice_images.py cuts element files → Generated
|
||||
- formula / user / placeholder rows are skipped
|
||||
3. Executor generates SVGs (svg_output/)
|
||||
|
||||
@@ -64,7 +64,7 @@ ownership.
|
||||
|
||||
| Mode | Output structure contract |
|
||||
|---|---|
|
||||
| `standard` / `fidelity` | Author project-canonical SVG prototypes and an intentional new Master/Layout/slot system. Source visual language and assets may guide the design, but source ownership, keys, picker names, parent relationships, placeholders, and repeated Slide-local elements do not define or seed the output topology. Use the compact authored-preset group only for exact registered preset matches. |
|
||||
| `standard` / `fidelity` | Author project-canonical SVG prototypes and an intentional new Master/Layout/slot system. Source visual language and assets may guide the design, but source ownership, keys, picker names, parent relationships, placeholders, and repeated Slide-local elements do not define or seed the output topology. For every new contour, use an editable basic primitive, then an exact compact authored preset, then a Boolean result; use freeform only when those cannot express it faithfully. |
|
||||
| `mirror` | Materialize a new workspace from the validated source graph one-to-one: keep the Master/Layout identities and parentage, slide assignments, placeholder type/index/bounds, and supported visual/native-object facts that are actually present. Edit the authoring IR; materialization may rehydrate converter-supported native payload only for unchanged source refs. Mechanical normalization maps fixed-layer source groups into the direct atoms required by the current explicit SVG contract while preserving ownership, paint order, and appearance; it must not invent missing facts or semantically redesign the graph. |
|
||||
|
||||
Every page remains a complete standalone SVG preview.
|
||||
@@ -83,6 +83,8 @@ bundle into an authored template. `mirror` instead preserves the supported
|
||||
expanded lossless source representation. The exact syntax and validation
|
||||
contract remain owned by
|
||||
[`shared-standards-core.md`](./shared-standards-core.md) and the native-shape reference.
|
||||
When one preset is insufficient, apply the same reference's Boolean gate before
|
||||
hand-authoring a freeform.
|
||||
|
||||
**Hard rule — complete mirror graph**: Preserve every supported source Layout represented by the validated import,
|
||||
including Layouts unused by source Slides. Emit one complete source-page
|
||||
@@ -236,14 +238,15 @@ page_count: <N>
|
||||
- HEX values with role labels (primary / accent / background / text / etc.)
|
||||
- Brand-specific application rules when present (e.g. "KPI cards rotate blue→green→red→yellow")
|
||||
|
||||
## III. Typography (omit when using the default `Arial, "Microsoft YaHei", sans-serif` stack)
|
||||
- Per-role font stacks ONLY when the template intentionally diverges (display serif title, brand typeface, etc.)
|
||||
- Font-install or embedding requirement when a non-preinstalled font leads any stack
|
||||
## III. Typography (omit without template-owned typeface identity)
|
||||
- Per-role stacks for identity (display serif, brand face, etc.)
|
||||
- A non-preinstalled face may lead only after user-confirmed target installation/approved install; no auto-embedding
|
||||
- Otherwise export a safe face; unavailable proprietary faces stay references. CSS tails aid preview, not deterministic PowerPoint fallback
|
||||
- Body baseline px (informational; `spec_lock.md` owns the actual values per project)
|
||||
|
||||
## IV. Signature Design Elements
|
||||
- Decorative motifs that ARE this template — top bar, gradient underline, logo treatment, brand emblem placement
|
||||
- Source-derived layout grammar — grid / column rhythm, page chrome, image zones, mask / crop behavior, overlay treatment, and density rhythm that make the template recognizable
|
||||
- Source-derived layout grammar — grid / column rhythm, page chrome, image zones, crop/clip behavior, scrim/overlay or baked-alpha treatment, and density rhythm that make the template recognizable
|
||||
- Optional XML snippet for any reusable component unique to this template
|
||||
|
||||
## V. Page Roster
|
||||
@@ -335,7 +338,7 @@ Templates must strictly follow the finalized template brief and the generated `d
|
||||
- **Color scheme**: Uses primary, secondary, and accent colors from the spec
|
||||
- **Font plan**: Uses the per-role font families declared in the spec
|
||||
- **Layout principles**: Margins and spacing conform to the spec
|
||||
- **Image system**: Image placement, crop / mask behavior, full-bleed zones, and overlay rules follow the source-derived norms in the spec
|
||||
- **Image system**: Image placement, crop/clip behavior, full-bleed zones, and scrim/overlay or baked-alpha treatment follow the source-derived norms in the spec
|
||||
- **Deck application**: Template Overview describes the recurring situations, audiences/outcomes, and representative roles; Page Roster factually describes the actual prototypes and reusable slots without prescribing future use
|
||||
|
||||
If PPTX import output exists:
|
||||
@@ -372,7 +375,7 @@ template.
|
||||
|---|---|---|
|
||||
| Lossless import SVG | Native-payload backing | Retain complete imported metadata, native object boundaries, hidden carriers, and source-scope identity. Keep it immutable and resolve it only through validated source refs. |
|
||||
| Authoring IR bundle | Editable template-creation source | Omit opaque native payload and duplicate hidden carriers from model context; retain visible shape intent and stable document-local source refs. Models read `authoring_summary.json`; tools read `authoring_manifest.json` for source paths and initial hashes. |
|
||||
| `standard` / `fidelity` output | Newly authored contract | Use `preset_shape_svg.py` compact canonical `<g>` output for exact preset matches, with paint from the confirmed brief / `design_spec.md`; use ordinary project SVG for other geometry. Reuse exported image/vector assets, not opaque source shape payload or source topology. |
|
||||
| `standard` / `fidelity` output | Newly authored contract | Use editable basic primitives directly, `preset_shape_svg.py` compact canonical `<g>` output for exact preset matches, and `shape_boolean_svg.py` for compound closed contours before allowing a necessary freeform. Paint comes from the confirmed brief / `design_spec.md`. Reuse exported image/vector assets, not opaque source shape payload or source topology. |
|
||||
| `mirror` output | Materialized preserved contract | Preserve currently supported imported metadata on unchanged Slide-local/slot refs, use the edited SVG fallback otherwise, and normalize fixed structural layers into semantic atoms. Strip IR-only source refs from final templates. |
|
||||
|
||||
**Validation**: Mirror does not silently use stale metadata. Materialization
|
||||
@@ -381,8 +384,9 @@ hash before reusing native payload. If an imported object cannot use the
|
||||
converter's supported native metadata after normalization, keep its current SVG fallback and report the
|
||||
limitation. For exact registered preset matches, `standard` / `fidelity`
|
||||
regenerate the compact helper group instead of transplanting opaque source
|
||||
payload; other geometry stays ordinary project SVG. `data-pptx-replace-with` remains
|
||||
reserved for optional PowerPoint-native Chart/Table replacement markers.
|
||||
payload; otherwise they apply the Boolean/freeform fallback gate above.
|
||||
`data-pptx-replace-with` remains reserved for optional PowerPoint-native
|
||||
Chart/Table replacement markers.
|
||||
|
||||
**Explicit template SVG contract**:
|
||||
|
||||
|
||||
+2
-2
@@ -13,9 +13,9 @@ Frosted-glass SaaS — translucent layered panels, flowing gradient light, float
|
||||
|
||||
## 2. Typography character
|
||||
|
||||
- Clean modern sans; light / medium weights; airy. Headlines can carry a luminous gradient on the dark field.
|
||||
- Clean modern sans; regular with selective bold; airy. Exact Light/Black requires a user-confirmed installed face. Headlines can carry a luminous gradient on the dark field.
|
||||
|
||||
> Families are chosen at confirmation `g`; this style asks for a clean, modern, slightly-light sans *character*.
|
||||
> Families are chosen at confirmation `g`; this style asks for a clean, modern, airy sans *character*.
|
||||
|
||||
## 3. Using the deck's colors
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Approachable and modern. Rounded cards, gentle elevation, friendly rhythm. For p
|
||||
## 1. Shape & decoration
|
||||
|
||||
- Shape language: rounded rectangles (`rx` 12-16), pill tags, soft containers. Consistent radius deck-wide.
|
||||
- Composition geometry: a large soft disc or blob bleeding off one edge as the color field; a pill chain or arc path replacing the boxed step row; one hero panel overlapping a full-width tinted band; an oversized rounded numeral behind the point. Cards are the container language, not the composition — vary the stage they sit on.
|
||||
- Composition geometry: a large soft disc or blob bleeding off one edge as the color field; a pill chain or exact native `arc` / `blockArc` preset replacing the boxed step row; one hero panel overlapping a full-width tinted band; an oversized rounded numeral behind the point. Use a custom arc path only when the presets cannot faithfully express the intended contour. Cards are the container language, not the composition — vary the stage they sit on.
|
||||
- Decoration: cards as the primary container; icon accents; numbered circles; gentle dividers. Moderate, in service of clarity.
|
||||
- Whitespace: comfortable padding inside cards; even gutters; balanced rather than austere.
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Strict Swiss-grid discipline. Modular grid, sharp geometry, aggressive whitespac
|
||||
|
||||
## 2. Typography character
|
||||
|
||||
- Sans-serif, single family; weight contrast (e.g. 900 / 300) over family contrast. Tight, rigorous spacing.
|
||||
- Sans-serif, single family; regular/bold contrast. Exact Light/Black requires a user-confirmed installed face. Tight, rigorous spacing.
|
||||
- Strong size hierarchy — large headlines, small precise body. Left-aligned, flush.
|
||||
|
||||
> Family is chosen at confirmation `g` by subject fit — this style asks for a grotesque / neo-grotesque *character*, not a specific font.
|
||||
|
||||
@@ -45,7 +45,7 @@ python3 scripts/update_repo.py
|
||||
| Area | Primary scripts | Documentation |
|
||||
|------|-----------------|---------------|
|
||||
| Conversion | `source_to_md.py`, `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py`, `pptx_to_svg.py` | [docs/conversion.md](./docs/conversion.md) |
|
||||
| Project management | `project_manager.py`, `page_context.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py`, `native_enhance_pptx.py` | [docs/project.md](./docs/project.md) |
|
||||
| Project management | `project_manager.py`, `page_context.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py`, `native_enhance_pptx.py`, `pptx_delivery_check.py` | [docs/project.md](./docs/project.md) |
|
||||
| SVG pipeline | `preset_shape_svg.py`, `shape_boolean_svg.py`, `svg_authoring_view.py`, `compact_svg_coordinates.py`, `mirror_template_materialize.py`, `finalize_svg.py`, `svg_to_pptx.py`, `template_preview_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `extract_svg_pictures.py`, `animation_config.py`, `notes_to_audio.py`, `narration_sync.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md); [native shape authoring](../references/native-shape-authoring.md) |
|
||||
| PPTX transitions | `pptx_transitions.py` | [docs/pptx-transitions.md](./docs/pptx-transitions.md) |
|
||||
| PPTX animations | `pptx_animations.py`, `animation_config.py` | [docs/pptx-animations.md](./docs/pptx-animations.md) |
|
||||
@@ -183,6 +183,7 @@ python3 scripts/native_enhance_pptx.py init <source.pptx> --name <project_slug>
|
||||
python3 scripts/native_enhance_pptx.py plan <project_path>
|
||||
python3 scripts/native_enhance_pptx.py validate <project_path>
|
||||
python3 scripts/native_enhance_pptx.py apply <project_path>
|
||||
python3 scripts/pptx_delivery_check.py <finished.pptx>
|
||||
```
|
||||
|
||||
Native preset shape authoring (one registry-backed fragment on stdout):
|
||||
@@ -254,7 +255,7 @@ python3 scripts/finalize_svg.py <project_path>
|
||||
python3 scripts/svg_to_pptx.py <project_path>
|
||||
```
|
||||
|
||||
`finalize_svg.py` optimizes raster images by default using `2x` display pixels and max `2560px`. Native `svg_to_pptx.py` defaults to `--image-sizing cap`: only oversized full source images are reduced to max `2560px`, so later PowerPoint resizing keeps more image detail. Use `svg_to_pptx.py --image-sizing display --image-scale 2` only for aggressive size reduction, or `--no-image-optimize` when the native PPTX must embed original image bytes.
|
||||
`finalize_svg.py` optimizes raster images by default using `2x` display pixels and max `2560px`. Native `svg_to_pptx.py` defaults to `--image-sizing cap`: oversized full sources normally reduce toward `2560px`, but cropped or stretched placements (including imported picture crops) retain enough source pixels to avoid undersupplying the visible frame. Use `svg_to_pptx.py --image-sizing display --image-scale 2` only for aggressive size reduction, or `--no-image-optimize` when the native PPTX must embed original image bytes.
|
||||
|
||||
`finalize_svg.py` remains mandatory because it creates the self-contained `svg_final/` visual preview. Those SVGs may be opened directly or inserted into PowerPoint as SVG pictures. The only supported generated-PPTX path is `svg_output/` through the project SVG-to-DrawingML converter; `-s final` is diagnostic-only, and PowerPoint's manual Convert-to-Shape operation is unsupported.
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ images in a folder. Intentionally does NOT prescribe a layout — the Strategist
|
||||
decides narrative intent (hero / atmosphere / side-by-side / accent) per
|
||||
references/strategist-image.md; this tool only supplies the numbers.
|
||||
|
||||
When a canvas is specified, also reports the reference image/text area sizes
|
||||
that would apply *if* an image is placed side-by-side with body text. Those
|
||||
numbers are conditional on the Strategist picking the side-by-side intent.
|
||||
After resolving the canvas from the project, an explicit override, or the
|
||||
ppt169 fallback, also reports the reference image/text area sizes that would
|
||||
apply *if* an image is placed side-by-side with body text. Those numbers are
|
||||
conditional on the Strategist picking the side-by-side intent.
|
||||
|
||||
Usage:
|
||||
python scripts/analyze_images.py <images_folder_path>
|
||||
@@ -23,9 +24,11 @@ Output:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from console_encoding import configure_utf8_stdio
|
||||
@@ -33,7 +36,7 @@ from console_encoding import configure_utf8_stdio
|
||||
configure_utf8_stdio()
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageOps
|
||||
except ImportError:
|
||||
print("Error: PIL/Pillow not installed. Run: pip install Pillow")
|
||||
sys.exit(1)
|
||||
@@ -55,6 +58,8 @@ except ImportError:
|
||||
},
|
||||
}
|
||||
|
||||
from project_utils import get_project_info, normalize_canvas_format
|
||||
|
||||
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
|
||||
OFFICE_VECTOR_EXTENSIONS = {".emf", ".wmf"}
|
||||
REPORT_WIDTH = 100
|
||||
@@ -93,32 +98,6 @@ def _load_image_manifest(images_dir: str) -> dict[str, dict]:
|
||||
return manifest
|
||||
|
||||
|
||||
def _warn_office_vectors_without_manifest(images_dir: str, manifest: dict[str, dict]) -> None:
|
||||
"""Warn when EMF/WMF files cannot be reported because manifest metadata is absent."""
|
||||
if manifest:
|
||||
return
|
||||
|
||||
vector_files = [
|
||||
path for path in Path(images_dir).iterdir()
|
||||
if path.is_file() and path.suffix.lower() in OFFICE_VECTOR_EXTENSIONS
|
||||
]
|
||||
if not vector_files:
|
||||
return
|
||||
|
||||
print(
|
||||
f"[WARN] Found {len(vector_files)} office vector file(s) (.emf/.wmf) "
|
||||
"but no image_manifest.json found."
|
||||
)
|
||||
print(
|
||||
" Dimensions and metadata unknown — these assets will NOT appear "
|
||||
"in the analysis report."
|
||||
)
|
||||
print(
|
||||
" To fix: ensure image_manifest.json is present in images/ "
|
||||
"(auto-generated by doc_to_md.py for DOCX sources)."
|
||||
)
|
||||
|
||||
|
||||
def _manifest_ratio(meta: dict | None) -> float | None:
|
||||
"""Return a positive display ratio from manifest metadata."""
|
||||
if not meta:
|
||||
@@ -197,6 +176,29 @@ def _apply_manifest_metadata(result: ImageAnalysis, meta: dict | None) -> None:
|
||||
result["pptx_native_supported"] = meta.get("pptx_native_supported", True)
|
||||
|
||||
|
||||
def _has_transparent_pixels(image: Image.Image) -> bool:
|
||||
"""Return whether any frame contains a pixel with alpha below 255."""
|
||||
original_frame = image.tell()
|
||||
frame_count = int(getattr(image, "n_frames", 1))
|
||||
try:
|
||||
for frame_index in range(frame_count):
|
||||
image.seek(frame_index)
|
||||
if "A" not in image.getbands() and "transparency" not in image.info:
|
||||
continue
|
||||
rgba = image.convert("RGBA")
|
||||
alpha = rgba.getchannel("A")
|
||||
try:
|
||||
extrema = alpha.getextrema()
|
||||
finally:
|
||||
alpha.close()
|
||||
rgba.close()
|
||||
if extrema and extrema[0] < 255:
|
||||
return True
|
||||
finally:
|
||||
image.seek(original_frame)
|
||||
return False
|
||||
|
||||
|
||||
def _result_from_manifest(
|
||||
filename: str,
|
||||
filepath: str,
|
||||
@@ -212,12 +214,26 @@ def _result_from_manifest(
|
||||
'width': width,
|
||||
'height': height,
|
||||
'aspect_ratio': ratio,
|
||||
'pixel_aspect_ratio': meta.get('pixel_ratio') or ratio,
|
||||
'pixel_aspect_ratio': None,
|
||||
'source_display_ratio': ratio,
|
||||
'ratio_source': 'manifest',
|
||||
'format': Path(filename).suffix.lstrip('.').upper(),
|
||||
'has_transparent_pixels': None,
|
||||
'layout_hint': classify_ratio(ratio),
|
||||
'filesize_kb': os.path.getsize(filepath) / 1024,
|
||||
}
|
||||
_apply_manifest_metadata(result, meta)
|
||||
suffix = Path(filename).suffix.lower()
|
||||
is_office_vector = suffix in OFFICE_VECTOR_EXTENSIONS
|
||||
result["asset_kind"] = meta.get(
|
||||
"asset_kind",
|
||||
"office_vector" if is_office_vector else "vector",
|
||||
)
|
||||
result["svg_renderable"] = meta.get("svg_renderable", suffix == ".svg")
|
||||
result["pptx_native_supported"] = meta.get(
|
||||
"pptx_native_supported",
|
||||
is_office_vector or suffix == ".svg",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -311,66 +327,87 @@ def compute_layout_dimensions(
|
||||
return _try_left_right_width_constrained()
|
||||
|
||||
|
||||
def analyze_images(images_dir: str) -> list[ImageAnalysis]:
|
||||
def _analyze_images(images_dir: str) -> tuple[list[ImageAnalysis], list[str]]:
|
||||
"""Analyze all image files in a directory.
|
||||
|
||||
Args:
|
||||
images_dir: Directory that contains image files.
|
||||
|
||||
Returns:
|
||||
A list of image analysis records sorted by filename.
|
||||
Sorted image analysis records and supported files that could not be read.
|
||||
"""
|
||||
|
||||
results: list[ImageAnalysis] = []
|
||||
errors: list[str] = []
|
||||
manifest = _load_image_manifest(images_dir)
|
||||
seen_filenames: set[str] = set()
|
||||
|
||||
# Iterate through all files in the directory
|
||||
for filename in sorted(os.listdir(images_dir)):
|
||||
filepath = os.path.join(images_dir, filename)
|
||||
if not os.path.isfile(filepath):
|
||||
continue
|
||||
|
||||
suffix = Path(filename).suffix.lower()
|
||||
meta = manifest.get(filename)
|
||||
|
||||
# Check if it is an image file
|
||||
if os.path.isfile(filepath) and Path(filename).suffix.lower() in IMAGE_EXTENSIONS:
|
||||
if suffix in IMAGE_EXTENSIONS:
|
||||
try:
|
||||
with Image.open(filepath) as img:
|
||||
width, height = img.size
|
||||
pixel_ratio = width / height
|
||||
aspect_ratio = _manifest_ratio(meta) or pixel_ratio
|
||||
layout_hint = classify_ratio(aspect_ratio)
|
||||
image_format = img.format or suffix.lstrip(".").upper()
|
||||
has_transparent_pixels = _has_transparent_pixels(img)
|
||||
oriented = ImageOps.exif_transpose(img)
|
||||
try:
|
||||
width, height = oriented.size
|
||||
finally:
|
||||
if oriented is not img:
|
||||
oriented.close()
|
||||
|
||||
aspect_ratio = width / height
|
||||
|
||||
result: ImageAnalysis = {
|
||||
'filename': filename,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'aspect_ratio': aspect_ratio,
|
||||
'pixel_aspect_ratio': pixel_ratio,
|
||||
'ratio_source': 'manifest' if meta else 'pixel',
|
||||
'layout_hint': layout_hint,
|
||||
'pixel_aspect_ratio': aspect_ratio,
|
||||
'source_display_ratio': _manifest_ratio(meta),
|
||||
'ratio_source': 'native',
|
||||
'format': image_format,
|
||||
'has_transparent_pixels': has_transparent_pixels,
|
||||
'layout_hint': classify_ratio(aspect_ratio),
|
||||
'filesize_kb': os.path.getsize(filepath) / 1024
|
||||
}
|
||||
_apply_manifest_metadata(result, meta)
|
||||
results.append(result)
|
||||
seen_filenames.add(filename)
|
||||
except Exception as e:
|
||||
print(f"[WARN] Cannot read {filename}: {e}")
|
||||
elif os.path.isfile(filepath) and meta:
|
||||
except (
|
||||
EOFError,
|
||||
OSError,
|
||||
SyntaxError,
|
||||
ValueError,
|
||||
ZeroDivisionError,
|
||||
Image.DecompressionBombError,
|
||||
) as exc:
|
||||
message = f"{filename}: {exc}"
|
||||
errors.append(message)
|
||||
print(f"[WARN] Cannot read {message}")
|
||||
elif meta:
|
||||
result = _result_from_manifest(filename, filepath, meta)
|
||||
if result:
|
||||
results.append(result)
|
||||
seen_filenames.add(filename)
|
||||
else:
|
||||
message = f"{filename}: manifest has no valid display_ratio"
|
||||
errors.append(message)
|
||||
print(f"[WARN] Cannot analyze {message}")
|
||||
elif suffix in OFFICE_VECTOR_EXTENSIONS:
|
||||
message = f"{filename}: image_manifest.json metadata is required"
|
||||
errors.append(message)
|
||||
print(f"[WARN] Cannot analyze {message}")
|
||||
|
||||
for filename, meta in sorted(manifest.items()):
|
||||
if filename in seen_filenames:
|
||||
continue
|
||||
filepath = os.path.join(images_dir, filename)
|
||||
if not os.path.isfile(filepath):
|
||||
continue
|
||||
result = _result_from_manifest(filename, filepath, meta)
|
||||
if result:
|
||||
results.append(result)
|
||||
return results, errors
|
||||
|
||||
_warn_office_vectors_without_manifest(images_dir, manifest)
|
||||
|
||||
def analyze_images(images_dir: str) -> list[ImageAnalysis]:
|
||||
"""Analyze readable image files while preserving the existing public API."""
|
||||
results, _ = _analyze_images(images_dir)
|
||||
return results
|
||||
|
||||
|
||||
@@ -379,7 +416,6 @@ def enrich_with_layout(
|
||||
canvas_key: str,
|
||||
) -> None:
|
||||
"""Add computed layout dimensions to each result in-place."""
|
||||
fmt = CANVAS_FORMATS.get(canvas_key, {})
|
||||
margins = LAYOUT_MARGINS.get(canvas_key)
|
||||
|
||||
if not margins:
|
||||
@@ -413,7 +449,7 @@ def print_results(results: list[ImageAnalysis]) -> None:
|
||||
print("-" * REPORT_WIDTH)
|
||||
|
||||
for i, img in enumerate(results, 1):
|
||||
ratio_source = str(img.get('ratio_source', 'pixel'))
|
||||
ratio_source = str(img.get('ratio_source', 'native'))
|
||||
usage_count = int(img.get('usage_count', 1))
|
||||
base = f"{i:<4} {img['width']:<7} {img['height']:<7} {img['aspect_ratio']:<7.2f} {ratio_source:<8} {usage_count:<5} {img['filesize_kb']:<10.1f}KB {img['layout_hint']:<20}"
|
||||
if has_layout:
|
||||
@@ -515,26 +551,115 @@ def generate_markdown(results: list[ImageAnalysis], canvas_key: str) -> None:
|
||||
print("\n" + "=" * REPORT_WIDTH + "\n")
|
||||
|
||||
|
||||
def save_csv(results: list[ImageAnalysis], csv_path: str) -> None:
|
||||
"""Save analysis results to a CSV file."""
|
||||
has_layout = 'layout_type' in results[0] if results else False
|
||||
|
||||
# NOTE: ImageArea_SxS / TextArea_SxS apply only if Strategist picks the
|
||||
# side-by-side intent for this image (see strategist-image.md). The tool
|
||||
# does not prescribe a layout.
|
||||
with open(csv_path, 'w', encoding='utf-8') as f:
|
||||
if has_layout:
|
||||
f.write("No,Filename,Width,Height,AspectRatio,PixelAspectRatio,RatioSource,UsageCount,DisplayRatioVariants,AssetKind,SvgRenderable,PptxNativeSupported,SizeKB,Category,ImageArea_SxS,TextArea_SxS\n")
|
||||
for i, img in enumerate(results, 1):
|
||||
f.write(f"{i},{img['filename']},{img['width']},{img['height']},{img['aspect_ratio']:.2f},{img.get('pixel_aspect_ratio', img['aspect_ratio']):.2f},{img.get('ratio_source', 'pixel')},{img.get('usage_count', 1)},{img.get('display_ratio_variants', '')},{img.get('asset_kind', 'bitmap')},{img.get('svg_renderable', True)},{img.get('pptx_native_supported', True)},{img['filesize_kb']:.1f},{img['layout_hint']},{img['image_w']}x{img['image_h']},{img['text_w']}x{img['text_h']}\n")
|
||||
else:
|
||||
f.write("No,Filename,Width,Height,AspectRatio,PixelAspectRatio,RatioSource,UsageCount,DisplayRatioVariants,AssetKind,SvgRenderable,PptxNativeSupported,SizeKB,Category\n")
|
||||
for i, img in enumerate(results, 1):
|
||||
f.write(f"{i},{img['filename']},{img['width']},{img['height']},{img['aspect_ratio']:.2f},{img.get('pixel_aspect_ratio', img['aspect_ratio']):.2f},{img.get('ratio_source', 'pixel')},{img.get('usage_count', 1)},{img.get('display_ratio_variants', '')},{img.get('asset_kind', 'bitmap')},{img.get('svg_renderable', True)},{img.get('pptx_native_supported', True)},{img['filesize_kb']:.1f},{img['layout_hint']}\n")
|
||||
print(f"\nCSV saved to: {csv_path}")
|
||||
def _format_optional_number(value: object, digits: int = 2) -> str:
|
||||
"""Format a numeric value for CSV, leaving unavailable facts blank."""
|
||||
if not isinstance(value, (int, float)):
|
||||
return ""
|
||||
return f"{float(value):.{digits}f}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
def save_csv(
|
||||
results: list[ImageAnalysis],
|
||||
csv_path: str | Path,
|
||||
include_layout: bool | None = None,
|
||||
) -> None:
|
||||
"""Atomically save analysis results to a standards-compliant CSV file."""
|
||||
target = Path(csv_path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if include_layout is None:
|
||||
include_layout = bool(results and "layout_type" in results[0])
|
||||
header = [
|
||||
"No",
|
||||
"Filename",
|
||||
"Width",
|
||||
"Height",
|
||||
"AspectRatio",
|
||||
"PixelAspectRatio",
|
||||
"SourceDisplayRatio",
|
||||
"RatioSource",
|
||||
"Format",
|
||||
"HasTransparentPixels",
|
||||
"UsageCount",
|
||||
"DisplayRatioVariants",
|
||||
"AssetKind",
|
||||
"SvgRenderable",
|
||||
"PptxNativeSupported",
|
||||
"SizeKB",
|
||||
"Category",
|
||||
]
|
||||
if include_layout:
|
||||
header.extend(["ImageArea_SxS", "TextArea_SxS"])
|
||||
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
newline="",
|
||||
prefix=f".{target.name}.",
|
||||
suffix=".tmp",
|
||||
dir=target.parent,
|
||||
delete=False,
|
||||
) as handle:
|
||||
temporary_path = Path(handle.name)
|
||||
writer = csv.writer(handle, lineterminator="\n")
|
||||
writer.writerow(header)
|
||||
for index, image in enumerate(results, 1):
|
||||
row = [
|
||||
index,
|
||||
image["filename"],
|
||||
image["width"],
|
||||
image["height"],
|
||||
_format_optional_number(image["aspect_ratio"]),
|
||||
_format_optional_number(image.get("pixel_aspect_ratio")),
|
||||
_format_optional_number(image.get("source_display_ratio")),
|
||||
image.get("ratio_source", "native"),
|
||||
image.get("format", ""),
|
||||
image.get("has_transparent_pixels", ""),
|
||||
image.get("usage_count", 1),
|
||||
image.get("display_ratio_variants", ""),
|
||||
image.get("asset_kind", "bitmap"),
|
||||
image.get("svg_renderable", True),
|
||||
image.get("pptx_native_supported", True),
|
||||
_format_optional_number(image["filesize_kb"], digits=1),
|
||||
image["layout_hint"],
|
||||
]
|
||||
if include_layout:
|
||||
image_area = (
|
||||
f"{image['image_w']}x{image['image_h']}"
|
||||
if "image_w" in image
|
||||
else ""
|
||||
)
|
||||
text_area = (
|
||||
f"{image['text_w']}x{image['text_h']}"
|
||||
if "text_w" in image
|
||||
else ""
|
||||
)
|
||||
row.extend([image_area, text_area])
|
||||
writer.writerow(row)
|
||||
os.replace(temporary_path, target)
|
||||
temporary_path = None
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
print(f"\nCSV saved to: {target}")
|
||||
|
||||
|
||||
def _resolve_canvas_key(images_dir: Path, override: str | None) -> tuple[str, str]:
|
||||
"""Resolve canvas from an explicit override, project context, or fallback."""
|
||||
if override:
|
||||
return normalize_canvas_format(override), "--canvas"
|
||||
|
||||
project_dir = images_dir.parent if images_dir.name == "images" else images_dir
|
||||
project_info = get_project_info(str(project_dir))
|
||||
project_canvas = normalize_canvas_format(str(project_info.get("format", "")))
|
||||
if project_canvas in CANVAS_FORMATS:
|
||||
return project_canvas, "project"
|
||||
return "ppt169", "fallback"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Run the CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze image sizes and compute PPT layout dimensions"
|
||||
@@ -545,48 +670,59 @@ def main() -> None:
|
||||
)
|
||||
parser.add_argument(
|
||||
"--canvas",
|
||||
default="ppt169",
|
||||
help=f"Canvas format key (default: ppt169). Available: {', '.join(sorted(CANVAS_FORMATS.keys()))}"
|
||||
help=(
|
||||
"Canvas format override. By default, infer it from the project "
|
||||
f"directory and fall back to ppt169. Available: "
|
||||
f"{', '.join(sorted(CANVAS_FORMATS.keys()))}"
|
||||
),
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(argv)
|
||||
images_dir = Path(args.images_dir).resolve()
|
||||
|
||||
images_dir = os.path.abspath(args.images_dir)
|
||||
|
||||
if not os.path.exists(images_dir):
|
||||
if not images_dir.exists():
|
||||
print(f"Error: Directory not found: {images_dir}")
|
||||
sys.exit(1)
|
||||
return 1
|
||||
|
||||
if not os.path.isdir(images_dir):
|
||||
if not images_dir.is_dir():
|
||||
print(f"Error: Not a directory: {images_dir}")
|
||||
sys.exit(1)
|
||||
return 1
|
||||
|
||||
canvas_key = args.canvas
|
||||
canvas_key, canvas_source = _resolve_canvas_key(images_dir, args.canvas)
|
||||
if canvas_key not in CANVAS_FORMATS:
|
||||
print(f"Error: Unknown canvas format '{canvas_key}'. Available: {', '.join(sorted(CANVAS_FORMATS.keys()))}")
|
||||
sys.exit(1)
|
||||
available = ", ".join(sorted(CANVAS_FORMATS.keys()))
|
||||
print(f"Error: Unknown canvas format '{canvas_key}'. Available: {available}")
|
||||
return 1
|
||||
|
||||
fmt = CANVAS_FORMATS[canvas_key]
|
||||
print(f"Analyzing: {images_dir}")
|
||||
print(f"Canvas: {fmt.get('name', canvas_key)} ({fmt.get('width', '?')}x{fmt.get('height', '?')})")
|
||||
print(
|
||||
f"Canvas: {fmt.get('name', canvas_key)} "
|
||||
f"({fmt.get('width', '?')}x{fmt.get('height', '?')}; {canvas_source})"
|
||||
)
|
||||
|
||||
results = analyze_images(images_dir)
|
||||
results, errors = _analyze_images(str(images_dir))
|
||||
enrich_with_layout(results, canvas_key)
|
||||
|
||||
if results:
|
||||
enrich_with_layout(results, canvas_key)
|
||||
print_results(results)
|
||||
generate_markdown(results, canvas_key)
|
||||
|
||||
# Save to CSV file (saved under the project's analysis/ directory,
|
||||
# alongside the PPTX intake bundle)
|
||||
parent_dir = os.path.dirname(images_dir)
|
||||
analysis_dir = os.path.join(parent_dir, "analysis")
|
||||
os.makedirs(analysis_dir, exist_ok=True)
|
||||
csv_path = os.path.join(analysis_dir, "image_analysis.csv")
|
||||
save_csv(results, csv_path)
|
||||
else:
|
||||
print("No image files found in the directory.")
|
||||
print("No readable supported image files found in the directory.")
|
||||
|
||||
analysis_dir = images_dir.parent / "analysis"
|
||||
csv_path = analysis_dir / "image_analysis.csv"
|
||||
save_csv(results, csv_path, include_layout=canvas_key in LAYOUT_MARGINS)
|
||||
|
||||
if errors:
|
||||
print(
|
||||
f"[ERROR] {len(errors)} supported image file(s) could not be analyzed; "
|
||||
"the current report was still written.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""
|
||||
PPT Master - Animation Config Tool
|
||||
|
||||
Create and validate optional per-object PPTX animation sidecar files.
|
||||
Create and validate optional PPTX animation and deterministic Morph sidecars.
|
||||
|
||||
Usage:
|
||||
python3 scripts/animation_config.py scaffold <project_path>
|
||||
@@ -43,7 +43,7 @@ configure_utf8_stdio()
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Create or validate PPTX animation sidecar configuration.',
|
||||
description='Create or validate PPTX motion sidecar configuration.',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest='command', required=True)
|
||||
|
||||
@@ -46,9 +46,9 @@ from pptx_to_svg.ooxml_loader import OoxmlPackage # noqa: E402
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
def _font_pair(theme_root, font_tag: str) -> dict[str, str]:
|
||||
"""Read one <a:majorFont> / <a:minorFont> into {latin, ea} (skip empties)."""
|
||||
out: dict[str, str] = {}
|
||||
def _font_pair(theme_root, font_tag: str) -> dict[str, object]:
|
||||
"""Read one theme font family, including explicit CJK script mappings."""
|
||||
out: dict[str, object] = {}
|
||||
font = theme_root.find(f".//a:fontScheme/a:{font_tag}", NS)
|
||||
if font is None:
|
||||
return out
|
||||
@@ -58,6 +58,14 @@ def _font_pair(theme_root, font_tag: str) -> dict[str, str]:
|
||||
face = (elem.attrib.get("typeface") or "").strip()
|
||||
if face:
|
||||
out[key] = face
|
||||
scripts: dict[str, str] = {}
|
||||
for elem in font.findall("a:font", NS):
|
||||
script = (elem.attrib.get("script") or "").strip()
|
||||
face = (elem.attrib.get("typeface") or "").strip()
|
||||
if script in {"Hans", "Hant", "Jpan", "Hang"} and face:
|
||||
scripts[script] = face
|
||||
if scripts:
|
||||
out["scripts"] = scripts
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -44,16 +44,16 @@ Output files land directly under `project/images/`. Formula filenames should use
|
||||
Unified image generation entry point.
|
||||
|
||||
This script is the **Path A** API/proxy executor for generated images. In the
|
||||
PPT pipeline, always check the confirmed `image_ai_path` before running manifest
|
||||
mode: `host-native` uses the host's image tool directly and must not run
|
||||
`image_gen.py --manifest`; use `image_gen.py --render-md` only for its
|
||||
read-only Markdown sidecar.
|
||||
PPT pipeline, always check `design_spec.md §I / AI Image Acquisition Path`
|
||||
before running manifest mode: only `api` / `auto` permits Path A;
|
||||
`host-native` uses the host's image tool directly and `manual` uses the
|
||||
read-only Markdown sidecar. For a project manifest, a missing or unknown value
|
||||
fails closed and returns to Generate Step 4 recovery.
|
||||
|
||||
```bash
|
||||
python3 scripts/image_gen.py "A modern futuristic workspace"
|
||||
python3 scripts/image_gen.py "Abstract tech background" --aspect_ratio 16:9 --image_size 4K
|
||||
python3 scripts/image_gen.py "Concept car" -o projects/demo/images
|
||||
python3 scripts/image_gen.py "Beautiful landscape" -n "low quality, blurry, watermark"
|
||||
python3 scripts/image_gen.py --list-backends
|
||||
```
|
||||
|
||||
@@ -162,8 +162,11 @@ Analyze images in a project directory before writing the design spec or composin
|
||||
|
||||
```bash
|
||||
python3 scripts/analyze_images.py <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.
|
||||
|
||||
Use this as the default inventory and geometry source; it does not perform semantic image understanding. Generate planning follows the Strategist's context-first boundary: source context, captions / alt text / titles, filenames, user notes, and existing resource records come first. Only a specific asset whose meaning or safe placement remains materially ambiguous may be inspected, and the workflow never bulk-opens the image folder.
|
||||
|
||||
## `image_search.py`
|
||||
@@ -178,25 +181,27 @@ python3 scripts/image_search.py "offshore wind farm" \
|
||||
|
||||
For multiple web rows, `--batch images/image_queries.json` searches them concurrently (modest default, `--concurrency N` / `IMAGE_SEARCH_CONCURRENCY` to tune) instead of one call per row — the web sister of `image_gen.py --manifest`. Schema and status semantics: [`image-searcher.md`](../../references/image-searcher.md) §5.
|
||||
|
||||
Providers (Openverse and Wikimedia work with no key; configure Pexels / Pixabay for better stock-photo quality):
|
||||
Providers (Pexels / Pixabay are tried first when keyed; Openverse and Wikimedia are zero-config fallbacks):
|
||||
|
||||
| Provider | Config | Strength |
|
||||
|---|---|---|
|
||||
| `openverse` | zero-config | fallback aggregator: Wikimedia + Flickr + museums + rawpixel |
|
||||
| `wikimedia` | zero-config | educational, scientific, geographic, historical |
|
||||
| `pexels` | recommended: `PEXELS_API_KEY` | modern stock photography, people, workplace, lifestyle |
|
||||
| `pixabay` | recommended: `PIXABAY_API_KEY` | broad type coverage including photos and illustrations |
|
||||
| `openverse` | zero-config | fallback aggregator: Wikimedia + Flickr + museums + rawpixel |
|
||||
| `wikimedia` | zero-config | educational, scientific, geographic, historical |
|
||||
|
||||
Default search chain (when `--provider` is unset): zero-config providers first, then keyed providers whose API key is set in the environment. Keyed providers without a key are silently skipped. For polished visual decks, configure at least one keyed provider.
|
||||
Default search chain (when `--provider` is unset): configured Pexels, configured Pixabay, Openverse, then Wikimedia. Missing keyed credentials are silently skipped. For polished visual decks, configure at least one keyed provider.
|
||||
|
||||
`image_search.py` uses the same `.env` lookup order as `image_gen.py`, so skill installs can keep `PEXELS_API_KEY` / `PIXABAY_API_KEY` in `~/.ppt-master/.env`.
|
||||
|
||||
Query guidance:
|
||||
|
||||
Keep the Design Spec §VIII `Reference` as the full visual/crop intent; write a separate 1–4 word concrete provider query for this CLI.
|
||||
|
||||
| Case | Pattern |
|
||||
|---|---|
|
||||
| Generic stock concept | `boardroom meeting, professional editorial photography, natural light` |
|
||||
| China-specific landmark | Official Chinese place name + concrete scene |
|
||||
| Generic stock concept | `boardroom meeting` |
|
||||
| China-specific landmark | 1–4 official place/identity words |
|
||||
| Avoid | Negative prompt wording such as `not tourist snapshot` |
|
||||
|
||||
License filter:
|
||||
@@ -231,7 +236,7 @@ Output:
|
||||
|
||||
- Image saved to the specified output directory (auto-converts webp → jpg via Pillow when the filename extension demands)
|
||||
- `image_sources.json` manifest with full provenance (provider, license, license_tier, author, source URL, dimensions, attribution_text)
|
||||
- Manifest is idempotent on `filename` — rerunning replaces that entry only
|
||||
- Manifest is idempotent on `filename` and written atomically; damaged existing provenance blocks replacement
|
||||
|
||||
Allowed licenses (default): CC0, Public Domain, Pexels License, Pixabay Content License, CC BY, CC BY-SA. Auto-rejected: CC BY-NC, CC BY-ND, CC BY-NC-SA, CC BY-NC-ND, all rights reserved, unknown.
|
||||
|
||||
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
# Mask and Gradient Maintenance Smoke
|
||||
|
||||
Run this manual smoke from the repository root after changing gradient
|
||||
validation, gradient import/export, native background promotion, icon
|
||||
expansion, or mask rejection. It keeps XML in memory except for temporary SVG
|
||||
fixtures; do not turn it into a test framework or example deck.
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
scripts = Path("skills/ppt-master/scripts").resolve()
|
||||
sys.path.insert(0, str(scripts))
|
||||
|
||||
from pptx_to_svg.fill_to_svg import _angle_to_unit_endpoints
|
||||
from svg_quality_checker import SVGQualityChecker
|
||||
from svg_to_pptx.drawingml.converter import (
|
||||
SvgNativeConversionError,
|
||||
convert_svg_to_slide_shapes,
|
||||
)
|
||||
from svg_to_pptx.drawingml.styles import build_gradient_fill
|
||||
from svg_to_pptx.drawingml.utils import (
|
||||
parse_project_linear_gradient_coordinate,
|
||||
project_gradient_errors,
|
||||
project_mask_errors,
|
||||
)
|
||||
|
||||
SVG_NS = "http://www.w3.org/2000/svg"
|
||||
|
||||
|
||||
def svg(fragment):
|
||||
return ET.fromstring(
|
||||
f'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720" '
|
||||
f'data-pptx-page-role="content">{fragment}</svg>'
|
||||
)
|
||||
|
||||
|
||||
valid = svg(
|
||||
"""<defs>
|
||||
<linearGradient id="linear">
|
||||
<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">
|
||||
<stop offset="0" stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#0F172A"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect id="bg" x="0" y="0" width="1280" height="720"
|
||||
fill="url(#linear)"/>"""
|
||||
)
|
||||
assert not project_gradient_errors(valid)
|
||||
linear, radial = list(valid.find(f"{{{SVG_NS}}}defs"))
|
||||
linear_xml = build_gradient_fill(linear)
|
||||
radial_xml = build_gradient_fill(radial)
|
||||
assert '<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
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="ppt-master-gradient-smoke-") as tmp:
|
||||
source = Path(tmp) / "gradient.svg"
|
||||
source.write_text(
|
||||
ET.tostring(valid, encoding="unicode"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
trace = []
|
||||
slide_xml, *_ = convert_svg_to_slide_shapes(source, trace_out=trace)
|
||||
assert slide_xml.count("<p:bg>") == 1
|
||||
assert "<a:gradFill>" in slide_xml
|
||||
assert trace[0]["summary"]["promoted_backgrounds"] == 1
|
||||
assert any(
|
||||
event.get("decision") == "native-background"
|
||||
for event in trace[0]["events"]
|
||||
)
|
||||
|
||||
invalid_gradients = [
|
||||
(
|
||||
"""<linearGradient id="single">
|
||||
<stop offset="0" stop-color="#2563EB"/>
|
||||
</linearGradient>""",
|
||||
"requires at least two direct <stop> children",
|
||||
),
|
||||
(
|
||||
"""<linearGradient id="descending">
|
||||
<stop offset="1" stop-color="#2563EB"/>
|
||||
<stop offset="0" stop-color="#F97316"/>
|
||||
</linearGradient>""",
|
||||
"offsets must be non-decreasing",
|
||||
),
|
||||
(
|
||||
"""<linearGradient id="zero" x1="0.5" y1="0.5" x2="0.5" y2="0.5">
|
||||
<stop offset="0" stop-color="#2563EB"/>
|
||||
<stop offset="1" stop-color="#F97316"/>
|
||||
</linearGradient>""",
|
||||
"linear gradient axis must not collapse to one point",
|
||||
),
|
||||
]
|
||||
for definition, expected in invalid_gradients:
|
||||
errors = project_gradient_errors(svg(f"<defs>{definition}</defs>"))
|
||||
assert any(expected in error for error in errors), errors
|
||||
|
||||
mask_cases = [
|
||||
"""<defs><mask id="fade"><rect width="1" height="1"/></mask></defs>""",
|
||||
"""<rect width="100" height="100" mask="url(#fade)"/>""",
|
||||
"""<rect width="100" height="100" style="mask: url(#fade)"/>""",
|
||||
]
|
||||
for fragment in mask_cases:
|
||||
errors = project_mask_errors(svg(fragment))
|
||||
assert any("unsupported SVG mask" in error for error in errors), errors
|
||||
|
||||
checker = SVGQualityChecker()
|
||||
with tempfile.TemporaryDirectory(prefix="ppt-master-mask-smoke-") as tmp:
|
||||
for index, fragment in enumerate(mask_cases, start=1):
|
||||
source = Path(tmp) / f"mask-{index}.svg"
|
||||
source.write_text(
|
||||
ET.tostring(svg(fragment), encoding="unicode"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
checked = checker.check_file(str(source))
|
||||
assert any("mask" in error.lower() for error in checked["errors"])
|
||||
try:
|
||||
convert_svg_to_slide_shapes(source)
|
||||
except SvgNativeConversionError as exc:
|
||||
assert "invalid project mask" in str(exc)
|
||||
else:
|
||||
raise AssertionError(f"native export accepted {source.name}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="ppt-master-icon-mask-smoke-") as tmp:
|
||||
project = Path(tmp)
|
||||
icon_dir = project / "icons" / "imported"
|
||||
icon_dir.mkdir(parents=True)
|
||||
(icon_dir / "masked.svg").write_text(
|
||||
f"""<svg xmlns="{SVG_NS}" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<mask id="fade">
|
||||
<rect x="0" y="0" width="24" height="24" fill="#FFFFFF"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="24" height="24"
|
||||
fill="#000000" mask="url(#fade)"/>
|
||||
</svg>""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
source = project / "icon-mask.svg"
|
||||
source.write_text(
|
||||
ET.tostring(
|
||||
svg(
|
||||
"""<use data-icon="imported/masked"
|
||||
x="20" y="20" width="24" height="24"/>"""
|
||||
),
|
||||
encoding="unicode",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
checked = checker.check_file(str(source))
|
||||
assert any(
|
||||
"Icon imported/masked" in error and "mask" in error.lower()
|
||||
for error in checked["errors"]
|
||||
)
|
||||
try:
|
||||
convert_svg_to_slide_shapes(source)
|
||||
except SvgNativeConversionError as exc:
|
||||
assert "invalid project mask" in str(exc)
|
||||
else:
|
||||
raise AssertionError("native export accepted a masked icon")
|
||||
|
||||
x1, y1, x2, y2 = _angle_to_unit_endpoints(30)
|
||||
assert any(value < 0 or value > 1 for value in (x1, y1, x2, y2))
|
||||
for value in (x1, y1, x2, y2):
|
||||
assert math.isclose(
|
||||
parse_project_linear_gradient_coordinate(str(value)),
|
||||
value,
|
||||
abs_tol=1e-9,
|
||||
)
|
||||
roundtrip = svg(
|
||||
f"""<defs>
|
||||
<linearGradient id="roundtrip"
|
||||
x1="{x1:.9f}" y1="{y1:.9f}" x2="{x2:.9f}" y2="{y2:.9f}">
|
||||
<stop offset="0" stop-color="#2563EB"/>
|
||||
<stop offset="1" stop-color="#F97316"/>
|
||||
</linearGradient>
|
||||
</defs>"""
|
||||
)
|
||||
assert not project_gradient_errors(roundtrip)
|
||||
roundtrip_gradient = roundtrip.find(
|
||||
f"{{{SVG_NS}}}defs/{{{SVG_NS}}}linearGradient"
|
||||
)
|
||||
angle = int(
|
||||
re.search(
|
||||
r'<a:lin ang="(\d+)"',
|
||||
build_gradient_fill(roundtrip_gradient),
|
||||
).group(1)
|
||||
) / 60000
|
||||
assert math.isclose(angle, 30, abs_tol=0.01), angle
|
||||
|
||||
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.
|
||||
@@ -36,7 +36,7 @@ One resolved animation-pane row contains these fields:
|
||||
| Duration | Finite positive schedule duration; scalable native behavior trees preserve their internal timing ratios |
|
||||
| Delay | Finite non-negative offset used by `after-previous` or as trigger-shape `TriggerDelayTime` |
|
||||
| Order | Positive integer sidecar order; ties retain stable SVG order |
|
||||
| Effect options | Effect-specific `direction`, `amount`, `color`, `font_name`, `relative`, or `size` values from PowerPoint `EffectParameters` |
|
||||
| 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 |
|
||||
|
||||
|
||||
+28
-2
@@ -202,6 +202,30 @@ failure rather than a silent downgrade.
|
||||
- An extension counts as successful only when the primary Choice contains the
|
||||
requested effect. A fallback alone is not success.
|
||||
|
||||
### 3.2 Deterministic Morph Identity
|
||||
|
||||
The generated route may add an explicit `slides.<destination>.morph` block to
|
||||
bind direct-root SVG groups across adjacent slides. The sidecar stable key is
|
||||
lowered to the same top-level `p:cNvPr@name="!!<key>"` on both final
|
||||
Slide-local objects. This does not create an Animation Pane row and does not
|
||||
change either object's numeric shape id.
|
||||
|
||||
The full plan is resolved before any SVG conversion so a source group named by
|
||||
the following slide remains a stable top-level target. Names are written only
|
||||
after flat/structured/preserve processing has finished; structured slide-shape
|
||||
roster expectations are then refreshed. Package read-back requires:
|
||||
|
||||
- the declared source to be the immediately preceding public slide;
|
||||
- exactly one `!!<key>` object on each side;
|
||||
- the same OOXML object container type on both sides;
|
||||
- Morph by object on the destination; and
|
||||
- no structural target, same-slide name collision, group/key conflict, or
|
||||
undeclared shared `!!` name on a Morph edge.
|
||||
|
||||
Morph without an explicit pair block retains PowerPoint's automatic matching
|
||||
behavior. Explicit pairing is generated-route authoring; direct-PPTX routes
|
||||
continue to preserve existing object names and transition XML.
|
||||
|
||||
---
|
||||
|
||||
## 4. Route Mapping
|
||||
@@ -211,9 +235,9 @@ failure rather than a silent downgrade.
|
||||
| Generated PPTX CLI | fade, 0.4s | click | auto-advance maps to both |
|
||||
| Recorded narration | Preserve resolved enter | narration | none remains visually none |
|
||||
| Template Fill v1 | fade, 0.5s | click | keep preserves source; legacy advance_after maps to both |
|
||||
| Native Enhance v1 | Confirmed plan effect | Confirmed timing module | Disabled transitions preserve unless the v1 plan explicitly selected none |
|
||||
| Native Enhance | Confirmed global/per-slide plan effect | Confirmed timing module | Explicit page entries override scope; disabled global transitions preserve unless the plan selected none |
|
||||
|
||||
Template Fill and Native Enhance keep their v1 route defaults.
|
||||
Template Fill and Native Enhance keep their established route defaults.
|
||||
The public `create_pptx_with_native_svg` Python API also retains its legacy
|
||||
0.5s default; the CLI explicitly passes 0.4s. Changing a default policy is a
|
||||
separate migration decision.
|
||||
@@ -270,6 +294,8 @@ Reject:
|
||||
- booleans passed as numeric API values;
|
||||
- multiple logical transition carriers;
|
||||
- unresolved MCE Requires or Ignorable prefixes.
|
||||
- invalid forced-Morph adjacency, identity uniqueness, object type, or
|
||||
destination effect.
|
||||
|
||||
Read-back must report the canonical native effect and complete effective
|
||||
options, while keeping the primary Choice child separate from the fallback. It
|
||||
|
||||
@@ -24,6 +24,8 @@ python3 scripts/project_manager.py page-context-report <project_path>
|
||||
Notes:
|
||||
- Files outside `projects/` are always copied into `sources/`
|
||||
- `--move` applies only to sources under the repository's `projects/` tree
|
||||
- A directly supplied supported bitmap is also copied into `images/` with a
|
||||
collision-safe basename while its original remains archived in `sources/`
|
||||
- Directory inputs are expanded non-recursively. After Step 1 conversion,
|
||||
pass the source file/directory once when generated Markdown lives beside the
|
||||
original source. If Step 1 used `-o` to write Markdown elsewhere, pass both
|
||||
|
||||
@@ -67,6 +67,231 @@ is owned by [`shared-standards-core.md`](../../references/shared-standards-core.
|
||||
authoring guidance in
|
||||
[`native-shape-authoring.md`](../../references/native-shape-authoring.md).
|
||||
|
||||
## Shape Boolean maintenance smoke
|
||||
|
||||
Run this manual smoke from the repository root after changing
|
||||
`shape_boolean_svg.py`, preset geometry, path conversion, or custom-geometry
|
||||
import/export. It uses only a gitignored `projects/_smoke_*` workspace and the
|
||||
inline-smoke convention from [`code-style.md`](../../../../docs/rules/code-style.md)
|
||||
§11; do not turn it into a test file or example deck.
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import pathops
|
||||
|
||||
project = Path(tempfile.mkdtemp(prefix="_smoke_shape_boolean_", dir="projects"))
|
||||
scripts = Path("skills/ppt-master/scripts")
|
||||
svg_output = project / "svg_output"
|
||||
svg_output.mkdir()
|
||||
(project / "spec_lock.md").write_text(
|
||||
"""<!-- ppt-master-schema: spec-lock/v1 -->
|
||||
# Execution Lock
|
||||
|
||||
## canvas
|
||||
- viewBox: 0 0 1280 720
|
||||
- format: ppt169
|
||||
## communication
|
||||
- audience:
|
||||
- objective:
|
||||
- core_message:
|
||||
## mode
|
||||
- mode: briefing
|
||||
## visual_style
|
||||
- visual_style: Boolean maintenance smoke
|
||||
## colors
|
||||
- bg: #FFFFFF
|
||||
- primary: #2563EB
|
||||
- accent: #F97316
|
||||
- text: #0F172A
|
||||
## typography
|
||||
- font_family: Arial, sans-serif
|
||||
- title_family: Arial, sans-serif
|
||||
- body_family: Arial, sans-serif
|
||||
- title: 36
|
||||
- body: 20
|
||||
## icons
|
||||
- library: none
|
||||
- inventory: none
|
||||
## page_rhythm
|
||||
- P01: dense
|
||||
## pptx_structure
|
||||
- mode: flat
|
||||
## forbidden
|
||||
- Unsupported SVG constructs
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def run_tool(script, *args):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(scripts / script), *map(str, args)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
return result.stdout.strip()
|
||||
|
||||
preset = run_tool(
|
||||
"preset_shape_svg.py", "render", "rightArrow",
|
||||
"--id", "preset-source", "--frame", "500", "120", "240", "120",
|
||||
"--fill", "#2563EB", "--stroke", "none",
|
||||
)
|
||||
source = project / "operands.svg"
|
||||
source.write_text(
|
||||
f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
|
||||
<defs><clipPath id="clip"><rect width="80" height="80"/></clipPath></defs>
|
||||
<g transform="translate(100 80) scale(1.2)">
|
||||
<rect id="body" x="40" y="40" width="400" height="240" rx="20"
|
||||
fill="#2563EB" stroke="#0F172A" stroke-width="5"
|
||||
stroke-dasharray="10 4"/>
|
||||
<circle id="cutout" cx="240" cy="160" r="70" fill="#F97316"/>
|
||||
<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"/>
|
||||
<path id="imported" d="M 240 320 H 400 V 420 H 240 Z"
|
||||
data-pptx-geometry-kind="custom" fill="#2563EB"/>
|
||||
<rect id="dashoffset" x="440" y="320" width="120" height="100"
|
||||
fill="#2563EB" stroke="#0F172A" stroke-dashoffset="2"/>
|
||||
<rect id="non-scaling" x="500" y="40" width="150" height="120"
|
||||
fill="#2563EB" stroke="#0F172A" stroke-width="5"
|
||||
stroke-dasharray="10 4" vector-effect="non-scaling-stroke"/>
|
||||
<circle id="non-scaling-cut" cx="625" cy="100" r="45" fill="#F97316"/>
|
||||
<rect id="far" x="800" y="320" width="100" height="80" fill="#2563EB"/>
|
||||
</g>
|
||||
{preset}
|
||||
<circle id="preset-cut" cx="690" cy="180" r="52" fill="#F97316"/>
|
||||
</svg>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
operations = [
|
||||
("union", "preset-source", "preset-cut"),
|
||||
("combine", "body", "cutout"),
|
||||
("fragment", "body", "cutout"),
|
||||
("intersect", "body", "cutout"),
|
||||
("subtract", "body", "cutout"),
|
||||
]
|
||||
expected_custom_shapes = 0
|
||||
for index, (operation, first, second) in enumerate(operations, start=1):
|
||||
fragment = run_tool(
|
||||
"shape_boolean_svg.py", "render", source, "--operation", operation,
|
||||
"--source", first, "--source", second, "--id", f"result-{operation}",
|
||||
)
|
||||
paths = list(
|
||||
ET.fromstring(
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg">{fragment}</svg>'
|
||||
)
|
||||
)
|
||||
assert paths and all(path.tag.endswith("}path") for path in paths)
|
||||
assert all(
|
||||
token not in fragment
|
||||
for token in ("clip-path=", "fill-rule=", "mask=", "transform=")
|
||||
)
|
||||
if operation == "fragment":
|
||||
assert len(paths) > 1
|
||||
assert [path.get("id") for path in paths] == [
|
||||
f"result-fragment-{piece}"
|
||||
for piece in range(1, len(paths) + 1)
|
||||
]
|
||||
else:
|
||||
assert len(paths) == 1
|
||||
assert paths[0].get("id") == f"result-{operation}"
|
||||
if operation == "combine":
|
||||
assert all(path.get("stroke-width") == "6" for path in paths)
|
||||
assert all(path.get("stroke-dasharray") == "12 4.8" for path in paths)
|
||||
if operation == "subtract":
|
||||
assert (paths[0].get("d") or "").count("M ") >= 2
|
||||
|
||||
expected_custom_shapes += len(paths)
|
||||
(svg_output / f"{index:02d}_{operation}.svg").write_text(
|
||||
'<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"/>'
|
||||
f"{fragment}</svg>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
non_scaling = ET.fromstring(
|
||||
run_tool(
|
||||
"shape_boolean_svg.py", "render", source, "--operation", "union",
|
||||
"--source", "non-scaling", "--source", "non-scaling-cut",
|
||||
"--id", "result-non-scaling",
|
||||
)
|
||||
)
|
||||
assert non_scaling.get("stroke-width") == "5"
|
||||
assert non_scaling.get("stroke-dasharray") == "10 4"
|
||||
assert non_scaling.get("vector-effect") == "non-scaling-stroke"
|
||||
|
||||
rejections = [
|
||||
("union", "body", "open", "open subpath"),
|
||||
("union", "body", "clipped", "uses clip-path"),
|
||||
("union", "body", "imported", "PPTX import/round-trip metadata"),
|
||||
("union", "body", "dashoffset", "stroke-dashoffset"),
|
||||
("intersect", "body", "far", "produced no filled area"),
|
||||
]
|
||||
for operation, first, second, expected_error in rejections:
|
||||
rejected = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(scripts / "shape_boolean_svg.py"),
|
||||
"render", str(source), "--operation", operation,
|
||||
"--source", first, "--source", second,
|
||||
"--id", f"reject-{second}",
|
||||
],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert rejected.returncode != 0
|
||||
assert expected_error in rejected.stderr, rejected.stderr
|
||||
|
||||
run_tool("svg_quality_checker.py", svg_output, "--format", "ppt169")
|
||||
pptx = project / "boolean-smoke.pptx"
|
||||
run_tool("svg_to_pptx.py", project, "--quick-test", "-o", pptx)
|
||||
with zipfile.ZipFile(pptx) as archive:
|
||||
slides = [
|
||||
name
|
||||
for name in archive.namelist()
|
||||
if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)
|
||||
]
|
||||
custom_shapes = sum(
|
||||
archive.read(name).count(b"<a:custGeom>")
|
||||
for name in slides
|
||||
)
|
||||
assert len(slides) == 5, slides
|
||||
assert custom_shapes == expected_custom_shapes
|
||||
|
||||
readback = project / "readback"
|
||||
run_tool(
|
||||
"pptx_to_svg.py", pptx, "-o", readback,
|
||||
"--inheritance-mode", "flat", "--strict",
|
||||
)
|
||||
slides = sorted((readback / "svg").glob("slide_*.svg"))
|
||||
readback_custom_shapes = sum(
|
||||
slide.read_text(encoding="utf-8").count('data-pptx-custgeom="')
|
||||
for slide in slides
|
||||
)
|
||||
assert len(slides) == 5, slides
|
||||
assert readback_custom_shapes == expected_custom_shapes
|
||||
print(
|
||||
f"Shape Boolean smoke: passed "
|
||||
f"({expected_custom_shapes} custom shapes; {project})"
|
||||
)
|
||||
PY
|
||||
```
|
||||
|
||||
The five inline negative cases must return nonzero and match their expected
|
||||
errors; every other command must pass. Open the printed
|
||||
`boolean-smoke.pptx` path in PowerPoint: the Subtract result must have a real
|
||||
hole, and every Fragment sibling must remain separately selectable.
|
||||
|
||||
## `compact_svg_coordinates.py`
|
||||
|
||||
Compact safe model-facing page-space coordinates without rewriting unrelated
|
||||
@@ -335,8 +560,9 @@ Behavior:
|
||||
structured-package validation, transitions, and animations are enforced before the
|
||||
builder publishes the PPTX and are reported as `enforced-at-build`, not as repeated
|
||||
postflight checks.
|
||||
- `font_portability` warns only when a complete font stack contains generic CSS families
|
||||
and no concrete family name. A recommended stack such as
|
||||
- `font_portability` warns when a complete font stack has no concrete family or when
|
||||
the converter resolves its Latin / East Asian role to a typeface that normally
|
||||
requires a custom installation. A recommended stack such as
|
||||
`"Microsoft YaHei", Arial, sans-serif` does not warn merely because it ends with a
|
||||
generic fallback.
|
||||
- Paragraph merging is enabled by default and trades some SVG line-layout fidelity for PowerPoint editability:
|
||||
@@ -368,7 +594,10 @@ Behavior:
|
||||
- After normal-flow publication, native export writes `validation/<output_stem>.report.json`. The report distinguishes authored Slides from internal Layout definitions, reruns ZIP integrity and published Slide-count checks, records slide/layout/master/notes part counts, labels relationship/structured/transition/animation validation as enforced at build time, links the final SVG quality report only when its SHA-256 source fingerprint matches the exact export inputs, and surfaces stale/unverified gates, unresolved template tokens, generic-only font stacks, and external image references. A matching final quality report with introduced warnings yields `passed-with-warnings` and a `quality_introduced_warnings=<N>` receipt instead of a clean `passed` claim.
|
||||
- By default, a successful command also prints a compact receipt instead of requiring a report read: `[POSTFLIGHT] status=<...> quality_gate=<...> slides=<N> warning_categories=<N>`, followed by one compact line per warning category and the `[PPTX]` / `[REPORT]` paths. Resource-warning lines carry counts; a non-passing quality gate carries its status. Routine agents use this receipt and do not load either complete validation JSON into model context. Full reports remain cold audit artifacts; failure investigation and explicit audits extract only the required fields. `--quiet` keeps suppressing successful-run output.
|
||||
- Before publishing structured template output, export reopens the temporary PPTX and validates the Slide → Layout → Master graph and registrations, Layout identity, placeholder identity, reusable bounds, and prompt/level-one sizes. A mismatch aborts publication. Flat release instead validates its single referenced Master/Layout shell and exact date/footer/slide-number hook roster before packaging.
|
||||
- SVG clip paths are still restricted for authored SVGs, but nested crop wrappers generated by PPTX import are mapped back to native picture crop / geometry when possible.
|
||||
- Authored SVG clip-path restrictions remain. Crop wrappers use an
|
||||
overflow-hidden viewport; preview-safe shape clips target the inner image in
|
||||
viewBox coordinates, while legacy imported wrapper clips remain compatible.
|
||||
Both map to native picture crop/geometry when possible.
|
||||
- Normal flow embeds speaker notes automatically unless `--no-notes` is used; quick-test always disables them
|
||||
- Recorded narration is opt-in:
|
||||
- `notes_to_audio.py` uses `edge-tts` by default, or a configured cloud TTS provider (`elevenlabs`, `minimax`, `qwen`, `cosyvoice`), and generates one audio file per slide into `audio/`
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# update_spec.py
|
||||
|
||||
> **Scope boundary**: this tool updates only deterministic global color and font
|
||||
> substitutions, writes the authoritative `spec_lock.md` first, and relies on
|
||||
> version control for rollback instead of creating parallel backups.
|
||||
> substitutions, writes the authoritative `spec_lock.md` only after the SVG
|
||||
> updates succeed, and relies on version control for rollback instead of
|
||||
> creating parallel backups.
|
||||
|
||||
Propagate a `spec_lock.md` value change to both the lock file and every `svg_output/*.svg`. The single edit surface for bulk style tweaks after generation.
|
||||
|
||||
@@ -17,8 +18,9 @@ Bare `<key>=<value>` (no dot) is treated as `colors.<key>=<value>` for backward
|
||||
One invocation = one change. The tool:
|
||||
|
||||
1. Reads the old value from `<project_path>/spec_lock.md`
|
||||
2. Writes the new value into `spec_lock.md`
|
||||
3. Propagates the change into every `.svg` under `svg_output/`
|
||||
2. Plans and propagates the change into every `.svg` under `svg_output/`
|
||||
3. Writes the new value into `spec_lock.md`; a global font replacement updates
|
||||
every existing `typography.*_family` row together
|
||||
4. Prints the list of files touched
|
||||
|
||||
## Examples
|
||||
@@ -32,14 +34,16 @@ python3 skills/ppt-master/scripts/update_spec.py projects/acme_ppt169_20260301 c
|
||||
|
||||
# change the deck-wide font family
|
||||
python3 skills/ppt-master/scripts/update_spec.py projects/acme_ppt169_20260301 \
|
||||
'typography.font_family="Inter", Arial, sans-serif'
|
||||
'typography.font_family=Arial, "Microsoft YaHei", sans-serif'
|
||||
```
|
||||
|
||||
## v2 scope
|
||||
|
||||
- **Supported**:
|
||||
- `colors.*` — HEX value replacement across `svg_output/*.svg` (case-insensitive).
|
||||
- `typography.font_family` — replaces the inner value of every `font-family="..."` / `font-family='...'` attribute.
|
||||
- `typography.font_family` — replaces the inner value of every
|
||||
`font-family="..."` / `font-family='...'` attribute and sets all existing
|
||||
`typography.*_family` lock rows to that universal family.
|
||||
- **Not supported**: typography sizes, icons, images, canvas, forbidden — these involve attribute-scoped or semantic replacements whose risk/benefit does not warrant bulk propagation. Edit `spec_lock.md` and the affected SVGs by hand, or re-author the pages.
|
||||
|
||||
## When to use
|
||||
@@ -53,6 +57,8 @@ python3 skills/ppt-master/scripts/update_spec.py projects/acme_ppt169_20260301 \
|
||||
|
||||
- HEX values (e.g. `#005587`) are unique enough in SVG content that literal replacement is safe
|
||||
- `font-family` substitution is scoped to the attribute; the outer quote character is preserved, and switched automatically if the new value contains the same quote
|
||||
- a global font substitution rewrites all existing family-role lock rows in one
|
||||
file write, so the universal SVG result cannot leave stale title/body roles
|
||||
- The tool refuses non-HEX inputs, unknown keys, and unsupported sections
|
||||
- No backups are created — the project folder should be under git so you can diff / revert
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import sys
|
||||
import shutil
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from console_encoding import configure_utf8_stdio
|
||||
@@ -63,10 +64,11 @@ from svg_to_pptx.use_expander import (
|
||||
)
|
||||
|
||||
|
||||
def safe_print(text: str) -> None:
|
||||
def safe_print(text: str, *, file: TextIO | None = None) -> None:
|
||||
"""Print text while tolerating Windows terminal encoding limits."""
|
||||
stream = file or sys.stdout
|
||||
try:
|
||||
print(text)
|
||||
print(text, file=stream)
|
||||
except UnicodeEncodeError:
|
||||
replacements = {
|
||||
chr(0x23F3): "[..]",
|
||||
@@ -79,7 +81,7 @@ def safe_print(text: str) -> None:
|
||||
}
|
||||
for source, target in replacements.items():
|
||||
text = text.replace(source, target)
|
||||
print(text)
|
||||
print(text, file=stream)
|
||||
|
||||
|
||||
def process_flatten_text(svg_file: Path, verbose: bool = False) -> bool:
|
||||
@@ -236,11 +238,17 @@ def finalize_project(
|
||||
)
|
||||
img_count += count
|
||||
img_errors += errs
|
||||
if img_errors:
|
||||
safe_print(
|
||||
f"[ERROR] Image alignment/embedding failed for "
|
||||
f"{img_errors} image(s); svg_final was not published",
|
||||
file=sys.stderr,
|
||||
)
|
||||
shutil.rmtree(svg_final, ignore_errors=True)
|
||||
return False
|
||||
if not quiet:
|
||||
if img_count > 0:
|
||||
msg = f" {img_count} image(s) aligned + embedded"
|
||||
if img_errors:
|
||||
msg += f" ({img_errors} error(s))"
|
||||
safe_print(msg)
|
||||
if office_vector_count:
|
||||
safe_print(
|
||||
|
||||
+72
-11
@@ -26,7 +26,7 @@ import time
|
||||
import requests
|
||||
|
||||
try:
|
||||
from PIL import Image as PILImage
|
||||
from PIL import Image as PILImage, ImageOps as PILImageOps
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
@@ -78,11 +78,6 @@ EXT_TO_PIL_FORMAT = {
|
||||
|
||||
def detect_image_extension(image_bytes: bytes, content_type: str = None) -> str | None:
|
||||
"""Best-effort detection of the real image format."""
|
||||
if content_type:
|
||||
clean_type = content_type.split(";", 1)[0].strip().lower()
|
||||
if clean_type in CONTENT_TYPE_TO_EXT:
|
||||
return CONTENT_TYPE_TO_EXT[clean_type]
|
||||
|
||||
if image_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return ".png"
|
||||
if image_bytes.startswith(b"\xff\xd8\xff"):
|
||||
@@ -95,6 +90,10 @@ def detect_image_extension(image_bytes: bytes, content_type: str = None) -> str
|
||||
return ".bmp"
|
||||
if image_bytes.startswith((b"II*\x00", b"MM\x00*")):
|
||||
return ".tiff"
|
||||
if content_type:
|
||||
clean_type = content_type.split(";", 1)[0].strip().lower()
|
||||
if clean_type in CONTENT_TYPE_TO_EXT:
|
||||
return CONTENT_TYPE_TO_EXT[clean_type]
|
||||
return None
|
||||
|
||||
|
||||
@@ -140,10 +139,35 @@ def save_image_bytes(image_bytes: bytes, path: str, content_type: str = None) ->
|
||||
if not target_format:
|
||||
raise ValueError(f"Unsupported output image extension: {target_ext}")
|
||||
|
||||
image = PILImage.open(io.BytesIO(image_bytes))
|
||||
if target_format == "JPEG" and image.mode in ("RGBA", "LA", "P"):
|
||||
image = image.convert("RGB")
|
||||
image.save(path, format=target_format)
|
||||
with PILImage.open(io.BytesIO(image_bytes)) as source:
|
||||
image = PILImageOps.exif_transpose(source)
|
||||
try:
|
||||
if target_format == "JPEG":
|
||||
has_alpha = (
|
||||
image.mode in ("RGBA", "LA")
|
||||
or "transparency" in getattr(image, "info", {})
|
||||
)
|
||||
if has_alpha:
|
||||
rgba = image.convert("RGBA")
|
||||
alpha = rgba.getchannel("A")
|
||||
rgb = rgba.convert("RGB")
|
||||
converted = PILImage.new("RGB", image.size, (255, 255, 255))
|
||||
converted.paste(rgb, mask=alpha)
|
||||
rgb.close()
|
||||
alpha.close()
|
||||
rgba.close()
|
||||
if image is not source:
|
||||
image.close()
|
||||
image = converted
|
||||
elif image.mode != "RGB":
|
||||
converted = image.convert("RGB")
|
||||
if image is not source:
|
||||
image.close()
|
||||
image = converted
|
||||
image.save(path, format=target_format)
|
||||
finally:
|
||||
if image is not source:
|
||||
image.close()
|
||||
|
||||
if actual_ext and actual_ext != target_ext:
|
||||
print(f" Converted: {actual_ext} -> {target_ext}")
|
||||
@@ -152,6 +176,27 @@ def save_image_bytes(image_bytes: bytes, path: str, content_type: str = None) ->
|
||||
return path
|
||||
|
||||
|
||||
def validate_image_file(path: str) -> str:
|
||||
"""Require an existing regular file that Pillow can read as an image."""
|
||||
image_path = Path(path)
|
||||
if not image_path.exists():
|
||||
raise RuntimeError(f"Image output path does not exist: {path}")
|
||||
if not image_path.is_file():
|
||||
raise RuntimeError(f"Image output path is not a file: {path}")
|
||||
if not HAS_PIL:
|
||||
raise RuntimeError(
|
||||
"Pillow is required to verify generated images. "
|
||||
"Install it with: pip install Pillow"
|
||||
)
|
||||
|
||||
try:
|
||||
with PILImage.open(image_path) as image:
|
||||
image.verify()
|
||||
except (OSError, ValueError, SyntaxError) as exc:
|
||||
raise RuntimeError(f"Image output is not readable: {path}: {exc}") from exc
|
||||
return str(image_path)
|
||||
|
||||
|
||||
def report_resolution(path: str) -> None:
|
||||
"""Try to report image resolution using PIL."""
|
||||
if HAS_PIL:
|
||||
@@ -176,11 +221,27 @@ def normalize_image_size(image_size: str) -> str:
|
||||
def is_rate_limit_error(exc: Exception) -> bool:
|
||||
"""Check whether the exception appears to be rate limiting."""
|
||||
err_str = str(exc).lower()
|
||||
status_code = getattr(exc, "status_code", None)
|
||||
error_code = getattr(exc, "code", None)
|
||||
response = getattr(exc, "response", None)
|
||||
error_name = type(exc).__name__.lower()
|
||||
if (
|
||||
status_code == 429
|
||||
or error_code == 429
|
||||
or getattr(response, "status_code", None) == 429
|
||||
or error_name in {"ratelimiterror", "toomanyrequestserror"}
|
||||
):
|
||||
return True
|
||||
return (
|
||||
"429" in err_str
|
||||
or "rate" in err_str
|
||||
or "rate limit" in err_str
|
||||
or "rate-limit" in err_str
|
||||
or "rate_limit" in err_str
|
||||
or "too many requests" in err_str
|
||||
or "quota" in err_str
|
||||
or "resource_exhausted" in err_str
|
||||
or "resource exhausted" in err_str
|
||||
or "throttl" in err_str
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -44,11 +44,12 @@ Usage:
|
||||
python3 image_gen.py --list-backends
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
@@ -344,32 +345,73 @@ def _resolve_backend() -> tuple[object, str]:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _confirmed_image_ai_path_for_manifest(manifest_path: str) -> str | None:
|
||||
"""Return confirmed image_ai_path for a project manifest, if present."""
|
||||
_AI_IMAGE_PATH_ROW_RE = re.compile(
|
||||
r"^\s*\|\s*AI Image Acquisition Path\s*\|\s*([^|]+?)\s*\|\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
VALID_AI_IMAGE_ACQUISITION_PATHS = {
|
||||
"api",
|
||||
"auto",
|
||||
"host-native",
|
||||
"manual",
|
||||
}
|
||||
|
||||
|
||||
def _project_design_spec_for_manifest(manifest_path: str) -> Path | None:
|
||||
"""Return a project Design Spec for an images/ manifest, when present."""
|
||||
path = Path(manifest_path).resolve()
|
||||
if path.parent.name != "images":
|
||||
return None
|
||||
result_file = path.parent.parent / "confirm_ui" / "result.json"
|
||||
if not result_file.exists():
|
||||
design_spec = path.parent.parent / "design_spec.md"
|
||||
return design_spec if design_spec.is_file() else None
|
||||
|
||||
|
||||
def _confirmed_image_acquisition_path_for_manifest(
|
||||
manifest_path: str,
|
||||
) -> str | None:
|
||||
"""Return the Design Spec's AI Image Acquisition Path, if present."""
|
||||
design_spec = _project_design_spec_for_manifest(manifest_path)
|
||||
if design_spec is None:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(result_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
text = design_spec.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
value = data.get("image_ai_path")
|
||||
if not isinstance(value, str):
|
||||
match = _AI_IMAGE_PATH_ROW_RE.search(text)
|
||||
if not match:
|
||||
return None
|
||||
return value.strip().lower().replace("_", "-")
|
||||
value = match.group(1).strip().lstrip("`*_ ").strip()
|
||||
token_match = re.match(
|
||||
r"^(host[\s_-]*native|api|auto|manual)(?![A-Za-z0-9_-])",
|
||||
value,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
selected = token_match.group(1) if token_match else value
|
||||
return re.sub(r"[\s_]+", "-", selected.strip().lower())
|
||||
|
||||
|
||||
def _guard_confirmed_non_api_path(manifest_path: str) -> None:
|
||||
"""Prevent accidental Path A execution after host-native/manual was confirmed."""
|
||||
image_ai_path = _confirmed_image_ai_path_for_manifest(manifest_path)
|
||||
if image_ai_path not in {"host-native", "manual"}:
|
||||
"""Allow project Path A only when its Design Spec explicitly permits it."""
|
||||
design_spec = _project_design_spec_for_manifest(manifest_path)
|
||||
if design_spec is None:
|
||||
return
|
||||
if image_ai_path == "host-native":
|
||||
acquisition_path = _confirmed_image_acquisition_path_for_manifest(manifest_path)
|
||||
if acquisition_path not in VALID_AI_IMAGE_ACQUISITION_PATHS:
|
||||
shown = acquisition_path or "(missing)"
|
||||
valid = ", ".join(sorted(VALID_AI_IMAGE_ACQUISITION_PATHS))
|
||||
print(
|
||||
"Error: confirmed image_ai_path is 'host-native'.\n"
|
||||
"Error: project manifest mode requires a valid "
|
||||
"AI Image Acquisition Path in design_spec.md §I.\n"
|
||||
f"Found: {shown!r}. Valid values: {valid}.\n"
|
||||
"Return to Generate Step 4 recovery and record the durable "
|
||||
"selection before running Path A."
|
||||
)
|
||||
sys.exit(1)
|
||||
if acquisition_path in {"api", "auto"}:
|
||||
return
|
||||
if acquisition_path == "host-native":
|
||||
print(
|
||||
"Error: Design Spec confirms AI Image Acquisition Path as 'host-native'.\n"
|
||||
"\n"
|
||||
"Do NOT run image_gen.py --manifest for this project. That command is Path A\n"
|
||||
"and may use the configured API/proxy backend. Use the host's native image\n"
|
||||
@@ -379,7 +421,7 @@ def _guard_confirmed_non_api_path(manifest_path: str) -> None:
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"Error: confirmed image_ai_path is 'manual'.\n"
|
||||
"Error: Design Spec confirms AI Image Acquisition Path as 'manual'.\n"
|
||||
"\n"
|
||||
"Do NOT run image_gen.py --manifest for this project. Render the Markdown\n"
|
||||
"sidecar and hand images/image_prompts.md to the user for external generation:\n"
|
||||
@@ -389,6 +431,7 @@ def _guard_confirmed_non_api_path(manifest_path: str) -> None:
|
||||
|
||||
|
||||
DEFAULT_MANIFEST_CONCURRENCY = 3
|
||||
MAX_MANIFEST_RATE_LIMIT_ATTEMPTS = 3
|
||||
|
||||
STATUS_PENDING = "Pending"
|
||||
STATUS_GENERATED = "Generated"
|
||||
@@ -397,18 +440,70 @@ STATUS_NEEDS_MANUAL = "Needs-Manual"
|
||||
VALID_STATUSES = {STATUS_PENDING, STATUS_GENERATED, STATUS_FAILED, STATUS_NEEDS_MANUAL}
|
||||
RETRYABLE_STATUSES = {STATUS_PENDING, STATUS_FAILED}
|
||||
REQUIRED_ITEM_FIELDS = ("filename", "prompt", "aspect_ratio", "status")
|
||||
VALID_PAGE_ROLES = {"local", "hero_page", "full_page"}
|
||||
VALID_TEXT_POLICIES = {"none", "embedded"}
|
||||
STRUCTURAL_IMAGE_TYPES = {
|
||||
"infographic",
|
||||
"flowchart",
|
||||
"framework",
|
||||
"matrix",
|
||||
"cycle",
|
||||
"funnel",
|
||||
"pyramid",
|
||||
"comparison",
|
||||
"timeline",
|
||||
"map",
|
||||
"scene",
|
||||
}
|
||||
LEGACY_IMAGE_TYPES = {"background", "hero", "portrait", "typography"}
|
||||
EARLY_LEGACY_IMAGE_TYPES = {"illustration", "photography"}
|
||||
VALID_IMAGE_TYPES = (
|
||||
STRUCTURAL_IMAGE_TYPES
|
||||
| LEGACY_IMAGE_TYPES
|
||||
| EARLY_LEGACY_IMAGE_TYPES
|
||||
)
|
||||
|
||||
|
||||
def _validate_bare_output_name(
|
||||
value: str,
|
||||
*,
|
||||
field_name: str,
|
||||
require_extension: bool = False,
|
||||
reject_parent_marker: bool = False,
|
||||
) -> Path:
|
||||
"""Require one cross-platform-safe basename, optionally with an extension."""
|
||||
value_path = Path(value)
|
||||
if (
|
||||
not value.strip()
|
||||
or value in {".", ".."}
|
||||
or reject_parent_marker and ".." in value
|
||||
or "/" in value
|
||||
or "\\" in value
|
||||
or ":" in value
|
||||
or value_path.is_absolute()
|
||||
or value_path.name != value
|
||||
):
|
||||
raise ValueError(
|
||||
f"{field_name} must be a bare filename without path components, "
|
||||
f"got {value!r}"
|
||||
)
|
||||
if require_extension and not value_path.suffix:
|
||||
raise ValueError(f"{field_name} must include an extension, got {value!r}")
|
||||
return value_path
|
||||
|
||||
|
||||
def load_manifest(path: str) -> dict:
|
||||
"""Load and validate an `image_prompts.json` manifest.
|
||||
|
||||
Schema (top level): {"items": [ ... ]}, optionally with
|
||||
`deck_style_anchor`, `color_scheme`, `generated_at`.
|
||||
`deck_rendering`, `color_scheme`, `generated_at`.
|
||||
|
||||
Each item requires: `filename`, `prompt`, `aspect_ratio`, `status`.
|
||||
Optional: `image_size`, `model`, `alt_text`, `purpose`, `type`,
|
||||
`last_error`.
|
||||
`page_role`, `text_policy`, `slice_grid`, `slice_names`, `last_error`.
|
||||
"""
|
||||
from image_backends.backend_common import normalize_image_size
|
||||
|
||||
try:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
@@ -423,11 +518,51 @@ def load_manifest(path: str) -> dict:
|
||||
f"got {type(data).__name__}"
|
||||
)
|
||||
|
||||
for field in ("project", "generated_at", "deck_rendering"):
|
||||
if field not in data:
|
||||
continue
|
||||
if not isinstance(data[field], str) or not data[field].strip():
|
||||
raise ValueError(
|
||||
f"{path}: field '{field}' must be a non-empty string when present"
|
||||
)
|
||||
if "deck_style_anchor" in data:
|
||||
legacy_anchor = data["deck_style_anchor"]
|
||||
if not (
|
||||
isinstance(legacy_anchor, str)
|
||||
and legacy_anchor.strip()
|
||||
or isinstance(legacy_anchor, dict)
|
||||
and legacy_anchor
|
||||
):
|
||||
raise ValueError(
|
||||
f"{path}: legacy field 'deck_style_anchor' must be a "
|
||||
"non-empty string or object when present"
|
||||
)
|
||||
|
||||
if "color_scheme" in data:
|
||||
color_scheme = data["color_scheme"]
|
||||
if not isinstance(color_scheme, dict) or not color_scheme:
|
||||
raise ValueError(
|
||||
f"{path}: field 'color_scheme' must be a non-empty object when present"
|
||||
)
|
||||
for key, value in color_scheme.items():
|
||||
if (
|
||||
not isinstance(key, str)
|
||||
or not key.strip()
|
||||
or not isinstance(value, str)
|
||||
or not value.strip()
|
||||
):
|
||||
raise ValueError(
|
||||
f"{path}: color_scheme keys and values must be non-empty strings"
|
||||
)
|
||||
|
||||
items = data.get("items")
|
||||
if not isinstance(items, list) or not items:
|
||||
raise ValueError(f"{path}: 'items' must be a non-empty array")
|
||||
|
||||
seen_filenames: set[str] = set()
|
||||
claimed_outputs: dict[str, str] = {}
|
||||
seen_stems: set[str] = set()
|
||||
missing_page_role = 0
|
||||
missing_text_policy = 0
|
||||
for i, item in enumerate(items):
|
||||
prefix = f"{path}: items[{i}]"
|
||||
if not isinstance(item, dict):
|
||||
@@ -444,10 +579,171 @@ def load_manifest(path: str) -> dict:
|
||||
f"{prefix} status '{item['status']}' is invalid. "
|
||||
f"Valid: {sorted(VALID_STATUSES)}"
|
||||
)
|
||||
if item["aspect_ratio"] not in ALL_ASPECT_RATIOS:
|
||||
raise ValueError(
|
||||
f"{prefix} aspect_ratio '{item['aspect_ratio']}' is invalid. "
|
||||
f"Valid: {ALL_ASPECT_RATIOS}"
|
||||
)
|
||||
if "image_size" in item:
|
||||
image_size = item["image_size"]
|
||||
if not isinstance(image_size, str) or not image_size.strip():
|
||||
raise ValueError(
|
||||
f"{prefix} field 'image_size' must be a non-empty string"
|
||||
)
|
||||
normalized_size = normalize_image_size(image_size)
|
||||
if normalized_size not in ALL_IMAGE_SIZES:
|
||||
raise ValueError(
|
||||
f"{prefix} image_size '{image_size}' is invalid. "
|
||||
f"Valid: {ALL_IMAGE_SIZES}"
|
||||
)
|
||||
|
||||
page_role = item.get("page_role")
|
||||
if page_role is None:
|
||||
missing_page_role += 1
|
||||
elif not isinstance(page_role, str) or page_role not in VALID_PAGE_ROLES:
|
||||
raise ValueError(
|
||||
f"{prefix} page_role '{page_role}' is invalid. "
|
||||
f"Valid: {sorted(VALID_PAGE_ROLES)}"
|
||||
)
|
||||
|
||||
text_policy = item.get("text_policy")
|
||||
if text_policy is None:
|
||||
missing_text_policy += 1
|
||||
elif (
|
||||
not isinstance(text_policy, str)
|
||||
or text_policy not in VALID_TEXT_POLICIES
|
||||
):
|
||||
raise ValueError(
|
||||
f"{prefix} text_policy '{text_policy}' is invalid. "
|
||||
f"Valid: {sorted(VALID_TEXT_POLICIES)}"
|
||||
)
|
||||
|
||||
image_type = item.get("type")
|
||||
if image_type is not None:
|
||||
normalized_type = (
|
||||
image_type.strip().lower()
|
||||
if isinstance(image_type, str)
|
||||
else ""
|
||||
)
|
||||
if normalized_type not in VALID_IMAGE_TYPES:
|
||||
raise ValueError(
|
||||
f"{prefix} type '{image_type}' is invalid. "
|
||||
f"Valid current/legacy values: {sorted(VALID_IMAGE_TYPES)}"
|
||||
)
|
||||
|
||||
for field in ("model", "alt_text", "purpose"):
|
||||
if field in item and (
|
||||
not isinstance(item[field], str) or not item[field].strip()
|
||||
):
|
||||
raise ValueError(
|
||||
f"{prefix} field '{field}' must be a non-empty string when present"
|
||||
)
|
||||
if "last_error" in item and not isinstance(item["last_error"], str):
|
||||
raise ValueError(f"{prefix} field 'last_error' must be a string")
|
||||
has_slice_grid = "slice_grid" in item
|
||||
has_slice_names = "slice_names" in item
|
||||
slice_outputs: list[str] = []
|
||||
if has_slice_grid != has_slice_names:
|
||||
raise ValueError(
|
||||
f"{prefix} fields 'slice_grid' and 'slice_names' must appear together"
|
||||
)
|
||||
if has_slice_grid:
|
||||
slice_grid = item["slice_grid"]
|
||||
grid_match = (
|
||||
re.fullmatch(r"([1-9]\d*)[xX]([1-9]\d*)", slice_grid.strip())
|
||||
if isinstance(slice_grid, str)
|
||||
else None
|
||||
)
|
||||
if grid_match is None:
|
||||
raise ValueError(
|
||||
f"{prefix} field 'slice_grid' must use positive RxC notation"
|
||||
)
|
||||
slice_names = item["slice_names"]
|
||||
if not isinstance(slice_names, str) or not slice_names.strip():
|
||||
raise ValueError(
|
||||
f"{prefix} field 'slice_names' must be a non-empty string"
|
||||
)
|
||||
names = [name.strip() for name in slice_names.split(",")]
|
||||
if any(not name for name in names):
|
||||
raise ValueError(
|
||||
f"{prefix} field 'slice_names' contains an empty name"
|
||||
)
|
||||
rows, cols = map(int, grid_match.groups())
|
||||
if len(names) != rows * cols:
|
||||
raise ValueError(
|
||||
f"{prefix} field 'slice_names' has {len(names)} names but "
|
||||
f"slice_grid {rows}x{cols} requires {rows * cols}"
|
||||
)
|
||||
normalized_outputs: set[str] = set()
|
||||
for name in names:
|
||||
name_path = _validate_bare_output_name(
|
||||
name,
|
||||
field_name=f"{prefix} slice output name",
|
||||
reject_parent_marker=True,
|
||||
)
|
||||
if name_path.suffix and name_path.suffix.lower() != ".png":
|
||||
raise ValueError(
|
||||
f"{prefix} slice output name {name!r} must omit its "
|
||||
"extension or use .png"
|
||||
)
|
||||
output_name = (
|
||||
name if name_path.suffix else f"{name}.png"
|
||||
).casefold()
|
||||
if output_name in normalized_outputs:
|
||||
raise ValueError(
|
||||
f"{prefix} field 'slice_names' repeats output "
|
||||
f"{output_name!r}"
|
||||
)
|
||||
normalized_outputs.add(output_name)
|
||||
slice_outputs.append(output_name)
|
||||
|
||||
fname = item["filename"]
|
||||
if fname in seen_filenames:
|
||||
raise ValueError(f"{prefix} duplicate filename '{fname}'")
|
||||
seen_filenames.add(fname)
|
||||
filename_path = _validate_bare_output_name(
|
||||
fname,
|
||||
field_name=f"{prefix} field 'filename'",
|
||||
require_extension=True,
|
||||
)
|
||||
normalized_filename = fname.casefold()
|
||||
if normalized_filename in claimed_outputs:
|
||||
raise ValueError(
|
||||
f"{prefix} output filename {fname!r} conflicts with "
|
||||
f"{claimed_outputs[normalized_filename]} (case-insensitive)"
|
||||
)
|
||||
claimed_outputs[normalized_filename] = f"manifest output {fname!r}"
|
||||
|
||||
stem = filename_path.stem.casefold()
|
||||
if stem in seen_stems:
|
||||
raise ValueError(
|
||||
f"{prefix} duplicate filename stem '{filename_path.stem}' "
|
||||
"would reuse backend output"
|
||||
)
|
||||
seen_stems.add(stem)
|
||||
|
||||
for output_name in slice_outputs:
|
||||
if output_name in claimed_outputs:
|
||||
raise ValueError(
|
||||
f"{prefix} slice output {output_name!r} conflicts with "
|
||||
f"{claimed_outputs[output_name]} (case-insensitive)"
|
||||
)
|
||||
claimed_outputs[output_name] = (
|
||||
f"slice output {output_name!r} from items[{i}]"
|
||||
)
|
||||
|
||||
legacy_parts = []
|
||||
if missing_page_role:
|
||||
legacy_parts.append(
|
||||
f"{missing_page_role} item(s) missing page_role (resolved as local)"
|
||||
)
|
||||
if missing_text_policy:
|
||||
legacy_parts.append(
|
||||
f"{missing_text_policy} item(s) missing text_policy (resolved as none)"
|
||||
)
|
||||
if legacy_parts:
|
||||
print(
|
||||
f"Warning: {path}: legacy manifest compatibility: "
|
||||
+ "; ".join(legacy_parts),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@@ -473,6 +769,29 @@ def save_manifest(path: str, data: dict) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def _materialize_manifest_image(saved_path: str, target_path: Path) -> str:
|
||||
"""Validate backend output and place it at the manifest's exact target path."""
|
||||
from image_backends.backend_common import (
|
||||
save_image_bytes,
|
||||
validate_image_file,
|
||||
)
|
||||
|
||||
source_path = Path(saved_path)
|
||||
validate_image_file(str(source_path))
|
||||
|
||||
if source_path.resolve() != target_path.resolve():
|
||||
try:
|
||||
image_bytes = source_path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not read image output {source_path}: {exc}"
|
||||
) from exc
|
||||
save_image_bytes(image_bytes, str(target_path))
|
||||
|
||||
validate_image_file(str(target_path))
|
||||
return str(target_path)
|
||||
|
||||
|
||||
def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
initial_concurrency: int,
|
||||
image_size: str,
|
||||
@@ -481,9 +800,14 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
"""Run Pending/Failed items through the backend with adaptive concurrency.
|
||||
|
||||
Strategy:
|
||||
- Verify every `Generated` item's target before treating it as done;
|
||||
missing or unreadable output returns to `Failed` for this run.
|
||||
- Start at `initial_concurrency` workers per batch.
|
||||
- On any rate-limit error in a batch, halve concurrency (min 1) and
|
||||
requeue the rate-limited items.
|
||||
requeue the rate-limited items within a fixed attempt budget.
|
||||
- A rate limit at concurrency 1 or after the budget is exhausted is
|
||||
recorded as `status: Failed` + `last_error`; the current run then stops
|
||||
without switching providers.
|
||||
- Per-item failures are recorded as `status: Failed` + `last_error`
|
||||
and not retried within this run. `Failed` remains retryable and
|
||||
non-terminal; the Step 5 gate must resolve it by rerunning this
|
||||
@@ -494,9 +818,40 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
|
||||
Returns (ok_count, failed_count, skipped_count).
|
||||
"""
|
||||
from image_backends.backend_common import is_rate_limit_error
|
||||
manifest_output_dir = Path(manifest_path).resolve().parent
|
||||
if Path(output_dir).resolve() != manifest_output_dir:
|
||||
raise ValueError(
|
||||
"Manifest outputs must stay beside image_prompts.json: "
|
||||
f"expected {manifest_output_dir}, got {Path(output_dir).resolve()}"
|
||||
)
|
||||
output_dir = str(manifest_output_dir)
|
||||
|
||||
from image_backends.backend_common import (
|
||||
is_rate_limit_error,
|
||||
validate_image_file,
|
||||
)
|
||||
|
||||
items = manifest["items"]
|
||||
repaired_generated = False
|
||||
for item in items:
|
||||
if item["status"] != STATUS_GENERATED:
|
||||
continue
|
||||
target_path = Path(output_dir) / item["filename"]
|
||||
try:
|
||||
validate_image_file(str(target_path))
|
||||
except RuntimeError as exc:
|
||||
item["status"] = STATUS_FAILED
|
||||
item["last_error"] = (
|
||||
f"Generated file validation failed: {exc}"
|
||||
)[:500]
|
||||
repaired_generated = True
|
||||
print(
|
||||
f" [RETRY] {item['filename']} was marked Generated but its "
|
||||
f"target is invalid: {exc}"
|
||||
)
|
||||
if repaired_generated:
|
||||
save_manifest(manifest_path, manifest)
|
||||
|
||||
pending_idx = [
|
||||
i for i, it in enumerate(items) if it["status"] in RETRYABLE_STATUSES
|
||||
]
|
||||
@@ -520,6 +875,8 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
fail_count = 0
|
||||
current = max(1, initial_concurrency)
|
||||
state_lock = threading.Lock()
|
||||
rate_limit_attempts: dict[int, int] = {}
|
||||
stopped_for_rate_limit = False
|
||||
|
||||
def _one(idx: int):
|
||||
item = items[idx]
|
||||
@@ -532,6 +889,10 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
filename=Path(item["filename"]).stem,
|
||||
model=item.get("model", model),
|
||||
)
|
||||
saved_path = _materialize_manifest_image(
|
||||
saved_path,
|
||||
Path(output_dir) / item["filename"],
|
||||
)
|
||||
return idx, saved_path, None
|
||||
except Exception as exc: # noqa: BLE001 — backend raises arbitrary types
|
||||
return idx, None, exc
|
||||
@@ -560,8 +921,35 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
print(f" [OK] {item['filename']}")
|
||||
elif is_rate_limit_error(exc):
|
||||
rate_limited = True
|
||||
queue.append(idx)
|
||||
print(f" [RATE] {item['filename']} — requeued")
|
||||
attempts = rate_limit_attempts.get(idx, 0) + 1
|
||||
rate_limit_attempts[idx] = attempts
|
||||
if (
|
||||
current == 1
|
||||
or attempts >= MAX_MANIFEST_RATE_LIMIT_ATTEMPTS
|
||||
):
|
||||
boundary = (
|
||||
"serial concurrency reached"
|
||||
if current == 1
|
||||
else "rate-limit attempt budget exhausted"
|
||||
)
|
||||
item["status"] = STATUS_FAILED
|
||||
item["last_error"] = (
|
||||
f"Rate limit persisted ({boundary}; "
|
||||
f"attempt {attempts}): {exc}"
|
||||
)[:500]
|
||||
fail_count += 1
|
||||
stopped_for_rate_limit = True
|
||||
print(
|
||||
f" [FAIL] {item['filename']}: {exc} "
|
||||
f"({boundary}; status=Failed)"
|
||||
)
|
||||
else:
|
||||
queue.append(idx)
|
||||
print(
|
||||
f" [RATE] {item['filename']} — requeued "
|
||||
f"(attempt {attempts}/"
|
||||
f"{MAX_MANIFEST_RATE_LIMIT_ATTEMPTS})"
|
||||
)
|
||||
else:
|
||||
item["status"] = STATUS_FAILED
|
||||
item["last_error"] = str(exc)[:500]
|
||||
@@ -572,7 +960,13 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
)
|
||||
save_manifest(manifest_path, manifest)
|
||||
|
||||
if rate_limited and current > 1:
|
||||
if stopped_for_rate_limit:
|
||||
print(
|
||||
"\n Persistent rate limit reached the run boundary. "
|
||||
"Stopping without switching providers; untouched items remain retryable.\n"
|
||||
)
|
||||
break
|
||||
if rate_limited and current > 1 and queue:
|
||||
new_current = max(1, current // 2)
|
||||
print(
|
||||
f"\n ⚠ Rate-limit hit — concurrency {current} → {new_current}, "
|
||||
@@ -583,9 +977,17 @@ def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
|
||||
elif queue:
|
||||
time.sleep(2)
|
||||
|
||||
run_state = "Stopped" if stopped_for_rate_limit else "Done"
|
||||
remaining_note = ""
|
||||
if stopped_for_rate_limit:
|
||||
remaining = sum(
|
||||
1 for item in items if item["status"] in RETRYABLE_STATUSES
|
||||
)
|
||||
remaining_note = f"; {remaining} item(s) remain retryable"
|
||||
print(
|
||||
f"\n[Manifest] Done: {ok_count} ok / {fail_count} failed "
|
||||
f"({skipped} pre-skipped). Manifest written to {manifest_path}"
|
||||
f"\n[Manifest] {run_state}: {ok_count} ok / {fail_count} failed "
|
||||
f"({skipped} pre-skipped{remaining_note}). "
|
||||
f"Manifest written to {manifest_path}"
|
||||
)
|
||||
if fail_count:
|
||||
print(
|
||||
@@ -623,7 +1025,16 @@ def render_manifest_md(manifest: dict) -> str:
|
||||
project = manifest.get("project")
|
||||
generated_at = manifest.get("generated_at")
|
||||
color_scheme = manifest.get("color_scheme") or {}
|
||||
anchor = manifest.get("deck_style_anchor")
|
||||
deck_rendering = manifest.get("deck_rendering")
|
||||
if not deck_rendering:
|
||||
legacy_anchor = manifest.get("deck_style_anchor")
|
||||
if isinstance(legacy_anchor, dict):
|
||||
deck_rendering = (
|
||||
legacy_anchor.get("visual_style")
|
||||
or json.dumps(legacy_anchor, ensure_ascii=False, sort_keys=True)
|
||||
)
|
||||
else:
|
||||
deck_rendering = legacy_anchor
|
||||
|
||||
if project:
|
||||
lines.append(f"> Project: {project}")
|
||||
@@ -634,8 +1045,8 @@ def render_manifest_md(manifest: dict) -> str:
|
||||
f"{k.capitalize()} {v}" for k, v in color_scheme.items()
|
||||
)
|
||||
lines.append(f"> Color scheme: {cs}")
|
||||
if anchor:
|
||||
lines.append(f"> Deck Style Anchor: {anchor}")
|
||||
if deck_rendering:
|
||||
lines.append(f"> Deck Rendering: {deck_rendering}")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
@@ -648,11 +1059,20 @@ def render_manifest_md(manifest: dict) -> str:
|
||||
for label, key in (
|
||||
("Purpose", "purpose"),
|
||||
("Type", "type"),
|
||||
("Page role", "page_role"),
|
||||
("Text policy", "text_policy"),
|
||||
("Aspect ratio", "aspect_ratio"),
|
||||
("Image size", "image_size"),
|
||||
("Model", "model"),
|
||||
("Slice grid", "slice_grid"),
|
||||
("Slice names", "slice_names"),
|
||||
("Status", "status"),
|
||||
):
|
||||
value = item.get(key)
|
||||
if not value and key == "page_role":
|
||||
value = "local (legacy default)"
|
||||
elif not value and key == "text_policy":
|
||||
value = "none (legacy default)"
|
||||
if value:
|
||||
lines.append(f"| {label} | {value} |")
|
||||
if item.get("last_error"):
|
||||
@@ -759,6 +1179,15 @@ def main() -> None:
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.filename is not None:
|
||||
try:
|
||||
_validate_bare_output_name(
|
||||
args.filename,
|
||||
field_name="--filename",
|
||||
)
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
|
||||
if args.reference_image is not None:
|
||||
# Reference editing is a single-image-only enhancement; keep it out of
|
||||
# the manifest / sidecar / list surfaces entirely.
|
||||
@@ -800,8 +1229,25 @@ def main() -> None:
|
||||
print(f"Rendered Markdown sidecar: {md_path}")
|
||||
return
|
||||
|
||||
manifest = None
|
||||
manifest_output_dir = None
|
||||
if args.manifest:
|
||||
if not os.path.isfile(args.manifest):
|
||||
print(f"Error: manifest file not found: {args.manifest}")
|
||||
sys.exit(1)
|
||||
_guard_confirmed_non_api_path(args.manifest)
|
||||
try:
|
||||
manifest = load_manifest(args.manifest)
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
manifest_output_dir = Path(args.manifest).resolve().parent
|
||||
if args.output and Path(args.output).resolve() != manifest_output_dir:
|
||||
print(
|
||||
"Error: --output cannot redirect manifest items outside the "
|
||||
f"manifest directory ({manifest_output_dir})"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
_load_image_env_file()
|
||||
@@ -818,22 +1264,13 @@ def main() -> None:
|
||||
print(f"Using backend: {backend_name}\n")
|
||||
|
||||
if args.manifest:
|
||||
if not os.path.isfile(args.manifest):
|
||||
print(f"Error: manifest file not found: {args.manifest}")
|
||||
sys.exit(1)
|
||||
try:
|
||||
manifest = load_manifest(args.manifest)
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
concurrency = _resolve_concurrency(args.concurrency)
|
||||
try:
|
||||
_, failed, _ = _run_manifest(
|
||||
manifest, args.manifest, backend,
|
||||
initial_concurrency=concurrency,
|
||||
image_size=args.image_size,
|
||||
output_dir=args.output or str(Path(args.manifest).parent),
|
||||
output_dir=str(manifest_output_dir),
|
||||
model=args.model,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+5
@@ -446,6 +446,11 @@ def score_candidate(candidate: AssetCandidate, request: ImageSearchRequest) -> f
|
||||
"""
|
||||
if not candidate.license_tier:
|
||||
return float("-inf")
|
||||
if (
|
||||
candidate.license_tier == LICENSE_TIER_ATTRIBUTION_REQUIRED
|
||||
and not candidate.author.strip()
|
||||
):
|
||||
return float("-inf")
|
||||
|
||||
required_misses = missing_required_terms(candidate, request.required_terms)
|
||||
if required_misses:
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"""
|
||||
PPT Master - Native Enhance PPTX Entrypoint
|
||||
|
||||
Public CLI wrapper for native enhancement of existing PPTX decks. V1 delegates
|
||||
to the narration/timings implementation while keeping the stable command name
|
||||
aligned with the native-enhance workflow.
|
||||
Public CLI wrapper for native enhancement of existing PPTX decks. It delegates
|
||||
to the shared native core for delivery checks, notes, narration, timings, and
|
||||
global or per-slide transitions while keeping the stable workflow command.
|
||||
|
||||
Usage:
|
||||
python3 scripts/native_enhance_pptx.py init <source.pptx> [--name project_name]
|
||||
|
||||
+1592
-213
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -938,7 +938,7 @@
|
||||
"effect_options": {
|
||||
"font_name": {
|
||||
"type": "string",
|
||||
"default": "??"
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -171,6 +171,27 @@ ANIMATION_TIMING_OPTION_FIELDS = (
|
||||
)
|
||||
ANIMATION_RESTARTS = ('always', 'when-not-active', 'never')
|
||||
ANIMATION_AFTER_EFFECTS = ('none', 'dim', 'hide', 'hide-on-next-click')
|
||||
_NON_CONCRETE_FONT_NAMES = frozenset({
|
||||
'-apple-system',
|
||||
'blinkmacsystemfont',
|
||||
'cursive',
|
||||
'emoji',
|
||||
'fantasy',
|
||||
'inherit',
|
||||
'initial',
|
||||
'math',
|
||||
'monospace',
|
||||
'revert',
|
||||
'revert-layer',
|
||||
'sans-serif',
|
||||
'serif',
|
||||
'system-ui',
|
||||
'ui-monospace',
|
||||
'ui-rounded',
|
||||
'ui-sans-serif',
|
||||
'ui-serif',
|
||||
'unset',
|
||||
})
|
||||
|
||||
# Legacy directional names retain their historical semantics by desugaring
|
||||
# into one canonical effect plus the matching PowerPoint EffectParameters
|
||||
@@ -267,6 +288,17 @@ def _load_native_animations() -> dict[str, dict[str, Any]]:
|
||||
f'native animation preset {key!r} option {option_name!r} '
|
||||
'must be an object'
|
||||
)
|
||||
required = option_spec.get('required', False)
|
||||
if not isinstance(required, bool):
|
||||
raise RuntimeError(
|
||||
f'native animation preset {key!r} option {option_name!r} '
|
||||
'required must be a boolean'
|
||||
)
|
||||
if required and 'default' in option_spec:
|
||||
raise RuntimeError(
|
||||
f'native animation preset {key!r} option {option_name!r} '
|
||||
'cannot define both required and default'
|
||||
)
|
||||
option_type = option_spec.get('type')
|
||||
if option_type == 'enum':
|
||||
values = option_spec.get('values')
|
||||
@@ -492,6 +524,28 @@ def _normalize_animation_color(value: object, field: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _normalize_powerpoint_font_name(value: object, field: str) -> str:
|
||||
"""Return one concrete PowerPoint font name without checking installation."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f'{field} must be one concrete PowerPoint font name: {value!r}'
|
||||
)
|
||||
normalized = value.strip()
|
||||
if len(normalized) > 255:
|
||||
raise ValueError(f'{field} exceeds 255 characters')
|
||||
if ',' in normalized:
|
||||
raise ValueError(
|
||||
f'{field} must be one concrete PowerPoint font name, '
|
||||
f'not a CSS font stack: {value!r}'
|
||||
)
|
||||
if normalized.casefold() in _NON_CONCRETE_FONT_NAMES:
|
||||
raise ValueError(
|
||||
f'{field} must be one concrete PowerPoint font name, '
|
||||
f'not a generic family or CSS-wide keyword: {value!r}'
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_animation_effect_options(
|
||||
effect: str,
|
||||
options: object = None,
|
||||
@@ -518,6 +572,18 @@ def normalize_animation_effect_options(
|
||||
f'animation effect {effect!r} does not support effect option(s): '
|
||||
f'{unsupported}; supported options: {supported}'
|
||||
)
|
||||
missing_required = sorted(
|
||||
name
|
||||
for name, spec in option_specs.items()
|
||||
if spec.get('required') and name not in options
|
||||
)
|
||||
if missing_required:
|
||||
required_fields = ', '.join(
|
||||
f'effect_options.{name}' for name in missing_required
|
||||
)
|
||||
raise ValueError(
|
||||
f'animation effect {effect!r} requires {required_fields}'
|
||||
)
|
||||
|
||||
normalized: dict[str, object] = {}
|
||||
for name, value in options.items():
|
||||
@@ -550,11 +616,17 @@ def normalize_animation_effect_options(
|
||||
)
|
||||
normalized[name] = number
|
||||
elif option_type == 'string':
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f'{field} must be a non-empty string: {value!r}')
|
||||
if len(value) > 255:
|
||||
raise ValueError(f'{field} exceeds 255 characters')
|
||||
normalized[name] = value
|
||||
if name == 'font_name':
|
||||
normalized[name] = _normalize_powerpoint_font_name(value, field)
|
||||
else:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f'{field} must be a non-empty string: {value!r}'
|
||||
)
|
||||
normalized_value = value.strip()
|
||||
if len(normalized_value) > 255:
|
||||
raise ValueError(f'{field} exceeds 255 characters')
|
||||
normalized[name] = normalized_value
|
||||
elif option_type == 'boolean':
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f'{field} must be a boolean: {value!r}')
|
||||
@@ -2252,10 +2324,18 @@ def _read_effect_options(
|
||||
value.get('val')
|
||||
for value in node.iter(_qn(PML_NS, 'strVal'))
|
||||
)
|
||||
if len(fonts) != 1 or not fonts[0]:
|
||||
errors.append('emphasis_change_font row has an invalid font name')
|
||||
else:
|
||||
values[name] = fonts[0]
|
||||
if len(fonts) != 1:
|
||||
errors.append(
|
||||
'emphasis_change_font row must contain one font name'
|
||||
)
|
||||
continue
|
||||
try:
|
||||
values[name] = _normalize_powerpoint_font_name(
|
||||
fonts[0],
|
||||
'emphasis_change_font row font name',
|
||||
)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
elif name == 'relative':
|
||||
motions = list(row.iter(_qn(PML_NS, 'animMotion')))
|
||||
if len(motions) != 1:
|
||||
@@ -3445,11 +3525,17 @@ def get_animation_help() -> str:
|
||||
|
||||
def describe_animation_effect(effect: object) -> dict[str, Any]:
|
||||
"""Return the author-facing option contract for one animation effect."""
|
||||
canonical, implied_options = normalize_animation_effect_request(
|
||||
canonical = normalize_animation_effect(
|
||||
effect,
|
||||
allow_none=False,
|
||||
allow_modes=False,
|
||||
)
|
||||
assert canonical is not None
|
||||
implied_options = (
|
||||
dict(ANIMATION_ALIAS_OPTIONS.get(effect, {}))
|
||||
if isinstance(effect, str)
|
||||
else {}
|
||||
)
|
||||
option_contract: dict[str, Any] = {}
|
||||
for name, raw_spec in NATIVE_ANIMATIONS[canonical]['effectOptions'].items():
|
||||
spec = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -200,6 +200,36 @@ def build_source_profile(
|
||||
SOURCE_INDEX_NAME = "source_profile.json"
|
||||
|
||||
|
||||
def _load_source_index(index_path: Path) -> dict[str, Any]:
|
||||
"""Load and validate an existing multi-deck source index."""
|
||||
if not index_path.exists():
|
||||
return {}
|
||||
if not index_path.is_file():
|
||||
raise RuntimeError(f"Source index is not a file: {index_path}")
|
||||
|
||||
try:
|
||||
loaded = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeError) as exc:
|
||||
raise RuntimeError(
|
||||
f"Source index contains invalid JSON and was left unchanged: {index_path}"
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Cannot read source index: {index_path}: {exc}") from exc
|
||||
|
||||
if not isinstance(loaded, dict) or not isinstance(loaded.get("decks"), list):
|
||||
raise RuntimeError(
|
||||
f"Source index must be a JSON object with a decks array and was left unchanged: "
|
||||
f"{index_path}"
|
||||
)
|
||||
for index, deck in enumerate(loaded["decks"]):
|
||||
if not isinstance(deck, dict):
|
||||
raise RuntimeError(
|
||||
f"Source index decks[{index}] must be an object and was left unchanged: "
|
||||
f"{index_path}"
|
||||
)
|
||||
return loaded
|
||||
|
||||
|
||||
def upsert_source_index(output_dir: Path, digest: dict[str, Any]) -> Path:
|
||||
"""Merge one deck's digest into the single multi-deck index `source_profile.json`.
|
||||
|
||||
@@ -209,14 +239,7 @@ def upsert_source_index(output_dir: Path, digest: dict[str, Any]) -> Path:
|
||||
with the same stem replaces its entry in place.
|
||||
"""
|
||||
index_path = output_dir / SOURCE_INDEX_NAME
|
||||
index: dict[str, Any] = {}
|
||||
if index_path.is_file():
|
||||
try:
|
||||
loaded = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict) and isinstance(loaded.get("decks"), list):
|
||||
index = loaded
|
||||
except (json.JSONDecodeError, OSError):
|
||||
index = {}
|
||||
index = _load_source_index(index_path)
|
||||
stem = digest.get("stem")
|
||||
decks = [d for d in index.get("decks", []) if d.get("stem") != stem]
|
||||
decks.append(digest)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared, dependency-light OPC package relationship validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
PACKAGE_REL_NS = (
|
||||
"http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
)
|
||||
_RELATIONSHIPS_TAG = f"{{{PACKAGE_REL_NS}}}Relationships"
|
||||
_RELATIONSHIP_TAG = f"{{{PACKAGE_REL_NS}}}Relationship"
|
||||
_OPC_UNRESERVED = frozenset(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
_ASCII_LOWER_TRANSLATION = str.maketrans(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
"abcdefghijklmnopqrstuvwxyz",
|
||||
)
|
||||
|
||||
|
||||
def canonical_opc_part_path(path: str) -> str | None:
|
||||
"""Return an OPC-equivalent package path key, or None when invalid."""
|
||||
if (
|
||||
not path
|
||||
or "\\" in path
|
||||
or "?" in path
|
||||
or "#" in path
|
||||
or path.endswith("/")
|
||||
or "//" in path
|
||||
or any(ord(char) <= 0x20 for char in path)
|
||||
):
|
||||
return None
|
||||
output: list[str] = []
|
||||
index = 0
|
||||
while index < len(path):
|
||||
char = path[index]
|
||||
if char != "%":
|
||||
output.append(char)
|
||||
index += 1
|
||||
continue
|
||||
if (
|
||||
index + 2 >= len(path)
|
||||
or re.fullmatch(
|
||||
r"[0-9A-Fa-f]{2}",
|
||||
path[index + 1:index + 3],
|
||||
)
|
||||
is None
|
||||
):
|
||||
return None
|
||||
value = int(path[index + 1:index + 3], 16)
|
||||
decoded = chr(value)
|
||||
if value in {0, ord("/"), ord("\\")}:
|
||||
return None
|
||||
output.append(
|
||||
decoded
|
||||
if decoded in _OPC_UNRESERVED
|
||||
else f"%{value:02X}"
|
||||
)
|
||||
index += 3
|
||||
|
||||
decoded_path = "".join(output)
|
||||
if decoded_path.rsplit("/", 1)[-1] in {".", ".."}:
|
||||
return None
|
||||
normalized = posixpath.normpath(decoded_path)
|
||||
if (
|
||||
not normalized
|
||||
or normalized in {".", ".."}
|
||||
or normalized.startswith("/")
|
||||
or normalized.startswith("../")
|
||||
):
|
||||
return None
|
||||
return normalized.translate(_ASCII_LOWER_TRANSLATION)
|
||||
|
||||
|
||||
def _source_part_for_rels(rels_path: str) -> str | None:
|
||||
filename = posixpath.basename(rels_path)
|
||||
if filename == ".rels" or not filename.endswith(".rels"):
|
||||
return None
|
||||
source_dir = posixpath.dirname(posixpath.dirname(rels_path))
|
||||
source_name = filename.removesuffix(".rels")
|
||||
return (
|
||||
posixpath.join(source_dir, source_name)
|
||||
if source_dir
|
||||
else source_name
|
||||
)
|
||||
|
||||
|
||||
def resolve_internal_opc_target(
|
||||
rels_path: str,
|
||||
target: str,
|
||||
) -> str | None:
|
||||
"""Resolve one valid internal OPC Target to its canonical package key."""
|
||||
target_path_query = target.split("#", 1)[0]
|
||||
if (
|
||||
"\\" in target
|
||||
or "?" in target_path_query
|
||||
or any(ord(char) <= 0x20 for char in target)
|
||||
):
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(target)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.scheme or parsed.netloc or parsed.query:
|
||||
return None
|
||||
|
||||
source_part = _source_part_for_rels(rels_path)
|
||||
if parsed.path.startswith("/"):
|
||||
resolved = parsed.path[1:]
|
||||
elif parsed.path:
|
||||
base_dir = posixpath.dirname(source_part) if source_part else ""
|
||||
resolved = (
|
||||
posixpath.join(base_dir, parsed.path)
|
||||
if base_dir
|
||||
else parsed.path
|
||||
)
|
||||
elif source_part and "#" in target:
|
||||
resolved = source_part
|
||||
else:
|
||||
return None
|
||||
return canonical_opc_part_path(resolved)
|
||||
|
||||
|
||||
def verify_internal_relationships(extract_dir: Path) -> list[str]:
|
||||
"""Return invalid or dangling internal relationships in an OPC package."""
|
||||
package_parts: set[str] = set()
|
||||
for path in extract_dir.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
key = canonical_opc_part_path(
|
||||
path.relative_to(extract_dir).as_posix()
|
||||
)
|
||||
if key is not None:
|
||||
package_parts.add(key)
|
||||
|
||||
problems: list[str] = []
|
||||
for rels_path in sorted(extract_dir.rglob("*.rels")):
|
||||
rels_rel = rels_path.relative_to(extract_dir).as_posix()
|
||||
try:
|
||||
root = ET.parse(rels_path).getroot()
|
||||
except ET.ParseError as exc:
|
||||
problems.append(
|
||||
f"{rels_rel} -> <invalid relationships XML: {exc}>"
|
||||
)
|
||||
continue
|
||||
if root.tag != _RELATIONSHIPS_TAG:
|
||||
problems.append(
|
||||
f"{rels_rel} -> <invalid Relationships namespace>"
|
||||
)
|
||||
continue
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
for element in root:
|
||||
if element.tag != _RELATIONSHIP_TAG:
|
||||
problems.append(
|
||||
f"{rels_rel} -> <invalid relationships child "
|
||||
f"{element.tag!r}>"
|
||||
)
|
||||
continue
|
||||
relationship_id = (element.attrib.get("Id") or "").strip()
|
||||
relationship_type = (
|
||||
element.attrib.get("Type") or ""
|
||||
).strip()
|
||||
target = (element.attrib.get("Target") or "").strip()
|
||||
target_mode = (
|
||||
element.attrib.get("TargetMode") or ""
|
||||
).strip()
|
||||
|
||||
if not relationship_id:
|
||||
problems.append(f"{rels_rel} -> <missing relationship Id>")
|
||||
elif relationship_id in seen_ids:
|
||||
problems.append(
|
||||
f"{rels_rel} -> <duplicate relationship Id "
|
||||
f"{relationship_id!r}>"
|
||||
)
|
||||
else:
|
||||
seen_ids.add(relationship_id)
|
||||
if not relationship_type:
|
||||
problems.append(
|
||||
f"{rels_rel} -> <missing relationship Type>"
|
||||
)
|
||||
if not target:
|
||||
problems.append(f"{rels_rel} -> <missing Target>")
|
||||
continue
|
||||
if target_mode and target_mode.lower() not in {
|
||||
"internal",
|
||||
"external",
|
||||
}:
|
||||
problems.append(
|
||||
f"{rels_rel} -> <invalid TargetMode "
|
||||
f"{target_mode!r}>"
|
||||
)
|
||||
continue
|
||||
if target_mode.lower() == "external":
|
||||
continue
|
||||
|
||||
resolved = resolve_internal_opc_target(rels_rel, target)
|
||||
if resolved is None:
|
||||
problems.append(
|
||||
f"{rels_rel} -> <invalid Target {target!r}>"
|
||||
)
|
||||
elif resolved not in package_parts:
|
||||
problems.append(f"{rels_rel} -> {resolved}")
|
||||
return problems
|
||||
+158
-80
@@ -23,16 +23,25 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
from zipfile import BadZipFile
|
||||
|
||||
from console_encoding import configure_utf8_stdio
|
||||
from template_import.manifest import build_manifest
|
||||
from template_import.native_structure import write_native_structure_bundle
|
||||
from template_import.native_structure import (
|
||||
CONTRACT_NAME,
|
||||
SOURCE_TEMPLATE_NAME,
|
||||
write_native_structure_bundle,
|
||||
)
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
_MANIFEST_NAME = "manifest.json"
|
||||
_CONVERSION_REPORT_NAME = "conversion-report.json"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Build the CLI argument parser for the import entry point."""
|
||||
@@ -84,6 +93,37 @@ def parse_args() -> argparse.Namespace:
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _managed_asset_paths(output_dir: Path) -> set[Path]:
|
||||
"""Read the previous manifest's exact exported-asset roster."""
|
||||
manifest_path = output_dir / _MANIFEST_NAME
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return set()
|
||||
if not isinstance(manifest, dict):
|
||||
return set()
|
||||
assets = manifest.get("assets")
|
||||
if not isinstance(assets, dict):
|
||||
return set()
|
||||
export_dir = assets.get("exportDir")
|
||||
asset_names = assets.get("allAssets")
|
||||
if export_dir != "assets" or not isinstance(asset_names, list):
|
||||
return set()
|
||||
if any(
|
||||
not isinstance(name, str)
|
||||
or not name
|
||||
or name in {".", ".."}
|
||||
or "/" in name
|
||||
or "\\" in name
|
||||
for name in asset_names
|
||||
):
|
||||
return set()
|
||||
return {
|
||||
Path("assets") / name
|
||||
for name in asset_names
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""CLI entry point: write the PPTX reference workspace to disk."""
|
||||
args = parse_args()
|
||||
@@ -100,100 +140,138 @@ def main() -> int:
|
||||
if args.output
|
||||
else pptx_path.with_name(f"{pptx_path.stem}_template_import")
|
||||
)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.skip_manifest and args.manifest_only:
|
||||
print("Error: --skip-manifest and --manifest-only cannot be used together")
|
||||
return 1
|
||||
|
||||
manifest = None
|
||||
native_structure = None
|
||||
manifest_path = output_dir / "manifest.json"
|
||||
if not args.skip_manifest:
|
||||
try:
|
||||
manifest = build_manifest(
|
||||
pptx_path,
|
||||
output_dir,
|
||||
include_flat_svg=(
|
||||
not args.manifest_only and args.inheritance_mode == "both"
|
||||
previous_assets = _managed_asset_paths(output_dir)
|
||||
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging_root = Path(tempfile.mkdtemp(
|
||||
prefix=f".{output_dir.name}.import-",
|
||||
dir=output_dir.parent,
|
||||
))
|
||||
staged_dir = staging_root / "generated"
|
||||
staged_dir.mkdir()
|
||||
|
||||
try:
|
||||
manifest = None
|
||||
native_structure = None
|
||||
manifest_path = staged_dir / _MANIFEST_NAME
|
||||
if not args.skip_manifest:
|
||||
try:
|
||||
manifest = build_manifest(
|
||||
pptx_path,
|
||||
staged_dir,
|
||||
include_flat_svg=(
|
||||
not args.manifest_only and args.inheritance_mode == "both"
|
||||
),
|
||||
)
|
||||
except (RuntimeError, OSError, ValueError) as exc:
|
||||
print(f"Error: failed to extract PPTX metadata: {exc}")
|
||||
return 1
|
||||
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
native_structure = write_native_structure_bundle(
|
||||
pptx_path,
|
||||
staged_dir,
|
||||
manifest,
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f"Error: failed to write native structure bundle: {exc}")
|
||||
return 1
|
||||
|
||||
result = None
|
||||
total_bytes = 0
|
||||
if not args.manifest_only:
|
||||
from pptx_to_svg import convert_pptx_to_svg
|
||||
from pptx_to_svg.converter import ConvertOptions
|
||||
|
||||
options = ConvertOptions(
|
||||
media_subdir="assets",
|
||||
embed_images=args.embed_images,
|
||||
keep_hidden=False,
|
||||
inheritance_mode=args.inheritance_mode,
|
||||
asset_name_map=(
|
||||
manifest.get("assets", {}).get("assetMap", {})
|
||||
if manifest else {}
|
||||
),
|
||||
)
|
||||
except (RuntimeError, OSError, ValueError) as exc:
|
||||
print(f"Error: failed to extract PPTX metadata: {exc}")
|
||||
return 1
|
||||
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
native_structure = write_native_structure_bundle(
|
||||
pptx_path,
|
||||
output_dir,
|
||||
manifest,
|
||||
try:
|
||||
result = convert_pptx_to_svg(pptx_path, staged_dir, options)
|
||||
except (BadZipFile, ET.ParseError, OSError, RuntimeError, ValueError) as exc:
|
||||
print(f"Error: failed to convert PPTX template source: {exc}")
|
||||
return 1
|
||||
total_bytes = sum(
|
||||
len(art.svg.encode("utf-8"))
|
||||
for art in result.slides
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f"Error: failed to write native structure bundle: {exc}")
|
||||
|
||||
from pptx_to_svg.converter import publish_staged_workspace
|
||||
|
||||
try:
|
||||
publish_staged_workspace(
|
||||
output_dir,
|
||||
staged_dir,
|
||||
managed_root_files={
|
||||
_MANIFEST_NAME,
|
||||
CONTRACT_NAME,
|
||||
SOURCE_TEMPLATE_NAME,
|
||||
_CONVERSION_REPORT_NAME,
|
||||
},
|
||||
managed_relative_paths=previous_assets,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
print(f"Error: failed to publish PPTX template workspace: {exc}")
|
||||
return 1
|
||||
|
||||
if args.manifest_only:
|
||||
print(f"Imported PPTX template source: {pptx_path.name}")
|
||||
if args.manifest_only:
|
||||
print(f"Imported PPTX template source: {pptx_path.name}")
|
||||
print(f"Output directory: {output_dir}")
|
||||
if manifest is not None:
|
||||
print(f"Manifest: {manifest_path.name}")
|
||||
print(f"Native structure: {CONTRACT_NAME}")
|
||||
print(f"Source package analysis copy: {SOURCE_TEMPLATE_NAME}")
|
||||
print(
|
||||
"Source structure assessment: "
|
||||
f"{native_structure['strategy']['recommendedMode']}"
|
||||
)
|
||||
print("Template output mode: explicit SVG structure")
|
||||
print(f"Assets exported: {len(manifest['assets']['allAssets'])}")
|
||||
print(f"Common assets: {len(manifest['assets']['commonAssets'])}")
|
||||
print(f"Slides analyzed: {len(manifest['slides'])}")
|
||||
print(f"Layouts (unique): {len(manifest.get('layouts', []))}")
|
||||
print(f"Masters (unique): {len(manifest.get('masters', []))}")
|
||||
return 0
|
||||
|
||||
print(f"Inheritance mode: {args.inheritance_mode}")
|
||||
print(f"Exported SVG slides: {len(result.slides)}")
|
||||
if args.inheritance_mode in {"layered", "both"}:
|
||||
print(f"Exported masters: {len(result.masters)}")
|
||||
print(f"Exported layouts: {len(result.layouts)}")
|
||||
print("Inheritance graph: svg/inheritance.json")
|
||||
if result.flat_slides:
|
||||
print(f"Flat companion slides: {len(result.flat_slides)} (svg-flat/)")
|
||||
if result.diagnostics:
|
||||
print(
|
||||
f"Source recovery warnings: {len(result.diagnostics)} "
|
||||
f"({_CONVERSION_REPORT_NAME})"
|
||||
)
|
||||
print(f"SVG bytes (primary): {total_bytes}")
|
||||
print(f"Output directory: {output_dir}")
|
||||
if manifest is not None:
|
||||
print(f"Manifest: {manifest_path.name}")
|
||||
print("Native structure: native_structure.json")
|
||||
print("Source package analysis copy: source_template.pptx")
|
||||
if native_structure is not None:
|
||||
print(
|
||||
"Source structure assessment: "
|
||||
f"{native_structure['strategy']['recommendedMode']}"
|
||||
f"{native_structure['strategy']['recommendedMode']}; "
|
||||
"create-template rebuilds explicit SVG structure"
|
||||
)
|
||||
print("Template output mode: explicit SVG structure")
|
||||
print(f"Assets exported: {len(manifest['assets']['allAssets'])}")
|
||||
print(f"Common assets: {len(manifest['assets']['commonAssets'])}")
|
||||
print(f"Slides analyzed: {len(manifest['slides'])}")
|
||||
print(f"Layouts (unique): {len(manifest.get('layouts', []))}")
|
||||
print(f"Masters (unique): {len(manifest.get('masters', []))}")
|
||||
return 0
|
||||
|
||||
from pptx_to_svg import convert_pptx_to_svg
|
||||
from pptx_to_svg.converter import ConvertOptions
|
||||
|
||||
options = ConvertOptions(
|
||||
media_subdir="assets",
|
||||
embed_images=args.embed_images,
|
||||
keep_hidden=False,
|
||||
inheritance_mode=args.inheritance_mode,
|
||||
asset_name_map=manifest.get("assets", {}).get("assetMap", {}) if manifest else {},
|
||||
)
|
||||
try:
|
||||
result = convert_pptx_to_svg(pptx_path, output_dir, options)
|
||||
except (BadZipFile, ET.ParseError, OSError, RuntimeError, ValueError) as exc:
|
||||
print(f"Error: failed to convert PPTX template source: {exc}")
|
||||
return 1
|
||||
total_bytes = sum(len(art.svg.encode("utf-8")) for art in result.slides)
|
||||
|
||||
print(f"Inheritance mode: {args.inheritance_mode}")
|
||||
print(f"Exported SVG slides: {len(result.slides)}")
|
||||
if args.inheritance_mode in {"layered", "both"}:
|
||||
print(f"Exported masters: {len(result.masters)}")
|
||||
print(f"Exported layouts: {len(result.layouts)}")
|
||||
print("Inheritance graph: svg/inheritance.json")
|
||||
if result.flat_slides:
|
||||
print(f"Flat companion slides: {len(result.flat_slides)} (svg-flat/)")
|
||||
if result.diagnostics:
|
||||
print(
|
||||
f"Source recovery warnings: {len(result.diagnostics)} "
|
||||
"(conversion-report.json)"
|
||||
)
|
||||
print(f"SVG bytes (primary): {total_bytes}")
|
||||
print(f"Output directory: {output_dir}")
|
||||
if native_structure is not None:
|
||||
print(
|
||||
"Source structure assessment: "
|
||||
f"{native_structure['strategy']['recommendedMode']}; "
|
||||
"create-template rebuilds explicit SVG structure"
|
||||
)
|
||||
return 0
|
||||
finally:
|
||||
shutil.rmtree(staging_root, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+356
-17
@@ -14,10 +14,15 @@ loads the package and reports basic per-slide structure to verify wiring.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from html import unescape
|
||||
from pathlib import Path, PurePosixPath
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from .color_resolver import ColorPalette
|
||||
from .emu_units import NS
|
||||
@@ -31,6 +36,40 @@ from .ooxml_loader import (
|
||||
from .slide_to_svg import assemble_part_solo, assemble_slide
|
||||
|
||||
|
||||
_CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"})
|
||||
_MANAGED_PRIMARY_SVG_RE = re.compile(
|
||||
r"(?:slide_\d+|master_\d+_[A-Za-z0-9_-]+|layout_\d+_[A-Za-z0-9_-]+)\.svg"
|
||||
)
|
||||
_MANAGED_FLAT_SVG_RE = re.compile(r"slide_\d+\.svg")
|
||||
_SVG_HREF_RE = re.compile(
|
||||
r"\b(?:href|xlink:href)\s*=\s*[\"']([^\"']+)[\"']"
|
||||
)
|
||||
|
||||
|
||||
def _validate_media_subdir(value: str) -> None:
|
||||
"""Reject media output paths that can escape the conversion workspace."""
|
||||
path = Path(value)
|
||||
if path.drive or path.anchor or path.is_absolute() or ".." in path.parts:
|
||||
raise ValueError(
|
||||
f"media_subdir must stay within the output workspace: {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_media_filename(filename: str) -> None:
|
||||
"""Require one media basename so asset maps cannot redirect writes."""
|
||||
path = Path(filename)
|
||||
if (
|
||||
not filename
|
||||
or filename in {".", ".."}
|
||||
or path.drive
|
||||
or path.anchor
|
||||
or path.name != filename
|
||||
or "/" in filename
|
||||
or "\\" in filename
|
||||
):
|
||||
raise ValueError(f"Media filename must be a basename: {filename!r}")
|
||||
|
||||
|
||||
def _extract_theme_info(
|
||||
theme: PartRef,
|
||||
palette: ColorPalette,
|
||||
@@ -77,6 +116,11 @@ def _extract_theme_info(
|
||||
cs = fnt.find("a:cs", NS)
|
||||
if cs is not None and cs.attrib.get("typeface"):
|
||||
fonts[f"{role_prefix}ComplexScript"] = cs.attrib["typeface"]
|
||||
for supplemental in fnt.findall("a:font", NS):
|
||||
script = supplemental.attrib.get("script", "")
|
||||
typeface = supplemental.attrib.get("typeface", "")
|
||||
if script in _CJK_THEME_SCRIPTS and typeface:
|
||||
fonts[f"{role_prefix}Script{script}"] = typeface
|
||||
|
||||
return colors, fonts
|
||||
|
||||
@@ -233,6 +277,8 @@ def convert_pptx_to_svg(
|
||||
f"inheritance_mode must be 'flat', 'layered', or 'both', "
|
||||
f"got {options.inheritance_mode!r}"
|
||||
)
|
||||
if not options.embed_images:
|
||||
_validate_media_subdir(options.media_subdir)
|
||||
emit_layered = options.inheritance_mode in {"layered", "both"}
|
||||
emit_flat = options.inheritance_mode in {"flat", "both"}
|
||||
result = ConvertResult(
|
||||
@@ -477,9 +523,269 @@ def _render_part(
|
||||
)
|
||||
|
||||
|
||||
def _write_artifacts(output_dir: Path, result: ConvertResult,
|
||||
options: ConvertOptions) -> None:
|
||||
"""Write SVG + media files to output_dir.
|
||||
def _path_lexists(path: Path) -> bool:
|
||||
"""Return whether a path or symlink exists without following the symlink."""
|
||||
return path.exists() or path.is_symlink()
|
||||
|
||||
|
||||
def _managed_svg_paths(output_dir: Path) -> list[Path]:
|
||||
"""Return converter-owned SVG files without traversing user directories."""
|
||||
managed: list[Path] = []
|
||||
for dirname, filename_re in (
|
||||
("svg", _MANAGED_PRIMARY_SVG_RE),
|
||||
("svg-flat", _MANAGED_FLAT_SVG_RE),
|
||||
):
|
||||
svg_dir = output_dir / dirname
|
||||
if svg_dir.is_symlink():
|
||||
managed.append(svg_dir)
|
||||
continue
|
||||
if not svg_dir.is_dir():
|
||||
continue
|
||||
managed.extend(
|
||||
path
|
||||
for path in svg_dir.iterdir()
|
||||
if filename_re.fullmatch(path.name)
|
||||
and (path.is_file() or path.is_symlink())
|
||||
)
|
||||
inheritance = svg_dir / "inheritance.json"
|
||||
if dirname == "svg" and _path_lexists(inheritance):
|
||||
managed.append(inheritance)
|
||||
return managed
|
||||
|
||||
|
||||
def _referenced_local_paths(
|
||||
output_dir: Path,
|
||||
svg_paths: list[Path],
|
||||
) -> set[Path]:
|
||||
"""Resolve local media referenced by converter-owned SVGs."""
|
||||
referenced: set[Path] = set()
|
||||
output_abs = output_dir.absolute()
|
||||
for svg_path in svg_paths:
|
||||
if svg_path.is_symlink() or not svg_path.is_file():
|
||||
continue
|
||||
try:
|
||||
svg_text = svg_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError):
|
||||
continue
|
||||
for raw_href in _SVG_HREF_RE.findall(svg_text):
|
||||
href = unescape(raw_href)
|
||||
parsed = urlsplit(href)
|
||||
if parsed.scheme or parsed.netloc or not parsed.path:
|
||||
continue
|
||||
href_path = unquote(parsed.path)
|
||||
if Path(href_path).is_absolute():
|
||||
continue
|
||||
target = Path(os.path.normpath(str(svg_path.parent / href_path)))
|
||||
try:
|
||||
relative = target.absolute().relative_to(output_abs)
|
||||
except ValueError:
|
||||
continue
|
||||
if relative.parts:
|
||||
referenced.add(relative)
|
||||
return referenced
|
||||
|
||||
|
||||
def _validated_relative_paths(paths: set[str | Path]) -> set[Path]:
|
||||
"""Normalize caller-supplied managed paths and reject output escapes."""
|
||||
normalized: set[Path] = set()
|
||||
for value in paths:
|
||||
path = Path(value)
|
||||
if (
|
||||
path.drive
|
||||
or path.anchor
|
||||
or path.is_absolute()
|
||||
or not path.parts
|
||||
or ".." in path.parts
|
||||
):
|
||||
raise ValueError(f"Managed artifact path must stay relative: {value}")
|
||||
normalized.add(path)
|
||||
return normalized
|
||||
|
||||
|
||||
def _reject_symlink_ancestors(
|
||||
root: Path,
|
||||
relative_paths: set[Path],
|
||||
) -> None:
|
||||
"""Reject managed paths that would traverse a preserved user symlink."""
|
||||
for relative in relative_paths:
|
||||
current = root
|
||||
for component in relative.parts[:-1]:
|
||||
current /= component
|
||||
if current.is_symlink():
|
||||
raise RuntimeError(
|
||||
"Managed artifact path crosses an unmanaged symlink: "
|
||||
f"{relative}"
|
||||
)
|
||||
|
||||
|
||||
def _remove_managed_paths(candidate_dir: Path, relative_paths: set[Path]) -> None:
|
||||
"""Remove only the previous converter roster from a candidate workspace."""
|
||||
_reject_symlink_ancestors(candidate_dir, relative_paths)
|
||||
parents: set[Path] = set()
|
||||
for relative in sorted(
|
||||
relative_paths,
|
||||
key=lambda item: len(item.parts),
|
||||
reverse=True,
|
||||
):
|
||||
target = candidate_dir / relative
|
||||
if target.is_symlink() or target.is_file():
|
||||
target.unlink()
|
||||
elif target.is_dir():
|
||||
raise RuntimeError(
|
||||
"Managed artifact path collides with a preserved directory: "
|
||||
f"{relative}"
|
||||
)
|
||||
parent = target.parent
|
||||
while parent != candidate_dir:
|
||||
parents.add(parent)
|
||||
parent = parent.parent
|
||||
|
||||
for parent in sorted(parents, key=lambda item: len(item.parts), reverse=True):
|
||||
if parent.is_symlink() or not parent.is_dir():
|
||||
continue
|
||||
try:
|
||||
parent.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _overlay_staged_tree(staged_dir: Path, candidate_dir: Path) -> None:
|
||||
"""Overlay generated artifacts without overwriting unmanaged user files."""
|
||||
for source in sorted(staged_dir.rglob("*")):
|
||||
relative = source.relative_to(staged_dir)
|
||||
target = candidate_dir / relative
|
||||
_reject_symlink_ancestors(candidate_dir, {relative})
|
||||
if source.is_symlink():
|
||||
raise RuntimeError(
|
||||
f"Generated artifact must not be a symlink: {relative}"
|
||||
)
|
||||
if source.is_dir():
|
||||
if (
|
||||
target.is_symlink()
|
||||
or (_path_lexists(target) and not target.is_dir())
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Generated artifact collides with unmanaged path: {relative}"
|
||||
)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if _path_lexists(target):
|
||||
if target.is_dir() or target.is_symlink():
|
||||
raise RuntimeError(
|
||||
f"Generated artifact collides with unmanaged path: {relative}"
|
||||
)
|
||||
if target.read_bytes() != source.read_bytes():
|
||||
raise RuntimeError(
|
||||
f"Generated artifact collides with unmanaged file: {relative}"
|
||||
)
|
||||
shutil.copy2(source, target)
|
||||
|
||||
|
||||
def publish_staged_workspace(
|
||||
output_dir: Path,
|
||||
staged_dir: Path,
|
||||
*,
|
||||
managed_root_files: set[str | Path] | None = None,
|
||||
managed_relative_paths: set[str | Path] | None = None,
|
||||
) -> None:
|
||||
"""Atomically publish generated artifacts while preserving user files.
|
||||
|
||||
Converter-owned SVGs, their local media references, and the named managed
|
||||
artifacts are replaced as one roster. Everything else already present in
|
||||
the output directory is copied into the candidate unchanged.
|
||||
"""
|
||||
output_dir = output_dir.absolute()
|
||||
staged_dir = staged_dir.absolute()
|
||||
if (
|
||||
output_dir == staged_dir
|
||||
or output_dir in staged_dir.parents
|
||||
or staged_dir in output_dir.parents
|
||||
):
|
||||
raise ValueError(
|
||||
"Staged and output workspaces must not contain one another"
|
||||
)
|
||||
output_resolved = output_dir.resolve(strict=False)
|
||||
try:
|
||||
Path.cwd().resolve().relative_to(output_resolved)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Output workspace must not contain the current working directory"
|
||||
)
|
||||
if not staged_dir.is_dir():
|
||||
raise ValueError(f"Staged workspace does not exist: {staged_dir}")
|
||||
if (
|
||||
output_dir.is_symlink()
|
||||
or (_path_lexists(output_dir) and not output_dir.is_dir())
|
||||
):
|
||||
raise RuntimeError(f"Output path must be a real directory: {output_dir}")
|
||||
|
||||
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
transaction_dir = Path(tempfile.mkdtemp(
|
||||
prefix=f".{output_dir.name}.publish-",
|
||||
dir=output_dir.parent,
|
||||
))
|
||||
candidate_dir = transaction_dir / "candidate"
|
||||
backup_dir = transaction_dir / "previous"
|
||||
preserve_backup = False
|
||||
|
||||
try:
|
||||
if output_dir.is_dir():
|
||||
shutil.copytree(output_dir, candidate_dir, symlinks=True)
|
||||
else:
|
||||
candidate_dir.mkdir()
|
||||
|
||||
managed_svg = _managed_svg_paths(output_dir)
|
||||
relative_paths = {
|
||||
path.relative_to(output_dir)
|
||||
for path in managed_svg
|
||||
}
|
||||
relative_paths.update(_referenced_local_paths(output_dir, managed_svg))
|
||||
relative_paths.add(Path("conversion-report.json"))
|
||||
relative_paths.update(_validated_relative_paths(managed_root_files or set()))
|
||||
relative_paths.update(_validated_relative_paths(managed_relative_paths or set()))
|
||||
_remove_managed_paths(candidate_dir, relative_paths)
|
||||
_overlay_staged_tree(staged_dir, candidate_dir)
|
||||
|
||||
if output_dir.is_dir():
|
||||
try:
|
||||
os.replace(output_dir, backup_dir)
|
||||
os.replace(candidate_dir, output_dir)
|
||||
except BaseException as publish_error:
|
||||
try:
|
||||
if _path_lexists(backup_dir):
|
||||
if _path_lexists(output_dir):
|
||||
failed_output = transaction_dir / "failed-publish"
|
||||
os.replace(output_dir, failed_output)
|
||||
os.replace(backup_dir, output_dir)
|
||||
except BaseException as restore_error:
|
||||
if (
|
||||
not _path_lexists(backup_dir)
|
||||
and _path_lexists(output_dir)
|
||||
):
|
||||
raise publish_error
|
||||
preserve_backup = _path_lexists(backup_dir)
|
||||
raise RuntimeError(
|
||||
"Failed to publish the new workspace and restore the "
|
||||
"previous workspace; recovery directory: "
|
||||
f"{transaction_dir}"
|
||||
) from restore_error
|
||||
raise
|
||||
else:
|
||||
os.replace(candidate_dir, output_dir)
|
||||
finally:
|
||||
if not preserve_backup:
|
||||
shutil.rmtree(transaction_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _write_artifact_tree(
|
||||
output_dir: Path,
|
||||
result: ConvertResult,
|
||||
options: ConvertOptions,
|
||||
) -> None:
|
||||
"""Write a complete converter roster into an empty staging directory.
|
||||
|
||||
Layout:
|
||||
- ``svg/`` primary view (layered when emitted, otherwise flat)
|
||||
@@ -490,34 +796,32 @@ def _write_artifacts(output_dir: Path, result: ConvertResult,
|
||||
svg_dir = output_dir / "svg"
|
||||
svg_dir.mkdir(exist_ok=True)
|
||||
media_dir = output_dir / options.media_subdir
|
||||
media_written: set[str] = set()
|
||||
media_written: dict[str, bytes] = {}
|
||||
|
||||
def _write_media(media: dict[str, bytes]) -> None:
|
||||
def _collect_media(media: dict[str, bytes]) -> None:
|
||||
for filename, blob in media.items():
|
||||
_validate_media_filename(filename)
|
||||
if filename in media_written:
|
||||
if media_written[filename] != blob:
|
||||
raise RuntimeError(
|
||||
f"Asset filename collision with different bytes: {filename}"
|
||||
)
|
||||
continue
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = media_dir / filename
|
||||
if target.exists():
|
||||
if target.read_bytes() != blob:
|
||||
raise RuntimeError(f"Asset filename collision with different bytes: {filename}")
|
||||
else:
|
||||
target.write_bytes(blob)
|
||||
media_written.add(filename)
|
||||
media_written[filename] = blob
|
||||
|
||||
# Layered mode: write masters and layouts first so they sort ahead of slides.
|
||||
for art in result.masters:
|
||||
(svg_dir / art.filename).write_text(art.svg, encoding="utf-8")
|
||||
_write_media(art.media_files)
|
||||
_collect_media(art.media_files)
|
||||
for art in result.layouts:
|
||||
(svg_dir / art.filename).write_text(art.svg, encoding="utf-8")
|
||||
_write_media(art.media_files)
|
||||
_collect_media(art.media_files)
|
||||
|
||||
# Slides (primary view).
|
||||
for art in result.slides:
|
||||
target = svg_dir / f"slide_{art.index:02d}.svg"
|
||||
target.write_text(art.svg, encoding="utf-8")
|
||||
_write_media(art.media_files)
|
||||
_collect_media(art.media_files)
|
||||
|
||||
# Inheritance graph alongside the layered SVGs (only meaningful when we
|
||||
# actually emitted a layered view).
|
||||
@@ -531,9 +835,44 @@ def _write_artifacts(output_dir: Path, result: ConvertResult,
|
||||
for art in result.flat_slides:
|
||||
target = flat_dir / f"slide_{art.index:02d}.svg"
|
||||
target.write_text(art.svg, encoding="utf-8")
|
||||
_write_media(art.media_files)
|
||||
_collect_media(art.media_files)
|
||||
|
||||
_write_conversion_report(output_dir, result)
|
||||
if media_written:
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
for filename, blob in media_written.items():
|
||||
target = media_dir / filename
|
||||
if _path_lexists(target):
|
||||
if (
|
||||
target.is_symlink()
|
||||
or not target.is_file()
|
||||
or target.read_bytes() != blob
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Asset filename collision with different bytes: {filename}"
|
||||
)
|
||||
continue
|
||||
target.write_bytes(blob)
|
||||
|
||||
|
||||
def _write_artifacts(
|
||||
output_dir: Path,
|
||||
result: ConvertResult,
|
||||
options: ConvertOptions,
|
||||
) -> None:
|
||||
"""Stage a complete conversion, then atomically publish its exact roster."""
|
||||
output_dir = output_dir.absolute()
|
||||
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging_root = Path(tempfile.mkdtemp(
|
||||
prefix=f".{output_dir.name}.convert-",
|
||||
dir=output_dir.parent,
|
||||
))
|
||||
staged_dir = staging_root / "generated"
|
||||
try:
|
||||
_write_artifact_tree(staged_dir, result, options)
|
||||
publish_staged_workspace(output_dir, staged_dir)
|
||||
finally:
|
||||
shutil.rmtree(staging_root, ignore_errors=True)
|
||||
|
||||
|
||||
def _write_conversion_report(output_dir: Path, result: ConvertResult) -> None:
|
||||
|
||||
+3
-2
@@ -24,7 +24,8 @@ Strategy:
|
||||
preserveAspectRatio="none".
|
||||
- With srcRect, or with a single oversized tile that covers the frame -> wrap
|
||||
the <image> in a nested <svg viewBox> in the unit rectangle [0,1] x [0,1],
|
||||
so cropping is expressed as the visible viewBox region.
|
||||
with overflow hidden so cropping is expressed identically in browsers and
|
||||
PowerPoint.
|
||||
- Repeating tile fills still use the legacy plain-image fallback; a repeated
|
||||
pattern cannot be represented by the project's native picture-crop subset.
|
||||
- Image bytes are written through the result; the slide assembler decides
|
||||
@@ -160,7 +161,7 @@ def convert_blip_fill(
|
||||
f'width="{fmt_num(xfrm.w)}" height="{fmt_num(xfrm.h)}" '
|
||||
f'viewBox="{fmt_num(vb_l, 5)} {fmt_num(vb_t, 5)} '
|
||||
f'{fmt_num(vb_w, 5)} {fmt_num(vb_h, 5)}" '
|
||||
f'preserveAspectRatio="none">'
|
||||
f'preserveAspectRatio="none" overflow="hidden">'
|
||||
f'<image href="{href}" x="0" y="0" width="1" height="1" '
|
||||
f'preserveAspectRatio="none"{opacity_attr}/>'
|
||||
f"</svg>"
|
||||
|
||||
+66
-25
@@ -64,7 +64,7 @@ from .ooxml_loader import (
|
||||
SlideRef,
|
||||
inherited_shape_visibility,
|
||||
)
|
||||
from .pic_to_svg import convert_blip_fill, convert_picture
|
||||
from .pic_to_svg import MediaResolutionError, convert_blip_fill, convert_picture
|
||||
from .prstgeom_to_svg import GeomResult, convert_prst_geom
|
||||
from .preset_svg_markup import serialize_preset_layers
|
||||
from .shape_walker import (
|
||||
@@ -214,7 +214,7 @@ def assemble_slide(
|
||||
ctx, canvas_w, canvas_h,
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
except (ValueError, MediaResolutionError) as exc:
|
||||
if strict:
|
||||
raise
|
||||
ctx.diagnose(
|
||||
@@ -340,7 +340,7 @@ def assemble_part_solo(
|
||||
)
|
||||
try:
|
||||
bg_xml = _emit_part_background(fake_slide, ctx, canvas_w, canvas_h)
|
||||
except ValueError as exc:
|
||||
except (ValueError, MediaResolutionError) as exc:
|
||||
if strict:
|
||||
raise
|
||||
ctx.diagnose(
|
||||
@@ -458,7 +458,7 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
|
||||
embed_inline=ctx.embed_images,
|
||||
asset_name_map=ctx.asset_name_map,
|
||||
)
|
||||
except ValueError as exc:
|
||||
except (ValueError, MediaResolutionError) as exc:
|
||||
if ctx.strict:
|
||||
raise
|
||||
ctx.diagnose(
|
||||
@@ -892,6 +892,8 @@ def _clip_blip_image(image_xml: str, geom: GeomResult | None,
|
||||
"""Clip image fills to the owning shape geometry when it is not a plain rect."""
|
||||
if geom is None or geom.tag == "line":
|
||||
return image_xml
|
||||
if geom.attrs.get("data-pptx-prst") == "rect":
|
||||
return image_xml
|
||||
if geom.tag == "rect" and not geom.attrs.get("rx") and not geom.attrs.get("ry"):
|
||||
return image_xml
|
||||
|
||||
@@ -919,22 +921,35 @@ def _inject_clip_path(image_xml: str, clip_id: str) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str:
|
||||
result = convert_picture(
|
||||
node.xml, node.xfrm, ctx.slide_part, ctx.pkg,
|
||||
media_subdir=ctx.media_subdir,
|
||||
embed_inline=ctx.embed_images,
|
||||
asset_name_map=ctx.asset_name_map,
|
||||
)
|
||||
sp_pr = node.xml.find("p:spPr", NS)
|
||||
geom = _resolve_geometry(node, sp_pr)
|
||||
try:
|
||||
result = convert_picture(
|
||||
node.xml, node.xfrm, ctx.slide_part, ctx.pkg,
|
||||
media_subdir=ctx.media_subdir,
|
||||
embed_inline=ctx.embed_images,
|
||||
asset_name_map=ctx.asset_name_map,
|
||||
)
|
||||
except MediaResolutionError as exc:
|
||||
if ctx.strict:
|
||||
raise
|
||||
ctx.diagnose(
|
||||
"object-replaced",
|
||||
str(exc),
|
||||
"replace only this picture with a visible placeholder",
|
||||
)
|
||||
return _fallback_node_svg(node, ctx, top_level=top_level)
|
||||
if not result.svg:
|
||||
return ""
|
||||
ctx.media.update(result.media)
|
||||
effect_metadata = unsupported_target_effect_metadata(
|
||||
node.xml.find("p:spPr", NS),
|
||||
sp_pr,
|
||||
"picture",
|
||||
)
|
||||
_diagnose_unsupported_effect(ctx, effect_metadata)
|
||||
clipped_svg = _clip_blip_image(result.svg, geom, ctx)
|
||||
picture_svg = _inject_root_svg_attrs(
|
||||
result.svg,
|
||||
clipped_svg,
|
||||
{**_object_metadata(node, ctx), **effect_metadata},
|
||||
)
|
||||
return _wrap_shape_group(
|
||||
@@ -1041,11 +1056,30 @@ def _convert_graphic_fallback(node: ShapeNode, ctx: AssemblyContext,
|
||||
extra_attrs=replacement_attrs,
|
||||
)
|
||||
|
||||
preview_svg = ""
|
||||
if ctx.render_graphic_previews:
|
||||
try:
|
||||
preview_svg = _render_graphic_preview(node, ctx)
|
||||
except MediaResolutionError as exc:
|
||||
if ctx.strict:
|
||||
raise
|
||||
ctx.diagnose(
|
||||
"preview-omitted",
|
||||
str(exc),
|
||||
"omit the missing baked preview and retain the native, "
|
||||
"normalized, or placeholder fallback",
|
||||
)
|
||||
|
||||
chart_replacement_attrs: list[str] = []
|
||||
chart_payload_metadata = ""
|
||||
if uri in {CHART_URI, CHARTEX_URI}:
|
||||
rendered, chart_replacement_attrs, chart_payload_metadata = (
|
||||
_render_graphic_chart(node, ctx, graphic_data)
|
||||
_render_graphic_chart(
|
||||
node,
|
||||
ctx,
|
||||
graphic_data,
|
||||
preview_svg,
|
||||
)
|
||||
)
|
||||
if rendered:
|
||||
inner = (
|
||||
@@ -1061,17 +1095,25 @@ def _convert_graphic_fallback(node: ShapeNode, ctx: AssemblyContext,
|
||||
extra_attrs=chart_replacement_attrs,
|
||||
)
|
||||
|
||||
if uri == "http://schemas.openxmlformats.org/presentationml/2006/ole" and ctx.render_graphic_previews:
|
||||
rendered = _render_graphic_preview(node, ctx)
|
||||
if rendered:
|
||||
labelled = rendered + "\n" + _graphic_preview_label(node, "ole preview")
|
||||
if uri == "http://schemas.openxmlformats.org/presentationml/2006/ole":
|
||||
if preview_svg:
|
||||
labelled = (
|
||||
preview_svg
|
||||
+ "\n"
|
||||
+ _graphic_preview_label(node, "ole preview")
|
||||
)
|
||||
return _wrap_shape_group(labelled, node, ctx, top_level=top_level)
|
||||
|
||||
if ctx.render_graphic_previews:
|
||||
rendered = _render_graphic_preview(node, ctx)
|
||||
if rendered:
|
||||
labelled = rendered + "\n" + _graphic_preview_label(node, f"{uri.rsplit('/', 1)[-1]} preview")
|
||||
return _wrap_shape_group(labelled, node, ctx, top_level=top_level)
|
||||
if preview_svg:
|
||||
labelled = (
|
||||
preview_svg
|
||||
+ "\n"
|
||||
+ _graphic_preview_label(
|
||||
node,
|
||||
f"{uri.rsplit('/', 1)[-1]} preview",
|
||||
)
|
||||
)
|
||||
return _wrap_shape_group(labelled, node, ctx, top_level=top_level)
|
||||
|
||||
label = uri.rsplit("/", 1)[-1]
|
||||
placeholder = (
|
||||
@@ -1165,6 +1207,7 @@ def _render_graphic_chart(
|
||||
node: ShapeNode,
|
||||
ctx: AssemblyContext,
|
||||
graphic_data: ET.Element | None,
|
||||
preview_svg: str,
|
||||
) -> tuple[str, list[str], str]:
|
||||
"""Return a chart fallback plus native Chart replacement metadata."""
|
||||
result = extract_native_chart_payload(
|
||||
@@ -1187,9 +1230,7 @@ def _render_graphic_chart(
|
||||
f'{_xml_escape(result.native_status)}"'
|
||||
)
|
||||
|
||||
rendered = ""
|
||||
if ctx.render_graphic_previews:
|
||||
rendered = _render_graphic_preview(node, ctx)
|
||||
rendered = preview_svg
|
||||
if rendered:
|
||||
replacement_attrs.append('data-pptx-fallback-kind="source-preview"')
|
||||
elif result.normalized_svg:
|
||||
|
||||
+111
-16
@@ -25,6 +25,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from svg_to_pptx.drawingml.utils import detect_text_lang, is_cjk_char
|
||||
|
||||
from .color_resolver import ColorPalette, find_color_elem, resolve_color
|
||||
from .emu_units import (
|
||||
NS, Xfrm, fmt_num, emu_to_px, format_ooxml_alpha,
|
||||
@@ -576,11 +578,31 @@ def _build_run(
|
||||
latin_face = _typeface_chain(style_chain, "latin")
|
||||
ea_face = _typeface_chain(style_chain, "ea")
|
||||
cs_face = _typeface_chain(style_chain, "cs")
|
||||
lang = _attr_chain(style_chain, "lang")
|
||||
alt_lang = _attr_chain(style_chain, "altLang")
|
||||
|
||||
# Resolve theme refs (e.g. typeface="+mn-lt" / "+mj-ea")
|
||||
latin_face = _resolve_theme_typeface(latin_face, theme_fonts)
|
||||
ea_face = _resolve_theme_typeface(ea_face, theme_fonts)
|
||||
cs_face = _resolve_theme_typeface(cs_face, theme_fonts)
|
||||
latin_face = _resolve_theme_typeface(
|
||||
latin_face,
|
||||
theme_fonts,
|
||||
text=text,
|
||||
lang=lang,
|
||||
alt_lang=alt_lang,
|
||||
)
|
||||
ea_face = _resolve_theme_typeface(
|
||||
ea_face,
|
||||
theme_fonts,
|
||||
text=text,
|
||||
lang=lang,
|
||||
alt_lang=alt_lang,
|
||||
)
|
||||
cs_face = _resolve_theme_typeface(
|
||||
cs_face,
|
||||
theme_fonts,
|
||||
text=text,
|
||||
lang=lang,
|
||||
alt_lang=alt_lang,
|
||||
)
|
||||
|
||||
font_family = _build_font_stack(latin_face, ea_face, cs_face)
|
||||
|
||||
@@ -698,8 +720,63 @@ def _typeface_chain(
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_theme_typeface(face: str | None, theme_fonts: dict[str, str]) -> str | None:
|
||||
"""Theme references look like '+mj-lt' (major latin) / '+mn-ea' (minor EA)."""
|
||||
def _theme_script_from_lang(lang: str | None) -> str | None:
|
||||
"""Map a DrawingML language tag to one theme supplemental-script key."""
|
||||
if not lang:
|
||||
return None
|
||||
normalized = lang.strip().replace("_", "-").lower()
|
||||
if not normalized:
|
||||
return None
|
||||
parts = normalized.split("-")
|
||||
primary = parts[0]
|
||||
if primary == "ja":
|
||||
return "Jpan"
|
||||
if primary == "ko":
|
||||
return "Hang"
|
||||
if primary != "zh":
|
||||
return None
|
||||
if any(part in {"hant", "cht", "tw", "hk", "mo"} for part in parts[1:]):
|
||||
return "Hant"
|
||||
return "Hans"
|
||||
|
||||
|
||||
def _theme_script_from_text(text: str) -> str | None:
|
||||
"""Infer a CJK theme script from glyph ranges, defaulting plain Han to Hans."""
|
||||
if any(
|
||||
0x3100 <= ord(char) <= 0x312F
|
||||
or 0x31A0 <= ord(char) <= 0x31BF
|
||||
for char in text
|
||||
):
|
||||
return "Hant"
|
||||
return {
|
||||
"ko-KR": "Hang",
|
||||
"ja-JP": "Jpan",
|
||||
"zh-CN": "Hans",
|
||||
}.get(detect_text_lang(text))
|
||||
|
||||
|
||||
def _run_theme_script(
|
||||
text: str,
|
||||
lang: str | None,
|
||||
alt_lang: str | None,
|
||||
) -> str | None:
|
||||
"""Resolve EA script from run language first, then alternate language/text."""
|
||||
return (
|
||||
_theme_script_from_lang(lang)
|
||||
or _theme_script_from_lang(alt_lang)
|
||||
or _theme_script_from_text(text)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_theme_typeface(
|
||||
face: str | None,
|
||||
theme_fonts: dict[str, str],
|
||||
*,
|
||||
text: str = "",
|
||||
lang: str | None = None,
|
||||
alt_lang: str | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve DrawingML major/minor Latin, EA, and complex-script tokens."""
|
||||
if not face or not face.startswith("+"):
|
||||
return face
|
||||
code = face[1:]
|
||||
@@ -707,10 +784,31 @@ def _resolve_theme_typeface(face: str | None, theme_fonts: dict[str, str]) -> st
|
||||
return theme_fonts.get("majorLatin") or face
|
||||
if code == "mn-lt":
|
||||
return theme_fonts.get("minorLatin") or face
|
||||
if code == "mj-ea":
|
||||
return theme_fonts.get("majorEastAsia") or theme_fonts.get("majorLatin") or face
|
||||
if code == "mn-ea":
|
||||
return theme_fonts.get("minorEastAsia") or theme_fonts.get("minorLatin") or face
|
||||
if code in {"mj-ea", "mn-ea"}:
|
||||
prefix = "major" if code.startswith("mj") else "minor"
|
||||
script = _run_theme_script(text, lang, alt_lang)
|
||||
script_face = (
|
||||
theme_fonts.get(f"{prefix}Script{script}")
|
||||
if script is not None else None
|
||||
)
|
||||
return (
|
||||
theme_fonts.get(f"{prefix}EastAsia")
|
||||
or script_face
|
||||
or theme_fonts.get(f"{prefix}Latin")
|
||||
or face
|
||||
)
|
||||
if code == "mj-cs":
|
||||
return (
|
||||
theme_fonts.get("majorComplexScript")
|
||||
or theme_fonts.get("majorLatin")
|
||||
or face
|
||||
)
|
||||
if code == "mn-cs":
|
||||
return (
|
||||
theme_fonts.get("minorComplexScript")
|
||||
or theme_fonts.get("minorLatin")
|
||||
or face
|
||||
)
|
||||
return face
|
||||
|
||||
|
||||
@@ -848,11 +946,7 @@ def _collect_text_defs(paragraphs: list[TextParagraph]) -> list[str]:
|
||||
|
||||
def _is_cjk(ch: str) -> bool:
|
||||
"""Check if a character is CJK (Chinese/Japanese/Korean) or full-width."""
|
||||
cp = ord(ch)
|
||||
return (0x4E00 <= cp <= 0x9FFF or 0x3400 <= cp <= 0x4DBF or
|
||||
0x2E80 <= cp <= 0x2EFF or 0x3000 <= cp <= 0x303F or
|
||||
0xFF00 <= cp <= 0xFFEF or 0xF900 <= cp <= 0xFAFF or
|
||||
0x20000 <= cp <= 0x2A6DF)
|
||||
return is_cjk_char(ch)
|
||||
|
||||
|
||||
def _char_width(ch: str, font_size: float, bold: bool) -> float:
|
||||
@@ -1197,11 +1291,12 @@ def _run_tspan_attrs(run: TextRun) -> str:
|
||||
"""Per-run overrides on a <tspan>. Only emit attributes that differ from
|
||||
the run that drove the parent <text> (we keep things simple: emit only
|
||||
overrides that can plausibly change run-to-run, never re-emit common
|
||||
defaults). For v1 we just always emit fill / font-size / weight to be
|
||||
safe — tspan inherits when omitted, so callers can simplify later.
|
||||
defaults). For v1 we always emit fill, font family, and font size so each
|
||||
imported run keeps its resolved typeface even when adjacent runs differ.
|
||||
"""
|
||||
parts = [
|
||||
f'fill="{run.fill}"',
|
||||
f'font-family="{run.font_family}"',
|
||||
f'font-size="{fmt_num(run.font_size_px)}"',
|
||||
]
|
||||
if run.fill_opacity < 1.0:
|
||||
|
||||
@@ -27,7 +27,7 @@ import re
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, MutableMapping
|
||||
from typing import Any, Iterable, Mapping, MutableMapping
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.sax.saxutils import quoteattr
|
||||
|
||||
@@ -926,6 +926,19 @@ class TransitionSummary:
|
||||
effect_options: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MorphPairExpectation:
|
||||
"""One forced-Morph name expected on two adjacent generated slides."""
|
||||
|
||||
source_slide_number: int
|
||||
destination_slide_number: int
|
||||
key: str
|
||||
|
||||
@property
|
||||
def shape_name(self) -> str:
|
||||
return f"!!{self.key}"
|
||||
|
||||
|
||||
def _qn(namespace: str, tag: str) -> str:
|
||||
return f"{{{namespace}}}{tag}"
|
||||
|
||||
@@ -2323,6 +2336,172 @@ def validate_pptx_transition_package(
|
||||
return summaries
|
||||
|
||||
|
||||
def _top_level_shape_types_by_name(
|
||||
slide_xml: bytes,
|
||||
) -> dict[str, list[str]]:
|
||||
"""Return top-level Selection Pane names and their OOXML container types."""
|
||||
root = LET.fromstring(slide_xml) if LET is not None else parse_source_xml(slide_xml)
|
||||
sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
|
||||
if sp_tree is None:
|
||||
raise ValueError("slide has no p:cSld/p:spTree")
|
||||
shapes: dict[str, list[str]] = {}
|
||||
for child in sp_tree:
|
||||
c_nv_pr = next(child.iter(_qn(PML_NS, "cNvPr")), None)
|
||||
name = c_nv_pr.get("name") if c_nv_pr is not None else None
|
||||
if not name:
|
||||
continue
|
||||
shapes.setdefault(name, []).append(_local_name(child.tag))
|
||||
return shapes
|
||||
|
||||
|
||||
def validate_pptx_morph_pairs(
|
||||
pptx_path: Path,
|
||||
expectations: Iterable[MorphPairExpectation],
|
||||
) -> None:
|
||||
"""Prove that every requested forced-Morph pair survives final packaging."""
|
||||
expected_pairs = tuple(expectations)
|
||||
if not expected_pairs:
|
||||
return
|
||||
|
||||
errors: list[str] = []
|
||||
slide_shapes: dict[int, dict[str, list[str]]] = {}
|
||||
slide_transitions: dict[int, TransitionSummary] = {}
|
||||
involved_slides = {
|
||||
slide_number
|
||||
for pair in expected_pairs
|
||||
for slide_number in (
|
||||
pair.source_slide_number,
|
||||
pair.destination_slide_number,
|
||||
)
|
||||
}
|
||||
try:
|
||||
with zipfile.ZipFile(pptx_path, "r") as package:
|
||||
names = set(package.namelist())
|
||||
for slide_number in sorted(involved_slides):
|
||||
part = f"ppt/slides/slide{slide_number}.xml"
|
||||
if part not in names:
|
||||
errors.append(f"{part}: Morph slide part is missing")
|
||||
continue
|
||||
slide_xml = package.read(part)
|
||||
try:
|
||||
slide_shapes[slide_number] = _top_level_shape_types_by_name(
|
||||
slide_xml
|
||||
)
|
||||
slide_transitions[slide_number] = read_slide_transition_xml(
|
||||
slide_xml
|
||||
)
|
||||
except Exception as exc:
|
||||
errors.append(f"{part}: Morph read-back failed: {exc}")
|
||||
except (OSError, zipfile.BadZipFile, KeyError, ET.ParseError) as exc:
|
||||
errors.append(f"unable to read PPTX Morph package: {exc}")
|
||||
|
||||
for slide_number, names_to_types in slide_shapes.items():
|
||||
duplicate_names = sorted(
|
||||
name
|
||||
for name, types in names_to_types.items()
|
||||
if name.startswith("!!") and len(types) != 1
|
||||
)
|
||||
if duplicate_names:
|
||||
errors.append(
|
||||
f"ppt/slides/slide{slide_number}.xml: duplicate forced-Morph "
|
||||
f"name(s): {', '.join(duplicate_names)}"
|
||||
)
|
||||
|
||||
for pair in expected_pairs:
|
||||
if pair.destination_slide_number != pair.source_slide_number + 1:
|
||||
errors.append(
|
||||
f'Morph pair "{pair.key}" must connect adjacent generated slides'
|
||||
)
|
||||
continue
|
||||
source_shapes = slide_shapes.get(pair.source_slide_number)
|
||||
destination_shapes = slide_shapes.get(pair.destination_slide_number)
|
||||
if source_shapes is None or destination_shapes is None:
|
||||
continue
|
||||
|
||||
shape_name = pair.shape_name
|
||||
source_types = source_shapes.get(shape_name, [])
|
||||
destination_types = destination_shapes.get(shape_name, [])
|
||||
if len(source_types) != 1:
|
||||
errors.append(
|
||||
f'Morph pair "{pair.key}" expected exactly one source object '
|
||||
f'named "{shape_name}" on slide {pair.source_slide_number}'
|
||||
)
|
||||
if len(destination_types) != 1:
|
||||
errors.append(
|
||||
f'Morph pair "{pair.key}" expected exactly one destination '
|
||||
f'object named "{shape_name}" on slide '
|
||||
f'{pair.destination_slide_number}'
|
||||
)
|
||||
if (
|
||||
len(source_types) == 1
|
||||
and len(destination_types) == 1
|
||||
and source_types[0] != destination_types[0]
|
||||
):
|
||||
errors.append(
|
||||
f'Morph pair "{pair.key}" changes OOXML object type from '
|
||||
f'{source_types[0]} to {destination_types[0]}'
|
||||
)
|
||||
|
||||
transition = slide_transitions.get(pair.destination_slide_number)
|
||||
if (
|
||||
transition is not None
|
||||
and (
|
||||
transition.canonical_effect != "morph"
|
||||
or transition.effect_options.get("morph_by") != "object"
|
||||
)
|
||||
):
|
||||
errors.append(
|
||||
f'Morph pair "{pair.key}" destination slide '
|
||||
f'{pair.destination_slide_number} does not use Morph by object'
|
||||
)
|
||||
|
||||
declared_names_by_edge: dict[tuple[int, int], set[str]] = {}
|
||||
for pair in expected_pairs:
|
||||
declared_names_by_edge.setdefault(
|
||||
(
|
||||
pair.source_slide_number,
|
||||
pair.destination_slide_number,
|
||||
),
|
||||
set(),
|
||||
).add(pair.shape_name)
|
||||
for source_slide_number in sorted(slide_shapes):
|
||||
destination_slide_number = source_slide_number + 1
|
||||
if destination_slide_number not in slide_shapes:
|
||||
continue
|
||||
transition = slide_transitions.get(destination_slide_number)
|
||||
if (
|
||||
transition is None
|
||||
or transition.canonical_effect != "morph"
|
||||
):
|
||||
continue
|
||||
source_names = {
|
||||
name
|
||||
for name in slide_shapes[source_slide_number]
|
||||
if name.startswith("!!")
|
||||
}
|
||||
destination_names = {
|
||||
name
|
||||
for name in slide_shapes[destination_slide_number]
|
||||
if name.startswith("!!")
|
||||
}
|
||||
declared_names = declared_names_by_edge.get(
|
||||
(source_slide_number, destination_slide_number),
|
||||
set(),
|
||||
)
|
||||
unexpected_names = sorted(
|
||||
(source_names & destination_names) - declared_names
|
||||
)
|
||||
if unexpected_names:
|
||||
errors.append(
|
||||
f"Morph edge {source_slide_number}->{destination_slide_number} "
|
||||
"contains undeclared forced name(s): "
|
||||
+ ", ".join(unexpected_names)
|
||||
)
|
||||
|
||||
if errors:
|
||||
raise ValueError("; ".join(dict.fromkeys(errors)))
|
||||
|
||||
|
||||
def _validate_package_use_timings(
|
||||
package: zipfile.ZipFile,
|
||||
names: list[str],
|
||||
|
||||
@@ -22,6 +22,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
@@ -75,8 +76,10 @@ from _dispatcher import ( # noqa: E402
|
||||
SOURCE_DIRNAME = "sources"
|
||||
TEXT_SOURCE_SUFFIXES = {".md", ".markdown", ".txt"}
|
||||
TABLE_TEXT_SUFFIXES = {".csv", ".tsv"}
|
||||
IMAGE_ASSET_SUFFIXES = {
|
||||
BITMAP_IMAGE_SUFFIXES = {
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif",
|
||||
}
|
||||
IMAGE_ASSET_SUFFIXES = BITMAP_IMAGE_SUFFIXES | {
|
||||
".emf", ".wmf", ".svg",
|
||||
}
|
||||
|
||||
@@ -84,6 +87,82 @@ IMAGE_ASSET_SUFFIXES = {
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
def _validate_image_manifest(
|
||||
payload: object,
|
||||
path: Path,
|
||||
) -> list[dict]:
|
||||
"""Require a safe, case-insensitively unique image manifest payload."""
|
||||
if not isinstance(payload, list):
|
||||
raise RuntimeError(
|
||||
f"Image manifest must be a JSON array: {path}"
|
||||
)
|
||||
|
||||
seen_filenames: dict[str, str] = {}
|
||||
for index, item in enumerate(payload):
|
||||
if not isinstance(item, dict):
|
||||
raise RuntimeError(
|
||||
f"Existing image manifest item {index} must be an object: {path}"
|
||||
)
|
||||
filename = item.get("filename")
|
||||
if (
|
||||
not isinstance(filename, str)
|
||||
or not filename.strip()
|
||||
or filename in {".", ".."}
|
||||
or "/" in filename
|
||||
or "\\" in filename
|
||||
or ":" in filename
|
||||
or Path(filename).is_absolute()
|
||||
or Path(filename).name != filename
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Image manifest item {index} has no safe bare filename: {path}"
|
||||
)
|
||||
normalized_filename = filename.casefold()
|
||||
if normalized_filename in seen_filenames:
|
||||
raise RuntimeError(
|
||||
f"Image manifest filename {filename!r} conflicts with "
|
||||
f"{seen_filenames[normalized_filename]!r} (case-insensitive): {path}"
|
||||
)
|
||||
seen_filenames[normalized_filename] = filename
|
||||
return payload
|
||||
|
||||
|
||||
def _read_existing_image_manifest(path: Path) -> list[dict]:
|
||||
"""Load an existing project image manifest or fail closed on corruption."""
|
||||
if not path.exists():
|
||||
return []
|
||||
if not path.is_file():
|
||||
raise RuntimeError(f"Existing image manifest is not a regular file: {path}")
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError(
|
||||
f"Existing image manifest is unreadable: {path} ({exc}); "
|
||||
"repair or restore it before importing more assets"
|
||||
) from exc
|
||||
return _validate_image_manifest(payload, path)
|
||||
|
||||
|
||||
def _write_json_atomic(path: Path, payload: object) -> None:
|
||||
"""Write JSON through a same-directory temporary file and atomic rename."""
|
||||
fd, temp_name = tempfile.mkstemp(
|
||||
prefix=f"{path.stem}.",
|
||||
suffix=".tmp",
|
||||
dir=str(path.parent),
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
os.replace(temp_name, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(temp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def is_url(value: str) -> bool:
|
||||
"""Return whether a string looks like an HTTP(S) URL."""
|
||||
parsed = urlparse(value)
|
||||
@@ -139,6 +218,17 @@ class ProjectManager:
|
||||
) -> str:
|
||||
base_path = Path(base_dir) if base_dir else self.base_dir
|
||||
|
||||
if (
|
||||
not project_name
|
||||
or project_name in {".", ".."}
|
||||
or Path(project_name).is_absolute()
|
||||
or "/" in project_name
|
||||
or "\\" in project_name
|
||||
):
|
||||
raise ValueError(
|
||||
"Project name must be a single, non-absolute path component"
|
||||
)
|
||||
|
||||
normalized_format = normalize_canvas_format(canvas_format)
|
||||
if normalized_format not in self.CANVAS_FORMATS:
|
||||
available = ", ".join(sorted(self.CANVAS_FORMATS.keys()))
|
||||
@@ -157,6 +247,10 @@ class ProjectManager:
|
||||
project_dir_name = f"{project_name}_{normalized_format}_{date_str}"
|
||||
project_path = base_path / project_dir_name
|
||||
|
||||
if not is_within_path(project_path, base_path):
|
||||
raise ValueError(
|
||||
f"Project directory must stay within the base directory: {base_path}"
|
||||
)
|
||||
if project_path.exists():
|
||||
raise FileExistsError(f"Project directory already exists: {project_path}")
|
||||
|
||||
@@ -405,16 +499,8 @@ class ProjectManager:
|
||||
|
||||
def _merge_image_manifest(self, source_items: list[dict], destination_manifest: Path) -> None:
|
||||
"""Merge per-source manifest items into the project-level manifest, keyed by filename."""
|
||||
existing_data: list[object] = []
|
||||
if destination_manifest.is_file():
|
||||
try:
|
||||
loaded = json.loads(destination_manifest.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, list):
|
||||
existing_data = loaded
|
||||
else:
|
||||
print(f"[WARN] Replacing non-list image manifest: {destination_manifest}")
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"[WARN] Replacing unreadable image manifest {destination_manifest}: {exc}")
|
||||
_validate_image_manifest(source_items, destination_manifest)
|
||||
existing_data = _read_existing_image_manifest(destination_manifest)
|
||||
|
||||
new_by_filename: dict[str, dict] = {}
|
||||
new_order: list[str] = []
|
||||
@@ -422,9 +508,10 @@ class ProjectManager:
|
||||
filename = item.get("filename")
|
||||
if not isinstance(filename, str):
|
||||
continue
|
||||
if filename not in new_by_filename:
|
||||
new_order.append(filename)
|
||||
new_by_filename[filename] = item
|
||||
normalized_filename = filename.casefold()
|
||||
if normalized_filename not in new_by_filename:
|
||||
new_order.append(normalized_filename)
|
||||
new_by_filename[normalized_filename] = item
|
||||
|
||||
merged: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
@@ -434,20 +521,19 @@ class ProjectManager:
|
||||
filename = item.get("filename")
|
||||
if not isinstance(filename, str):
|
||||
continue
|
||||
if filename in new_by_filename:
|
||||
merged.append(new_by_filename[filename])
|
||||
normalized_filename = filename.casefold()
|
||||
if normalized_filename in new_by_filename:
|
||||
merged.append(new_by_filename[normalized_filename])
|
||||
else:
|
||||
merged.append(item)
|
||||
seen.add(filename)
|
||||
seen.add(normalized_filename)
|
||||
|
||||
for filename in new_order:
|
||||
if filename not in seen:
|
||||
merged.append(new_by_filename[filename])
|
||||
for normalized_filename in new_order:
|
||||
if normalized_filename not in seen:
|
||||
merged.append(new_by_filename[normalized_filename])
|
||||
|
||||
destination_manifest.write_text(
|
||||
json.dumps(merged, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_validate_image_manifest(merged, destination_manifest)
|
||||
_write_json_atomic(destination_manifest, merged)
|
||||
|
||||
@staticmethod
|
||||
def _namespace_from_asset_dir(asset_dir: Path) -> str:
|
||||
@@ -462,10 +548,11 @@ class ProjectManager:
|
||||
source_file: Path,
|
||||
namespace: str,
|
||||
existing_manifest: dict[str, dict],
|
||||
occupied_names: set[str],
|
||||
) -> str:
|
||||
"""Return a short unique image filename for the runtime image pool."""
|
||||
candidate = images_dir / source_file.name
|
||||
if not candidate.exists():
|
||||
if candidate.name.casefold() not in occupied_names:
|
||||
return source_file.name
|
||||
try:
|
||||
meta = existing_manifest.get(candidate.name, {})
|
||||
@@ -483,7 +570,7 @@ class ProjectManager:
|
||||
counter = 2
|
||||
while True:
|
||||
candidate = images_dir / f"{stem}_{counter}{suffix}"
|
||||
if not candidate.exists():
|
||||
if candidate.name.casefold() not in occupied_names:
|
||||
return candidate.name
|
||||
try:
|
||||
meta = existing_manifest.get(candidate.name, {})
|
||||
@@ -509,31 +596,31 @@ class ProjectManager:
|
||||
return
|
||||
|
||||
try:
|
||||
source_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
source_payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"[WARN] Cannot read image manifest {manifest_path}: {exc}")
|
||||
return
|
||||
if not isinstance(source_data, list):
|
||||
print(f"[WARN] Ignoring non-list image manifest: {manifest_path}")
|
||||
try:
|
||||
source_data = _validate_image_manifest(source_payload, manifest_path)
|
||||
except RuntimeError as exc:
|
||||
print(f"[WARN] {exc}")
|
||||
return
|
||||
|
||||
images_dir = project_dir / "images"
|
||||
namespace = self._namespace_from_asset_dir(asset_dir)
|
||||
destination_manifest = images_dir / "image_manifest.json"
|
||||
existing_data = _read_existing_image_manifest(destination_manifest)
|
||||
images_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
namespace = self._namespace_from_asset_dir(asset_dir)
|
||||
existing_manifest: dict[str, dict] = {}
|
||||
destination_manifest = images_dir / "image_manifest.json"
|
||||
if destination_manifest.is_file():
|
||||
try:
|
||||
data = json.loads(destination_manifest.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
existing_manifest = {
|
||||
item["filename"]: item
|
||||
for item in data
|
||||
if isinstance(item, dict) and isinstance(item.get("filename"), str)
|
||||
}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
existing_manifest = {}
|
||||
existing_manifest = {
|
||||
item["filename"]: item
|
||||
for item in existing_data
|
||||
}
|
||||
occupied_names = {
|
||||
path.name.casefold()
|
||||
for path in images_dir.iterdir()
|
||||
if path.is_file()
|
||||
}
|
||||
rename_map: dict[str, str] = {}
|
||||
|
||||
copied_count = 0
|
||||
@@ -547,10 +634,12 @@ class ProjectManager:
|
||||
source_file,
|
||||
namespace,
|
||||
existing_manifest,
|
||||
occupied_names,
|
||||
)
|
||||
destination = images_dir / new_name
|
||||
if source_file.resolve() != destination.resolve():
|
||||
shutil.copy2(source_file, destination)
|
||||
occupied_names.add(new_name.casefold())
|
||||
rename_map[source_file.name] = new_name
|
||||
copied_count += 1
|
||||
|
||||
@@ -640,6 +729,7 @@ class ProjectManager:
|
||||
"archived": [],
|
||||
"markdown": [],
|
||||
"assets": [],
|
||||
"images": [],
|
||||
"analysis": [],
|
||||
"notes": [],
|
||||
"skipped": [],
|
||||
@@ -760,7 +850,18 @@ class ProjectManager:
|
||||
)
|
||||
summary["archived"].append(str(archived_path))
|
||||
|
||||
if suffix in PDF_SUFFIXES:
|
||||
if suffix in BITMAP_IMAGE_SUFFIXES:
|
||||
images_dir = project_dir / "images"
|
||||
images_dir.mkdir(parents=True, exist_ok=True)
|
||||
image_path = self._ensure_unique_path(images_dir / archived_path.name)
|
||||
shutil.copy2(archived_path, image_path)
|
||||
summary["images"].append(str(image_path))
|
||||
if image_path.name != archived_path.name:
|
||||
summary["notes"].append(
|
||||
f"{item}: copied runtime image as {image_path.name} "
|
||||
"to avoid a filename collision"
|
||||
)
|
||||
elif suffix in PDF_SUFFIXES:
|
||||
canonical_markdown_path = sources_dir / f"{archived_path.stem}.md"
|
||||
if archived_path.stem in explicit_markdown_stems:
|
||||
summary["notes"].append(
|
||||
@@ -1050,6 +1151,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print("\nImported asset directories:")
|
||||
for item in summary["assets"]:
|
||||
print(f" - {item}")
|
||||
if summary["images"]:
|
||||
print("\nRuntime image copies:")
|
||||
for item in summary["images"]:
|
||||
print(f" - {item}")
|
||||
if summary["analysis"]:
|
||||
print("\nAnalysis artifacts:")
|
||||
for item in summary["analysis"]:
|
||||
|
||||
@@ -71,7 +71,30 @@ _MARKDOWN_DATA_LINE_RE = re.compile(
|
||||
re.MULTILINE,
|
||||
)
|
||||
_IMAGE_PATH_SUFFIXES = frozenset(
|
||||
{".bmp", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff", ".webp"}
|
||||
{
|
||||
".bmp",
|
||||
".emf",
|
||||
".gif",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".png",
|
||||
".svg",
|
||||
".tif",
|
||||
".tiff",
|
||||
".webp",
|
||||
".wmf",
|
||||
}
|
||||
)
|
||||
_IMAGE_ACQUISITION_SOURCES = frozenset(
|
||||
{"ai", "web", "user", "formula", "placeholder", "slice"}
|
||||
)
|
||||
_IMAGE_CROP_POLICIES = frozenset({"adaptive", "no-crop"})
|
||||
_LEGACY_IMAGE_METADATA_KEYS = frozenset(
|
||||
{
|
||||
"image_rendering",
|
||||
"image_rendering_behavior",
|
||||
"image_rendering_references",
|
||||
}
|
||||
)
|
||||
_LEGACY_SPEC_LOCK_FORBIDDEN = frozenset({"Mixing icon libraries"})
|
||||
_SCAFFOLD_TOKEN_RE = re.compile(r"\{\{[A-Z_]+\}\}")
|
||||
@@ -176,6 +199,109 @@ def _looks_like_image_path(raw: str) -> bool:
|
||||
return bool(token) and Path(token).suffix.casefold() in _IMAGE_PATH_SUFFIXES
|
||||
|
||||
|
||||
def parse_spec_lock_image_value(key: str, value: str) -> dict[str, str]:
|
||||
"""Parse one image-lock row while preserving supported legacy rows.
|
||||
|
||||
Current rows use ``<path> | source=... | pattern=... | crop=...``. Legacy
|
||||
rows remain readable, but any row that starts using named metadata must
|
||||
provide the complete current contract.
|
||||
"""
|
||||
normalized_key = str(key).strip()
|
||||
normalized_value = str(value).strip()
|
||||
parts = [part.strip() for part in normalized_value.split("|")]
|
||||
path_part = parts[0] if parts else ""
|
||||
|
||||
if _looks_like_image_path(normalized_key) and not _looks_like_image_path(path_part):
|
||||
parts.insert(0, normalized_key)
|
||||
path_part = normalized_key
|
||||
elif (
|
||||
len(parts) >= 2
|
||||
and parts[0].casefold() in _IMAGE_ACQUISITION_SOURCES
|
||||
and _looks_like_image_path(parts[1])
|
||||
):
|
||||
path_part = parts[1]
|
||||
|
||||
metadata_parts = [part for part in parts[1:] if "=" in part]
|
||||
if not metadata_parts:
|
||||
legacy_crop = (
|
||||
"no-crop"
|
||||
if any(
|
||||
re.search(r"(?<![a-z])no-crop(?![a-z])", part, re.IGNORECASE)
|
||||
for part in parts[1:]
|
||||
)
|
||||
else ""
|
||||
)
|
||||
return {
|
||||
"path": path_part,
|
||||
"source": "",
|
||||
"pattern": "",
|
||||
"crop": legacy_crop,
|
||||
"legacy": "true",
|
||||
}
|
||||
|
||||
metadata: dict[str, str] = {}
|
||||
unsupported_parts: list[str] = []
|
||||
for part in parts[1:]:
|
||||
field, separator, raw = part.partition("=")
|
||||
if not separator:
|
||||
unsupported_parts.append(part)
|
||||
continue
|
||||
field = field.strip().casefold()
|
||||
if field in metadata:
|
||||
raise ValueError(f"repeats metadata field {field!r}")
|
||||
metadata[field] = raw.strip()
|
||||
|
||||
expected_fields = {"source", "pattern", "crop"}
|
||||
unknown_fields = sorted(set(metadata) - expected_fields)
|
||||
missing_fields = sorted(expected_fields - set(metadata))
|
||||
if unsupported_parts:
|
||||
shown = ", ".join(repr(part) for part in unsupported_parts)
|
||||
raise ValueError(f"has unsupported metadata token(s) {shown}")
|
||||
if unknown_fields:
|
||||
raise ValueError(
|
||||
f"has unknown metadata field(s) {', '.join(unknown_fields)}"
|
||||
)
|
||||
if missing_fields:
|
||||
raise ValueError(
|
||||
f"misses metadata field(s) {', '.join(missing_fields)}"
|
||||
)
|
||||
if not _looks_like_image_path(path_part):
|
||||
raise ValueError(f"has invalid image path {path_part!r}")
|
||||
|
||||
normalized_path = path_part.replace("\\", "/")
|
||||
path = Path(normalized_path)
|
||||
if (
|
||||
path.is_absolute()
|
||||
or len(path.parts) != 2
|
||||
or path.parts[0] != "images"
|
||||
or path.name in {"", ".", ".."}
|
||||
or ":" in path.name
|
||||
or normalized_path != f"images/{path.name}"
|
||||
):
|
||||
raise ValueError(
|
||||
f"must use canonical project path images/<filename>, got {path_part!r}"
|
||||
)
|
||||
|
||||
source = metadata["source"].casefold()
|
||||
if source not in _IMAGE_ACQUISITION_SOURCES:
|
||||
allowed = ", ".join(sorted(_IMAGE_ACQUISITION_SOURCES))
|
||||
raise ValueError(f"source must be one of {allowed}, got {metadata['source']!r}")
|
||||
if not metadata["pattern"]:
|
||||
raise ValueError("pattern must be non-empty")
|
||||
crop = metadata["crop"].casefold()
|
||||
if crop not in _IMAGE_CROP_POLICIES:
|
||||
allowed = ", ".join(sorted(_IMAGE_CROP_POLICIES))
|
||||
raise ValueError(f"crop must be one of {allowed}, got {metadata['crop']!r}")
|
||||
|
||||
return {
|
||||
"path": normalized_path,
|
||||
"source": source,
|
||||
"pattern": metadata["pattern"],
|
||||
"crop": crop,
|
||||
"legacy": "false",
|
||||
}
|
||||
|
||||
|
||||
def parse_spec_lock_artifact(
|
||||
lock_path: Path,
|
||||
*,
|
||||
@@ -873,6 +999,16 @@ def _validate_spec_lock_relations(
|
||||
f"unknown catalog id '{reference}'"
|
||||
)
|
||||
|
||||
for key, value in fields("images").items():
|
||||
if key.strip().casefold() in _LEGACY_IMAGE_METADATA_KEYS:
|
||||
continue
|
||||
try:
|
||||
parse_spec_lock_image_value(key, value)
|
||||
except ValueError as exc:
|
||||
errors.append(
|
||||
f"{markdown_name} schema: images row {key!r} {exc}"
|
||||
)
|
||||
|
||||
rhythm = fields("page_rhythm")
|
||||
layouts = fields("pptx_layouts")
|
||||
page_pptx_layouts = fields("page_pptx_layouts")
|
||||
|
||||
@@ -991,6 +991,21 @@ def audit_authority_graph(
|
||||
return normalized, cycles, findings
|
||||
|
||||
|
||||
def _parse_numbered_markdown_ids(raw_ids: str) -> list[int]:
|
||||
"""Expand one numbered-Markdown heading expression into its stable ids."""
|
||||
ids: list[int] = []
|
||||
for part in re.split(r"\s*·\s*", raw_ids):
|
||||
match = re.fullmatch(r"(\d+)(?:\s*[-–]\s*(\d+))?", part)
|
||||
if match is None:
|
||||
raise AuditError(f"Invalid numbered Markdown id expression: {raw_ids!r}")
|
||||
start = int(match.group(1))
|
||||
end = int(match.group(2) or start)
|
||||
if end < start:
|
||||
raise AuditError(f"Descending numbered Markdown id range: {raw_ids!r}")
|
||||
ids.extend(range(start, end + 1))
|
||||
return ids
|
||||
|
||||
|
||||
def _registry_ids(root: Path, config: dict[str, Any]) -> tuple[set[Any], str, list[Any]]:
|
||||
kind = config.get("kind")
|
||||
source = config.get("source")
|
||||
@@ -1005,9 +1020,15 @@ def _registry_ids(root: Path, config: dict[str, Any]) -> tuple[set[Any], str, li
|
||||
regex = re.compile(pattern, re.MULTILINE)
|
||||
except re.error as exc:
|
||||
raise AuditError(f"Invalid registry entry_pattern: {exc}") from exc
|
||||
id_group = "ids" if "ids" in regex.groupindex else "id"
|
||||
if id_group not in regex.groupindex:
|
||||
raise AuditError(
|
||||
f"Registry {config.get('name')} entry_pattern needs an id or ids group"
|
||||
)
|
||||
matched_ids = [
|
||||
int(match.group("id"))
|
||||
item
|
||||
for match in regex.finditer(_read_utf8(root / source))
|
||||
for item in _parse_numbered_markdown_ids(match.group(id_group))
|
||||
]
|
||||
duplicates = sorted(
|
||||
item for item, count in Counter(matched_ids).items() if count > 1
|
||||
@@ -1064,6 +1085,20 @@ def _registry_count_claims(paragraph: Paragraph, nouns: list[str]) -> list[int]:
|
||||
return claims
|
||||
|
||||
|
||||
def _is_comprehensive_registry_reference(paragraph: Paragraph, nouns: list[str]) -> bool:
|
||||
"""Return whether a block claims complete registry coverage."""
|
||||
scope_nouns = sorted(set(nouns + ["catalog", "registry", "file"]))
|
||||
scope_pattern = "|".join(re.escape(noun) for noun in scope_nouns)
|
||||
return re.search(
|
||||
rf"\b(?:all|every|entire|full)\b(?:\W+\w+){{0,4}}\W+"
|
||||
rf"(?:{scope_pattern})\b|"
|
||||
rf"\b(?:{scope_pattern})\b(?:\W+\w+){{0,4}}\W+"
|
||||
rf"\b(?:all|every|entire|full)\b|"
|
||||
r"\bfile is split\b",
|
||||
paragraph.normalized,
|
||||
) is not None
|
||||
|
||||
|
||||
def audit_registries(
|
||||
root: Path,
|
||||
configs: list[dict[str, Any]],
|
||||
@@ -1216,33 +1251,25 @@ def audit_registries(
|
||||
)
|
||||
)
|
||||
|
||||
ranges = sorted(
|
||||
ranges = [
|
||||
tuple(sorted((int(match.group(1)), int(match.group(2)))))
|
||||
for match in range_pattern.finditer(paragraph.text)
|
||||
)
|
||||
comprehensive = re.search(
|
||||
r"\b(all|every|entire|full|file is split)\b",
|
||||
paragraph.normalized,
|
||||
)
|
||||
]
|
||||
comprehensive = _is_comprehensive_registry_reference(paragraph, nouns)
|
||||
if ranges and comprehensive:
|
||||
merged: list[list[int]] = []
|
||||
declared_ids = {
|
||||
int(match.group(1))
|
||||
for match in id_pattern.finditer(paragraph.text)
|
||||
}
|
||||
for start, end in ranges:
|
||||
if not merged or start > merged[-1][1] + 1:
|
||||
merged.append([start, end])
|
||||
else:
|
||||
merged[-1][1] = max(merged[-1][1], end)
|
||||
declared_count = sum(end - start + 1 for start, end in merged)
|
||||
covers_ids = all(
|
||||
any(start <= item <= end for start, end in merged)
|
||||
for item in numeric_ids
|
||||
)
|
||||
if declared_count != len(numeric_ids) or not covers_ids:
|
||||
declared_ids.update(range(start, end + 1))
|
||||
if declared_ids != numeric_ids:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity="error",
|
||||
code="REGISTRY_RANGE_MISMATCH",
|
||||
message=(
|
||||
f"{name} comprehensive ranges cover {declared_count} ids; "
|
||||
f"{name} comprehensive ranges cover {len(declared_ids)} ids; "
|
||||
f"registry contains {len(numeric_ids)}"
|
||||
),
|
||||
path=paragraph.path,
|
||||
|
||||
+61
-44
@@ -12,28 +12,28 @@
|
||||
"skills/ppt-master/templates/schemas/*.json"
|
||||
],
|
||||
"exclude": [],
|
||||
"max_tokens": 365400
|
||||
"max_tokens": 388200
|
||||
},
|
||||
"file_budgets": {
|
||||
"AGENTS.md": 2350,
|
||||
"AGENTS.md": 2400,
|
||||
"skills/ppt-master/SKILL.md": 850,
|
||||
"skills/ppt-master/references/executor-base.md": 7250,
|
||||
"skills/ppt-master/references/executor-base.md": 8125,
|
||||
"skills/ppt-master/references/executor-structured.md": 5300,
|
||||
"skills/ppt-master/references/executor-chart.md": 3100,
|
||||
"skills/ppt-master/references/executor-image.md": 900,
|
||||
"skills/ppt-master/references/executor-image.md": 1275,
|
||||
"skills/ppt-master/references/executor-web-image.md": 500,
|
||||
"skills/ppt-master/references/executor-notes.md": 900,
|
||||
"skills/ppt-master/references/shared-standards.md": 250,
|
||||
"skills/ppt-master/references/shared-standards-core.md": 10800,
|
||||
"skills/ppt-master/references/svg-effects.md": 11600,
|
||||
"skills/ppt-master/references/shared-standards-core.md": 11100,
|
||||
"skills/ppt-master/references/svg-effects.md": 11725,
|
||||
"skills/ppt-master/references/native-data-interface.md": 6200,
|
||||
"skills/ppt-master/references/pptx-structure-interface.md": 4300,
|
||||
"skills/ppt-master/references/strategist.md": 13450,
|
||||
"skills/ppt-master/references/strategist-image.md": 2250,
|
||||
"skills/ppt-master/references/strategist.md": 14025,
|
||||
"skills/ppt-master/references/strategist-image.md": 2500,
|
||||
"skills/ppt-master/references/strategist-template.md": 2100,
|
||||
"skills/ppt-master/templates/design_spec_reference.md": 2850,
|
||||
"skills/ppt-master/templates/spec_lock_reference.md": 2350,
|
||||
"skills/ppt-master/workflows/generate-pptx.md": 12700,
|
||||
"skills/ppt-master/templates/design_spec_reference.md": 3125,
|
||||
"skills/ppt-master/templates/spec_lock_reference.md": 2375,
|
||||
"skills/ppt-master/workflows/generate-pptx.md": 13100,
|
||||
"skills/ppt-master/workflows/stages/apply-template-workspace.md": 1800
|
||||
},
|
||||
"load_sets": {
|
||||
@@ -45,7 +45,7 @@
|
||||
"skills/ppt-master/SKILL.md",
|
||||
"skills/ppt-master/workflows/routing.md"
|
||||
],
|
||||
"max_tokens": 5425
|
||||
"max_tokens": 5700
|
||||
},
|
||||
"support.sponsor-recommendation.en": {
|
||||
"description": "English sponsor context for explicit user requests for model, AI image model, API/provider, or hosted-service recommendations.",
|
||||
@@ -93,7 +93,7 @@
|
||||
"skills/ppt-master/templates/README.md",
|
||||
"skills/ppt-master/templates/decks/README.md"
|
||||
],
|
||||
"max_tokens": 57475
|
||||
"max_tokens": 58225
|
||||
},
|
||||
"route.create-template.layout": {
|
||||
"description": "Create Layout path through Template_Designer, SVG core, and the structured PPTX interface.",
|
||||
@@ -111,7 +111,7 @@
|
||||
"skills/ppt-master/templates/README.md",
|
||||
"skills/ppt-master/templates/layouts/README.md"
|
||||
],
|
||||
"max_tokens": 57675
|
||||
"max_tokens": 58400
|
||||
},
|
||||
"route.enhance-native-pptx": {
|
||||
"description": "Finished-PPTX native enhancement route.",
|
||||
@@ -122,7 +122,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/workflows/native-enhance-pptx.md"
|
||||
],
|
||||
"max_tokens": 8375
|
||||
"max_tokens": 9600
|
||||
},
|
||||
"route.fill-native-pptx": {
|
||||
"description": "Raw-PPTX native fill route.",
|
||||
@@ -133,7 +133,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/workflows/template-fill-pptx.md"
|
||||
],
|
||||
"max_tokens": 11075
|
||||
"max_tokens": 11375
|
||||
},
|
||||
"route.generate.planning": {
|
||||
"description": "Generate-PPTX planning through Strategist, including reference-first whole-document authoring and representative multi-source custom mode/style synthesis; schemas and optional scaffolds are tool-consumed.",
|
||||
@@ -172,6 +172,19 @@
|
||||
],
|
||||
"max_tokens": 62000
|
||||
},
|
||||
"route.generate.quick-test": {
|
||||
"description": "Explicit disposable Generate quick-test short circuit with the shared SVG authoring core.",
|
||||
"scope": "cumulative",
|
||||
"include": [
|
||||
"bootstrap.routing"
|
||||
],
|
||||
"files": [
|
||||
"skills/ppt-master/workflows/generate-pptx.md",
|
||||
"skills/ppt-master/workflows/profiles/quick-test.md",
|
||||
"skills/ppt-master/references/shared-standards-core.md"
|
||||
],
|
||||
"max_tokens": 30825
|
||||
},
|
||||
"route.generate.planning-image": {
|
||||
"description": "Generate-PPTX planning context after a non-none image source is proposed or confirmed.",
|
||||
"scope": "cumulative",
|
||||
@@ -180,7 +193,7 @@
|
||||
"stage.generate.strategist.image-layout"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 70000
|
||||
"max_tokens": 74850
|
||||
},
|
||||
"route.generate.planning-formula": {
|
||||
"description": "Formula-only planning context with no non-formula image resource or layout catalog.",
|
||||
@@ -210,7 +223,7 @@
|
||||
"registry": "image-renderings"
|
||||
}
|
||||
],
|
||||
"max_tokens": 76000
|
||||
"max_tokens": 79100
|
||||
},
|
||||
"stage.generate.apply-template-workspace": {
|
||||
"description": "Conditional Step 3 workspace validation, installation, and fusion framework.",
|
||||
@@ -322,7 +335,7 @@
|
||||
"stage.generate.executor.notes"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 82475
|
||||
"max_tokens": 85625
|
||||
},
|
||||
"route.generate.flat-ai-two-types": {
|
||||
"description": "Generate-PPTX context with AI images, representative multi-source custom rendering, and two local types.",
|
||||
@@ -335,7 +348,7 @@
|
||||
"stage.generate.image.ai-two-types"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 123000
|
||||
"max_tokens": 133200
|
||||
},
|
||||
"route.generate.flat-in-hand-image": {
|
||||
"description": "Generate-PPTX context with provided, placeholder, or formula images and no acquisition role.",
|
||||
@@ -347,7 +360,7 @@
|
||||
"stage.generate.executor.notes"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 96300
|
||||
"max_tokens": 106525
|
||||
},
|
||||
"route.generate.flat-web-image": {
|
||||
"description": "Generate-PPTX context with web image acquisition.",
|
||||
@@ -360,7 +373,7 @@
|
||||
"stage.generate.image.web"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 103575
|
||||
"max_tokens": 113800
|
||||
},
|
||||
"route.enhance-native-pptx.audio": {
|
||||
"description": "Enhance Native PPTX with the shared narration-audio stage.",
|
||||
@@ -370,7 +383,7 @@
|
||||
"stage.shared.generate-audio"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 11125
|
||||
"max_tokens": 14500
|
||||
},
|
||||
"route.generate.beautify-flat-no-image": {
|
||||
"description": "Generate-PPTX 1:1 beautify profile on the flat no-image path.",
|
||||
@@ -380,7 +393,7 @@
|
||||
"profile.generate.beautify-pptx"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 89325
|
||||
"max_tokens": 92525
|
||||
},
|
||||
"route.generate.flat-no-image-chart": {
|
||||
"description": "Default flat Generate-PPTX path with chart authoring, native-data replacement, and verification.",
|
||||
@@ -393,7 +406,7 @@
|
||||
"stage.generate.verify-charts"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 106550
|
||||
"max_tokens": 109975
|
||||
},
|
||||
"route.generate.brand-flat-no-image": {
|
||||
"description": "Brand-preset flat Generate-PPTX path without image acquisition.",
|
||||
@@ -405,7 +418,7 @@
|
||||
"stage.generate.template.brand"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 89500
|
||||
"max_tokens": 92650
|
||||
},
|
||||
"route.generate.deck-structured-no-image": {
|
||||
"description": "Deck-preset structured Generate-PPTX path without image acquisition.",
|
||||
@@ -417,7 +430,7 @@
|
||||
"stage.generate.template.deck"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 99425
|
||||
"max_tokens": 102600
|
||||
},
|
||||
"route.generate.layout-structured-no-image": {
|
||||
"description": "Layout-preset structured Generate-PPTX path without image acquisition.",
|
||||
@@ -429,7 +442,7 @@
|
||||
"stage.generate.template.layout"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 99875
|
||||
"max_tokens": 103050
|
||||
},
|
||||
"route.generate.topic-only-flat-no-image": {
|
||||
"description": "Topic research followed by the default flat no-image Generate-PPTX path.",
|
||||
@@ -439,7 +452,7 @@
|
||||
"route.generate.flat-no-image"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 83875
|
||||
"max_tokens": 87025
|
||||
},
|
||||
"stage.generate.executor.flat": {
|
||||
"description": "Incremental flat Executor core with representative multi-source custom mode/style execution.",
|
||||
@@ -549,7 +562,7 @@
|
||||
"stage.generate.executor.image"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 59100
|
||||
"max_tokens": 67800
|
||||
},
|
||||
"stage.generate.topic-research": {
|
||||
"description": "Gap-targeted factual intake stage.",
|
||||
@@ -565,7 +578,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/workflows/profiles/beautify-pptx.md"
|
||||
],
|
||||
"max_tokens": 6900
|
||||
"max_tokens": 6950
|
||||
},
|
||||
"stage.generate.refine-spec": {
|
||||
"description": "Explicit post-confirmation spec refinement runbook.",
|
||||
@@ -619,7 +632,7 @@
|
||||
"skills/ppt-master/scripts/docs/pptx-transitions.md",
|
||||
"skills/ppt-master/scripts/docs/svg-pipeline.md"
|
||||
],
|
||||
"max_tokens": 21300
|
||||
"max_tokens": 28750
|
||||
},
|
||||
"stage.generate.video-motion-plan": {
|
||||
"description": "Conditional resolved animation-to-video handoff contract.",
|
||||
@@ -635,7 +648,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/references/animations.md"
|
||||
],
|
||||
"max_tokens": 4500
|
||||
"max_tokens": 6950
|
||||
},
|
||||
"stage.shared.generate-audio": {
|
||||
"description": "Shared narration-audio stage.",
|
||||
@@ -643,7 +656,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/workflows/stages/generate-audio.md"
|
||||
],
|
||||
"max_tokens": 2800
|
||||
"max_tokens": 4925
|
||||
},
|
||||
"governance.failure-recovery": {
|
||||
"description": "Global stop, retry, and resume policy.",
|
||||
@@ -675,7 +688,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/references/native-shape-authoring.md"
|
||||
],
|
||||
"max_tokens": 3100
|
||||
"max_tokens": 4500
|
||||
},
|
||||
"route.generate.planning-template": {
|
||||
"description": "Generate-PPTX planning context after an explicit template workspace is installed.",
|
||||
@@ -685,7 +698,7 @@
|
||||
"stage.generate.strategist.template"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 60250
|
||||
"max_tokens": 62275
|
||||
},
|
||||
"route.generate.planning-template-ai": {
|
||||
"description": "Explicit template workspace plus confirmed AI-image planning.",
|
||||
@@ -695,7 +708,7 @@
|
||||
"route.generate.planning-ai"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 72700
|
||||
"max_tokens": 81175
|
||||
},
|
||||
"stage.generate.executor.chart": {
|
||||
"description": "Conditional chart/table page execution rules.",
|
||||
@@ -711,7 +724,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/references/strategist-image.md"
|
||||
],
|
||||
"max_tokens": 2250
|
||||
"max_tokens": 2500
|
||||
},
|
||||
"stage.generate.strategist.image-layout": {
|
||||
"description": "Non-formula image planning plus the image-layout pattern catalog required for Section VIII rows.",
|
||||
@@ -722,7 +735,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/references/image-layout-patterns.md"
|
||||
],
|
||||
"max_tokens": 8250
|
||||
"max_tokens": 14675
|
||||
},
|
||||
"stage.generate.strategist.template": {
|
||||
"description": "Conditional Strategist module for an explicitly installed template workspace.",
|
||||
@@ -749,7 +762,7 @@
|
||||
"skills/ppt-master/references/image-layout-spec.md",
|
||||
"skills/ppt-master/references/svg-image-embedding.md"
|
||||
],
|
||||
"max_tokens": 11625
|
||||
"max_tokens": 18425
|
||||
},
|
||||
"stage.generate.executor.web-image": {
|
||||
"description": "Conditional sourced-image attribution layered on image execution.",
|
||||
@@ -760,7 +773,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/references/executor-web-image.md"
|
||||
],
|
||||
"max_tokens": 12050
|
||||
"max_tokens": 18900
|
||||
},
|
||||
"stage.generate.executor.notes": {
|
||||
"description": "Post-SVG speaker-notes generation rules.",
|
||||
@@ -776,7 +789,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/references/native-shape-authoring.md"
|
||||
],
|
||||
"max_tokens": 3100
|
||||
"max_tokens": 4500
|
||||
},
|
||||
"stage.shared.svg-effects": {
|
||||
"description": "Conditional advanced SVG paint, effects, transforms, and geometry for Generate Executor or Create Template.",
|
||||
@@ -784,7 +797,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/references/svg-effects.md"
|
||||
],
|
||||
"max_tokens": 11600
|
||||
"max_tokens": 11725
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
@@ -934,7 +947,7 @@
|
||||
"name": "image-layout-patterns",
|
||||
"kind": "numbered_markdown",
|
||||
"source": "skills/ppt-master/references/image-layout-patterns.md",
|
||||
"entry_pattern": "^(?P<id>\\d+)\\.\\s+\\*\\*",
|
||||
"entry_pattern": "^(?P<ids>\\d+(?:\\s*(?:[-–]|·)\\s*\\d+)*)\\.\\s+\\*\\*",
|
||||
"reference_terms": [
|
||||
"image-layout-patterns",
|
||||
"image-text layout patterns"
|
||||
@@ -1120,6 +1133,10 @@
|
||||
"glob": "skills/ppt-master/scripts/docs/prompt_audit.md",
|
||||
"reason": "Maintainer-only documentation for this audit tool."
|
||||
},
|
||||
{
|
||||
"glob": "skills/ppt-master/scripts/docs/mask-gradient-smoke.md",
|
||||
"reason": "Maintainer-only executable smoke; never loaded by generation roles."
|
||||
},
|
||||
{
|
||||
"glob": "skills/ppt-master/scripts/docs/update_spec.md",
|
||||
"reason": "Maintainer command reference for an optional helper script."
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Shared SVG Resource Path Helpers
|
||||
PPT Master - Shared SVG Resource Helpers
|
||||
|
||||
Centralizes project-relative SVG resource lookup used by the checker,
|
||||
finalizer, and SVG-to-PPTX exporter.
|
||||
Centralizes project-relative resource lookup and nested-SVG closure validation
|
||||
used by the checker, finalizer, and SVG-to-PPTX exporter.
|
||||
|
||||
Usage:
|
||||
Imported by scripts; not intended as a standalone CLI.
|
||||
|
||||
Examples:
|
||||
from resource_paths import resolve_external_image_reference
|
||||
from resource_paths import svg_image_payload_error
|
||||
|
||||
Dependencies:
|
||||
None
|
||||
@@ -17,13 +18,45 @@ Dependencies:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
from urllib.parse import unquote, unquote_to_bytes, urlsplit
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
SVG_WORK_DIR_NAMES = frozenset({'svg_output', 'svg_final', 'svg-flat', 'svg_flat'})
|
||||
TEMPLATE_SOURCE_DIR_NAME = 'templates'
|
||||
TEMPLATE_SPEC_FILENAME = 'design_spec.md'
|
||||
_SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
|
||||
_SVG_URL_REFERENCE_RE = re.compile(r'url\(\s*([^)]+?)\s*\)', re.IGNORECASE)
|
||||
_SVG_CSS_URL_ATTRIBUTES = frozenset({
|
||||
'background',
|
||||
'background-image',
|
||||
'clip-path',
|
||||
'color-profile',
|
||||
'cursor',
|
||||
'fill',
|
||||
'filter',
|
||||
'marker',
|
||||
'marker-end',
|
||||
'marker-mid',
|
||||
'marker-start',
|
||||
'mask',
|
||||
'stroke',
|
||||
'style',
|
||||
})
|
||||
_SVG_DIRECT_RESOURCE_ATTRIBUTES = frozenset({'poster', 'src'})
|
||||
_SVG_DATA_URI_DEPTH_LIMIT = 8
|
||||
_SVG_EXTERNAL_DOCTYPE_RE = re.compile(
|
||||
br'<!DOCTYPE\b[^>]*\b(?:PUBLIC|SYSTEM)\b',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_XML_STYLESHEET_HREF_RE = re.compile(
|
||||
r'\bhref\s*=\s*([\'"])(.*?)\1',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def project_root_for_svg_path(svg_path: Path) -> Path:
|
||||
@@ -59,6 +92,146 @@ def icon_search_dirs_for_svg(svg_path: Path) -> tuple[Path, Path | None]:
|
||||
return icon_search_dirs_for_project(project_root_for_svg_path(svg_path))
|
||||
|
||||
|
||||
def _decode_svg_data_uri(raw: str) -> tuple[bytes | None, str | None]:
|
||||
"""Decode an SVG data URI; return ``(None, None)`` for other media."""
|
||||
value = raw.strip().strip('\'"')
|
||||
if not value.lower().startswith('data:'):
|
||||
return None, None
|
||||
header, separator, payload = value.partition(',')
|
||||
media_type = header[5:].split(';', 1)[0].strip().lower()
|
||||
if media_type != 'image/svg+xml':
|
||||
return None, None
|
||||
if not separator:
|
||||
return None, 'invalid embedded SVG data URI'
|
||||
is_base64 = any(
|
||||
token.strip().lower() == 'base64'
|
||||
for token in header.split(';')[1:]
|
||||
)
|
||||
try:
|
||||
decoded = (
|
||||
base64.b64decode(payload, validate=True)
|
||||
if is_base64
|
||||
else unquote_to_bytes(payload)
|
||||
)
|
||||
except (ValueError, binascii.Error):
|
||||
return None, 'invalid embedded SVG data URI'
|
||||
return decoded, None
|
||||
|
||||
|
||||
def _svg_reference_error(raw: str, depth: int) -> str | None:
|
||||
"""Return an external or recursively embedded SVG resource error."""
|
||||
value = raw.strip().strip('\'"')
|
||||
if not value:
|
||||
return 'empty resource reference'
|
||||
if value.startswith('#'):
|
||||
return None
|
||||
if not value.lower().startswith('data:'):
|
||||
return f'unpackaged external resource {value!r}'
|
||||
|
||||
nested_svg, decode_error = _decode_svg_data_uri(value)
|
||||
if decode_error is not None:
|
||||
return decode_error
|
||||
if nested_svg is None:
|
||||
return None
|
||||
if depth >= _SVG_DATA_URI_DEPTH_LIMIT:
|
||||
return 'embedded SVG resource nesting exceeds the safety limit'
|
||||
nested_error = _svg_image_payload_error(nested_svg, depth + 1)
|
||||
if nested_error is None:
|
||||
return None
|
||||
return f'embedded SVG resource is not closed: {nested_error}'
|
||||
|
||||
|
||||
def _xml_stylesheet_error(raw_bytes: bytes, depth: int) -> str | None:
|
||||
"""Return an external XML stylesheet processing-instruction error."""
|
||||
parser = ET.XMLPullParser(events=('pi',))
|
||||
try:
|
||||
parser.feed(raw_bytes)
|
||||
parser.close()
|
||||
except ET.ParseError:
|
||||
return None
|
||||
for _event, instruction in parser.read_events():
|
||||
text = (instruction.text or '').strip()
|
||||
if not text.lower().startswith('xml-stylesheet'):
|
||||
continue
|
||||
match = _XML_STYLESHEET_HREF_RE.search(text)
|
||||
if match is None:
|
||||
return 'XML stylesheet processing instruction lacks href'
|
||||
reference_error = _svg_reference_error(match.group(2), depth)
|
||||
if reference_error is not None:
|
||||
return f'XML stylesheet is not closed: {reference_error}'
|
||||
return None
|
||||
|
||||
|
||||
def _svg_image_payload_error(raw_bytes: bytes, depth: int) -> str | None:
|
||||
"""Return why one SVG image payload is not a closed packaged resource."""
|
||||
if _SVG_EXTERNAL_DOCTYPE_RE.search(raw_bytes):
|
||||
return 'unpackaged external XML doctype'
|
||||
try:
|
||||
root = ET.fromstring(raw_bytes)
|
||||
except ET.ParseError as exc:
|
||||
return f'invalid SVG XML: {exc}'
|
||||
if root.tag != f'{{{_SVG_NAMESPACE}}}svg':
|
||||
return 'root must use the SVG namespace'
|
||||
|
||||
stylesheet_error = _xml_stylesheet_error(raw_bytes, depth)
|
||||
if stylesheet_error is not None:
|
||||
return stylesheet_error
|
||||
for elem in root.iter():
|
||||
tag = str(elem.tag).rsplit('}', 1)[-1]
|
||||
for raw_name, raw_value in elem.attrib.items():
|
||||
name = raw_name.rsplit('}', 1)[-1]
|
||||
if name == 'href' and tag != 'a':
|
||||
reference_error = _svg_reference_error(raw_value, depth)
|
||||
if reference_error is not None:
|
||||
return reference_error
|
||||
elif name in _SVG_DIRECT_RESOURCE_ATTRIBUTES:
|
||||
reference_error = _svg_reference_error(raw_value, depth)
|
||||
if reference_error is not None:
|
||||
return reference_error
|
||||
elif name == 'data' and tag == 'object':
|
||||
reference_error = _svg_reference_error(raw_value, depth)
|
||||
if reference_error is not None:
|
||||
return reference_error
|
||||
elif name == 'srcset' and raw_value.strip():
|
||||
return 'srcset resource lists are not closed SVG resources'
|
||||
|
||||
if name not in _SVG_CSS_URL_ATTRIBUTES:
|
||||
continue
|
||||
for match in _SVG_URL_REFERENCE_RE.finditer(raw_value):
|
||||
reference_error = _svg_reference_error(match.group(1), depth)
|
||||
if reference_error is not None:
|
||||
return f'url() resource is not closed: {reference_error}'
|
||||
|
||||
if tag != 'style':
|
||||
continue
|
||||
style_text = ''.join(elem.itertext())
|
||||
if re.search(r'@import\b', style_text, flags=re.IGNORECASE):
|
||||
return 'unpackaged CSS import'
|
||||
for match in _SVG_URL_REFERENCE_RE.finditer(style_text):
|
||||
reference_error = _svg_reference_error(match.group(1), depth)
|
||||
if reference_error is not None:
|
||||
return f'url() resource is not closed: {reference_error}'
|
||||
return None
|
||||
|
||||
|
||||
def svg_image_payload_error(raw_bytes: bytes) -> str | None:
|
||||
"""Return why a nested SVG image is not a closed packaged resource."""
|
||||
return _svg_image_payload_error(raw_bytes, 0)
|
||||
|
||||
|
||||
def svg_data_uri_payload_error(raw: str) -> str | None:
|
||||
"""Return why an inline SVG image data URI is not a closed resource."""
|
||||
nested_svg, decode_error = _decode_svg_data_uri(raw)
|
||||
if decode_error is not None:
|
||||
return decode_error
|
||||
if nested_svg is None:
|
||||
return None
|
||||
nested_error = _svg_image_payload_error(nested_svg, 0)
|
||||
if nested_error is None:
|
||||
return None
|
||||
return f'inline SVG data URI is not closed: {nested_error}'
|
||||
|
||||
|
||||
def external_image_reference_candidates(svg_dir: Path, href: str) -> list[Path]:
|
||||
"""Return candidate paths for a non-data-URI SVG image href."""
|
||||
parsed = urlsplit(href)
|
||||
@@ -70,20 +243,30 @@ def external_image_reference_candidates(svg_dir: Path, href: str) -> list[Path]:
|
||||
else href.split('?', 1)[0].split('#', 1)[0]
|
||||
)
|
||||
svg_dir = Path(svg_dir)
|
||||
project_root = project_root_for_svg_path(svg_dir)
|
||||
return [
|
||||
project_root = project_root_for_svg_path(svg_dir).resolve()
|
||||
candidates = [
|
||||
svg_dir / decoded,
|
||||
project_root / decoded,
|
||||
project_root / 'images' / decoded,
|
||||
project_root / 'templates' / decoded,
|
||||
]
|
||||
safe_candidates: list[Path] = []
|
||||
for candidate in candidates:
|
||||
resolved = candidate.resolve()
|
||||
try:
|
||||
resolved.relative_to(project_root)
|
||||
except ValueError:
|
||||
continue
|
||||
if resolved not in safe_candidates:
|
||||
safe_candidates.append(resolved)
|
||||
return safe_candidates
|
||||
|
||||
|
||||
def resolve_external_image_reference(svg_dir: Path, href: str) -> Path | None:
|
||||
"""Resolve an SVG image href to an existing file, or return None."""
|
||||
for candidate in external_image_reference_candidates(svg_dir, href):
|
||||
if candidate.exists():
|
||||
return candidate.resolve()
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
PPT Master - Shape Boolean SVG Fragment Tool
|
||||
|
||||
Combine closed SVG shapes and print the resulting canonical SVG path fragment
|
||||
to stdout. Result geometry is baked into SVG root coordinates: replace the
|
||||
operands at the SVG root while preserving the primary operand's z-order, and
|
||||
never reinsert the result into an original transformed ancestor. The source
|
||||
SVG is read-only; this tool never rewrites the page.
|
||||
to stdout. Result geometry is in SVG-root coordinate space: replace the
|
||||
operands at their original z-order under the final semantic or structured
|
||||
parent, never under the old transformed ancestor. The source SVG is read-only;
|
||||
this tool never rewrites the page.
|
||||
|
||||
Usage:
|
||||
python3 scripts/shape_boolean_svg.py render SVG_FILE \
|
||||
@@ -41,9 +41,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Print the Boolean result of closed SVG shapes as canonical SVG "
|
||||
"path fragments in SVG root coordinates. Insert the result at the "
|
||||
"SVG root in the primary operand's z-order, not under an original "
|
||||
"transformed ancestor. The source file is never modified."
|
||||
"path fragments in SVG-root coordinate space. Insert the result at "
|
||||
"the original z-order under the final semantic or structured "
|
||||
"parent, never under the old transformed ancestor. The source file "
|
||||
"is never modified."
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
@@ -51,7 +52,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
render_parser = subparsers.add_parser(
|
||||
"render",
|
||||
help="Print root-coordinate Boolean-result SVG paths to stdout.",
|
||||
help="Print SVG-root-coordinate Boolean-result paths to stdout.",
|
||||
)
|
||||
render_parser.add_argument(
|
||||
"svg_file",
|
||||
|
||||
@@ -175,6 +175,17 @@ def slice_sheet(
|
||||
f"{total_cells} cells; provide exactly one name per cell"
|
||||
)
|
||||
safe_names = [_safe_basename(n) for n in names] if names else None
|
||||
if safe_names:
|
||||
seen_outputs: set[str] = set()
|
||||
for name in safe_names:
|
||||
output_name = name if Path(name).suffix else f"{name}.png"
|
||||
normalized_output = output_name.casefold()
|
||||
if normalized_output in seen_outputs:
|
||||
raise ValueError(
|
||||
f"--names repeats output filename {output_name!r} "
|
||||
"(case-insensitive)"
|
||||
)
|
||||
seen_outputs.add(normalized_output)
|
||||
if alpha and safe_names:
|
||||
for name in safe_names:
|
||||
suffix = Path(name).suffix.lower()
|
||||
|
||||
+13
-6
@@ -912,7 +912,7 @@ def _write_emit_result(result_file: str, url: str, markdown_path: str) -> None:
|
||||
print(f" [WARN] Could not write --emit-result: {exc}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Run the CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Web to Markdown Converter (Python)")
|
||||
@@ -926,7 +926,7 @@ def main() -> None:
|
||||
help="On success, write the saved output path as JSON to this file "
|
||||
"(single-URL dispatcher use, so a title-named file can be located)")
|
||||
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.dir:
|
||||
CONFIG["output_dir"] = args.dir
|
||||
@@ -942,11 +942,16 @@ def main() -> None:
|
||||
and not l.strip().startswith("#")]
|
||||
targets.extend(lines)
|
||||
else:
|
||||
print(f"Error: File {args.file} not found")
|
||||
print(f"Error: File {args.file} not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not targets:
|
||||
parser.print_help()
|
||||
sys.exit(0)
|
||||
parser.print_usage(sys.stderr)
|
||||
print(
|
||||
"web_to_md.py: error: at least one URL or --file is required",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
results = []
|
||||
for i, url in enumerate(targets):
|
||||
@@ -970,10 +975,12 @@ def main() -> None:
|
||||
for r in results:
|
||||
if not r[0]:
|
||||
print(f" - {r[1]}: {r[2]}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Disable warnings for verify=False if needed, though often useful to see
|
||||
import urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
main()
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -597,8 +597,10 @@ def create_app(
|
||||
logger.warning('slide parse failed: %s: %s', svg_file.name, exc)
|
||||
_cache_put(_LIST_CACHE, _LIST_CACHE_LOCK, path_str, mtime, disk_count)
|
||||
|
||||
mem_count = len(annotations.get(svg_file.name, {}))
|
||||
annotation_count = max(disk_count, mem_count)
|
||||
if svg_file.name in annotations:
|
||||
annotation_count = len(annotations[svg_file.name])
|
||||
else:
|
||||
annotation_count = disk_count
|
||||
|
||||
slides.append({
|
||||
'name': svg_file.name,
|
||||
@@ -614,16 +616,43 @@ def create_app(
|
||||
def _safe_svg_path(name: str):
|
||||
"""Validate slide name and return safe path. Returns None if invalid.
|
||||
|
||||
The early string checks reject obvious bad inputs; the resolve()+startswith()
|
||||
The early string checks reject obvious bad inputs; the resolve()+relative_to()
|
||||
check is the authoritative path traversal guard.
|
||||
"""
|
||||
if '/' in name or '\\' in name or '..' in name:
|
||||
return None
|
||||
svg_file = (svg_dir / name).resolve()
|
||||
if not str(svg_file).startswith(str(svg_dir.resolve())):
|
||||
try:
|
||||
svg_file.relative_to(svg_dir.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return svg_file
|
||||
|
||||
def _get_annotation_snapshot(name: str):
|
||||
"""Return the page's complete staged annotation state, loading it once."""
|
||||
annotations = app.config['ANNOTATIONS']
|
||||
if name in annotations:
|
||||
return annotations[name], None
|
||||
|
||||
svg_file = _safe_svg_path(name)
|
||||
if svg_file is None:
|
||||
return None, (jsonify({'error': 'Invalid slide name'}), 400)
|
||||
if not svg_file.exists():
|
||||
return None, (jsonify({'error': 'Slide not found'}), 404)
|
||||
|
||||
try:
|
||||
root = ET.parse(str(svg_file)).getroot()
|
||||
except ET.ParseError as exc:
|
||||
logger.warning('slide parse failed: %s: %s', name, exc)
|
||||
return None, (jsonify({'error': f'Failed to parse SVG: {exc}'}), 500)
|
||||
|
||||
assign_temp_ids(root)
|
||||
annotations[name] = {
|
||||
item['element_id']: item['annotation']
|
||||
for item in parse_annotations(root)
|
||||
}
|
||||
return annotations[name], None
|
||||
|
||||
@app.route('/api/slide/<name>')
|
||||
def get_slide(name: str):
|
||||
svg_file = _safe_svg_path(name)
|
||||
@@ -686,11 +715,13 @@ def create_app(
|
||||
(content, warnings, disk_annotations, id_to_tag),
|
||||
)
|
||||
|
||||
mem_annotations = app.config['ANNOTATIONS'].get(name, {})
|
||||
merged: dict[str, str] = {}
|
||||
for ann in disk_annotations:
|
||||
merged[ann['element_id']] = ann['annotation']
|
||||
merged.update(mem_annotations)
|
||||
if name in app.config['ANNOTATIONS']:
|
||||
merged = dict(app.config['ANNOTATIONS'][name])
|
||||
else:
|
||||
merged = {
|
||||
ann['element_id']: ann['annotation']
|
||||
for ann in disk_annotations
|
||||
}
|
||||
|
||||
annotations_list = [
|
||||
{
|
||||
@@ -728,29 +759,28 @@ def create_app(
|
||||
if len(annotation) > 10000:
|
||||
return jsonify({'error': 'Annotation too long (max 10000 chars)'}), 400
|
||||
|
||||
if name not in app.config['ANNOTATIONS']:
|
||||
app.config['ANNOTATIONS'][name] = {}
|
||||
annotations, error = _get_annotation_snapshot(name)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
app.config['ANNOTATIONS'][name][element_id] = annotation
|
||||
annotations[element_id] = annotation
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'annotations_count': len(app.config['ANNOTATIONS'][name]),
|
||||
'annotations_count': len(annotations),
|
||||
})
|
||||
|
||||
@app.route('/api/slide/<name>/annotate/<element_id>', methods=['DELETE'])
|
||||
def delete_annotate(name: str, element_id: str):
|
||||
annotations = app.config['ANNOTATIONS']
|
||||
# Ensure the file key exists so save-all knows to rewrite this file
|
||||
# even if no new annotations were added (pure delete path).
|
||||
if name not in annotations:
|
||||
annotations[name] = {}
|
||||
if element_id in annotations[name]:
|
||||
del annotations[name][element_id]
|
||||
annotations, error = _get_annotation_snapshot(name)
|
||||
if error is not None:
|
||||
return error
|
||||
if element_id in annotations:
|
||||
del annotations[element_id]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'annotations_count': len(annotations.get(name, {})),
|
||||
'annotations_count': len(annotations),
|
||||
})
|
||||
|
||||
@app.route('/api/slide/<name>/edit', methods=['POST'])
|
||||
@@ -896,6 +926,7 @@ def create_app(
|
||||
|
||||
filenames = sorted(set(annotations.keys()) | set(pending_edits.keys()))
|
||||
for filename in filenames:
|
||||
has_staged_annotations = filename in annotations
|
||||
anns = annotations.get(filename, {})
|
||||
edits = pending_edits.get(filename, [])
|
||||
# anns may be empty when the user deleted all annotations — still
|
||||
@@ -921,6 +952,8 @@ def create_app(
|
||||
item['element_id']: item['annotation']
|
||||
for item in parse_annotations(root)
|
||||
}
|
||||
if not has_staged_annotations:
|
||||
anns = old_annotations
|
||||
|
||||
# Clear all existing annotations from the file before writing current state
|
||||
for elem in root.iter():
|
||||
|
||||
+95
-40
@@ -46,12 +46,12 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import unquote
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
@@ -59,6 +59,11 @@ if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||
from resource_paths import ( # noqa: E402
|
||||
resolve_external_image_reference,
|
||||
svg_data_uri_payload_error,
|
||||
svg_image_payload_error,
|
||||
)
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
@@ -119,14 +124,7 @@ def _resolve_image_path(href: str, svg_dir: Path) -> Path | None:
|
||||
"""
|
||||
if not href:
|
||||
return None
|
||||
decoded = unquote(href)
|
||||
if decoded.startswith(('http://', 'https://', 'file://')):
|
||||
return None
|
||||
if os.path.isabs(decoded):
|
||||
candidate = Path(decoded)
|
||||
else:
|
||||
candidate = (svg_dir / decoded).resolve()
|
||||
return candidate if candidate.exists() else None
|
||||
return resolve_external_image_reference(svg_dir, href)
|
||||
|
||||
|
||||
def _is_svg_image(img_path: Path, raw_bytes: bytes) -> bool:
|
||||
@@ -156,6 +154,29 @@ def _load_pil_image(img_path: Path) -> 'PILImage' | None:
|
||||
return None
|
||||
|
||||
|
||||
def _prepare_raster_for_geometry(img: 'PILImage') -> 'PILImage':
|
||||
"""Apply EXIF orientation and materialize palette/tRNS transparency."""
|
||||
from PIL import ImageOps
|
||||
|
||||
prepared = ImageOps.exif_transpose(img)
|
||||
if prepared.mode == 'P':
|
||||
prepared = prepared.convert('RGBA' if _has_alpha(prepared) else 'RGB')
|
||||
elif (
|
||||
'transparency' in getattr(prepared, 'info', {})
|
||||
and prepared.mode not in {'RGBA', 'LA'}
|
||||
):
|
||||
prepared = prepared.convert('RGBA')
|
||||
return prepared
|
||||
|
||||
|
||||
def _has_exif_geometry_transform(img: 'PILImage') -> bool:
|
||||
"""Return whether EXIF requires a physical mirror or rotation."""
|
||||
try:
|
||||
return int(img.getexif().get(274, 1)) in range(2, 9)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_for_save(img: 'PILImage', mime_type: str) -> 'PILImage':
|
||||
"""Coerce a PIL image into a mode that the target format can save.
|
||||
|
||||
@@ -166,15 +187,17 @@ def _normalize_for_save(img: 'PILImage', mime_type: str) -> 'PILImage':
|
||||
if img.mode in ('RGBA', 'LA'):
|
||||
from PIL import Image
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
alpha = img.getchannel('A') if img.mode == 'RGBA' else None
|
||||
alpha = img.getchannel('A')
|
||||
background.paste(img.convert('RGB'), mask=alpha)
|
||||
return background
|
||||
if img.mode != 'RGB':
|
||||
return img.convert('RGB')
|
||||
return img
|
||||
# PNG / GIF / WEBP — preserve alpha if present
|
||||
# Lossless output — preserve alpha if present.
|
||||
if img.mode == 'P':
|
||||
return img.convert('RGBA' if 'A' in img.getbands() else 'RGB')
|
||||
return img.convert('RGBA' if _has_alpha(img) else 'RGB')
|
||||
if img.mode not in {'1', 'L', 'LA', 'I', 'I;16', 'RGB', 'RGBA'}:
|
||||
return img.convert('RGBA' if _has_alpha(img) else 'RGB')
|
||||
return img
|
||||
|
||||
|
||||
@@ -182,9 +205,7 @@ def _has_alpha(img: 'PILImage') -> bool:
|
||||
"""Return whether a PIL image has transparency."""
|
||||
if img.mode in ('RGBA', 'LA'):
|
||||
return True
|
||||
if img.mode == 'P':
|
||||
return 'transparency' in getattr(img, 'info', {})
|
||||
return False
|
||||
return 'transparency' in getattr(img, 'info', {})
|
||||
|
||||
|
||||
def _target_size(
|
||||
@@ -207,6 +228,8 @@ def _target_size(
|
||||
def _downscale_to_target(img: 'PILImage', target_w: int, target_h: int) -> tuple['PILImage', bool]:
|
||||
"""Downscale without upsampling."""
|
||||
width, height = img.size
|
||||
if width <= 0 or height <= 0:
|
||||
return img, False
|
||||
ratio = min(target_w / width, target_h / height, 1.0)
|
||||
if ratio >= 1.0:
|
||||
return img, False
|
||||
@@ -231,9 +254,11 @@ def _encode_pil_to_data_uri(
|
||||
bytes for that path.
|
||||
"""
|
||||
original_mime_type = get_mime_type(src_path.name, fallback_bytes)
|
||||
mime_type = original_mime_type
|
||||
if compress and mime_type == 'image/png' and not _has_alpha(img):
|
||||
mime_type = 'image/jpeg'
|
||||
# Match native export: only original JPEG assets stay lossy. PNG remains
|
||||
# PNG, while BMP/TIFF and other static raster formats become lossless PNG.
|
||||
mime_type = (
|
||||
'image/jpeg' if original_mime_type == 'image/jpeg' else 'image/png'
|
||||
)
|
||||
pil_format = _PIL_FORMAT_BY_MIME.get(mime_type, 'PNG')
|
||||
|
||||
# Encode current PIL image
|
||||
@@ -251,17 +276,20 @@ def _encode_pil_to_data_uri(
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
# If caller passed the original bytes and they're smaller (because PIL
|
||||
# round-tripping an asset that was already well-compressed inflates it),
|
||||
# fall back to those.
|
||||
chosen = encoded_bytes
|
||||
if fallback_bytes and mime_type == original_mime_type and len(fallback_bytes) < len(encoded_bytes):
|
||||
chosen = fallback_bytes
|
||||
|
||||
chosen = _optimize_image_bytes(
|
||||
chosen, mime_type, compress=compress, max_dimension=max_dimension,
|
||||
optimized_bytes = _optimize_image_bytes(
|
||||
encoded_bytes, mime_type, compress=compress, max_dimension=max_dimension,
|
||||
)
|
||||
|
||||
# If the original represents the same uncropped pixels and is smaller,
|
||||
# retain it instead of inflating an already efficient PNG/JPEG.
|
||||
chosen = optimized_bytes
|
||||
if (
|
||||
fallback_bytes
|
||||
and mime_type == original_mime_type
|
||||
and len(fallback_bytes) < len(optimized_bytes)
|
||||
):
|
||||
chosen = fallback_bytes
|
||||
|
||||
b64 = base64.b64encode(chosen).decode('ascii')
|
||||
return f'data:{mime_type};base64,{b64}', len(chosen)
|
||||
|
||||
@@ -306,7 +334,10 @@ def _process_one_image(
|
||||
href = _get_href(image)
|
||||
if not href:
|
||||
return False, None
|
||||
if href.startswith('data:'):
|
||||
if href.lower().startswith('data:'):
|
||||
payload_error = svg_data_uri_payload_error(href)
|
||||
if payload_error is not None:
|
||||
return False, payload_error
|
||||
return False, None # already inline
|
||||
|
||||
img_path = _resolve_image_path(href, svg_dir)
|
||||
@@ -325,6 +356,9 @@ def _process_one_image(
|
||||
return False, None
|
||||
|
||||
if _is_svg_image(img_path, raw_bytes):
|
||||
payload_error = svg_image_payload_error(raw_bytes)
|
||||
if payload_error is not None:
|
||||
return False, f'{img_path.name}: {payload_error}'
|
||||
_embed_raw_image(image, img_path, raw_bytes)
|
||||
if verbose:
|
||||
print(f' [OK] {img_path.name} (svg, embedded as-is)')
|
||||
@@ -352,11 +386,18 @@ def _process_one_image(
|
||||
print(f' [OK] {img_path.name} (animated, embedded as-is)')
|
||||
return True, None
|
||||
|
||||
geometry_normalized = _has_exif_geometry_transform(img)
|
||||
img = _prepare_raster_for_geometry(img)
|
||||
box_x = _parse_float(image.get('x'))
|
||||
box_y = _parse_float(image.get('y'))
|
||||
box_w = _parse_float(image.get('width'))
|
||||
box_h = _parse_float(image.get('height'))
|
||||
if box_w <= 0 or box_h <= 0:
|
||||
if (
|
||||
not math.isfinite(box_w)
|
||||
or not math.isfinite(box_h)
|
||||
or box_w <= 0
|
||||
or box_h <= 0
|
||||
):
|
||||
return False, 'zero-sized box'
|
||||
|
||||
par_attr = image.get('preserveAspectRatio') or ''
|
||||
@@ -367,8 +408,10 @@ def _process_one_image(
|
||||
# ------------------------------------------------------------------
|
||||
final_img: 'PILImage' = img
|
||||
new_x, new_y, new_w, new_h = box_x, box_y, box_w, box_h
|
||||
transformed = False # True iff bitmap content changed (crop happened)
|
||||
transformed = geometry_normalized
|
||||
target_box_w, target_box_h = box_w, box_h
|
||||
preserve_stretch = False
|
||||
preserve_slice = False
|
||||
|
||||
if not par_attr:
|
||||
# No preserveAspectRatio at all. The previous pipeline's fix-aspect
|
||||
@@ -380,13 +423,20 @@ def _process_one_image(
|
||||
align, mode = parse_preserve_aspect_ratio(par_attr)
|
||||
if align == 'none':
|
||||
# Author wants stretch-to-box; preserve geometry, embed bytes.
|
||||
pass
|
||||
preserve_stretch = True
|
||||
elif mode == 'slice':
|
||||
x_anchor, y_anchor = get_crop_anchor(align)
|
||||
cropped = crop_image_to_size(img, int(box_w), int(box_h),
|
||||
x_anchor, y_anchor)
|
||||
final_img = cropped
|
||||
cropped_img = crop_image_to_size(
|
||||
img, box_w, box_h, x_anchor, y_anchor
|
||||
)
|
||||
final_img = cropped_img
|
||||
transformed = True
|
||||
preserve_slice = not math.isclose(
|
||||
cropped_img.size[0] / cropped_img.size[1],
|
||||
box_w / box_h,
|
||||
rel_tol=1e-6,
|
||||
abs_tol=1e-9,
|
||||
)
|
||||
else: # meet (or any other mode → treat as meet)
|
||||
new_w_calc, new_h_calc, off_x, off_y = calculate_fitted_dimensions(
|
||||
img.size[0], img.size[1], box_w, box_h, mode='meet',
|
||||
@@ -425,7 +475,11 @@ def _process_one_image(
|
||||
image.set('y', _format_number(new_y))
|
||||
image.set('width', _format_number(new_w))
|
||||
image.set('height', _format_number(new_h))
|
||||
if 'preserveAspectRatio' in image.attrib:
|
||||
if preserve_stretch:
|
||||
image.set('preserveAspectRatio', 'none')
|
||||
elif preserve_slice:
|
||||
image.set('preserveAspectRatio', par_attr)
|
||||
elif 'preserveAspectRatio' in image.attrib:
|
||||
del image.attrib['preserveAspectRatio']
|
||||
|
||||
if verbose:
|
||||
@@ -481,8 +535,10 @@ def align_and_embed_images_in_svg(
|
||||
try:
|
||||
tree = ET.parse(svg_path)
|
||||
except ET.ParseError as exc:
|
||||
if verbose:
|
||||
print(f' [ERROR] {svg_path.name}: parse failed ({exc})')
|
||||
print(
|
||||
f' [ERROR] {svg_path.name}: parse failed ({exc})',
|
||||
file=sys.stderr,
|
||||
)
|
||||
return (0, 1)
|
||||
root = tree.getroot()
|
||||
|
||||
@@ -511,10 +567,9 @@ def align_and_embed_images_in_svg(
|
||||
processed += 1
|
||||
elif err:
|
||||
errors += 1
|
||||
if verbose:
|
||||
print(f' [WARN] {svg_path.name}: {err}')
|
||||
print(f' [ERROR] {svg_path.name}: {err}', file=sys.stderr)
|
||||
|
||||
if processed > 0 and not dry_run:
|
||||
if processed > 0 and errors == 0 and not dry_run:
|
||||
tree.write(svg_path, encoding='utf-8', xml_declaration=False)
|
||||
|
||||
return (processed, errors)
|
||||
|
||||
+8
-4
@@ -86,8 +86,8 @@ def get_crop_anchor(align: str) -> tuple[float, float]:
|
||||
|
||||
def crop_image_to_size(
|
||||
img: Image.Image,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
target_width: float,
|
||||
target_height: float,
|
||||
x_anchor: float = 0.5,
|
||||
y_anchor: float = 0.5,
|
||||
) -> Image.Image:
|
||||
@@ -108,6 +108,10 @@ def crop_image_to_size(
|
||||
Cropped PIL Image object (preserving original resolution)
|
||||
"""
|
||||
img_width, img_height = img.size
|
||||
if img_width <= 0 or img_height <= 0:
|
||||
raise ValueError('source image dimensions must be positive')
|
||||
if target_width <= 0 or target_height <= 0:
|
||||
raise ValueError('target image dimensions must be positive')
|
||||
|
||||
# Calculate target aspect ratio
|
||||
target_ratio = target_width / target_height
|
||||
@@ -117,11 +121,11 @@ def crop_image_to_size(
|
||||
if img_ratio > target_ratio:
|
||||
# Original image is wider; crop left and right sides
|
||||
crop_height = img_height
|
||||
crop_width = int(img_height * target_ratio)
|
||||
crop_width = max(1, min(img_width, int(round(img_height * target_ratio))))
|
||||
else:
|
||||
# Original image is taller; crop top and bottom sides
|
||||
crop_width = img_width
|
||||
crop_height = int(img_width / target_ratio)
|
||||
crop_height = max(1, min(img_height, int(round(img_width / target_ratio))))
|
||||
|
||||
# Calculate crop position based on anchor point
|
||||
extra_width = img_width - crop_width
|
||||
|
||||
+1180
-294
File diff suppressed because it is too large
Load Diff
+273
-7
@@ -1,4 +1,4 @@
|
||||
"""Animation sidecar loading, SVG target scanning, and validation."""
|
||||
"""Motion sidecar loading, SVG target scanning, and validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -55,6 +55,22 @@ class GroupTarget:
|
||||
structurally_static: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MorphPair:
|
||||
"""One explicit PowerPoint Morph identity across adjacent slides."""
|
||||
|
||||
source_slide: str
|
||||
destination_slide: str
|
||||
key: str
|
||||
source_group_id: str
|
||||
destination_group_id: str
|
||||
|
||||
@property
|
||||
def shape_name(self) -> str:
|
||||
"""Return the Selection Pane name PowerPoint uses for forced matching."""
|
||||
return f'!!{self.key}'
|
||||
|
||||
|
||||
def _tag_name(elem: ET.Element) -> str:
|
||||
return elem.tag.replace(f'{{{SVG_NS}}}', '')
|
||||
|
||||
@@ -426,6 +442,7 @@ def validate_transition_config(config: dict[str, Any]) -> list[str]:
|
||||
inherited_effect=default_effect,
|
||||
)
|
||||
)
|
||||
errors.extend(_morph_scope_errors(slide_name, slide_cfg))
|
||||
return errors
|
||||
|
||||
|
||||
@@ -481,6 +498,229 @@ def _transition_scope_errors(
|
||||
return errors
|
||||
|
||||
|
||||
def _morph_scope_errors(
|
||||
slide_name: object,
|
||||
slide_cfg: dict[str, Any],
|
||||
) -> list[str]:
|
||||
"""Validate one destination slide's deterministic Morph declaration."""
|
||||
if 'morph' not in slide_cfg:
|
||||
return []
|
||||
label = f'slide "{slide_name}" morph'
|
||||
morph = slide_cfg['morph']
|
||||
if not isinstance(morph, dict):
|
||||
return [f'animations.json {label} must be an object']
|
||||
|
||||
errors = _unknown_field_errors(
|
||||
morph,
|
||||
frozenset({'from', 'pairs'}),
|
||||
label,
|
||||
)
|
||||
source_slide = morph.get('from')
|
||||
if not isinstance(source_slide, str) or not source_slide.strip():
|
||||
errors.append(
|
||||
f'animations.json {label} field "from" must be a non-empty slide stem'
|
||||
)
|
||||
|
||||
pairs = morph.get('pairs')
|
||||
if not isinstance(pairs, dict) or not pairs:
|
||||
errors.append(
|
||||
f'animations.json {label} field "pairs" must be a non-empty object'
|
||||
)
|
||||
else:
|
||||
source_groups: dict[str, str] = {}
|
||||
destination_groups: dict[str, str] = {}
|
||||
for key, pair in pairs.items():
|
||||
pair_label = f'{label} pair "{key}"'
|
||||
if (
|
||||
not isinstance(key, str)
|
||||
or not key.strip()
|
||||
or key != key.strip()
|
||||
or key.startswith('!!')
|
||||
or any(ord(char) < 32 for char in key)
|
||||
):
|
||||
errors.append(
|
||||
f'animations.json {pair_label} key must be a trimmed, '
|
||||
'non-empty name without the !! prefix or control characters'
|
||||
)
|
||||
if not isinstance(pair, dict):
|
||||
errors.append(f'animations.json {pair_label} must be an object')
|
||||
continue
|
||||
errors.extend(
|
||||
_unknown_field_errors(
|
||||
pair,
|
||||
frozenset({'from', 'to'}),
|
||||
pair_label,
|
||||
)
|
||||
)
|
||||
for field in ('from', 'to'):
|
||||
value = pair.get(field)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
errors.append(
|
||||
f'animations.json {pair_label} field "{field}" must '
|
||||
'be a non-empty top-level group id'
|
||||
)
|
||||
source_group = pair.get('from')
|
||||
if isinstance(source_group, str) and source_group.strip():
|
||||
previous = source_groups.setdefault(source_group, str(key))
|
||||
if previous != str(key):
|
||||
errors.append(
|
||||
f'animations.json {label} source group '
|
||||
f'"{source_group}" is assigned to both "{previous}" '
|
||||
f'and "{key}"'
|
||||
)
|
||||
destination_group = pair.get('to')
|
||||
if isinstance(destination_group, str) and destination_group.strip():
|
||||
previous = destination_groups.setdefault(
|
||||
destination_group,
|
||||
str(key),
|
||||
)
|
||||
if previous != str(key):
|
||||
errors.append(
|
||||
f'animations.json {label} destination group '
|
||||
f'"{destination_group}" is assigned to both "{previous}" '
|
||||
f'and "{key}"'
|
||||
)
|
||||
|
||||
transition = slide_cfg.get('transition')
|
||||
if not isinstance(transition, dict) or 'effect' not in transition:
|
||||
errors.append(
|
||||
f'animations.json {label} requires an explicit slide transition '
|
||||
'effect "morph"'
|
||||
)
|
||||
else:
|
||||
try:
|
||||
effect, options = normalize_transition_effect_request(
|
||||
transition.get('effect'),
|
||||
transition.get('effect_options'),
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
if effect != 'morph':
|
||||
errors.append(
|
||||
f'animations.json {label} requires transition effect "morph"'
|
||||
)
|
||||
elif options.get('morph_by', 'object') != 'object':
|
||||
errors.append(
|
||||
f'animations.json {label} requires Morph by object'
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _resolve_morph_pairs(
|
||||
slide_order: list[str],
|
||||
config: dict[str, Any],
|
||||
) -> tuple[list[MorphPair], list[str]]:
|
||||
"""Resolve sidecar Morph declarations against the actual slide order."""
|
||||
slides = config.get('slides', {})
|
||||
if not isinstance(slides, dict):
|
||||
return [], ['animations.json field "slides" must be an object']
|
||||
|
||||
order_by_slide = {slide_name: index for index, slide_name in enumerate(slide_order)}
|
||||
pairs: list[MorphPair] = []
|
||||
errors: list[str] = []
|
||||
assignments: dict[str, dict[str, str]] = {}
|
||||
keys: dict[str, dict[str, str]] = {}
|
||||
declared_keys_by_destination: dict[str, set[str]] = {}
|
||||
|
||||
for destination_slide, slide_cfg in slides.items():
|
||||
if not isinstance(slide_cfg, dict) or 'morph' not in slide_cfg:
|
||||
continue
|
||||
scope_errors = _morph_scope_errors(destination_slide, slide_cfg)
|
||||
if scope_errors:
|
||||
errors.extend(scope_errors)
|
||||
continue
|
||||
destination_index = order_by_slide.get(str(destination_slide))
|
||||
if destination_index is None:
|
||||
errors.append(
|
||||
'animations.json morph destination slide is missing: '
|
||||
f'{destination_slide}'
|
||||
)
|
||||
continue
|
||||
if destination_index == 0:
|
||||
errors.append(
|
||||
'animations.json first slide cannot declare an incoming Morph: '
|
||||
f'{destination_slide}'
|
||||
)
|
||||
continue
|
||||
|
||||
morph = slide_cfg['morph']
|
||||
source_slide = str(morph['from'])
|
||||
expected_source = slide_order[destination_index - 1]
|
||||
if source_slide != expected_source:
|
||||
errors.append(
|
||||
f'animations.json slide "{destination_slide}" morph.from must '
|
||||
f'reference the immediately preceding slide "{expected_source}", '
|
||||
f'not "{source_slide}"'
|
||||
)
|
||||
continue
|
||||
|
||||
declared_keys_by_destination[str(destination_slide)] = set(
|
||||
morph['pairs']
|
||||
)
|
||||
for key, pair in morph['pairs'].items():
|
||||
resolved = MorphPair(
|
||||
source_slide=source_slide,
|
||||
destination_slide=str(destination_slide),
|
||||
key=str(key),
|
||||
source_group_id=str(pair['from']),
|
||||
destination_group_id=str(pair['to']),
|
||||
)
|
||||
pair_conflict = False
|
||||
for slide_name, group_id in (
|
||||
(resolved.source_slide, resolved.source_group_id),
|
||||
(resolved.destination_slide, resolved.destination_group_id),
|
||||
):
|
||||
slide_assignments = assignments.setdefault(slide_name, {})
|
||||
previous_key = slide_assignments.setdefault(group_id, resolved.key)
|
||||
if previous_key != resolved.key:
|
||||
errors.append(
|
||||
f'animations.json Morph group "{slide_name}/{group_id}" '
|
||||
f'is assigned to both "{previous_key}" and "{resolved.key}"'
|
||||
)
|
||||
pair_conflict = True
|
||||
slide_keys = keys.setdefault(slide_name, {})
|
||||
previous_group = slide_keys.setdefault(resolved.key, group_id)
|
||||
if previous_group != group_id:
|
||||
errors.append(
|
||||
f'animations.json Morph key "{resolved.key}" maps to both '
|
||||
f'"{slide_name}/{previous_group}" and '
|
||||
f'"{slide_name}/{group_id}"'
|
||||
)
|
||||
pair_conflict = True
|
||||
if not pair_conflict:
|
||||
pairs.append(resolved)
|
||||
|
||||
for destination_slide, declared_keys in declared_keys_by_destination.items():
|
||||
destination_index = order_by_slide[destination_slide]
|
||||
source_slide = slide_order[destination_index - 1]
|
||||
shared_keys = (
|
||||
set(keys.get(source_slide, {}))
|
||||
& set(keys.get(destination_slide, {}))
|
||||
)
|
||||
unexpected_keys = sorted(shared_keys - declared_keys)
|
||||
if unexpected_keys:
|
||||
errors.append(
|
||||
f'animations.json slide "{destination_slide}" Morph would '
|
||||
'force undeclared adjacent key(s): '
|
||||
+ ', '.join(f'"{key}"' for key in unexpected_keys)
|
||||
)
|
||||
return pairs, list(dict.fromkeys(errors))
|
||||
|
||||
|
||||
def resolve_morph_pairs(
|
||||
slide_order: list[str],
|
||||
config: dict[str, Any] | None,
|
||||
) -> tuple[MorphPair, ...]:
|
||||
"""Return validated deterministic Morph pairs in authored order."""
|
||||
if not config:
|
||||
return ()
|
||||
pairs, errors = _resolve_morph_pairs(slide_order, config)
|
||||
if errors:
|
||||
raise ValueError('; '.join(errors))
|
||||
return tuple(pairs)
|
||||
|
||||
|
||||
def validate_animation_config_errors(config: dict[str, Any]) -> list[str]:
|
||||
"""Return fatal object-animation errors that must block export."""
|
||||
errors = _unknown_field_errors(
|
||||
@@ -513,7 +753,7 @@ def validate_animation_config_errors(config: dict[str, Any]) -> list[str]:
|
||||
errors.extend(
|
||||
_unknown_field_errors(
|
||||
slide_cfg,
|
||||
frozenset({'transition', 'animation', 'groups'}),
|
||||
frozenset({'transition', 'animation', 'groups', 'morph'}),
|
||||
f'slide "{slide_name}"',
|
||||
)
|
||||
)
|
||||
@@ -694,6 +934,14 @@ def validate_animation_config(
|
||||
warnings.append(_duplicate_target_error(slide_name, duplicates))
|
||||
|
||||
known_slides = set(targets_by_slide)
|
||||
known_groups_by_slide: dict[str, dict[str, GroupTarget]] = {}
|
||||
for slide_name, slide_targets in targets_by_slide.items():
|
||||
ambiguous_ids = set(duplicates_by_slide.get(slide_name, ()))
|
||||
known_groups_by_slide[slide_name] = {
|
||||
target.group_id: target
|
||||
for target in slide_targets
|
||||
if target.group_id not in ambiguous_ids
|
||||
}
|
||||
slides = config.get('slides', {})
|
||||
if not isinstance(slides, dict):
|
||||
return list(dict.fromkeys(warnings))
|
||||
@@ -710,11 +958,7 @@ def validate_animation_config(
|
||||
slide_targets = targets_by_slide.get(slide_name, [])
|
||||
duplicate_ids = duplicates_by_slide.get(slide_name, ())
|
||||
ambiguous_ids = set(duplicate_ids)
|
||||
known_groups = {
|
||||
target.group_id: target
|
||||
for target in slide_targets
|
||||
if target.group_id not in ambiguous_ids
|
||||
}
|
||||
known_groups = known_groups_by_slide.get(slide_name, {})
|
||||
groups = slide_cfg.get('groups', {})
|
||||
if not isinstance(groups, dict):
|
||||
continue
|
||||
@@ -755,6 +999,28 @@ def validate_animation_config(
|
||||
'animations.json trigger_shape references non-triggerable '
|
||||
f'structural group: {slide_name}/{trigger_shape}'
|
||||
)
|
||||
|
||||
morph_pairs, morph_errors = _resolve_morph_pairs(
|
||||
list(targets_by_slide),
|
||||
config,
|
||||
)
|
||||
warnings.extend(morph_errors)
|
||||
for pair in morph_pairs:
|
||||
for slide_name, group_id in (
|
||||
(pair.source_slide, pair.source_group_id),
|
||||
(pair.destination_slide, pair.destination_group_id),
|
||||
):
|
||||
target = known_groups_by_slide.get(slide_name, {}).get(group_id)
|
||||
if target is None:
|
||||
warnings.append(
|
||||
'animations.json Morph references missing or ambiguous group: '
|
||||
f'{slide_name}/{group_id}'
|
||||
)
|
||||
elif target.structurally_static:
|
||||
warnings.append(
|
||||
'animations.json Morph references structural group: '
|
||||
f'{slide_name}/{group_id}'
|
||||
)
|
||||
return list(dict.fromkeys(warnings))
|
||||
|
||||
|
||||
|
||||
+21
@@ -54,6 +54,7 @@ from .utils import (
|
||||
project_geometry_length_errors,
|
||||
project_gradient_errors,
|
||||
project_image_aspect_ratio_errors,
|
||||
project_mask_errors,
|
||||
project_marker_errors,
|
||||
project_opacity_errors,
|
||||
project_paint_errors,
|
||||
@@ -288,6 +289,22 @@ def _require_project_paints(
|
||||
)
|
||||
|
||||
|
||||
def _require_project_masks(
|
||||
root: ET.Element,
|
||||
svg_path: Path | str,
|
||||
) -> None:
|
||||
"""Reject SVG masks before native conversion can silently drop them."""
|
||||
errors = project_mask_errors(root)
|
||||
if not errors:
|
||||
return
|
||||
preview = '; '.join(errors[:8])
|
||||
suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
|
||||
raise SvgNativeConversionError(
|
||||
f'{Path(svg_path).name}: invalid project mask(s): '
|
||||
f'{preview}{suffix}'
|
||||
)
|
||||
|
||||
|
||||
def _require_project_definitions(
|
||||
root: ET.Element,
|
||||
svg_path: Path | str,
|
||||
@@ -735,6 +752,8 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
# translation here and apply pivot-centre compensation to ``a:off``
|
||||
# below instead.
|
||||
rotate_pivot = _extract_rotate_pivot(transform) if not matrix_supported else None
|
||||
if rotate_pivot is not None:
|
||||
angle_deg = parse_transform_operations(transform)[0][1][0]
|
||||
if matrix_supported:
|
||||
child_ctx = ctx.child(
|
||||
0, 0, 1.0, 1.0,
|
||||
@@ -1483,6 +1502,7 @@ def convert_svg_to_slide_shapes(
|
||||
_require_project_stroke_styles(root, svg_path)
|
||||
_require_project_opacities(root, svg_path)
|
||||
_require_project_paints(root, svg_path)
|
||||
_require_project_masks(root, svg_path)
|
||||
_require_project_definitions(root, svg_path)
|
||||
_require_project_paint_references(root, svg_path)
|
||||
_require_project_line_end_markers(root, svg_path)
|
||||
@@ -1564,6 +1584,7 @@ def convert_svg_to_slide_shapes(
|
||||
_require_project_stroke_styles(root, svg_path)
|
||||
_require_project_opacities(root, svg_path)
|
||||
_require_project_paints(root, svg_path)
|
||||
_require_project_masks(root, svg_path)
|
||||
_require_project_definitions(root, svg_path)
|
||||
_require_project_paint_references(root, svg_path)
|
||||
_require_project_gradients(root, svg_path)
|
||||
|
||||
+646
-108
File diff suppressed because it is too large
Load Diff
+13
-12
@@ -16,7 +16,7 @@ from .utils import (
|
||||
classify_project_marker_shape,
|
||||
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_gradient_ratio, parse_project_linear_gradient_coordinate,
|
||||
parse_project_stroke_dasharray, parse_project_stroke_enum,
|
||||
quantize_ooxml_alpha, quantize_ooxml_unit_ratio,
|
||||
)
|
||||
@@ -96,17 +96,18 @@ def build_gradient_fill(
|
||||
gs_list = '\n'.join(stops_xml)
|
||||
|
||||
if tag == 'linearGradient':
|
||||
def parse_grad_coord(val_str: str, default: float = 0.0) -> float:
|
||||
val_str = val_str.strip()
|
||||
if val_str.endswith('%'):
|
||||
return float(val_str.rstrip('%')) / 100.0
|
||||
v = float(val_str)
|
||||
return v / 100.0 if v > 1.0 else v
|
||||
|
||||
x1 = parse_grad_coord(grad_elem.get('x1', '0'))
|
||||
y1 = parse_grad_coord(grad_elem.get('y1', '0'))
|
||||
x2 = parse_grad_coord(grad_elem.get('x2', '1'))
|
||||
y2 = parse_grad_coord(grad_elem.get('y2', '1'))
|
||||
x1 = parse_project_linear_gradient_coordinate(
|
||||
grad_elem.get('x1', '0')
|
||||
)
|
||||
y1 = parse_project_linear_gradient_coordinate(
|
||||
grad_elem.get('y1', '0')
|
||||
)
|
||||
x2 = parse_project_linear_gradient_coordinate(
|
||||
grad_elem.get('x2', '1')
|
||||
)
|
||||
y2 = parse_project_linear_gradient_coordinate(
|
||||
grad_elem.get('y2', '0')
|
||||
)
|
||||
|
||||
angle_rad = math.atan2(y2 - y1, x2 - x1)
|
||||
angle_deg = math.degrees(angle_rad)
|
||||
|
||||
+242
-29
@@ -147,6 +147,25 @@ _SERIF_LATIN = {
|
||||
'Book Antiqua', 'Cambria', 'SimSun', 'Liberation Serif', 'DejaVu Serif',
|
||||
}
|
||||
|
||||
# Common Office/OS faces accepted without a custom-font warning on their
|
||||
# corresponding target locale. Actual playback availability remains
|
||||
# target-specific; keep these examples aligned with strategist.md §g.
|
||||
PPT_SAFE_FONTS = frozenset({
|
||||
'microsoft yahei', 'simhei', 'simsun', 'kaiti', 'fangsong',
|
||||
'dengxian', 'microsoft jhenghei',
|
||||
'pingfang sc', 'heiti sc', 'songti sc', 'stsong',
|
||||
'yu gothic', 'yu gothic ui', 'yu mincho',
|
||||
'meiryo', 'meiryo ui',
|
||||
'ms gothic', 'ms mincho', 'ms pgothic', 'ms pmincho', 'ms ui gothic',
|
||||
'malgun gothic', 'gulim', 'dotum', 'batang',
|
||||
'arial', 'arial black', 'calibri', 'segoe ui', 'verdana',
|
||||
'helvetica', 'helvetica neue', 'tahoma', 'trebuchet ms',
|
||||
'times new roman', 'times', 'georgia', 'cambria', 'palatino',
|
||||
'garamond', 'book antiqua',
|
||||
'consolas', 'courier new', 'menlo', 'monaco',
|
||||
'impact',
|
||||
})
|
||||
|
||||
# Parsed SVG stroke-dasharray values -> DrawingML prstDash
|
||||
DASH_PRESETS = {
|
||||
(4.0, 4.0): 'dash',
|
||||
@@ -201,6 +220,9 @@ PROJECT_DEFINITION_TAGS = frozenset({
|
||||
'radialGradient',
|
||||
})
|
||||
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_FILTER_PRIMITIVES = frozenset({
|
||||
'feDropShadow',
|
||||
'feGaussianBlur',
|
||||
@@ -1079,6 +1101,22 @@ def _is_unit_axis_reflection(
|
||||
operations: tuple[tuple[str, tuple[float, ...]], ...],
|
||||
) -> bool:
|
||||
"""Return whether a transform is translation plus an unscaled axis flip."""
|
||||
has_explicit_flip = any(
|
||||
(
|
||||
name == 'scale'
|
||||
and (
|
||||
args[0] < 0
|
||||
or (len(args) > 1 and args[1] < 0)
|
||||
)
|
||||
)
|
||||
or (
|
||||
name == 'matrix'
|
||||
and (args[0] < 0 or args[3] < 0)
|
||||
)
|
||||
for name, args in operations
|
||||
)
|
||||
if not has_explicit_flip:
|
||||
return False
|
||||
matrix = _transform_operations_matrix(operations)
|
||||
a, b, c, d, _e, _f = matrix
|
||||
return (
|
||||
@@ -2202,6 +2240,36 @@ def project_paint_reference_errors(root: ET.Element) -> list[str]:
|
||||
return sorted(errors)
|
||||
|
||||
|
||||
def project_mask_errors(root: ET.Element) -> list[str]:
|
||||
"""Reject SVG masks that native PPTX conversion cannot preserve."""
|
||||
errors: set[str] = set()
|
||||
for elem in root.iter():
|
||||
label = _transform_element_label(elem)
|
||||
if _svg_element_tag(elem) == 'mask':
|
||||
errors.add(
|
||||
f'{label} is an unsupported SVG mask definition; replace it '
|
||||
'with editable overlay or Boolean/cutout shapes, an image '
|
||||
'clip-path, or pre-rendered alpha imagery'
|
||||
)
|
||||
|
||||
sources: list[str] = []
|
||||
if any(
|
||||
name.rsplit('}', 1)[-1].lower() == 'mask'
|
||||
for name in elem.attrib
|
||||
):
|
||||
sources.append('mask attribute')
|
||||
if 'mask' in parse_inline_style(elem.get('style')):
|
||||
sources.append('inline style mask property')
|
||||
if sources:
|
||||
errors.add(
|
||||
f'{label} uses unsupported SVG mask presentation via '
|
||||
f'{", ".join(sources)}; native PPTX export would drop the '
|
||||
'effect. Use editable overlay or Boolean/cutout shapes, an '
|
||||
'image clip-path, or pre-rendered alpha imagery'
|
||||
)
|
||||
return sorted(errors)
|
||||
|
||||
|
||||
def parse_project_gradient_ratio(raw: str) -> float:
|
||||
"""Parse one normalized gradient coordinate or stop offset."""
|
||||
number, unit = _parse_svg_length_parts(raw)
|
||||
@@ -2214,6 +2282,24 @@ def parse_project_gradient_ratio(raw: str) -> float:
|
||||
return number
|
||||
|
||||
|
||||
def parse_project_linear_gradient_coordinate(raw: str) -> float:
|
||||
"""Parse one objectBoundingBox linear-gradient projection coordinate."""
|
||||
number, unit = _parse_svg_length_parts(raw)
|
||||
if unit == '%':
|
||||
number /= 100.0
|
||||
elif unit:
|
||||
raise ValueError('must be unitless or a percentage')
|
||||
if not (
|
||||
PROJECT_LINEAR_GRADIENT_COORDINATE_MIN
|
||||
<= number
|
||||
<= PROJECT_LINEAR_GRADIENT_COORDINATE_MAX
|
||||
):
|
||||
raise ValueError(
|
||||
'must be within -0.105..1.105 or -10.5%..110.5%'
|
||||
)
|
||||
return number
|
||||
|
||||
|
||||
def project_gradient_errors(root: ET.Element) -> list[str]:
|
||||
"""Validate the normalized native gradient authoring interface."""
|
||||
errors: set[str] = set()
|
||||
@@ -2243,25 +2329,61 @@ def project_gradient_errors(root: ET.Element) -> list[str]:
|
||||
'use normalized objectBoundingBox coordinates'
|
||||
)
|
||||
|
||||
coordinate_names = (
|
||||
('x1', 'y1', 'x2', 'y2')
|
||||
if tag == 'linearGradient'
|
||||
else ('cx', 'cy', 'r', 'fx', 'fy')
|
||||
)
|
||||
for coordinate_name in coordinate_names:
|
||||
raw_coordinate = gradient.get(coordinate_name)
|
||||
if raw_coordinate is None:
|
||||
continue
|
||||
try:
|
||||
coordinate = parse_project_gradient_ratio(raw_coordinate)
|
||||
except ValueError:
|
||||
errors.add(
|
||||
f'{label} {coordinate_name} must be a normalized finite '
|
||||
f'value from 0 to 1 or 0% to 100%; got {raw_coordinate!r}'
|
||||
if tag == 'linearGradient':
|
||||
coordinate_defaults = {
|
||||
'x1': '0',
|
||||
'y1': '0',
|
||||
'x2': '1',
|
||||
'y2': '0',
|
||||
}
|
||||
coordinates: dict[str, float] = {}
|
||||
for coordinate_name, default in coordinate_defaults.items():
|
||||
raw_coordinate = gradient.get(coordinate_name, default)
|
||||
try:
|
||||
coordinates[coordinate_name] = (
|
||||
parse_project_linear_gradient_coordinate(raw_coordinate)
|
||||
)
|
||||
except ValueError:
|
||||
errors.add(
|
||||
f'{label} {coordinate_name} must be a finite '
|
||||
'objectBoundingBox projection coordinate within '
|
||||
'-0.105..1.105 or -10.5%..110.5%; '
|
||||
f'got {raw_coordinate!r}'
|
||||
)
|
||||
if len(coordinates) == 4 and (
|
||||
math.isclose(
|
||||
coordinates['x1'],
|
||||
coordinates['x2'],
|
||||
rel_tol=0.0,
|
||||
abs_tol=1e-12,
|
||||
)
|
||||
continue
|
||||
if coordinate_name == 'r' and coordinate <= 0:
|
||||
errors.add(f'{label} r must be greater than 0')
|
||||
and math.isclose(
|
||||
coordinates['y1'],
|
||||
coordinates['y2'],
|
||||
rel_tol=0.0,
|
||||
abs_tol=1e-12,
|
||||
)
|
||||
):
|
||||
errors.add(
|
||||
f'{label} linear gradient axis must not collapse to one '
|
||||
'point; use different x1/y1 and x2/y2 coordinates'
|
||||
)
|
||||
else:
|
||||
for coordinate_name in ('cx', 'cy', 'r', 'fx', 'fy'):
|
||||
raw_coordinate = gradient.get(coordinate_name)
|
||||
if raw_coordinate is None:
|
||||
continue
|
||||
try:
|
||||
coordinate = parse_project_gradient_ratio(raw_coordinate)
|
||||
except ValueError:
|
||||
errors.add(
|
||||
f'{label} {coordinate_name} must be a normalized finite '
|
||||
'value from 0 to 1 or 0% to 100%; '
|
||||
f'got {raw_coordinate!r}'
|
||||
)
|
||||
continue
|
||||
if coordinate_name == 'r' and coordinate <= 0:
|
||||
errors.add(f'{label} r must be greater than 0')
|
||||
|
||||
stops: list[ET.Element] = []
|
||||
for child in list(gradient):
|
||||
@@ -2275,20 +2397,35 @@ def project_gradient_errors(root: ET.Element) -> list[str]:
|
||||
)
|
||||
continue
|
||||
stops.append(child)
|
||||
if not stops:
|
||||
errors.add(f'{label} requires at least one direct <stop> child')
|
||||
if len(stops) < 2:
|
||||
errors.add(
|
||||
f'{label} requires at least two direct <stop> children for '
|
||||
'native PPTX gradient interpolation'
|
||||
)
|
||||
previous_offset: float | None = None
|
||||
for index, stop in enumerate(stops, start=1):
|
||||
stop_label = f'{label} stop #{index}'
|
||||
raw_offset = stop.get('offset')
|
||||
try:
|
||||
if raw_offset is None:
|
||||
raise ValueError
|
||||
parse_project_gradient_ratio(raw_offset)
|
||||
offset = parse_project_gradient_ratio(raw_offset)
|
||||
except ValueError:
|
||||
errors.add(
|
||||
f'{stop_label} offset must be explicit and within 0..1 '
|
||||
f'or 0%..100%; got {raw_offset!r}'
|
||||
)
|
||||
else:
|
||||
if (
|
||||
previous_offset is not None
|
||||
and offset < previous_offset
|
||||
):
|
||||
errors.add(
|
||||
f'{label} stop offsets must be non-decreasing; '
|
||||
f'stop #{index} offset {raw_offset!r} precedes a '
|
||||
f'larger offset at stop #{index - 1}'
|
||||
)
|
||||
previous_offset = offset
|
||||
style_values = parse_inline_style(stop.get('style'))
|
||||
if not (style_values.get('stop-color') or stop.get('stop-color')):
|
||||
errors.add(f'{stop_label} requires an explicit stop-color')
|
||||
@@ -2810,18 +2947,94 @@ def parse_font_family(font_family_str: str) -> dict[str, str]:
|
||||
return {'latin': final_latin, 'ea': ea_font}
|
||||
|
||||
|
||||
def is_cjk_char(ch: str) -> bool:
|
||||
"""Check if a character is CJK (Chinese/Japanese/Korean)."""
|
||||
def unsafe_exported_font_faces(font_family_str: str) -> dict[str, str]:
|
||||
"""Return resolved PPTX typefaces that require a custom installation."""
|
||||
return {
|
||||
role: family
|
||||
for role, family in parse_font_family(font_family_str).items()
|
||||
if family.strip().lower() not in PPT_SAFE_FONTS
|
||||
}
|
||||
|
||||
|
||||
def _is_han_char(ch: str) -> bool:
|
||||
"""Return whether one character belongs to a Han ideograph block."""
|
||||
cp = ord(ch)
|
||||
return (0x4E00 <= cp <= 0x9FFF or 0x3400 <= cp <= 0x4DBF or
|
||||
0x2E80 <= cp <= 0x2EFF or 0x3000 <= cp <= 0x303F or
|
||||
0xFF00 <= cp <= 0xFFEF or 0xF900 <= cp <= 0xFAFF or
|
||||
0x20000 <= cp <= 0x2A6DF)
|
||||
return (
|
||||
0x3400 <= cp <= 0x4DBF
|
||||
or 0x4E00 <= cp <= 0x9FFF
|
||||
or 0xF900 <= cp <= 0xFAFF
|
||||
or 0x20000 <= cp <= 0x2EE5F
|
||||
or 0x30000 <= cp <= 0x323AF
|
||||
)
|
||||
|
||||
|
||||
def _is_hiragana_char(ch: str) -> bool:
|
||||
cp = ord(ch)
|
||||
return (
|
||||
0x3040 <= cp <= 0x309F
|
||||
or 0x1B001 <= cp <= 0x1B11F
|
||||
)
|
||||
|
||||
|
||||
def _is_katakana_char(ch: str) -> bool:
|
||||
cp = ord(ch)
|
||||
return (
|
||||
0x30A0 <= cp <= 0x30FF
|
||||
or 0x31F0 <= cp <= 0x31FF
|
||||
or 0xFF65 <= cp <= 0xFF9F
|
||||
or 0x1AFF0 <= cp <= 0x1AFFF
|
||||
or cp == 0x1B000
|
||||
or 0x1B120 <= cp <= 0x1B16F
|
||||
)
|
||||
|
||||
|
||||
def _is_hangul_char(ch: str) -> bool:
|
||||
cp = ord(ch)
|
||||
return (
|
||||
0x1100 <= cp <= 0x11FF
|
||||
or 0x3130 <= cp <= 0x318F
|
||||
or 0xA960 <= cp <= 0xA97F
|
||||
or 0xAC00 <= cp <= 0xD7AF
|
||||
or 0xD7B0 <= cp <= 0xD7FF
|
||||
or 0xFFA0 <= cp <= 0xFFDC
|
||||
)
|
||||
|
||||
|
||||
def is_cjk_char(ch: str) -> bool:
|
||||
"""Return whether one character uses the project East Asian width model."""
|
||||
cp = ord(ch)
|
||||
return (
|
||||
_is_han_char(ch)
|
||||
or _is_hiragana_char(ch)
|
||||
or _is_katakana_char(ch)
|
||||
or _is_hangul_char(ch)
|
||||
or 0x2E80 <= cp <= 0x2FFF
|
||||
or 0x3000 <= cp <= 0x303F
|
||||
or 0x3100 <= cp <= 0x312F
|
||||
or 0x31A0 <= cp <= 0x31BF
|
||||
or 0x31C0 <= cp <= 0x31EF
|
||||
or 0xFF00 <= cp <= 0xFFEF
|
||||
)
|
||||
|
||||
|
||||
def detect_text_lang(text: str) -> str:
|
||||
"""Return a DrawingML language tag for a text run."""
|
||||
return 'zh-CN' if any(is_cjk_char(ch) for ch in text) else 'en-US'
|
||||
has_hangul = False
|
||||
has_kana = False
|
||||
has_east_asian_text = False
|
||||
for ch in text:
|
||||
has_hangul = has_hangul or _is_hangul_char(ch)
|
||||
has_kana = (
|
||||
has_kana
|
||||
or _is_hiragana_char(ch)
|
||||
or _is_katakana_char(ch)
|
||||
)
|
||||
has_east_asian_text = has_east_asian_text or is_cjk_char(ch)
|
||||
if has_hangul:
|
||||
return 'ko-KR'
|
||||
if has_kana:
|
||||
return 'ja-JP'
|
||||
return 'zh-CN' if has_east_asian_text else 'en-US'
|
||||
|
||||
|
||||
def _is_grapheme_extend(ch: str) -> bool:
|
||||
@@ -2944,7 +3157,7 @@ def split_project_text_clusters(text: str) -> list[str]:
|
||||
def resolve_text_run_fonts(text: str, fonts: dict[str, str]) -> dict[str, str]:
|
||||
"""Return DrawingML latin/ea/cs typefaces for one text run."""
|
||||
latin = fonts['latin']
|
||||
if detect_text_lang(text) == 'zh-CN':
|
||||
if detect_text_lang(text) != 'en-US':
|
||||
ea = fonts['ea']
|
||||
else:
|
||||
ea = latin
|
||||
|
||||
+196
-130
@@ -21,7 +21,6 @@ from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
@@ -29,11 +28,13 @@ from pptx import Presentation
|
||||
from pptx.util import Emu
|
||||
|
||||
from pptx_transitions import (
|
||||
MorphPairExpectation,
|
||||
NATIVE_TRANSITIONS,
|
||||
create_transition_xml,
|
||||
normalize_transition_effect_request,
|
||||
set_directory_use_timings,
|
||||
validate_generated_transition_xml,
|
||||
validate_pptx_morph_pairs,
|
||||
validate_pptx_transition_package,
|
||||
validate_seconds,
|
||||
)
|
||||
@@ -48,7 +49,13 @@ from pptx_animations import (
|
||||
validate_generated_animation_xml,
|
||||
validate_pptx_animation_package,
|
||||
)
|
||||
from pptx_opc_validation import (
|
||||
canonical_opc_part_path as _canonical_opc_part_path,
|
||||
resolve_internal_opc_target as _resolve_internal_opc_target,
|
||||
verify_internal_relationships,
|
||||
)
|
||||
|
||||
from ..animation_config import MorphPair, resolve_morph_pairs
|
||||
from ..drawingml.converter import convert_svg_to_slide_shapes
|
||||
from ..drawingml.theme_colors import (
|
||||
ThemeColorSpec,
|
||||
@@ -497,6 +504,79 @@ def _set_shape_name(elem: ET.Element, name: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _apply_morph_shape_names(
|
||||
extract_dir: Path,
|
||||
pairs: tuple[MorphPair, ...],
|
||||
slide_numbers: dict[str, int],
|
||||
shape_ids: dict[tuple[str, str], int],
|
||||
) -> dict[int, dict[str, str]]:
|
||||
"""Write forced-Morph names after all structure transformations finish."""
|
||||
assignments: dict[int, dict[str, tuple[str, str]]] = {}
|
||||
names_by_slide: dict[int, dict[str, str]] = {}
|
||||
for pair in pairs:
|
||||
for slide_name, group_id in (
|
||||
(pair.source_slide, pair.source_group_id),
|
||||
(pair.destination_slide, pair.destination_group_id),
|
||||
):
|
||||
slide_number = slide_numbers[slide_name]
|
||||
shape_id = str(shape_ids[(slide_name, group_id)])
|
||||
slide_assignments = assignments.setdefault(slide_number, {})
|
||||
previous = slide_assignments.setdefault(
|
||||
shape_id,
|
||||
(pair.shape_name, group_id),
|
||||
)
|
||||
if previous[0] != pair.shape_name:
|
||||
raise RuntimeError(
|
||||
f'Morph target "{slide_name}/{group_id}" resolves to shape '
|
||||
f'{shape_id} with conflicting names "{previous[0]}" and '
|
||||
f'"{pair.shape_name}"'
|
||||
)
|
||||
slide_names = names_by_slide.setdefault(slide_number, {})
|
||||
previous_group = slide_names.setdefault(pair.shape_name, group_id)
|
||||
if previous_group != group_id:
|
||||
raise RuntimeError(
|
||||
f'Morph name "{pair.shape_name}" maps to multiple objects '
|
||||
f'on slide "{slide_name}"'
|
||||
)
|
||||
|
||||
trace_names: dict[int, dict[str, str]] = {}
|
||||
for slide_number, slide_assignments in sorted(assignments.items()):
|
||||
slide_path = (
|
||||
extract_dir / "ppt" / "slides" / f"slide{slide_number}.xml"
|
||||
)
|
||||
tree = ET.parse(slide_path)
|
||||
root = tree.getroot()
|
||||
top_level_shapes = _top_level_shapes_by_id(root)
|
||||
desired_names = {
|
||||
shape_name
|
||||
for shape_name, _group_id in slide_assignments.values()
|
||||
}
|
||||
for shape_id, shape in top_level_shapes.items():
|
||||
if shape_id in slide_assignments:
|
||||
continue
|
||||
c_nv_pr = next(shape.iter(f"{{{PML_NS}}}cNvPr"), None)
|
||||
existing_name = (
|
||||
c_nv_pr.get("name") if c_nv_pr is not None else None
|
||||
)
|
||||
if existing_name in desired_names:
|
||||
raise RuntimeError(
|
||||
f'Morph name "{existing_name}" already belongs to an '
|
||||
f'unmapped object on slide {slide_number}'
|
||||
)
|
||||
|
||||
for shape_id, (shape_name, group_id) in slide_assignments.items():
|
||||
shape = top_level_shapes.get(shape_id)
|
||||
if shape is None:
|
||||
raise RuntimeError(
|
||||
f'Morph target "{group_id}" no longer resolves to a '
|
||||
f'Slide-local shape on slide {slide_number}'
|
||||
)
|
||||
_set_shape_name(shape, shape_name)
|
||||
trace_names.setdefault(slide_number, {})[group_id] = shape_name
|
||||
_write_xml_tree(slide_path, tree)
|
||||
return trace_names
|
||||
|
||||
|
||||
def _top_level_shape_name_roster(root: ET.Element) -> tuple[str, ...]:
|
||||
"""Return the exact visible top-level shape-name sequence for read-back."""
|
||||
sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
|
||||
@@ -4335,133 +4415,6 @@ def _prerender_legacy_pngs(
|
||||
return results
|
||||
|
||||
|
||||
_OPC_UNRESERVED = frozenset(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
_ASCII_LOWER_TRANSLATION = str.maketrans(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
"abcdefghijklmnopqrstuvwxyz",
|
||||
)
|
||||
|
||||
|
||||
def _canonical_opc_part_path(path: str) -> str | None:
|
||||
"""Return an OPC-equivalent package path key, or None when invalid."""
|
||||
if (
|
||||
not path
|
||||
or "\\" in path
|
||||
or path.endswith("/")
|
||||
or "//" in path
|
||||
or any(ord(char) <= 0x20 for char in path)
|
||||
):
|
||||
return None
|
||||
output: list[str] = []
|
||||
index = 0
|
||||
while index < len(path):
|
||||
char = path[index]
|
||||
if char != "%":
|
||||
output.append(char)
|
||||
index += 1
|
||||
continue
|
||||
if index + 2 >= len(path) or not re.fullmatch(r"[0-9A-Fa-f]{2}", path[index + 1:index + 3]):
|
||||
return None
|
||||
value = int(path[index + 1:index + 3], 16)
|
||||
decoded = chr(value)
|
||||
if value in {0, ord("/"), ord("\\")}:
|
||||
return None
|
||||
output.append(decoded if decoded in _OPC_UNRESERVED else f"%{value:02X}")
|
||||
index += 3
|
||||
|
||||
decoded_path = "".join(output)
|
||||
if decoded_path.rsplit("/", 1)[-1] in {".", ".."}:
|
||||
return None
|
||||
normalized = posixpath.normpath(decoded_path)
|
||||
if (
|
||||
not normalized
|
||||
or normalized in {".", ".."}
|
||||
or normalized.startswith("/")
|
||||
or normalized.startswith("../")
|
||||
):
|
||||
return None
|
||||
return normalized.translate(_ASCII_LOWER_TRANSLATION)
|
||||
|
||||
|
||||
def _source_part_for_rels(rels_path: str) -> str | None:
|
||||
"""Return the source part path represented by a relationship sidecar."""
|
||||
filename = posixpath.basename(rels_path)
|
||||
if filename == ".rels" or not filename.endswith(".rels"):
|
||||
return None
|
||||
source_dir = posixpath.dirname(posixpath.dirname(rels_path))
|
||||
source_name = filename[:-len(".rels")]
|
||||
return posixpath.join(source_dir, source_name) if source_dir else source_name
|
||||
|
||||
|
||||
def _resolve_internal_opc_target(rels_path: str, target: str) -> str | None:
|
||||
"""Resolve one valid internal OPC Target to its canonical package key."""
|
||||
target_path_query = target.split("#", 1)[0]
|
||||
if (
|
||||
"\\" in target
|
||||
or "?" in target_path_query
|
||||
or any(ord(char) <= 0x20 for char in target)
|
||||
):
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(target)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.scheme or parsed.netloc or parsed.query:
|
||||
return None
|
||||
|
||||
source_part = _source_part_for_rels(rels_path)
|
||||
if parsed.path.startswith("/"):
|
||||
resolved = parsed.path[1:]
|
||||
elif parsed.path:
|
||||
base_dir = posixpath.dirname(source_part) if source_part else ""
|
||||
resolved = posixpath.join(base_dir, parsed.path) if base_dir else parsed.path
|
||||
elif source_part and "#" in target:
|
||||
resolved = source_part
|
||||
else:
|
||||
return None
|
||||
return _canonical_opc_part_path(resolved)
|
||||
|
||||
|
||||
def _verify_internal_rels_targets(extract_dir: Path) -> list[str]:
|
||||
"""Return a list of dangling internal Targets across every .rels in the package.
|
||||
|
||||
Each entry is formatted as "<rels-path> -> <missing-target>". An empty list
|
||||
means every internal Target resolves to a real file in the package.
|
||||
"""
|
||||
package_parts: set[str] = set()
|
||||
for path in extract_dir.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
key = _canonical_opc_part_path(path.relative_to(extract_dir).as_posix())
|
||||
if key is not None:
|
||||
package_parts.add(key)
|
||||
problems: list[str] = []
|
||||
for rels_path in extract_dir.rglob('*.rels'):
|
||||
rels_rel = rels_path.relative_to(extract_dir).as_posix()
|
||||
try:
|
||||
root = ET.parse(rels_path).getroot()
|
||||
except ET.ParseError as exc:
|
||||
problems.append(f'{rels_rel} -> <invalid relationships XML: {exc}>')
|
||||
continue
|
||||
for elem in root:
|
||||
attrs = _relationship_attrs(elem)
|
||||
if attrs.get('TargetMode', '').lower() == 'external':
|
||||
continue
|
||||
target = attrs.get('Target')
|
||||
if not target:
|
||||
problems.append(f'{rels_rel} -> <missing Target>')
|
||||
continue
|
||||
resolved = _resolve_internal_opc_target(rels_rel, target)
|
||||
if resolved is None:
|
||||
problems.append(f'{rels_rel} -> <invalid Target {target!r}>')
|
||||
continue
|
||||
if resolved not in package_parts:
|
||||
problems.append(f'{rels_rel} -> {resolved}')
|
||||
return problems
|
||||
|
||||
|
||||
def _presentation_format(width: float, height: float) -> str:
|
||||
"""Map the slide aspect ratio to PowerPoint's PresentationFormat label.
|
||||
Non-standard ratios (square, portrait, banner crops) report 'Custom'.
|
||||
@@ -4743,6 +4696,41 @@ def create_pptx_with_native_svg(
|
||||
"""
|
||||
public_svg_files = list(svg_files)
|
||||
definition_svg_files = list(layout_definition_files or [])
|
||||
public_slide_names = [path.stem for path in public_svg_files]
|
||||
morph_pairs = resolve_morph_pairs(
|
||||
public_slide_names,
|
||||
animation_config,
|
||||
)
|
||||
public_slide_numbers = {
|
||||
slide_name: slide_number
|
||||
for slide_number, slide_name in enumerate(public_slide_names, 1)
|
||||
}
|
||||
morph_expectations = tuple(
|
||||
MorphPairExpectation(
|
||||
source_slide_number=public_slide_numbers[pair.source_slide],
|
||||
destination_slide_number=public_slide_numbers[
|
||||
pair.destination_slide
|
||||
],
|
||||
key=pair.key,
|
||||
)
|
||||
for pair in morph_pairs
|
||||
)
|
||||
morph_pairs_by_destination: dict[str, list[MorphPair]] = {}
|
||||
morph_group_overrides_by_slide: dict[str, set[str]] = {}
|
||||
for pair in morph_pairs:
|
||||
morph_pairs_by_destination.setdefault(
|
||||
pair.destination_slide,
|
||||
[],
|
||||
).append(pair)
|
||||
morph_group_overrides_by_slide.setdefault(
|
||||
pair.source_slide,
|
||||
set(),
|
||||
).add(pair.source_group_id)
|
||||
morph_group_overrides_by_slide.setdefault(
|
||||
pair.destination_slide,
|
||||
set(),
|
||||
).add(pair.destination_group_id)
|
||||
morph_shape_ids: dict[tuple[str, str], int] = {}
|
||||
if definition_svg_files and pptx_structure != "structured":
|
||||
raise ValueError(
|
||||
"layout_definition_files requires pptx_structure='structured'"
|
||||
@@ -5099,6 +5087,20 @@ def create_pptx_with_native_svg(
|
||||
animation_trigger,
|
||||
animation_cli_overrides,
|
||||
)
|
||||
if morph_pairs_by_destination.get(svg_path.stem):
|
||||
if (
|
||||
slide_transition != "morph"
|
||||
or slide_transition_effect_options.get(
|
||||
"morph_by",
|
||||
"object",
|
||||
)
|
||||
!= "object"
|
||||
):
|
||||
raise ValueError(
|
||||
f'animations.json slide "{svg_path.stem}" '
|
||||
'declares deterministic Morph pairs, but '
|
||||
'the resolved transition is not Morph by object'
|
||||
)
|
||||
groups_value = slide_cfg.get('groups', {})
|
||||
if not isinstance(groups_value, dict):
|
||||
raise ValueError(
|
||||
@@ -5128,6 +5130,15 @@ def create_pptx_with_native_svg(
|
||||
if not animation_hard_disabled
|
||||
else frozenset()
|
||||
)
|
||||
converter_group_overrides = (
|
||||
explicit_animation_groups
|
||||
| frozenset(
|
||||
morph_group_overrides_by_slide.get(
|
||||
svg_path.stem,
|
||||
set(),
|
||||
)
|
||||
)
|
||||
)
|
||||
(
|
||||
slide_xml,
|
||||
media_files_dict,
|
||||
@@ -5145,7 +5156,7 @@ def create_pptx_with_native_svg(
|
||||
image_scale=image_scale,
|
||||
image_quality=image_quality,
|
||||
native_objects=native_objects,
|
||||
animation_group_overrides=explicit_animation_groups,
|
||||
animation_group_overrides=converter_group_overrides,
|
||||
theme_font_spec=active_theme_font_spec,
|
||||
theme_color_spec=active_theme_color_spec,
|
||||
promote_background=pptx_structure != "structured",
|
||||
@@ -5154,6 +5165,31 @@ def create_pptx_with_native_svg(
|
||||
else structure_trace,
|
||||
)
|
||||
)
|
||||
morph_group_ids = morph_group_overrides_by_slide.get(
|
||||
svg_path.stem,
|
||||
set(),
|
||||
)
|
||||
if morph_group_ids:
|
||||
target_ids_by_group: dict[str, list[int]] = {}
|
||||
for shape_id, group_id in anim_targets:
|
||||
target_ids_by_group.setdefault(
|
||||
str(group_id),
|
||||
[],
|
||||
).append(int(shape_id))
|
||||
for group_id in sorted(morph_group_ids):
|
||||
resolved_shape_ids = target_ids_by_group.get(
|
||||
group_id,
|
||||
[],
|
||||
)
|
||||
if len(resolved_shape_ids) != 1:
|
||||
raise ValueError(
|
||||
f'Morph target "{svg_path.stem}/{group_id}" '
|
||||
'must resolve to exactly one Slide-local '
|
||||
'PowerPoint shape'
|
||||
)
|
||||
morph_shape_ids[
|
||||
(svg_path.stem, group_id)
|
||||
] = resolved_shape_ids[0]
|
||||
# Order matters: OOXML schema requires <p:transition>
|
||||
# to precede <p:timing> inside <p:sld>. Both use the same
|
||||
# </p:sld> string-replace anchor, so transition must be
|
||||
@@ -5694,6 +5730,27 @@ def create_pptx_with_native_svg(
|
||||
f"{pruned_payload_parts} orphan payload part(s)"
|
||||
)
|
||||
|
||||
morph_trace_names = _apply_morph_shape_names(
|
||||
extract_dir,
|
||||
morph_pairs,
|
||||
public_slide_numbers,
|
||||
morph_shape_ids,
|
||||
)
|
||||
if template_shape_roster_expectations is not None:
|
||||
for slide_number in morph_trace_names:
|
||||
slide_part = f"ppt/slides/slide{slide_number}.xml"
|
||||
template_shape_roster_expectations[
|
||||
slide_part
|
||||
] = _top_level_shape_name_roster(
|
||||
ET.parse(extract_dir / slide_part).getroot()
|
||||
)
|
||||
if conversion_trace is not None:
|
||||
for trace_entry in conversion_trace:
|
||||
slide_number = int(trace_entry.get("slide_num", 0))
|
||||
names = morph_trace_names.get(slide_number)
|
||||
if names:
|
||||
trace_entry["morph_names"] = dict(sorted(names.items()))
|
||||
|
||||
# Update [Content_Types].xml
|
||||
content_types_path = extract_dir / '[Content_Types].xml'
|
||||
with open(content_types_path, 'r', encoding='utf-8') as f:
|
||||
@@ -5765,7 +5822,7 @@ def create_pptx_with_native_svg(
|
||||
if package_uses_timings:
|
||||
set_directory_use_timings(extract_dir)
|
||||
|
||||
rels_problems = _verify_internal_rels_targets(extract_dir)
|
||||
rels_problems = verify_internal_relationships(extract_dir)
|
||||
if rels_problems:
|
||||
details = '\n'.join(f' - {p}' for p in rels_problems)
|
||||
raise RuntimeError(
|
||||
@@ -5819,6 +5876,15 @@ def create_pptx_with_native_svg(
|
||||
raise RuntimeError(
|
||||
f'PPTX transition package validation failed: {exc}'
|
||||
) from exc
|
||||
try:
|
||||
validate_pptx_morph_pairs(
|
||||
temp_output_path,
|
||||
morph_expectations,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f'PPTX Morph package validation failed: {exc}'
|
||||
) from exc
|
||||
try:
|
||||
validate_pptx_animation_package(
|
||||
temp_output_path,
|
||||
|
||||
+24
-3
@@ -59,6 +59,7 @@ from ..drawingml.theme_fonts import (
|
||||
load_master_text_style_spec,
|
||||
load_theme_font_spec,
|
||||
)
|
||||
from ..drawingml.utils import unsafe_exported_font_faces
|
||||
from .narration import NARRATION_EXTENSIONS, find_narration_files, probe_audio_duration
|
||||
from .template_structure import (
|
||||
TemplateStructureError,
|
||||
@@ -211,11 +212,21 @@ def _source_resource_audit(svg_files: list[Path]) -> dict[str, object]:
|
||||
for stack in font_stacks
|
||||
if _font_stack_is_generic_only(stack)
|
||||
})
|
||||
unsafe_font_faces = [
|
||||
{
|
||||
'stack': stack,
|
||||
'role': role,
|
||||
'typeface': typeface,
|
||||
}
|
||||
for stack in sorted(font_stacks)
|
||||
for role, typeface in unsafe_exported_font_faces(stack).items()
|
||||
]
|
||||
return {
|
||||
'unresolved_template_tokens': placeholders,
|
||||
'fonts': {
|
||||
'stacks': sorted(font_stacks),
|
||||
'generic_only_stacks': generic_only_font_stacks,
|
||||
'unsafe_exported_faces': unsafe_font_faces,
|
||||
},
|
||||
'images': {
|
||||
**image_counts,
|
||||
@@ -298,6 +309,7 @@ def _postflight_warning_summaries(
|
||||
unresolved_token_count: int,
|
||||
external_image_count: int,
|
||||
generic_font_stack_count: int,
|
||||
unsafe_font_face_count: int,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return stable warning summaries for the terminal receipt."""
|
||||
warnings: list[str] = []
|
||||
@@ -311,6 +323,8 @@ def _postflight_warning_summaries(
|
||||
warnings.append(f'external_images={external_image_count}')
|
||||
if generic_font_stack_count:
|
||||
warnings.append(f'generic_only_font_stacks={generic_font_stack_count}')
|
||||
if unsafe_font_face_count:
|
||||
warnings.append(f'unsafe_exported_font_faces={unsafe_font_face_count}')
|
||||
return tuple(warnings)
|
||||
|
||||
|
||||
@@ -370,12 +384,14 @@ def _write_postflight_report(
|
||||
unresolved_tokens = source_audit['unresolved_template_tokens']
|
||||
external_image_count = source_audit['images']['external']
|
||||
generic_only_font_stacks = source_audit['fonts']['generic_only_stacks']
|
||||
unsafe_font_faces = source_audit['fonts']['unsafe_exported_faces']
|
||||
if quality_gate == 'failed':
|
||||
report_status = 'failed'
|
||||
elif (
|
||||
not unresolved_tokens
|
||||
and not external_image_count
|
||||
and not generic_only_font_stacks
|
||||
and not unsafe_font_faces
|
||||
and not introduced_warning_count
|
||||
and quality_gate == 'passed'
|
||||
):
|
||||
@@ -420,7 +436,9 @@ def _write_postflight_report(
|
||||
'passed' if not external_image_count else 'warning'
|
||||
),
|
||||
'font_portability': (
|
||||
'passed' if not generic_only_font_stacks else 'warning'
|
||||
'passed'
|
||||
if not generic_only_font_stacks and not unsafe_font_faces
|
||||
else 'warning'
|
||||
),
|
||||
},
|
||||
'quality': quality,
|
||||
@@ -443,6 +461,7 @@ def _write_postflight_report(
|
||||
unresolved_token_count=len(unresolved_tokens),
|
||||
external_image_count=external_image_count,
|
||||
generic_font_stack_count=len(generic_only_font_stacks),
|
||||
unsafe_font_face_count=len(unsafe_font_faces),
|
||||
)
|
||||
return _PostflightReceipt(
|
||||
output_path=output_path,
|
||||
@@ -838,9 +857,11 @@ Recorded narration:
|
||||
parser.add_argument('--no-image-optimize', action='store_true',
|
||||
help='Disable native PPTX raster image optimization; embeds original image bytes.')
|
||||
parser.add_argument('--image-max-dimension', type=int, default=2560,
|
||||
help='Maximum optimized raster image dimension in pixels (default: 2560).')
|
||||
help='Preferred optimized raster cap in pixels; cap mode may retain more '
|
||||
'for cropped/stretched effective resolution (default: 2560).')
|
||||
parser.add_argument('--image-sizing', choices=['cap', 'display'], default='cap',
|
||||
help='Raster sizing mode: cap only limits source dimensions; '
|
||||
help='Raster sizing mode: cap limits source dimensions without '
|
||||
'undersupplying cropped/stretched visible pixels; '
|
||||
'display sizes from the SVG rendered box (default: cap).')
|
||||
parser.add_argument('--image-scale', type=float, default=2.0,
|
||||
help='Target optimized image pixels per SVG display pixel '
|
||||
|
||||
+98
-9
@@ -2,10 +2,10 @@
|
||||
"""
|
||||
PPT Master - Shape Boolean Core
|
||||
|
||||
Resolve closed SVG operands into root-coordinate paths and apply
|
||||
Resolve closed SVG operands into SVG-root-coordinate paths and apply
|
||||
PowerPoint-compatible merge-shapes operations without mutating the source SVG.
|
||||
Callers insert returned paths at the SVG root in the primary operand's z-order;
|
||||
reinserting them below an original transformed ancestor would transform twice.
|
||||
Callers insert returned paths at the original z-order under the final semantic
|
||||
or structured parent, never under the old transformed ancestor.
|
||||
|
||||
Usage:
|
||||
Import render_boolean_svg_fragments from svg_to_pptx.shape_boolean.
|
||||
@@ -49,10 +49,13 @@ from .drawingml.paths import (
|
||||
transform_path_commands,
|
||||
)
|
||||
from .drawingml.utils import (
|
||||
format_project_geometry_length,
|
||||
matrix_multiply,
|
||||
parse_inline_style,
|
||||
parse_opacity,
|
||||
parse_project_geometry_length,
|
||||
parse_project_stroke_dasharray,
|
||||
parse_project_stroke_enum,
|
||||
parse_transform_matrix,
|
||||
)
|
||||
|
||||
@@ -75,7 +78,7 @@ _DEFINITION_TAGS = frozenset({
|
||||
"symbol",
|
||||
})
|
||||
_ID_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.:-]*")
|
||||
_PRESENTATION_ATTRS = (
|
||||
_STYLE_OVERRIDE_ATTRS = (
|
||||
"fill",
|
||||
"fill-opacity",
|
||||
"opacity",
|
||||
@@ -86,6 +89,10 @@ _PRESENTATION_ATTRS = (
|
||||
"stroke-linecap",
|
||||
"stroke-linejoin",
|
||||
)
|
||||
_PRESENTATION_ATTRS = (
|
||||
*_STYLE_OVERRIDE_ATTRS,
|
||||
"vector-effect",
|
||||
)
|
||||
_ROUNDTRIP_TRANSPORT_ATTRS = frozenset({
|
||||
"data-pptx-custgeom",
|
||||
"data-pptx-custgeom-ref",
|
||||
@@ -115,8 +122,9 @@ def render_boolean_svg_fragments(
|
||||
|
||||
The first source is the primary shape: subtract removes all later sources
|
||||
from it, and every result inherits its effective presentation attributes.
|
||||
All transforms are baked into root SVG coordinates, so callers must insert
|
||||
the result at the root in the primary operand's z-order. Fragment returns
|
||||
All transforms are baked into SVG-root coordinates, so callers insert the
|
||||
result at the original z-order under the final semantic or structured
|
||||
parent, never under the old transformed ancestor. Fragment returns
|
||||
top-to-bottom, left-to-right sibling paths named ``<output_id>-1`` onward;
|
||||
every other operation returns one path named exactly ``output_id``.
|
||||
"""
|
||||
@@ -167,7 +175,10 @@ def render_boolean_svg_fragments(
|
||||
_element_to_pathops(element, parents, pathops)
|
||||
for element in selected
|
||||
]
|
||||
inherited_style = _effective_style(selected[0], parents)
|
||||
inherited_style = _materialize_baked_stroke_style(
|
||||
_effective_style(selected[0], parents),
|
||||
_combined_transform(_element_chain(selected[0], parents)),
|
||||
)
|
||||
inherited_style.update(style_overrides)
|
||||
|
||||
if normalized_operation == "fragment":
|
||||
@@ -227,14 +238,14 @@ def _validate_style_overrides(
|
||||
) -> dict[str, str]:
|
||||
if style is None:
|
||||
return {}
|
||||
unknown = sorted(set(style) - set(_PRESENTATION_ATTRS))
|
||||
unknown = sorted(set(style) - set(_STYLE_OVERRIDE_ATTRS))
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
"Unsupported shape Boolean style override(s): "
|
||||
+ ", ".join(unknown)
|
||||
)
|
||||
normalized: dict[str, str] = {}
|
||||
for name in _PRESENTATION_ATTRS:
|
||||
for name in _STYLE_OVERRIDE_ATTRS:
|
||||
if name not in style:
|
||||
continue
|
||||
value = style[name]
|
||||
@@ -443,6 +454,16 @@ def _validate_geometry_presentation(
|
||||
f"Shape Boolean source {_element_label(element)} uses filter; "
|
||||
"reapply the effect after materializing the result"
|
||||
)
|
||||
raw_dash_offset = inline.get(
|
||||
"stroke-dashoffset",
|
||||
current.get("stroke-dashoffset"),
|
||||
)
|
||||
if raw_dash_offset is not None:
|
||||
raise ValueError(
|
||||
f"Shape Boolean source {_element_label(element)} uses "
|
||||
"stroke-dashoffset; dashed-arc phase cannot be materialized "
|
||||
"as filled Boolean geometry"
|
||||
)
|
||||
raw_fill_rule = inline.get("fill-rule", current.get("fill-rule"))
|
||||
if (
|
||||
raw_fill_rule is not None
|
||||
@@ -468,6 +489,74 @@ def _combined_transform(chain: Sequence[ET.Element]) -> AffineMatrix:
|
||||
return matrix
|
||||
|
||||
|
||||
def _materialize_baked_stroke_style(
|
||||
style: Mapping[str, str],
|
||||
matrix: AffineMatrix,
|
||||
) -> dict[str, str]:
|
||||
"""Keep stroke metrics visually stable after baking a geometry transform."""
|
||||
materialized = dict(style)
|
||||
raw_vector_effect = materialized.get("vector-effect")
|
||||
vector_effect = None
|
||||
if raw_vector_effect is not None:
|
||||
try:
|
||||
vector_effect = parse_project_stroke_enum(
|
||||
"vector-effect",
|
||||
raw_vector_effect,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Shape Boolean primary vector-effect "
|
||||
f"{raw_vector_effect!r} {exc}"
|
||||
) from exc
|
||||
materialized["vector-effect"] = vector_effect
|
||||
|
||||
stroke = materialized.get("stroke")
|
||||
if not stroke or stroke.strip().lower() in {"none", "transparent"}:
|
||||
return materialized
|
||||
if vector_effect == "non-scaling-stroke":
|
||||
return materialized
|
||||
|
||||
a, b, c, d, _e, _f = matrix
|
||||
scale = math.sqrt(abs(a * d - b * c))
|
||||
if not math.isfinite(scale):
|
||||
raise ValueError(
|
||||
"Shape Boolean primary transform produces a non-finite stroke scale"
|
||||
)
|
||||
if scale == 1.0:
|
||||
return materialized
|
||||
|
||||
raw_width = materialized.get("stroke-width", "1")
|
||||
try:
|
||||
source_width = parse_project_geometry_length(
|
||||
raw_width,
|
||||
"stroke-width",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Shape Boolean primary stroke-width {raw_width!r} {exc}"
|
||||
) from exc
|
||||
materialized["stroke-width"] = format_project_geometry_length(
|
||||
source_width * scale
|
||||
)
|
||||
|
||||
raw_dasharray = materialized.get("stroke-dasharray")
|
||||
if raw_dasharray is None or raw_dasharray.strip().lower() == "none":
|
||||
return materialized
|
||||
try:
|
||||
parsed_dasharray = parse_project_stroke_dasharray(raw_dasharray)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Shape Boolean primary stroke-dasharray {raw_dasharray!r} {exc}"
|
||||
) from exc
|
||||
if parsed_dasharray is not None:
|
||||
_preset, values = parsed_dasharray
|
||||
materialized["stroke-dasharray"] = " ".join(
|
||||
format_project_geometry_length(value * scale)
|
||||
for value in values
|
||||
)
|
||||
return materialized
|
||||
|
||||
|
||||
def _shape_commands(element: ET.Element) -> list[PathCommand]:
|
||||
tag = _local_name(element.tag)
|
||||
if tag == "path":
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PPT Master - Repository Updater
|
||||
|
||||
Pull the latest Git checkout and sync Python dependencies when requirements
|
||||
change.
|
||||
Pull the latest Git checkout and sync Python dependencies when the effective
|
||||
requirements include tree changes.
|
||||
|
||||
Usage:
|
||||
python3 skills/ppt-master/scripts/update_repo.py
|
||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -59,14 +60,14 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Pull the latest repository changes and sync Python dependencies "
|
||||
"only when requirements.txt changes."
|
||||
"only when the requirements include tree changes."
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-pip",
|
||||
action="store_true",
|
||||
help="Skip Python dependency sync even if requirements.txt changed.",
|
||||
help="Skip Python dependency sync even if the requirements include tree changed.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
@@ -88,14 +89,75 @@ def run_command(args: list[str], *, check: bool = True) -> subprocess.CompletedP
|
||||
)
|
||||
|
||||
|
||||
def file_digest(path: Path) -> str | None:
|
||||
def _requirement_includes(path: Path, content: str) -> list[Path]:
|
||||
"""Resolve local -r/--requirement includes relative to their owning file."""
|
||||
includes: list[Path] = []
|
||||
for raw_line in content.splitlines():
|
||||
try:
|
||||
tokens = shlex.split(raw_line, comments=True, posix=True)
|
||||
except ValueError:
|
||||
continue
|
||||
if not tokens:
|
||||
continue
|
||||
|
||||
option = tokens[0]
|
||||
include_value: str | None = None
|
||||
if option in {"-r", "--requirement"}:
|
||||
if len(tokens) >= 2:
|
||||
include_value = tokens[1]
|
||||
elif option.startswith("--requirement="):
|
||||
include_value = option.partition("=")[2]
|
||||
elif option.startswith("-r") and len(option) > 2:
|
||||
include_value = option[2:].lstrip("=")
|
||||
|
||||
if not include_value:
|
||||
continue
|
||||
include_path = Path(include_value)
|
||||
if not include_path.is_absolute():
|
||||
include_path = path.parent / include_path
|
||||
includes.append(include_path)
|
||||
return includes
|
||||
|
||||
|
||||
def requirements_digest(path: Path) -> str | None:
|
||||
"""Hash one requirements file and its recursive local include closure."""
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(8192), b""):
|
||||
digest.update(chunk)
|
||||
visited: set[Path] = set()
|
||||
|
||||
def visit(current: Path) -> None:
|
||||
try:
|
||||
resolved = current.resolve()
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
f"Unable to resolve requirements file {current}: {exc}"
|
||||
) from exc
|
||||
if resolved in visited:
|
||||
digest.update(b"repeat\0")
|
||||
return
|
||||
visited.add(resolved)
|
||||
|
||||
if not resolved.is_file():
|
||||
digest.update(b"missing\0")
|
||||
return
|
||||
try:
|
||||
content = resolved.read_bytes()
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
f"Unable to read requirements file {resolved}: {exc}"
|
||||
) from exc
|
||||
digest.update(b"file\0")
|
||||
digest.update(len(content).to_bytes(8, "big"))
|
||||
digest.update(content)
|
||||
for included in _requirement_includes(
|
||||
resolved,
|
||||
content.decode("utf-8", errors="replace"),
|
||||
):
|
||||
visit(included)
|
||||
|
||||
visit(path)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
@@ -131,7 +193,7 @@ def sync_python_dependencies() -> None:
|
||||
print_status("requirements.txt not found; skipping Python dependency sync.")
|
||||
return
|
||||
|
||||
print_status("requirements.txt changed. Syncing Python dependencies...")
|
||||
print_status("Requirements include tree changed. Syncing Python dependencies...")
|
||||
result = run_command([sys.executable, "-m", "pip", "install", "-r", str(REQUIREMENTS_FILE)])
|
||||
if result.stdout.strip():
|
||||
print_status(result.stdout.strip())
|
||||
@@ -148,7 +210,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
ensure_clean_tracked_worktree()
|
||||
|
||||
before_head = get_head_revision()
|
||||
before_requirements = file_digest(REQUIREMENTS_FILE)
|
||||
before_requirements = requirements_digest(REQUIREMENTS_FILE)
|
||||
|
||||
print_status(f"Repository: {REPO_ROOT}")
|
||||
pull_result = run_command(["git", "pull", "--ff-only"])
|
||||
@@ -158,7 +220,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print_status(pull_result.stderr.strip())
|
||||
|
||||
after_head = get_head_revision()
|
||||
after_requirements = file_digest(REQUIREMENTS_FILE)
|
||||
after_requirements = requirements_digest(REQUIREMENTS_FILE)
|
||||
|
||||
if before_head == after_head:
|
||||
print_status("Repository is already up to date.")
|
||||
@@ -170,7 +232,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
elif before_requirements != after_requirements:
|
||||
sync_python_dependencies()
|
||||
else:
|
||||
print_status("requirements.txt unchanged. Skipping Python dependency sync.")
|
||||
print_status(
|
||||
"Requirements include tree unchanged. Skipping Python dependency sync."
|
||||
)
|
||||
|
||||
print_status(
|
||||
"Note: system dependencies such as Node.js and Pandoc still need "
|
||||
|
||||
@@ -4,21 +4,23 @@
|
||||
Examples:
|
||||
python3 update_spec.py <project_path> primary=#0066AA
|
||||
python3 update_spec.py <project_path> colors.text=#111111
|
||||
python3 update_spec.py <project_path> typography.font_family='"PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
python3 update_spec.py <project_path> \\
|
||||
typography.font_family='Arial, "Microsoft YaHei", sans-serif'
|
||||
|
||||
v2 scope:
|
||||
- `colors.*` — HEX value replacement across svg_output/*.svg (case-insensitive match).
|
||||
- `typography.font_family` — replaces the inner value of every `font-family="..."`
|
||||
/ `font-family='...'` attribute in svg_output/*.svg. This is a global replace:
|
||||
every text element becomes the new family, regardless of role.
|
||||
every text element becomes the new family, regardless of role, and every
|
||||
existing `typography.*_family` lock row is updated to the same value.
|
||||
|
||||
Bare `key=value` (no dot) is treated as `colors.key=value` for backward compat.
|
||||
|
||||
Other keys (typography sizes, per-role `typography.*_family` overrides, icons,
|
||||
images, canvas, forbidden) are intentionally NOT supported — they involve
|
||||
attribute-scoped or semantic replacements whose risk/benefit does not warrant
|
||||
bulk propagation. For per-role family changes, edit spec_lock.md and re-author
|
||||
the affected pages.
|
||||
Other keys as independent targets (typography sizes, per-role
|
||||
`typography.*_family` overrides, icons, images, canvas, forbidden) are
|
||||
intentionally NOT supported — they involve attribute-scoped or semantic
|
||||
replacements whose risk/benefit does not warrant bulk propagation. For
|
||||
per-role family changes, edit spec_lock.md and re-author the affected pages.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -36,23 +38,42 @@ HEX_RE = re.compile(r"^#(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
|
||||
FONT_FAMILY_RE = re.compile(r"""(font-family\s*=\s*)(["'])(.*?)\2""")
|
||||
|
||||
|
||||
def rewrite_lock(lock_path: Path, section: str, key: str, new_value: str) -> None:
|
||||
"""Rewrite the single `- key: old_value` line under `## section`."""
|
||||
def plan_lock_values(
|
||||
lock_path: Path, section: str, updates: dict[str, str]
|
||||
) -> str:
|
||||
"""Return a lock rewrite only after every target row validates."""
|
||||
lines = lock_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
in_section = False
|
||||
found: set[str] = set()
|
||||
for i, raw in enumerate(lines):
|
||||
stripped = raw.rstrip("\n")
|
||||
stripped = raw.rstrip("\r\n")
|
||||
line_ending = raw[len(stripped) :]
|
||||
if stripped.startswith("## "):
|
||||
in_section = stripped[3:].strip() == section
|
||||
continue
|
||||
if not in_section:
|
||||
continue
|
||||
m = re.match(r"^(-\s+)([A-Za-z0-9_]+)(\s*:\s*)(.+?)(\s*)$", stripped)
|
||||
if m and m.group(2) == key:
|
||||
lines[i] = f"{m.group(1)}{m.group(2)}{m.group(3)}{new_value}{m.group(5)}\n"
|
||||
lock_path.write_text("".join(lines), encoding="utf-8")
|
||||
return
|
||||
raise KeyError(f"key {key!r} not found under section {section!r} in {lock_path}")
|
||||
if not m or m.group(2) not in updates:
|
||||
continue
|
||||
key = m.group(2)
|
||||
if key in found:
|
||||
raise ValueError(
|
||||
f"duplicate key {key!r} under section {section!r} in {lock_path}"
|
||||
)
|
||||
found.add(key)
|
||||
lines[i] = (
|
||||
f"{m.group(1)}{key}{m.group(3)}{updates[key]}{m.group(5)}"
|
||||
f"{line_ending}"
|
||||
)
|
||||
|
||||
missing = set(updates) - found
|
||||
if missing:
|
||||
missing_list = ", ".join(sorted(missing))
|
||||
raise KeyError(
|
||||
f"key(s) {missing_list} not found under section {section!r} in {lock_path}"
|
||||
)
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def replace_color_in_svgs(
|
||||
@@ -76,7 +97,10 @@ def replace_color_in_svgs(
|
||||
"""
|
||||
if not HEX_RE.match(old_hex) or not HEX_RE.match(new_hex):
|
||||
raise ValueError(f"not a HEX color: old={old_hex!r} new={new_hex!r}")
|
||||
pattern = re.compile(re.escape(old_hex), re.IGNORECASE)
|
||||
pattern = re.compile(
|
||||
rf"{re.escape(old_hex)}(?![0-9A-Fa-f])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
planned: list[tuple[Path, str, int]] = []
|
||||
for svg in sorted(svg_dir.glob("*.svg")):
|
||||
text = svg.read_text(encoding="utf-8")
|
||||
@@ -136,8 +160,11 @@ def main() -> int:
|
||||
ap.add_argument("project_path", type=Path, help="project folder containing spec_lock.md and svg_output/")
|
||||
ap.add_argument(
|
||||
"assignment",
|
||||
help="section.key=value (e.g. colors.primary=#0066AA, typography.font_family='\"Inter\", Arial, sans-serif'). "
|
||||
"Bare key=value is treated as colors.key=value.",
|
||||
help=(
|
||||
"section.key=value (e.g. colors.primary=#0066AA, "
|
||||
"typography.font_family='Arial, \"Microsoft YaHei\", sans-serif'). "
|
||||
"Bare key=value is treated as colors.key=value."
|
||||
),
|
||||
)
|
||||
ap.add_argument(
|
||||
"--dry-run",
|
||||
@@ -183,6 +210,7 @@ def main() -> int:
|
||||
return 2
|
||||
|
||||
old_value = section_map[key]
|
||||
lock_changes: list[tuple[str, str, str]] = []
|
||||
|
||||
if section == "colors":
|
||||
if not HEX_RE.match(new_value):
|
||||
@@ -195,20 +223,41 @@ def main() -> int:
|
||||
# avoids a state where lock claims new_value but SVGs still hold
|
||||
# old_value — that state silences re-runs (parse_lock would then
|
||||
# see new_value == old_value and exit early).
|
||||
try:
|
||||
planned_lock = plan_lock_values(lock, "colors", {key: new_value})
|
||||
except (KeyError, ValueError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
changed = replace_color_in_svgs(svg_dir, old_value, new_value, dry_run=args.dry_run)
|
||||
lock_changes = [(key, old_value, new_value)]
|
||||
if not args.dry_run:
|
||||
rewrite_lock(lock, "colors", key, new_value)
|
||||
lock.write_text(planned_lock, encoding="utf-8")
|
||||
elif section == "typography" and key == "font_family":
|
||||
if old_value == new_value:
|
||||
print(f"no change: typography.font_family already = {new_value}")
|
||||
family_keys = [
|
||||
name
|
||||
for name in section_map
|
||||
if name == "font_family" or name.endswith("_family")
|
||||
]
|
||||
lock_changes = [
|
||||
(name, section_map[name], new_value)
|
||||
for name in family_keys
|
||||
if section_map[name] != new_value
|
||||
]
|
||||
if not lock_changes:
|
||||
print(f"no change: all typography.*_family rows already = {new_value}")
|
||||
return 0
|
||||
try:
|
||||
planned_lock = plan_lock_values(
|
||||
lock,
|
||||
"typography",
|
||||
{name: new_value for name in family_keys},
|
||||
)
|
||||
changed = replace_font_family_in_svgs(svg_dir, new_value, dry_run=args.dry_run)
|
||||
except ValueError as e:
|
||||
except (KeyError, ValueError) as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 2
|
||||
if not args.dry_run:
|
||||
rewrite_lock(lock, "typography", key, new_value)
|
||||
lock.write_text(planned_lock, encoding="utf-8")
|
||||
else:
|
||||
print(
|
||||
f"error: {section}.{key} is not supported by update_spec.py.\n"
|
||||
@@ -219,10 +268,17 @@ def main() -> int:
|
||||
return 2
|
||||
|
||||
if args.dry_run:
|
||||
print(f"[dry-run] spec_lock.md: {section}.{key} {old_value} → {new_value}")
|
||||
for lock_key, previous, replacement in lock_changes:
|
||||
print(
|
||||
f"[dry-run] spec_lock.md: "
|
||||
f"{section}.{lock_key} {previous} → {replacement}"
|
||||
)
|
||||
print(f"[dry-run] svg_output/: {len(changed)} file(s) would be updated")
|
||||
else:
|
||||
print(f"spec_lock.md: {section}.{key} {old_value} → {new_value}")
|
||||
for lock_key, previous, replacement in lock_changes:
|
||||
print(
|
||||
f"spec_lock.md: {section}.{lock_key} {previous} → {replacement}"
|
||||
)
|
||||
print(f"svg_output/: {len(changed)} file(s) updated")
|
||||
for p, n in changed:
|
||||
suffix = "replacement" if n == 1 else "replacements"
|
||||
|
||||
+3
-3
@@ -35,10 +35,10 @@ The first two rows are literal Claude asset facts. Background and neutral rows a
|
||||
|
||||
| Role | Family | Weight |
|
||||
|---|---|---|
|
||||
| title | `"Styrene A", "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif` | 600–700 |
|
||||
| body | `"Anthropic Sans", "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif` | 400 |
|
||||
| title | `Arial, "Microsoft YaHei", sans-serif` | 600–700 |
|
||||
| body | `Arial, "Microsoft YaHei", sans-serif` | 400 |
|
||||
|
||||
> `Styrene A` and `Anthropic Sans` are proprietary and unlikely to be installed on viewer machines. PPT Master does not bundle or automatically embed them; use the declared fallback chain unless the user supplies an installed/approved font workflow.
|
||||
> `Styrene A` and `Anthropic Sans` are references. PPT Master neither auto-embeds fonts nor follows CSS tails in PowerPoint. The rows above are the default Windows/Office export; replace them only with a user-confirmed target-installed face.
|
||||
|
||||
## IV. Logo
|
||||
|
||||
|
||||
+3
-3
@@ -35,10 +35,10 @@ The four primary brand colors (Blue / Green / Yellow / Red) carry equal weight i
|
||||
|
||||
| Role | Family | Weight |
|
||||
|---|---|---|
|
||||
| title | `Google Sans, Roboto, "Microsoft YaHei", sans-serif` | 500–700 |
|
||||
| body | `Roboto, "Microsoft YaHei", sans-serif` | 400 |
|
||||
| title | `"Segoe UI", "Microsoft YaHei", sans-serif` | 500–700 |
|
||||
| body | `"Segoe UI", "Microsoft YaHei", sans-serif` | 400 |
|
||||
|
||||
> `Google Sans` is proprietary. PPT Master does not bundle or automatically embed it; use the `Roboto` / `Microsoft YaHei` fallback unless the user supplies an installed/approved font workflow.
|
||||
> `Google Sans` and `Roboto` are references. PPT Master neither auto-embeds fonts nor follows CSS tails in PowerPoint. The rows above are the default Windows/Office export; replace them only with a user-confirmed target-installed face.
|
||||
|
||||
## IV. Logo
|
||||
|
||||
|
||||
+5
-3
@@ -209,9 +209,11 @@
|
||||
| 对象 | 模板表达 |
|
||||
|---|---|
|
||||
| 基础节点/容器 | `<rect>`、`<circle>`、`<ellipse>` |
|
||||
| 细关系线 | `<line>` 或少量开放 `<path>` |
|
||||
| 直线关系/分隔/引线 | `<line>` |
|
||||
| 预设可精确表达的弯折/曲线关系 | 完整 compact authored `bentConnector*` / `curvedConnector*` `<g>`;端点不附着 |
|
||||
| 标准块箭头/流程节点 | 仅在 preset 精确匹配时使用完整 compact authored-preset `<g>` |
|
||||
| 自定义数据几何 | `<path>`、`<polygon>`、`<polyline>` |
|
||||
| 单一预设不能表达、但封闭形状可组合的对象 | 优先用 `shape_boolean_svg.py` 物化 Merge Shapes 结果 |
|
||||
| 图元、预设、Boolean 都不能表达的数据/语义/锁定风格几何 | `<path>`、`<polygon>`、`<polyline>` |
|
||||
| 数据图表 | 默认 Shape fallback;符合条件时附带 native replacement marker |
|
||||
|
||||
**Forbidden — inferred native semantics**: 概念图、流程图和框架图不添加 `data-pptx-replace-with="chart"`;普通关系线不添加 Connector attachment metadata。
|
||||
@@ -440,7 +442,7 @@ python3 skills/ppt-master/scripts/compact_svg_coordinates.py \
|
||||
|
||||
**Reference — not a constraint**: 半圆标签只在“标签从属于当前信息块”是结构信息时使用。颜色、圆角和高度由项目适配;它不是卡片的默认装饰。
|
||||
|
||||
**Forbidden — cover hack**: 不用“全圆角矩形 + 同色覆盖矩形”拼接单侧圆角;需要时直接使用一个可编辑 path。
|
||||
**Forbidden — cover hack**: 不把“全圆角矩形 + 同色覆盖矩形”作为两个未合并对象叠放来伪造单侧圆角。需要时优先以封闭的圆角矩形和矩形为 operands,通过 `shape_boolean_svg.py` 物化 Union 结果;只有 Boolean 仍不能忠实表达时才手写可编辑 path。
|
||||
|
||||
### 11.2 Nested Card Border
|
||||
|
||||
|
||||
@@ -18,10 +18,13 @@ This library is **Shape-first**. Infographics, process diagrams, architecture
|
||||
diagrams, frameworks, and the default rendering of data charts export as
|
||||
independently editable PowerPoint shapes:
|
||||
|
||||
- Use ordinary SVG primitives for basic nodes and containers. Thin directional
|
||||
relationships use `<line>` / supported open `<path>` geometry with registered
|
||||
arrow markers; they remain static editable shapes and do not create node
|
||||
attachment or automatic routing.
|
||||
- Use ordinary SVG primitives for basic nodes, containers, and straight
|
||||
relationships; they export as editable PowerPoint shapes. A straight
|
||||
directional relationship uses `<line>` with a registered arrow marker.
|
||||
- When a bent or curved relationship exactly matches a stock Connector contour,
|
||||
prefer a compact authored `bentConnector*` / `curvedConnector*` preset. It is
|
||||
an unconnected native Connector shape and does not create node attachment or
|
||||
automatic routing.
|
||||
- Use a stock PowerPoint preset for a solid block arrow, chevron, or standard
|
||||
flowchart node when it exactly expresses the intended object. A template may
|
||||
retain the complete compact atomic `<g>` generated by `preset_shape_svg.py`,
|
||||
@@ -32,13 +35,17 @@ independently editable PowerPoint shapes:
|
||||
adjustments, or paint changes, regenerate the whole group instead of editing
|
||||
metadata or paths. PPTX import and `mirror` retain their separate expanded
|
||||
lossless representation; do not copy that transport form into new templates.
|
||||
- Keep custom contours, branded geometry, and data-driven marks as ordinary
|
||||
`<path>` / `<polygon>` geometry. They still export as editable DrawingML
|
||||
shapes; native preset identity is not required for editability.
|
||||
- Do not put authored Connector metadata or endpoint attachment metadata in
|
||||
this template library. Existing Connector topology imported from a source
|
||||
PPTX belongs to the preserve/mirror round-trip contract, not chart-template
|
||||
authoring.
|
||||
- When no single preset suffices but closed operands can express the object,
|
||||
materialize the Union / Combine / Fragment / Intersect / Subtract result with
|
||||
`shape_boolean_svg.py` before considering a hand-authored contour.
|
||||
- Keep a hand-authored `<path>` / `<polygon>` only for branded, data-defined,
|
||||
locked organic / hand-drawn, or other contours that primitives, an exact
|
||||
preset, and Boolean materialization cannot faithfully express. They still
|
||||
export as editable DrawingML shapes.
|
||||
- Authored Connector metadata comes only from `preset_shape_svg.py`; never
|
||||
hand-write endpoint attachment metadata. Existing attached Connector topology
|
||||
imported from a source PPTX belongs to the preserve/mirror round-trip
|
||||
contract, not chart-template authoring.
|
||||
|
||||
Data-backed charts retain the separate opt-in replacement route described
|
||||
below. Conceptual diagrams and frameworks are never labeled as native charts.
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
<g id="branch-skeleton" data-pptx-bounds="120 108 1040 489">
|
||||
<circle cx="640" cy="360" r="112" fill="none" stroke="#6366F1" stroke-width="1.5" stroke-opacity="0.12"/>
|
||||
<circle cx="640" cy="360" r="160" fill="none" stroke="#6366F1" stroke-width="1.5" stroke-opacity="0.06"/>
|
||||
<!-- Main curves: center to branches -->
|
||||
<!-- Main straight relationships: center to branches -->
|
||||
<!-- Top Right -->
|
||||
<path d="M 703,330 C 795,280 880,225 930,195" stroke="#10B981" stroke-width="3" fill="none" stroke-opacity="0.35"/>
|
||||
<line x1="703" y1="330" x2="930" y2="195" stroke="#10B981" stroke-width="3" stroke-opacity="0.35"/>
|
||||
<!-- Bottom Right -->
|
||||
<path d="M 703,390 C 795,440 880,495 930,525" stroke="#F59E0B" stroke-width="3" fill="none" stroke-opacity="0.35"/>
|
||||
<line x1="703" y1="390" x2="930" y2="525" stroke="#F59E0B" stroke-width="3" stroke-opacity="0.35"/>
|
||||
<!-- Top Left -->
|
||||
<path d="M 577,330 C 485,280 400,225 350,195" stroke="#3B82F6" stroke-width="3" fill="none" stroke-opacity="0.35"/>
|
||||
<line x1="577" y1="330" x2="350" y2="195" stroke="#3B82F6" stroke-width="3" stroke-opacity="0.35"/>
|
||||
<!-- Bottom Left -->
|
||||
<path d="M 577,390 C 485,440 400,495 350,525" stroke="#F43F5E" stroke-width="3" fill="none" stroke-opacity="0.35"/>
|
||||
<line x1="577" y1="390" x2="350" y2="525" stroke="#F43F5E" stroke-width="3" stroke-opacity="0.35"/>
|
||||
<!-- ===== Thin Lines: Branches to Sub-items ===== -->
|
||||
<!-- Top Right (Product) -->
|
||||
<line x1="1050" y1="195" x2="1075" y2="156" stroke="#10B981" stroke-width="1.5" stroke-opacity="0.3"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
+14
-2
@@ -166,7 +166,11 @@ 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. Copy the selected `Layout pattern` id/name and modifiers verbatim; 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. Copy the recommended `Layout pattern` id/name and modifiers verbatim as a preferred composition; 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.
|
||||
|
||||
Put native paint/overlay intent in §IX `Layout` plus `Images` for imagery—not a new field; state semantic job/layering, while Executor chooses type, stops, opacity, and geometry.
|
||||
|
||||
### 2.4 Complete page roster and notes
|
||||
|
||||
@@ -194,7 +198,15 @@ Write one ordered Slide block per page. Slide count and order must equal §I `Pa
|
||||
- **Presentation purpose**: <inform, persuade, inspire, instruct, report, or resolved combination>
|
||||
```
|
||||
|
||||
Add `Visualization` and `Images` to a Slide block when it consumes §VII/§VIII rows or uses a page-local visualization. State whether `Visualization` is data-driven when source values determine geometry; this page-level declaration remains authoritative even when no catalog reference fits. Add `Native-ready: yes|no` only for independent data charts or pure text-grid tables. Add `Fact IDs` for sourced claims and `Data class: scenario` for invented demo values. Add `Cover impact` to P01 except on preservation paths; add `Closing impact` only when the final page genuinely resolves the deck. Roster ids/count/order and final content are authoritative. Image patterns preserve their selected semantic composition; chart rows only offer page-local references. Executor owns geometry, hierarchy, treatment, and sparse local garnish.
|
||||
Append either or both optional lines only when the capability earns a place;
|
||||
never write an empty or `none` placeholder:
|
||||
|
||||
```markdown
|
||||
- **Native shape suggestion**: <semantic object/result plus candidate preset/Connector family or Boolean operation/operand roles>
|
||||
- **Motion suggestion**: <communication job plus desired page-entry or reveal relationship/order>
|
||||
```
|
||||
|
||||
Add `Visualization` and `Images` to a Slide block when it consumes §VII/§VIII rows or uses a page-local visualization. State whether `Visualization` is data-driven when source values determine geometry; this page-level declaration remains authoritative even when no catalog reference fits. Add `Native shape suggestion` only when a literal PowerPoint preset, a stock bent/curved Connector contour, or a compound silhouette/cutout/intersection/fragment may strengthen the page; describe the semantic object/result and candidate family or Boolean operation/operands, not coordinates, paths, exact preset keys, endpoint/site metadata, or attachment promises. Executor chooses the actual basic primitive, preset, Boolean construction, or necessary freeform. Add `Motion suggestion` only when a page transition or progressive reveal would strengthen communication; describe its purpose and semantic order/relationship in natural language, not registry keys, Effect Options, durations, group ids, or coverage. When it depends on visible image states, describe those fully revealed units in the same page's `Layout` / `Images`; the suggestion does not create missing visual content. It remains a recommendation: Executor owns the exact native behavior and may simplify it or choose no motion unless that would violate an explicit user requirement. Add `Native-ready: yes|no` only for independent data charts or pure text-grid tables. Add `Fact IDs` for sourced claims and `Data class: scenario` for invented demo values. Add `Cover impact` to P01 except on preservation paths; add `Closing impact` only when the final page genuinely resolves the deck. Roster ids/count/order and final content are authoritative. §VIII image patterns are preferred composition references; chart rows only offer page-local references. Executor owns geometry, hierarchy, treatment, and sparse local garnish.
|
||||
|
||||
For free-design pages, describe `Layout` through relationships, hierarchy, regions, and column spans; do not prescribe element-level `x`, `y`, `width`, or `height` or duplicate the global geometry in §II/§V. Exact coordinates belong to Executor SVG authoring. Preserve literal geometry only when the user explicitly requires it or a mirror/template preservation contract owns it.
|
||||
|
||||
|
||||
@@ -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=#2 Left image | crop=no-crop`. Omit unplaced sheets.
|
||||
- `images`: `- <key>: <path> | source=<via> | pattern=<layout> | crop=<adaptive|no-crop>`; e.g. `- p04: images/a.png | source=user | pattern=#2 Left image | crop=no-crop`. Use the canonical `images/<filename>` path; `source` and `crop` exactly project §VIII, while `pattern` preserves its ordered catalog ids or normalized custom prose. 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`.
|
||||
|
||||
@@ -274,7 +274,7 @@ Before composing Step 2, extract the template's reusable norms from the previous
|
||||
| Canvas / page geometry | `manifest.json` slide size, SVG `width` / `height` / `viewBox` | `[fact]` canvas format, pixel dimensions, source `viewBox`, and aspect ratio |
|
||||
| Identity system | theme colors, font usage, logo / emblem assets, recurring backgrounds | `[fact]` when imported; `[suggested]` only for visual estimates |
|
||||
| Layout grammar | masters / layouts, repeated chrome, margins, columns, card grids, section dividers | Template-specific rules, not generic spacing boilerplate |
|
||||
| Image system | image crops, masks, full-bleed zones, hero-image placement, mosaic rules, caption / overlay treatment | Template-specific image-placement rules with source examples |
|
||||
| Image system | image crops/clips, scrim/overlay treatments, baked-alpha treatments, full-bleed zones, hero-image placement, mosaic rules, captions | Template-specific image-placement rules with source examples |
|
||||
| Density rhythm | title scale, content block count, whitespace balance, dense vs. breathing pages | Page-type guidance for Strategist / Executor |
|
||||
| Page roster semantics | cover / TOC / chapter / content / ending variants and their intended content slots | `design_spec.md §V Page Roster` rows |
|
||||
| Asset policy | source images / icons / textures that are part of the template vs. sample-only content | `design_spec.md §VI Assets` or omit sample-only assets |
|
||||
@@ -549,7 +549,11 @@ expresses one complete object, they use the compact canonical
|
||||
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. `mirror` preserves the expanded
|
||||
paint changes require a new helper render. When actual `standard` / `fidelity`
|
||||
construction needs a Boolean result over closed shapes, 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
|
||||
lossless source contract in a new workspace and may only normalize transport details required by
|
||||
the current compiler. Mirror never performs commonality
|
||||
extraction, semantic synthesis, merge/split, promotion/demotion, renaming, or
|
||||
|
||||
@@ -133,6 +133,8 @@ Multi-deck: several PPTX files may be imported into one main-pipeline project
|
||||
|
||||
**Source ownership boundary**: Use the automatic import mode shown above. Only inputs already under the repository's `projects/` tree move into the target project's `sources/`; every other local path is copied and remains untouched, even if `--move` is supplied. Use `--copy` when a projects-local input must also remain in place. If Step 1 wrote Markdown beside the original sources, pass that source path/directory once. If Step 1 used `-o` to write Markdown elsewhere, pass both the original source path(s)/directory and the Markdown output path(s)/directory. Intermediate artifacts (e.g., `_files/`) are handled automatically.
|
||||
|
||||
Direct supported bitmap inputs follow both boundaries: the original is archived under `sources/`, and a collision-safe basename is copied into `images/` for analysis and §VIII planning. SVG/EMF/WMF remain source assets unless they arrive through a converter companion manifest that supplies their display metadata. This does not classify an asset's role; Strategist still decides whether it is used.
|
||||
|
||||
**✅ Checkpoint — Confirm project structure created successfully, `sources/` contains all source files, converted materials are ready. Proceed to Step 3.**
|
||||
|
||||
---
|
||||
@@ -228,7 +230,7 @@ If the user opted out of the page but did not delegate confirmation, skip launch
|
||||
|
||||
With `refine_spec: true`, run [`refine-spec`](stages/refine-spec.md) after Gate 1: review that same file in chat, accept arbitrary revisions, touch no lock, and stop until explicit approval. Revisions supersede only affected decisions. Otherwise skip the stop.
|
||||
|
||||
After the review closes, author `spec_lock.md` from the approved Design Spec and context. Preserve identity/refinements, every recurring typography role, reusable routing anchors, and each placed image's source/pattern/crop policy; omit page-local garnish and never write a separate image palette. Apply `strategist-template.md` §3 when active. Unhonorable requirements follow [`failure-recovery.md`](governance/failure-recovery.md).
|
||||
After the review closes, author `spec_lock.md` from the approved Design Spec and context. Preserve identity/refinements, every recurring typography role, reusable routing anchors, and each placed image's source/preferred-pattern reference/crop policy; omit page-local garnish and never write a separate image palette. Apply `strategist-template.md` §3 when active. Unhonorable requirements follow [`failure-recovery.md`](governance/failure-recovery.md).
|
||||
|
||||
**Conditional — split-mode note** (not a separate confirmation): after listing the Strategist confirmation stage details, append one short line (rendered in the user's language, prefixed with 💡) only when the confirmed mode is `split` or upstream-load signals make a fresh execution context materially useful. Judge those signals from recommended page count, source-material bulk, and substantial `topic-research` web-fetch accumulation:
|
||||
|
||||
@@ -248,7 +250,7 @@ If the user provided images or formula PNGs were rendered, run analysis **before
|
||||
python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images
|
||||
```
|
||||
|
||||
> 🔁 **Image facts are regenerated on change, never maintained as a second store.** `images/` is the live working folder and single source of truth; `analysis/image_analysis.csv` is its regenerated view. Run `analyze_images.py` before the first inventory read, then reuse that CSV while `images/` is unchanged. Re-run after import/acquisition or any user addition, removal, or replacement; if the folder becomes empty, treat the inventory as empty and ignore a stale CSV.
|
||||
> 🔁 **Image facts are regenerated on change, never maintained as a second store.** `images/` is the live working folder and single source of truth; `analysis/image_analysis.csv` is its regenerated view. Run `analyze_images.py` before the first inventory read, then reuse that CSV while `images/` is unchanged. Re-run after import/acquisition or any user addition, removal, or replacement; an empty folder produces a fresh header-only CSV rather than leaving stale facts.
|
||||
|
||||
> ⚠️ **Image understanding**: Do not bulk-open images. Strategist uses source context, captions / alt / titles, filenames, user notes, existing resource records, and `image_analysis.csv` first; only a specifically ambiguous asset may be inspected under [`strategist-image.md`](../references/strategist-image.md). Record the result in §VIII. Executor never reopens source images for semantic discovery or reselection.
|
||||
|
||||
@@ -299,6 +301,8 @@ A deck with only `ai` rows never loads `image-searcher.md`; a deck with only `we
|
||||
|
||||
> ⚠️ **web path — batch multiple rows**: when ≥2 rows are `Acquire Via: web`, write all queries into `images/image_queries.json` and run `image_search.py --batch` once (concurrent acquisition, status written back), instead of one CLI call per row. A single web row may use the positional single-query form. See [image-searcher.md](../references/image-searcher.md) §5.
|
||||
|
||||
> Web query boundary: keep §VIII `Reference` as the locked full subject/focal/crop intent; each `image_queries.json.query` is a separate 1–4 word concrete provider query. Search/review never rewrites the Design Spec or lock to fit a returned candidate.
|
||||
|
||||
> 💡 **ai path — spot illustrations as one sheet**: when the §VIII image resource plan needs ≥3 same-family spot illustrations as decorative accessories, generate **one grid sheet** (a single `ai` sheet row) instead of one row per element, then slice it (workflow step 2.5 below). Choose sheet geometry from intended placement: `1xN` / `Nx1` are useful for extreme portrait / landscape cells, and a designed `MxN` grid is valid when its cell ratio fits the planned elements. The sheet row is generated but not placed; each cut **element row** (`Acquire Via: slice`) is placed and must appear in `spec_lock.md images`. One generation = one coherent style across all pieces. Resource contract + the geometry rules: [image-generator.md](../references/image-generator.md) §4.3.
|
||||
|
||||
> ⚠️ **Honor the Design Spec's confirmed image source before running any generation command**: the `ai` generation path (Path A = `image_gen.py` API / Path B = host-native tool / Offline Manual) is **not** auto-only — the production value recorded in `design_spec.md §I` wins. `host-native` forces Path B even when `IMAGE_BACKEND` is configured; `api` forces Path A; `manual` forces offline. Never reopen `result.json` here, and never run `image_gen.py --manifest` when the recorded value is `host-native` or `manual`. Full selection rule: [image-generator.md](../references/image-generator.md) §7 Path Selection.
|
||||
@@ -357,7 +361,7 @@ Read references/visual-styles/<resolved-id>.md # one preset id, or each `visu
|
||||
| `spec_lock.md images` or §VIII contains at least one image/formula row, or an active template carries bundled images | `executor-image.md` + `image-layout-patterns.md` + `image-layout-spec.md` + `svg-image-embedding.md` |
|
||||
| At least one placed image has `Status: Sourced` | `executor-web-image.md` after the image branch |
|
||||
| The locked style/current page calls for noncanonical or alpha paint, dash/cap/join, tracking/decoration/outline, gradient/filter/glow/shadow, path/transform/clipping, or another constructed effect | `svg-effects.md` before authoring that value or effect |
|
||||
| A page calls for a literal PowerPoint stock shape, an explicit Merge Shapes operation, or shape-built dimensional form (cylinder/pedestal, layered diagram, reflection, ground plane) | `native-shape-authoring.md` before selecting or materializing that geometry. This trigger is image-independent: a text-only, data-only, or icon-only page reaches it the same way |
|
||||
| §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 | `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.
|
||||
@@ -386,7 +390,9 @@ python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live --daemon
|
||||
|
||||
**Visual Construction Phase**: generate SVG pages sequentially, one at a time, in one continuous pass → `<project_path>/svg_output/`
|
||||
|
||||
Each completed SVG MUST be a standalone, complete representation of that slide's visible design. Template SVGs and locked planning artifacts may guide construction, but export must not reach back to them to add visible objects omitted from `svg_output/`. Speaker notes, animation, narration, transitions, and direct native-PPTX workflows remain separately owned artifacts/capabilities. When a page actually needs a literal stock shape or an explicit Merge Shapes operation, load and apply [`native-shape-authoring.md`](../references/native-shape-authoring.md) before drawing or materializing it. Diagram relationships remain Shape-first; do not infer a preset from contour similarity.
|
||||
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.
|
||||
|
||||
**Motion-ready image composition**: When an adopted §IX `Motion suggestion` or explicit user requirement depends on distinct in-slide image states or cross-slide image continuity, author those visible states now under [`executor-image.md`](../references/executor-image.md). Give each independently revealable or continuing ordinary Slide-local unit a descriptive direct-root `<g id>`; structured atoms/slots retain their declared boundaries and are targetable only when that contract permits. Do not defer required visible content or reshape structure for the later stage. This is SVG preparation, not early animation authoring: effects, pairing, order, and timing remain in the conditional custom stage after notes. A page-transition-only suggestion requires no extra visible layer; deterministic Morph still needs the continuing object as a direct-root group on both pages.
|
||||
|
||||
`template_reuse_scope: mirror|layout` pages MUST start from the complete `page_layouts` SVG, keep inherited visible objects, and preserve root Master/Layout identity plus stable atoms/slots. Strict preserves that reusable contract; under `layout`, the once-loaded Design Spec's `Template Application` may still authorize carrier text/tspan reflow inside unchanged slot bounds. Adaptive uses the current or new Layout key/name already declared by Strategist. If construction proves that fixed atoms or slot topology/bounds must change, stop and return upstream for Strategist to repair the owning plan and lock, validate and read back the affected fragments, then resume; Executor never mutates `spec_lock.md`. `mirror` changes only visible text values while preserving text/tspan topology and attributes. `style` follows the flat paragraph below without structure metadata.
|
||||
|
||||
@@ -437,6 +443,21 @@ python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> --stage final
|
||||
|
||||
> **Visual self-check (opt-in)?** If the user explicitly asked for a per-page visual re-pass on the SVGs ("跑一下视觉自检 / 视觉回看", "visual review", "check pages visually", etc.), run the [`visual-review`](stages/visual-review.md) quality-gate stage before Step 7. Do NOT run it by default and do NOT recommend it based on inferred model capability or deck size — trigger is user request only.
|
||||
|
||||
> **Motion execution (conditional)?** Visible-layer preparation belongs to the
|
||||
> main SVG pass above. After `notes/total.md` exists, inspect §IX
|
||||
> `Motion suggestion` rows, explicit user motion requirements, and an existing
|
||||
> `<project_path>/animations.json`. With none of the three, keep the exporter
|
||||
> defaults (`fade` page transition, per-element animation `none`) and load no
|
||||
> motion reference. An existing sidecar always runs
|
||||
> [`customize-animations`](stages/customize-animations.md) to validate and
|
||||
> resolve preserve/adjust/replace intent before export. With no sidecar, a
|
||||
> deck-wide request loads [`animations.md`](../references/animations.md) and
|
||||
> resolves Step 7.3 flags; any `Motion suggestion` or per-slide/per-object
|
||||
> request runs the custom stage. Strategist owns the communication purpose;
|
||||
> Executor owns exact native effects, options, order, timing, and whether a
|
||||
> non-literal suggestion should simplify to `none`. Never add motion for
|
||||
> coverage or variation.
|
||||
|
||||
---
|
||||
|
||||
### Step 7: Post-processing & Export
|
||||
@@ -445,6 +466,8 @@ python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> --stage final
|
||||
|
||||
🚧 **Image readiness GATE**: When any required resource row is `Needs-Manual`, every expected file and derived slice output MUST exist under `<project_path>/images/` before Step 7.1. If any file is absent, pause and list the exact filenames. After the files arrive, rerun `analyze_images.py`, replace each dashed placeholder in `svg_output/`, reconcile every `no-crop` container to the measured native ratio, then rerun the final SVG quality check so the gate covers the changed sources.
|
||||
|
||||
After the separate readiness gate above has supplied every required manual file, the final SVG quality check closes each usable terminal §VIII row through `spec_lock.md images`, the exact locked file, and a real `<image href>`; it rejects unplanned/wrong-path references and also validates Sourced provenance/license records, image-specific visible credits, and effective per-placement pixel scale under `meet` / `slice` / `none`.
|
||||
|
||||
**Failure recovery**: On a command failure, repair the owning source artifact and resume from that failed sub-step per [`failure-recovery.md`](./governance/failure-recovery.md). Do not restart planning unless its owning source changed.
|
||||
|
||||
**Hard rule — strict serial commands**: Run the following commands one at a time. Do not combine them in one code block or shell invocation. Enter the next sub-step only after the current command exits successfully and its success criterion is true.
|
||||
@@ -471,6 +494,15 @@ python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>
|
||||
python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
|
||||
```
|
||||
|
||||
For deck-wide motion settings, append the resolved flags from
|
||||
[`animations.md`](../references/animations.md). When the conditional custom
|
||||
stage preserves or produces `<project_path>/animations.json`, keep the base command above:
|
||||
the exporter reads the sidecar automatically. Explicit motion flags override
|
||||
the corresponding sidecar default/slide fields, while group overrides remain
|
||||
unless `-a none` hard-disables all object motion; do not mix deck-wide flags
|
||||
with a custom sidecar in the normal workflow. With no adopted motion input or
|
||||
existing sidecar, preserve the normal `fade` / `none` defaults.
|
||||
|
||||
**Success criterion**: The command exits successfully and produces:
|
||||
|
||||
- `exports/<project_name>_<timestamp>.pptx`
|
||||
|
||||
+77
-11
@@ -1,10 +1,10 @@
|
||||
---
|
||||
description: Native enhancement platform for existing PPTX files, starting with notes/audio/timings/transitions without SVG conversion
|
||||
description: Native enhancement platform for existing PPTX files, with delivery checks and scoped OOXML updates without SVG conversion
|
||||
---
|
||||
|
||||
# Enhance Native PPTX Route
|
||||
|
||||
> Top-level route for enhancing an existing PowerPoint deck without regenerating it. V1 implements the `narration` module: speaker notes, narration audio, slide auto-advance timings, and page transitions.
|
||||
> Top-level route for enhancing an existing PowerPoint deck without regenerating it. The current write scope is speaker notes, narration audio, slide auto-advance timings, and global or per-slide page transitions; read-only delivery checks always run.
|
||||
|
||||
This route treats a `.pptx` as the artifact to preserve. It archives the source file into a lightweight project, uses `ppt_to_md.py` only to understand slide content, then patches the archived PPTX package directly through OOXML zip operations.
|
||||
|
||||
@@ -52,15 +52,15 @@ source.pptx
|
||||
| `narration.audio` | Enabled | Embed one audio file per slide |
|
||||
| `narration.timings` | Enabled | Set narrated slides to auto-advance by audio duration |
|
||||
| `narration.transitions` | Enabled | Add page-level transitions for narrated/selected slides |
|
||||
| `delivery.check` | Planned | Font/media/hidden-slide/file-size validation |
|
||||
| `delivery.check` | Enabled | Read-only package/font/media/hidden-slide/file-size and existing-motion audit |
|
||||
| `media` | Planned | Background music, video, media compression |
|
||||
| `presenter` | Planned | Q&A notes, speaker cues, rehearsal artifacts |
|
||||
| `animation` | Planned | Explicit object-level animation only |
|
||||
| `visible-stamp` | Planned | Watermark/footer/logo; requires explicit confirmation |
|
||||
|
||||
**Default — V1 only**: Do not implement planned modules inside this route yet. Keep V1 focused on the narration module.
|
||||
**Default — current write scope only**: Do not implement planned write modules inside this route yet. Keep mutations limited to notes, narration audio, timings, and page transitions.
|
||||
|
||||
**Object animation boundary**: Object-level animation is not part of V1. If the user asks for it, treat it as a future module or a separate explicitly confirmed task.
|
||||
**Object animation boundary**: `delivery.check` reports existing object-animation presence, and apply proves its fingerprint is unchanged. It does not author or edit object animations. The shared animation writer builds a complete timing tree for generated slides and is not safe to append to an arbitrary native slide.
|
||||
|
||||
---
|
||||
|
||||
@@ -97,9 +97,11 @@ Project layout:
|
||||
| `<project>/notes/` | Per-slide spoken notes, named `001.md`, `002.md`, ... |
|
||||
| `<project>/audio/` | Per-slide narration media, named `001.mp3`, `002.mp3`, ... |
|
||||
| `<project>/exports/` | Enhanced PPTX copies |
|
||||
| `<project>/validation/` | Coverage reports and read-back artifacts |
|
||||
| `<project>/validation/` | Delivery checks, readiness reports, and read-back artifacts |
|
||||
|
||||
**Validation**: `project.json` contains `schema: native_pptx_enhancement_project.v1`, `kind: native_pptx_enhancement`, and `modules` containing `notes`, `audio`, `timings`, `transitions`.
|
||||
**Validation**: `project.json` contains `schema: native_pptx_enhancement_project.v1`, `kind: native_pptx_enhancement`, and `modules` containing `notes`, `audio`, `timings`, `transitions`, and `delivery.check`.
|
||||
|
||||
`init` records the archived source SHA-256 and ordered slide-part roster, then writes the intake audit to `<project>/validation/report.json`. Package-integrity, OPC part/content-type/relationship, XML, slide-inventory, transition, or object-animation errors stop before a project-local source is moved. The only retained historical structural baseline is the narrowly recognized legacy notes-slide relationship to a missing notes master; it remains visible in the report and apply may not add any new structural error.
|
||||
|
||||
**Source import rule**: When `<source.pptx>` is inside the repo's `projects/` tree, `init` moves it into `<project>/sources/`. When it is outside `projects/`, `init` copies it into `<project>/sources/`. The mode is recorded in `project.json` as `source_import.mode`.
|
||||
|
||||
@@ -123,6 +125,8 @@ If the project already existed or notes/audio coverage changed, refresh the draf
|
||||
python3 skills/ppt-master/scripts/native_enhance_pptx.py plan "<project>"
|
||||
```
|
||||
|
||||
Refresh preserves user-owned module enablement, timing padding, global transition settings, and `transitions.slides`; it recomputes file presence/coverage, resets the plan to `draft`, and requires confirmation again. Audio coverage in the plan is explicitly `decodability: unchecked`; `validate` owns the ffprobe gate. CLI flags override only fields explicitly supplied.
|
||||
|
||||
Present the plan to the user before generating notes or audio:
|
||||
|
||||
| Module | Recommended default | Confirmation question |
|
||||
@@ -131,6 +135,7 @@ Present the plan to the user before generating notes or audio:
|
||||
| `audio` | Enabled when user wants narration/video/autoplay | Generate one narration audio file per slide? |
|
||||
| `timings` | Enabled with audio | Set slide auto-advance from audio duration? |
|
||||
| `transitions` | Enabled, `fade` 0.5s | Add page transitions? Which canonical native effect, Effect Options, and duration? |
|
||||
| `delivery.check` | Always on, read-only | No confirmation required; review errors and advisories |
|
||||
|
||||
**⛔ BLOCKING**: Stop here and wait for explicit user confirmation. Do not generate notes, generate audio, or patch the PPTX until the user confirms the module plan.
|
||||
|
||||
@@ -142,7 +147,7 @@ Present the plan to the user before generating notes or audio:
|
||||
| Transitions disabled with a non-`none` configured effect | Preserve the source effect, including unknown `AlternateContent` | Preserve unless timings is enabled |
|
||||
| Explicit `none` | Remove the visual effect | Preserve, or write timing-only advance when timings is enabled |
|
||||
| Timings enabled with audio | Keep the resolved enter policy | Use audio duration plus narration padding; click disabled |
|
||||
| Timings disabled | Apply the confirmed enter policy only | Do not run `ffprobe`; do not add/change `advTm` or `useTimings` |
|
||||
| Timings disabled | Apply the confirmed enter policy only | Audio readiness may probe decodability; do not use duration or add/change `advTm` or `useTimings` |
|
||||
|
||||
The confirmed `modules.transitions` object may include `effect_options` beside
|
||||
an explicit canonical `effect`. Use
|
||||
@@ -150,6 +155,45 @@ an explicit canonical `effect`. Use
|
||||
Old names remain accepted only when reading compatibility input; a newly
|
||||
written plan stores the canonical effect and any implied options.
|
||||
|
||||
For explicit page selection or page-specific settings, add `slides` keyed by
|
||||
the 1-based `index` in `analysis/slide_index.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"modules": {
|
||||
"transitions": {
|
||||
"enabled": false,
|
||||
"effect": "fade",
|
||||
"duration": 0.5,
|
||||
"apply_without_audio": false,
|
||||
"slides": {
|
||||
"2": {},
|
||||
"3": {"duration": 0.8},
|
||||
"4": {
|
||||
"effect": "push",
|
||||
"effect_options": {"direction": "left"}
|
||||
},
|
||||
"5": {"effect": "none"},
|
||||
"6": {"effect": "preserve"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Per-slide entry | Behavior |
|
||||
|---|---|
|
||||
| `{}` | Select the page and inherit the global effect/options/duration |
|
||||
| Partial object | Inherit omitted global fields; a new explicit effect uses its own default options |
|
||||
| `effect: none` | Remove the visual transition; timings remain independently owned |
|
||||
| `effect: preserve` | Preserve the source visual transition; narration timing may still update advance |
|
||||
|
||||
An explicit `slides` entry selects a page even when it has no audio and
|
||||
`apply_without_audio` is false. With `transitions.enabled: false`, only listed
|
||||
pages opt in; unlisted pages preserve their source transition. Morph uses
|
||||
PowerPoint's automatic matching only—this route does not rename native objects
|
||||
to create deterministic pairs.
|
||||
|
||||
**Hard rule — no silent downgrade**: a requested native effect must be written with its complete validated Effect Options. Unknown effects or inapplicable options fail; unknown source effects are preserved when the transition module is disabled.
|
||||
|
||||
After confirmation, update `<project>/analysis/enhancement_plan.json`:
|
||||
@@ -200,7 +244,7 @@ Run coverage check:
|
||||
python3 skills/ppt-master/scripts/native_enhance_pptx.py validate "<project>"
|
||||
```
|
||||
|
||||
> Note: before audio generation, missing audio returns exit code `2` only when `audio.enabled` is true. Missing notes are not acceptable once this step is complete.
|
||||
> Note: missing, empty, unreadable, or undecodable requested material returns exit code `2`. Package, source-drift, plan/module, or duplicate native-enhance narration-carrier errors return `1`.
|
||||
|
||||
---
|
||||
|
||||
@@ -263,7 +307,20 @@ python3 skills/ppt-master/scripts/native_enhance_pptx.py apply "<project>" \
|
||||
|
||||
Without `--apply-transition-without-audio` (or the matching confirmed-plan
|
||||
field), the resolved enter policy is applied while processing slides with
|
||||
audio; slides without audio keep their source transition.
|
||||
audio; slides without audio keep their source transition unless explicitly
|
||||
listed in `modules.transitions.slides`.
|
||||
|
||||
`apply` reruns the same source/readiness/plan checks as `validate`. Enabling
|
||||
audio always requires every selected file to be decodable by ffprobe; enabling
|
||||
timings additionally consumes that duration for `advTm`. It refuses
|
||||
partial requested material, a changed source hash/slide roster, or a source
|
||||
that already contains a `native_enhance_audio_*` carrier. New audio/poster
|
||||
parts use collision-free names; an existing poster is reused only when its
|
||||
bytes match the tool marker exactly. Output must be a new `.pptx` under
|
||||
`exports/` or an external location; apply never overwrites either source or
|
||||
writes into project control directories. Every apply attempt invalidates the
|
||||
previous validation receipt, and a failed preflight records its current errors
|
||||
instead of leaving stale passed evidence.
|
||||
|
||||
Patch scope:
|
||||
|
||||
@@ -279,6 +336,13 @@ Patch scope:
|
||||
|
||||
**Hard rule**: Do not modify existing slide shapes, text bodies, images, chart data, master/layout parts, or existing non-target relationships.
|
||||
|
||||
Before publishing the candidate, apply validates transitions, timing/object
|
||||
animation structure, ZIP integrity, unique parts, internal relationships,
|
||||
slide count, and hidden-slide state. The narrowly allowed legacy missing
|
||||
notes-master finding may remain exactly equivalent, but the candidate must not
|
||||
introduce any structural error. Apply then writes both audits and the
|
||||
introduced-error delta to `<project>/validation/report.json`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Validate Output
|
||||
@@ -302,13 +366,15 @@ Check:
|
||||
| Auto-play | Narrated slides advance by audio duration |
|
||||
| Transition | Requested effect remains exact; preserved `AlternateContent` keeps its primary and fallback branches |
|
||||
| Timings disabled | Source `advTm` and package `useTimings` are not changed |
|
||||
| Delivery check | No newly introduced structural errors; source baseline and font/media/hidden-slide advisories reviewed |
|
||||
|
||||
```markdown
|
||||
## ✅ Native PPTX Enhancement V1 Complete
|
||||
|
||||
- [x] Project initialized at `<project>`
|
||||
- [x] Source PPTX archived into `<project>/sources/`
|
||||
- [x] V1 narration module applied
|
||||
- [x] Confirmed native enhancement modules applied
|
||||
- [x] Enhanced PPTX exported to `<project>/exports/<file>.pptx`
|
||||
- [x] Delivery postflight written to `<project>/validation/report.json`
|
||||
- [x] Read-back validation written to `<project>/validation/readback.md`
|
||||
```
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ python3 ${SKILL_DIR}/scripts/pptx_intake.py <project_path>/sources/<source.pptx>
|
||||
| Field | Use |
|
||||
|---|---|
|
||||
| `theme.palette.background` / `text` / `primary` / `accent1..6` | the deck's *declared* colors |
|
||||
| `theme.fonts.title` / `body` (`ea` = CJK, `latin`) | the deck's *declared* fonts |
|
||||
| `theme.fonts.title` / `body` (`latin` / `ea` / `cs`; `scripts` maps `Hans` / `Hant` / `Jpan` / `Hang` supplemental faces) | the deck's *declared* fonts; use the matching script when `ea` is empty |
|
||||
| `theme.sizes.title` / `body` (pt) | the deck's *declared* placeholder sizes (master `txStyles`) — the size a run inherits when it sets no explicit `sz`; `body` is the **level-1** default (coarsest, commonly over-reads) |
|
||||
| `theme.sizes.body_levels` (pt list) | the full master `bodyStyle` ramp (lvl1..lvl9, e.g. `[32, 28, 24, 20, …]`) — **reference context** so you can read a deeper level than the over-reading level-1, not an auto-seed |
|
||||
| `observed.colors` / `observed.fonts` (`latin` / `ea`, frequency-ranked) | a usage **sample / frequency hint** — run-level fonts + explicit `srgbClr` fills across slides |
|
||||
|
||||
@@ -52,8 +52,8 @@ route selection. After selection, the route authority owns execution.
|
||||
| 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 page transitions, auto-advance, or deck-wide animation settings | Load [`animations`](../references/animations.md) and apply its export-level contract |
|
||||
| User requests per-slide or object-level animation control | Run [`customize-animations`](./stages/customize-animations.md) during post-processing |
|
||||
| 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 |
|
||||
| Generate `design_spec.md §IX` contains `Motion suggestion`, `<project_path>/animations.json` already exists, or the user requests per-slide or object-level animation control | Run [`customize-animations`](./stages/customize-animations.md) after notes readiness and before Generate Step 7 |
|
||||
| Generated project needs narration audio | Run [`generate-audio`](./stages/generate-audio.md) after notes/export readiness |
|
||||
|
||||
**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.
|
||||
|
||||
+86
-34
@@ -5,9 +5,11 @@ description: Optional post-processing stage for per-slide and per-object animati
|
||||
# Customize Animations Stage
|
||||
|
||||
> Optional Generate-PPTX post-processing stage for per-slide or per-object
|
||||
> animation control. Run when the user asks to customize slide-specific motion,
|
||||
> object order, effects, timing, or reveals. Deck-wide transitions,
|
||||
> auto-advance, and deck-wide per-element object settings use
|
||||
> animation control. Run when Design Spec §IX contains at least one
|
||||
> `Motion suggestion`, when `<project_path>/animations.json` already exists, or
|
||||
> when the user asks to customize slide-specific motion, object order, effects,
|
||||
> timing, or reveals. Deck-wide transitions, auto-advance, and deck-wide
|
||||
> per-element settings without page-specific motion or an existing sidecar use
|
||||
> [`animations.md`](../../references/animations.md) directly and do not activate
|
||||
> this stage.
|
||||
|
||||
@@ -15,11 +17,12 @@ description: Optional post-processing stage for per-slide and per-object animati
|
||||
|
||||
| Condition | Action |
|
||||
|---|---|
|
||||
| Design Spec §IX contains at least one `Motion suggestion` | Run this stage after notes exist and before Generate Step 7 |
|
||||
| User asks for per-slide or per-object animation, reveal order, timing, or effect changes | Run this stage |
|
||||
| User only wants the default deck (page transitions, no element builds) | Do not run; normal `svg_to_pptx.py` export is enough |
|
||||
| User only wants deck-wide page transitions, auto-advance, or one per-element object animation policy | Do not run; apply [`animations.md`](../../references/animations.md) with exporter flags such as `-a auto` or `-a emphasis_spin` |
|
||||
| `<project_path>/animations.json` already exists | Run this stage to validate it and resolve preserve/adjust/replace intent before export |
|
||||
| No suggestion, motion request, or existing sidecar; user only wants the default deck | Do not run; normal export keeps page transitions and no element builds |
|
||||
| No existing sidecar; user only wants deck-wide page transitions, auto-advance, or one per-element object animation policy | Do not run; apply [`animations.md`](../../references/animations.md) with exporter flags such as `-a auto` or `-a emphasis_spin` |
|
||||
| `svg_output/*.svg` is missing | Complete the main Executor phase first |
|
||||
| `animations.json` exists | Resolve regeneration versus modification through the §1 intent gate before changing it |
|
||||
|
||||
---
|
||||
|
||||
@@ -39,9 +42,12 @@ description: Optional post-processing stage for per-slide and per-object animati
|
||||
|---|---|
|
||||
| Explicit regeneration / rewrite / replacement | Rebuild the semantic grouping plan and replace `animations.json`; the previous choreography is not a constraint |
|
||||
| Explicit adjustment / tuning / repair | Validate first, preserve the existing choreography where its semantic units remain valid, and migrate affected group references after any required regrouping |
|
||||
| Stage activated by new §IX suggestions without a user replacement request | Validate first; preserve valid existing choreography and adjust only the affected semantic units |
|
||||
| Existing sidecar with no new motion instruction | Validate and preserve it unchanged; if invalid, repair the owning sidecar/group reference before export |
|
||||
| Ambiguous generation request | Ask whether to regenerate from scratch or modify the current animation; do not choose on the user's behalf |
|
||||
|
||||
When the existing sidecar will be modified:
|
||||
Whenever an existing sidecar is present, validate it before deciding to
|
||||
preserve or modify it:
|
||||
|
||||
```bash
|
||||
python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
|
||||
@@ -54,15 +60,33 @@ the animation plan merely because it already exists.
|
||||
|
||||
**Optional-context fallback**: these semantic files inform this supporting stage but are not its gate artifacts. If any are absent, state what is missing and proceed with every remaining file plus visible SVG content. If all three context inputs are absent, use only explicit user instructions, visible SVG content, and the resolution rules in [`animations.md`](../../references/animations.md); do not infer detailed choreography beyond what the page itself expresses.
|
||||
|
||||
**Decision ownership — advice versus requirement**: A §IX
|
||||
`Motion suggestion` expresses the Strategist's recommended communication job or
|
||||
reveal relationship; it does not lock an effect, Effect Options, timing,
|
||||
trigger, group id, or coverage. Explicit user motion requirements remain
|
||||
mandatory. Executor maps an adopted suggestion to the native registry, may
|
||||
adjust its effect/order/timing or choose `none` when motion would reduce
|
||||
clarity, and never changes page content merely to justify animation.
|
||||
|
||||
**Hard rule — existing visible-layer boundary**: This stage may regroup existing content only under §2 visual equivalence; it MUST NOT create or modify a crop, comparison layer, scrim, lens, hotspot, annotation, or other visible image state to satisfy motion intent. When a required state is missing and ordinary Slide-local authoring can supply it, return to Generate Step 6, rerun the final SVG gate and notes, then resume here. If a structural boundary prevents that repair, simplify a non-binding suggestion to legal existing units, a page transition, or `none`; an explicit requirement follows failure recovery instead of changing structure.
|
||||
|
||||
**No-op is complete**: Evaluate suggestions before regrouping SVG content. If
|
||||
no `animations.json` exists, every page should retain the normal `fade`
|
||||
transition and no object builds, and no explicit user requirement remains
|
||||
unmet, change no SVG, create no sidecar, and return to Generate Step 7. Never
|
||||
author motion merely to expose a capability.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rebuild Semantic Animation Groups, Then List IDs
|
||||
## 2. Rebuild Semantic Motion Groups When Needed, Then List IDs
|
||||
|
||||
**Mandatory — content-first grouping audit**: inspect every slide's visible
|
||||
content against its communication job and speaker flow before treating any
|
||||
top-level `<g>` as an animation anchor. Existing groups are implementation
|
||||
evidence only. Keep a current group unchanged only after confirming that it
|
||||
already represents exactly one audience-facing reveal unit.
|
||||
**Mandatory when object-targeted motion is in scope — content-first grouping audit**:
|
||||
inspect every slide's visible content against its communication job and speaker
|
||||
flow before treating any top-level `<g>` as an animation anchor. Existing
|
||||
groups are implementation evidence only. Keep a current group unchanged only
|
||||
after confirming that it already represents exactly one audience-facing reveal
|
||||
unit or one continuing Morph object. Only a page-transition plan without
|
||||
explicit Morph pairs skips regrouping and group listing.
|
||||
|
||||
| Content condition | Required grouping action |
|
||||
|---|---|
|
||||
@@ -70,6 +94,7 @@ already represents exactly one audience-facing reveal unit.
|
||||
| One reveal unit is scattered across groups or root primitives | Merge or wrap its background, icon, label, value, and supporting text into one direct-root group |
|
||||
| A connector or arrow explains entry into a node or stage | Reveal it with the relationship or target unit that makes the connection intelligible |
|
||||
| A hero visual, overview graphic, takeaway, or warning has its own communication role | Give it its own semantic group |
|
||||
| The same semantic object continues across adjacent Morph pages | Isolate each endpoint as one direct-root group and keep both endpoints as compatible object kinds |
|
||||
| Several atoms express one inseparable idea | Keep them together; do not animate the atoms separately |
|
||||
| Page chrome, structural layers, or static framing | Preserve their structure and exclude them from ordinary animation targets |
|
||||
|
||||
@@ -139,6 +164,7 @@ Do not read the full scaffold unless it is needed as an editing starting point.
|
||||
| Layer | Config path | Use |
|
||||
|---|---|---|
|
||||
| Page transition | `defaults.transition` or `slides.<slide>.transition` | Control how one slide enters from the previous slide |
|
||||
| Deterministic Morph pair | `slides.<destination>.morph` | Bind one real source group to one real destination group when semantic identity continues across adjacent slides |
|
||||
| Page animation defaults | `defaults.animation` or `slides.<slide>.animation` | Control the default object-animation behavior for animated groups on a slide |
|
||||
| Object overrides | `slides.<slide>.groups.<group_id>` | Control order, effect, delay, or duration for a real SVG group |
|
||||
|
||||
@@ -186,6 +212,13 @@ Run
|
||||
before authoring Effect Options. Never infer that one effect accepts another
|
||||
effect's direction, shape, pattern, or boolean fields.
|
||||
|
||||
For a cross-slide object continuation that must not depend on PowerPoint's
|
||||
automatic matching, put one explicit `morph` block on the destination slide.
|
||||
Its `from` slide must be the immediately preceding exported SVG; each stable
|
||||
pair key binds one source direct-root group id to one destination direct-root
|
||||
group id. The exporter supplies PowerPoint's `!!` prefix. Use Morph by object;
|
||||
word/character Morph does not accept this object-pair contract.
|
||||
|
||||
### 3.2 Supported In-Slide Animations
|
||||
|
||||
Use the 203 canonical PowerPoint-native keys: 53 `entrance_*`, 33
|
||||
@@ -214,6 +247,8 @@ four-spoke amount.
|
||||
effects implicitly. Select an explicit canonical key when the plan calls for
|
||||
one.
|
||||
|
||||
**Hard rule — explicit semantic choreography**: When an adopted image-led plan depends on a specific reveal relationship or order, target its real groups with explicit canonical effects and order; do not delegate those material decisions to `auto`, `mixed`, or `random`. Those modes remain valid when generic entrance treatment is sufficient.
|
||||
|
||||
**Start modes**:
|
||||
|
||||
| Trigger | Behavior |
|
||||
@@ -226,8 +261,9 @@ one.
|
||||
|
||||
## 4. Edit `animations.json`
|
||||
|
||||
**Hard rule — write every slide explicitly; let groups inherit**. Each
|
||||
slide under `slides.<slide>` MUST carry its own complete `transition` and
|
||||
**Hard rule — when this stage writes `animations.json`, write every slide
|
||||
explicitly; let groups inherit**. Each slide under `slides.<slide>` MUST carry
|
||||
its own complete `transition` and
|
||||
`animation` block (effect + duration + stagger + trigger where applicable),
|
||||
even when the values match `defaults`. This makes per-page rhythm visible
|
||||
at a glance without mentally merging the inheritance chain. Group-level
|
||||
@@ -258,6 +294,8 @@ the deck-wide values copied into every complete new slide block.
|
||||
| `transition.effect` | Slide-specific page transition effect |
|
||||
| `transition.effect_options` | Effect-specific native PowerPoint options; requires an explicit slide-specific `transition.effect` |
|
||||
| `transition.duration` | Slide-specific page transition duration |
|
||||
| `morph.from` | Immediately preceding SVG stem for an explicit deterministic Morph transition |
|
||||
| `morph.pairs.<key>.from` / `.to` | Unique source/destination direct-root group ids that receive the shared PowerPoint name `!!<key>` |
|
||||
| `animation.effect` | Slide-specific default object animation effect |
|
||||
| `animation.duration` | Slide-specific default object schedule duration |
|
||||
| `animation.stagger` | Slide-specific delay between object animation rows |
|
||||
@@ -281,7 +319,8 @@ selected effect. Before writing a parameterized effect, run
|
||||
`python3 skills/ppt-master/scripts/pptx_animations.py --describe
|
||||
<canonical_effect>` and use the returned values exactly. `duration` owns
|
||||
PowerPoint Speed; `accelerate`/`decelerate` own smooth start/end, so do not
|
||||
invent duplicate fields.
|
||||
invent duplicate fields. Change Font's `font_name` is one concrete
|
||||
target-installed PowerPoint face, never a CSS font stack.
|
||||
|
||||
**Canonical example — every slide carries explicit transition + animation;
|
||||
groups appear only when they diverge**:
|
||||
@@ -318,31 +357,44 @@ groups appear only when they diverge**:
|
||||
the defaults. `03_market` lists only divergent groups. Structural chrome stays
|
||||
omitted unless a marker-free legacy name needs an explicitly reviewed override.
|
||||
|
||||
Use the complete two-slide deterministic Morph example in
|
||||
[`animations.md`](../../references/animations.md) §2.1; do not copy the source
|
||||
group into the destination slide's `groups` block merely to establish identity.
|
||||
|
||||
**Forbidden — SVG pollution**: do not add `data-*` animation attributes to SVG files. Animation customization belongs in `animations.json`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Validate, Refresh Derived SVGs, and Export
|
||||
## 5. Validate and Return to Generate Export
|
||||
|
||||
Run sequentially:
|
||||
When `animations.json` was newly created or changed after the §1 validation,
|
||||
run:
|
||||
|
||||
```bash
|
||||
python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
|
||||
python3 skills/ppt-master/scripts/finalize_svg.py <project_path>
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path>
|
||||
```
|
||||
|
||||
**Validation**: the exported native PPTX must reflect the per-slide and
|
||||
per-object overrides, and `svg_final/` must reflect any semantic regrouping
|
||||
performed in §2. `--animation none` still disables all per-element animation
|
||||
and overrides `animations.json`. Unknown animation
|
||||
After validation succeeds, return to
|
||||
[`generate-pptx.md`](../generate-pptx.md) Step 7.1. Generate owns note
|
||||
splitting, `finalize_svg.py`, native export, and the published postflight
|
||||
receipt; Step 7.3 reads `<project_path>/animations.json` automatically. If §2
|
||||
changed `svg_output/`, complete its required final SVG quality rerun before
|
||||
returning. Do not finalize or export independently from this stage.
|
||||
|
||||
**Validation**: The later native export must reflect the per-slide and
|
||||
per-object overrides. `--animation none` still disables all per-element
|
||||
animation and overrides `animations.json`. Unknown animation
|
||||
effects/modes/triggers; unsupported effect options; incompatible, boolean,
|
||||
non-finite, or out-of-range timing parameters; non-positive durations; negative
|
||||
delay/stagger; invalid order; missing slides/groups; and structural-layer
|
||||
targets fail validation. Transition validation remains strict. None of these
|
||||
failures substitutes a fallback effect or silently drops a requested target.
|
||||
Deterministic Morph also rejects non-adjacent source slides, missing or
|
||||
ambiguous direct-root groups, conflicting or undeclared shared keys, non-object
|
||||
Morph, and any target that does not remain one compatible Slide-local object
|
||||
after structure processing.
|
||||
|
||||
Generated export reads back row order, trigger, target, resolved effect,
|
||||
Generate Step 7 export reads back row order, trigger, target, resolved effect,
|
||||
duration, offset, timing placement, IDs, and shape references. Narration
|
||||
preserves these rows. Direct-PPTX routes fingerprint and preserve source object
|
||||
animation; they never author it. See
|
||||
@@ -350,8 +402,9 @@ animation; they never author it. See
|
||||
|
||||
### 5.1 Optional Video Motion Handoff
|
||||
|
||||
When a downstream video renderer will enhance the deck, export with
|
||||
`--conversion-trace` and derive its motion plan from that resolved trace:
|
||||
When a downstream video renderer will enhance the deck, have Generate Step 7.3
|
||||
append `--conversion-trace`. After that final export succeeds, derive the motion
|
||||
plan from its resolved trace:
|
||||
|
||||
```bash
|
||||
python3 skills/ppt-master/scripts/video_motion_plan.py \
|
||||
@@ -370,10 +423,9 @@ renderer parameters but cannot replace the source effect. See
|
||||
|
||||
## ✅ Customize Animations Complete
|
||||
|
||||
- [x] Semantic context and every slide's visible content were reviewed
|
||||
- [x] Each target is one post-regroup semantic unit with a real SVG id
|
||||
- [x] Regrouped SVG passed the final quality gate and refreshed `svg_final/`
|
||||
- [x] Every slide has explicit motion blocks; only divergent groups are listed
|
||||
- [x] Page and object motion were planned together with intentional timing
|
||||
- [x] Sidecar validation, re-export, semantic read-back, and package validation passed
|
||||
- [x] Any video plan came from the final resolved conversion trace
|
||||
- [x] Applicable semantic context and motion intent were resolved
|
||||
- [x] Adopted object targets use real post-regroup SVG ids when object motion is in scope
|
||||
- [x] `animations.json` is complete and validated when present; a no-op path creates none
|
||||
- [x] Any regrouped SVG passed the final quality gate
|
||||
- [x] Control returned to Generate Step 7 for preview, export, read-back, and package validation
|
||||
- [x] Any requested video plan waits for the final resolved conversion trace
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ Then jump to `### Step 6: Executor Phase` and run the documented pipeline:
|
||||
|
||||
Reload the Generate authority and required execution references; do not reconstruct or replay the earlier planning conversation.
|
||||
|
||||
**Source verification**: the execution session is fresh, but `design_spec.md §IX` already owns the complete approved page wording. Read only the relevant `sources/` passages needed to resolve explicit `Fact IDs` / source references or verify exact facts, quotes, names, and data already required by the current §IX block. Never turn §IX back into a skeleton, add new claims or details, or independently rewrite its content. If §IX lacks executable wording or evidence, stop and return to Generate Step 4 for Design Spec repair.
|
||||
**Source verification**: the execution session is fresh. Read only the relevant `sources/` passages needed to resolve explicit `Fact IDs` / source references or verify facts, quotes, names, and data required by the current §IX block. Follow [`executor-base.md`](../../references/executor-base.md) §2.1's content-vs-expression contract; source verification never authorizes a second outline. If §IX lacks executable content or evidence, stop and return to Generate Step 4 for Design Spec repair.
|
||||
|
||||
> Note: this stage does NOT duplicate Step 6 / Step 7 content. `generate-pptx.md` is the authoritative procedure; resume-execute only adds the resumption entry, sanity check, and source-verification guidance.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user