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:
@@ -6,8 +6,8 @@
|
||||
"repo": "https://github.com/obra/superpowers.git",
|
||||
"ref": "main",
|
||||
"adapter": "codex-plugin",
|
||||
"commit": "3dcbd5c4b48e02263fbf4a3c01e3fe4f81d584d9",
|
||||
"syncedAt": "2026-07-24T06:18:20Z"
|
||||
"commit": "44c9b2d6e889982ac18c27d05a19fefe335194e1",
|
||||
"syncedAt": "2026-07-29T16:00:04Z"
|
||||
},
|
||||
{
|
||||
"id": "superpowers-zh",
|
||||
@@ -24,8 +24,8 @@
|
||||
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
|
||||
"ref": "main",
|
||||
"adapter": "codex-plugin",
|
||||
"commit": "435d4a9cc982ffaf83fabbfbb8711ae6c178ffca",
|
||||
"syncedAt": "2026-07-19T16:00:01Z"
|
||||
"commit": "57f8e682af899b5d0e28d05b238c903c2fdeb913",
|
||||
"syncedAt": "2026-07-29T16:00:04Z"
|
||||
},
|
||||
{
|
||||
"id": "ui-ux-pro-max",
|
||||
@@ -60,8 +60,8 @@
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "47c7f92dbc4dd22a29982986458787000c4e7bc1",
|
||||
"syncedAt": "2026-07-28T15:59:58Z"
|
||||
"commit": "5203f537d152844a920caa66e865bc61c6ff4860",
|
||||
"syncedAt": "2026-07-29T16:00:04Z"
|
||||
},
|
||||
{
|
||||
"id": "frontend-slides",
|
||||
@@ -96,8 +96,8 @@
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "cbb6bf9917efb18d787e510fc41a5e9e388bbdf8",
|
||||
"syncedAt": "2026-07-28T15:59:58Z"
|
||||
"commit": "dd6c503df8c247b6544dadf2313c4cceff6b0281",
|
||||
"syncedAt": "2026-07-29T16:00:04Z"
|
||||
},
|
||||
{
|
||||
"id": "next-skills",
|
||||
@@ -105,8 +105,8 @@
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"adapter": "skill-collection",
|
||||
"commit": "ad618bf13fbc4be57d6b6136a20547af50708eb2",
|
||||
"syncedAt": "2026-07-28T15:59:58Z"
|
||||
"commit": "91c6309c52ab90a6344f9aba059dabf82e82bc0b",
|
||||
"syncedAt": "2026-07-29T16:00:04Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-07-28T16:02:23Z"
|
||||
"syncedAt": "2026-07-29T16:04:09Z"
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "next-skills",
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"commit": "ad618bf13fbc4be57d6b6136a20547af50708eb2",
|
||||
"commit": "91c6309c52ab90a6344f9aba059dabf82e82bc0b",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-07-28T15:59:58Z"
|
||||
"syncedAt": "2026-07-29T16:00:04Z"
|
||||
}
|
||||
|
||||
@@ -349,31 +349,14 @@ this gate is as machine-checkable as the others. Detail:
|
||||
> under the lock on the production-build rig**, not when the code compiles. That
|
||||
> GREEN is the deterministic stop for the fix loop; proceed to E.
|
||||
|
||||
**When the read can't be pushed down** (an ID minted per request, an
|
||||
all-dynamic page, a per-request auth/scope read the whole subtree needs), there
|
||||
is no shell to grow. Don't force one: opt the route into **runtime prefetching**
|
||||
so the prefetch runs the dynamic render ahead of the click and the soft nav
|
||||
commits the real content. See [Runtime Prefetching](https://nextjs.org/docs/app/guides/runtime-prefetching)
|
||||
for the mechanism (`prefetch = 'allow-runtime'` on the route plus a full
|
||||
`<Link prefetch={true}>`) and the [dynamic-data-during-prefetching insight](https://nextjs.org/docs/messages/instant-link-prefetch-partial)
|
||||
for adoption. The `instant()`-specific gotchas the docs don't cover:
|
||||
|
||||
- **The full prefetch is mandatory.** An auto/PPR prefetch bails before the
|
||||
runtime spawn (`subtreeHasSpeculativePrefetch`); only `prefetch={true}` /
|
||||
`kind: 'full'` reaches it. If you set `prefetch = 'allow-runtime'` and it's
|
||||
still RED, the link is doing an auto prefetch.
|
||||
- **All leaf slots must agree.** `allow-runtime` on the content segment but
|
||||
nothing on a sibling `@header`/`@sidebar` leaf leaves the route's runtime entry
|
||||
incomplete, so the lock falls back to the shell. Flip every leaf together.
|
||||
- **Prefetch the canonical URL.** A link whose href 307-redirects can't be
|
||||
prefetched — the prefetch receives the redirect, not the tree. Point the link
|
||||
and the prefetch at the final URL.
|
||||
- **Don't blanket the full prefetch.** It fetches _all_ the target's dynamic
|
||||
data; issuing it on hover for every link is wasteful. Scope `kind: 'full'` to
|
||||
the runtime-prefetch targets only.
|
||||
- **Marker must be a committed node, not RSC bytes.** The content is often a
|
||||
client component, so its text isn't in the prefetch response. Assert a
|
||||
`data-testid` that renders when the client subtree commits.
|
||||
**When URL data can't be pushed down** (for example, the whole page depends on
|
||||
`params`, `searchParams`, or the full URL), there may be no meaningful static
|
||||
shell to grow. Don't force one. Runtime prefetching can make the soft
|
||||
navigation instant, but it is outside this optimizer loop: it requires Partial
|
||||
Prefetching, a `<Link prefetch={true}>`, and cached URL-dependent content. See
|
||||
[Runtime Prefetching](https://nextjs.org/docs/app/guides/runtime-prefetching)
|
||||
and pattern 10 in `reference/patterns.md` for the requirements, cost trade-offs,
|
||||
manual prefetch caveat, and `instant()` test gotchas.
|
||||
|
||||
## E. PARITY: the refactor changed only whether the route is instant
|
||||
|
||||
@@ -441,3 +424,32 @@ three hold, you are not done.
|
||||
- `reference/real-app-patterns.md`: parallel routes, deferring an auth gate,
|
||||
initial-load vs soft-navigation shells, the empty-shell failure mode, the
|
||||
responsive-skeleton mismatch, edge cases.
|
||||
|
||||
## After optimization
|
||||
|
||||
Once the target routes are instant, check whether the app has already adopted
|
||||
Partial Prefetching (`partialPrefetching: true`, or the relevant destination
|
||||
still uses `prefetch = 'partial'` during an incremental rollout).
|
||||
|
||||
Make that check mechanically:
|
||||
|
||||
```bash
|
||||
rg -n "partialPrefetching|prefetch\s*=\s*['\"]partial['\"]" --glob 'next.config.*' --glob 'app/**' --glob 'src/app/**'
|
||||
```
|
||||
|
||||
If `partialPrefetching: true` is in config, the app is globally adopted. If only
|
||||
`prefetch = 'partial'` matches, treat those destination segments as adopted
|
||||
during an incremental rollout and keep checking any other target routes.
|
||||
|
||||
- **Already adopted:** for any URL-data route that stopped at the limitation
|
||||
above, consider a targeted `<Link prefetch={true}>` on the links where having
|
||||
that URL-specific content ready before the click is worth the per-link server
|
||||
work. Keep the default link behavior everywhere else so the shared App Shell
|
||||
remains the low-cost baseline.
|
||||
- **Not adopted yet:** recommend
|
||||
[`next-partial-prefetching-adoption`](https://github.com/vercel/next.js/tree/canary/skills/next-partial-prefetching-adoption).
|
||||
That skill moves the app onto the better prefetching model: shared App Shell
|
||||
prefetches by default, fewer duplicated full-prefetch requests for visible
|
||||
links, a link audit for existing `<Link prefetch={true}>` usage, and optional
|
||||
per-link runtime prefetching only where URL-specific content is worth the
|
||||
extra server work.
|
||||
|
||||
+21
-18
@@ -319,36 +319,39 @@ Prefer per-component boundaries inside the page (patterns #1–#5) over one big
|
||||
|
||||
**Insight:** the read's own insight surfaces on the client navigation when the boundary is too high — see [where to place the boundary](https://nextjs.org/docs/messages/blocking-prerender-dynamic#choosing-where-to-place-the-boundary).
|
||||
|
||||
## 10. Can't push the read down? Runtime-prefetch the whole route
|
||||
## 10. URL data that can't move
|
||||
|
||||
Patterns 1–9 grow a **static shell** by moving dynamic reads behind boundaries. Some routes resist that: an ID minted per request (`createId()`), an auth/scope resolution the whole subtree needs, a page that is _all_ dynamic by nature. The read can't move, so there is no meaningful shell to commit and the soft nav stays RED. The escape hatch is **runtime prefetching** — don't prerender a shell, run the dynamic render _in the prefetch_ so the whole route is warm before the click and the soft nav commits the real content instantly.
|
||||
Patterns 1–9 grow a **static shell** by moving dynamic reads behind boundaries. Session data from `cookies()` and `headers()` is handled by the earlier patterns. URL data is different: `params`, `searchParams`, and the full URL belong to one link, while the App Shell is shared by every link to the route.
|
||||
|
||||
It has **two halves — both required**, and route config alone is RED:
|
||||
If the whole route depends on URL data, pushing the read lower may leave no meaningful shared shell to commit. That is the optimizer's stop point, not another shell refactor. Return to `SKILL.md` after the optimization loop for the optional runtime-prefetch follow-up.
|
||||
|
||||
Runtime prefetching is the only way for this soft navigation to commit the
|
||||
URL-specific content before the click. It has **three requirements**:
|
||||
|
||||
```tsx
|
||||
// 1. The route opts in — on EVERY leaf (page/default) segment, parallel @slots
|
||||
// included, or instant validation re-triggers on the segments that lack it
|
||||
// (the same all-or-nothing coupling as an `instant = false` opt-out).
|
||||
export const prefetch = 'allow-runtime'
|
||||
// 1. The destination has adopted Partial Prefetching, either app-wide with
|
||||
// partialPrefetching: true or route-by-route with prefetch = 'partial'.
|
||||
|
||||
// 2. The <Link> asks for a FULL prefetch — that is what spawns the runtime
|
||||
// request. A default/auto prefetch only warms the static shell.
|
||||
<Link href={href} prefetch={true}>…</Link>
|
||||
// <Link prefetch> already issues the full prefetch on hover and on viewport
|
||||
// entry, so prefer it. An imperative full prefetch via router.prefetch needs
|
||||
// the non-exported PrefetchKind enum, so it has no clean public form.
|
||||
// 2. The navigation asks for a full prefetch — normally <Link prefetch={true}>.
|
||||
// A default/auto prefetch only warms the static shell.
|
||||
<Link href={href} prefetch={true}>
|
||||
…
|
||||
</Link>
|
||||
|
||||
// 3. The URL-dependent content is behind `use cache`, keyed by the resolved
|
||||
// params/searchParams/full URL value.
|
||||
```
|
||||
|
||||
Under `instant()` the runtime entry is what commits, so the real content (not a skeleton) shows under the lock — that is the GREEN.
|
||||
Under `instant()` the runtime entry is what commits, so the real content, not a skeleton, shows under the lock.
|
||||
|
||||
Gotchas (each cost real debugging time):
|
||||
|
||||
- **The full prefetch is mandatory.** With App Shells enabled an auto/PPR prefetch bails before the runtime spawn (`subtreeHasSpeculativePrefetch`); only `prefetch={true}` / `kind: 'full'` reaches it. If you set `prefetch = 'allow-runtime'` and it's still RED, the link is doing an auto prefetch.
|
||||
- **All leaf slots must agree.** `allow-runtime` on the content segment but `instant = false` (or nothing) on a sibling `@header`/`@sidebar` leaf leaves the route's runtime entry incomplete, so the lock falls back to the shell. Flip every leaf together.
|
||||
- **The full prefetch is mandatory.** With App Shells enabled an auto/PPR prefetch bails before the runtime spawn (`subtreeHasSpeculativePrefetch`); use `<Link prefetch={true}>` for normal links, or keep an existing manual full-prefetch abstraction if the app already owns one. If the route is still RED after caching the URL-dependent content, the navigation may still be doing an auto prefetch.
|
||||
- **Partial Prefetching must be adopted for the destination.** Runtime prefetching rides on the Partial Prefetching path. If the route is still RED after caching the URL-dependent content, check whether the link is still doing an auto prefetch or whether the destination never adopted Partial Prefetching.
|
||||
- **Prefetch the canonical URL.** A link whose href 307-redirects (a `/foo` that canonicalizes to `/`) can't be prefetched — the prefetch receives the redirect, not the tree. Point the link and the prefetch at the final URL.
|
||||
- **Don't blanket the full prefetch.** It fetches _all_ the target's dynamic data; issuing it on hover for every link (recents that point at whole chats) is wasteful. Scope `kind: 'full'` to the runtime-prefetch targets only.
|
||||
- **Don't blanket the full prefetch.** It fetches _all_ the target's dynamic data; enabling it for every visible link is wasteful. Scope `prefetch={true}` to the runtime-prefetch targets only, using the [runtime prefetching trade-offs](https://nextjs.org/docs/app/guides/runtime-prefetching#per-link-prefetching-trade-offs) and [hover-triggered prefetch](https://nextjs.org/docs/app/guides/prefetching#hover-triggered-prefetch) when many links are visible.
|
||||
- **Marker must be a committed node, not RSC bytes.** The content is often a client component, so its text isn't in the prefetch response — assert a `data-testid` that renders when the client subtree commits, not a substring of the stream.
|
||||
|
||||
Prefer a static shell (patterns 1–9) whenever the read can move: it's cheaper than a runtime prefetch and also covers hard load. Reach for runtime prefetching when the read genuinely can't move, or for a route that's all-dynamic by design.
|
||||
Prefer a static shell (patterns 1–9) whenever the URL-data read can move: it's cheaper than a runtime prefetch and also covers hard load. Runtime prefetching is only for URL-data reads that genuinely can't move, or routes whose useful content is all URL-specific.
|
||||
|
||||
**Insight:** [dynamic data during prefetching](https://nextjs.org/docs/messages/instant-link-prefetch-partial).
|
||||
|
||||
+12
-12
@@ -35,9 +35,9 @@ Talk to the user in terms of what they'll see — PRs, features, and how the app
|
||||
|
||||
## background
|
||||
|
||||
Adopting Partial Prefetching means every route still delivers what its links prefetched before, now split between the App Shell (static and cached content) and the per-link runtime data behind `prefetch = 'allow-runtime'`. The [guide](https://nextjs.org/docs/app/guides/adopting-partial-prefetching) is the canonical reference for what a prefetch contains and how to decide each case; this skill sequences that work against a running app.
|
||||
Adopting Partial Prefetching means every route still delivers what its links prefetched before, now split between the shared App Shell and any extra per-link data a link explicitly asks for. The [guide](https://nextjs.org/docs/app/guides/adopting-partial-prefetching) is the canonical reference for what a prefetch contains and how to decide each case; this skill sequences that work against a running app.
|
||||
|
||||
The catch that decides most of the sweep: `partial` warms only the shared App Shell, so a route keyed by `params`/`searchParams` needs [`prefetch = 'allow-runtime'`](https://nextjs.org/docs/app/guides/runtime-prefetching) to prefetch its content, not `partial` (the guide's [URL data](https://nextjs.org/docs/app/guides/adopting-partial-prefetching#url-data) section).
|
||||
The catch that decides most of the sweep: a default link warms only the shared App Shell. A route keyed by `params` or `searchParams` can prefetch more only after it has adopted Partial Prefetching and a specific link uses [`<Link prefetch={true}>`](https://nextjs.org/docs/app/api-reference/components/link#prefetch); then Next.js resolves the URL data and any cached content behind it before the click (the guide's [URL data](https://nextjs.org/docs/app/guides/adopting-partial-prefetching#url-data) section).
|
||||
|
||||
## working surfaces
|
||||
|
||||
@@ -56,15 +56,15 @@ If `partialPrefetching: true` is already set in `next.config.ts`, the app is ado
|
||||
|
||||
The work is identical either way — only the commit boundaries differ. Default by app size: one branch for a handful of links, route by route when the audit is big enough that reviewers need smaller diffs. Note the choice in your report.
|
||||
|
||||
Enumerate the links across the whole source tree, not only `app/` — they often live in `src/components` or shared UI packages: `grep -rnE '\bprefetch\b' --include='*.tsx' --include='*.jsx' .`. Keep the `prefetch={true}` and bare-prop matches (a bare prop is `true`) as the over-prefetching links this audit adopts destinations for, and drop `prefetch={false}` and other values. The same grep also surfaces imperative prefetching: a `router.prefetch(href, { kind: 'full' })` call (often behind a custom hook) is the imperative equivalent of `prefetch={true}`. Audit each such call site's destination the same way you would a link, and verify its prefetch payload in a production build ([step 4](#step-4-verify)) — the flag can change what an imperative `kind: 'full'` prefetch returns, and no insight covers it. If nothing matches, check for a custom link wrapper before calling the audit empty. If there's still nothing, say so in your report and move on to [step 2](#step-2-enable-the-flag).
|
||||
Enumerate the prefetch sites across the whole source tree, not only `app/` — they often live in `src/components` or shared UI packages: `rg -n '\bprefetch\b|router\.prefetch' -g '*.tsx' -g '*.jsx' .`. Keep the `<Link prefetch={true}>` and bare-prop matches (a bare prop is `true`) as the over-prefetching links this audit adopts destinations for, and drop `prefetch={false}` and other values. Also audit existing imperative [`router.prefetch()`](https://nextjs.org/docs/app/api-reference/functions/use-router#userouter) call sites with the same table, because they can be preserving the same "fetch before navigation" behavior and have no dev insight. For new navigation prefetching, prefer [`<Link>`](https://nextjs.org/docs/app/api-reference/components/link), which the docs call the primary navigation API; use [`router.prefetch()`](https://nextjs.org/docs/app/guides/prefetching#manual-prefetch) only for manual prefetching. If the app already passes an internal `kind` option, treat that as existing implementation detail, not a pattern to spread. If nothing matches, check for a custom link wrapper before calling the audit empty. If there's still nothing, say so in your report and move on to [step 2](#step-2-enable-the-flag).
|
||||
|
||||
Then, for each one:
|
||||
|
||||
1. **Click it** in `next dev`. The insight fires at navigation time, not when the link prefetches, so a link sitting in the viewport won't trip it — you have to navigate through it. This click is _verification_: it confirms the insight fires before you adopt and clears after. Without a browser, skip the click and adopt from [`link-prefetch-partial`](https://nextjs.org/docs/messages/instant-link-prefetch-partial) and the audit table below — the destination's structure tells you the row, and type-check gates the edit — then leave the live confirmation for the hand-off.
|
||||
2. **Adopt the destination.** Add `export const prefetch = 'partial'`. That clears the insight for every link pointing at it. If the route reads URL data (`params`, `searchParams`), `partial` warms only its skeleton (the guide's [URL data](https://nextjs.org/docs/app/guides/adopting-partial-prefetching#url-data) section), so it's an `allow-runtime` candidate for step 5, not a finished adoption. Keep `prefetch={true}` on its links and mark the route:
|
||||
1. **Click each `<Link>` in `next dev`.** The insight fires at navigation time, not when the link prefetches, so a link sitting in the viewport won't trip it — you have to navigate through it. This click is _verification_: it confirms the insight fires before you adopt and clears after. Imperative `router.prefetch()` sites have no equivalent insight, so audit them from source and verify them in production ([step 4](#step-4-verify)). Without a browser, skip the click and adopt from [`link-prefetch-partial`](https://nextjs.org/docs/messages/instant-link-prefetch-partial) and the audit table below — the destination's structure tells you the row, and type-check gates the edit — then leave the live confirmation for the hand-off.
|
||||
2. **Adopt the destination.** Add `export const prefetch = 'partial'`. That clears the insight for every link pointing at it. If the route reads URL data (`params`, `searchParams`), the default link still warms only its skeleton (the guide's [URL data](https://nextjs.org/docs/app/guides/adopting-partial-prefetching#url-data) section), so it's a runtime-prefetch candidate for step 5, not a finished adoption. Keep `prefetch={true}` on its links and mark the route:
|
||||
|
||||
```tsx
|
||||
// TODO(runtime-prefetch): assess with the user (prefetch = 'allow-runtime')
|
||||
// TODO(runtime-prefetch): assess with the user whether URL data should resolve before click.
|
||||
export const prefetch = 'partial'
|
||||
```
|
||||
|
||||
@@ -79,7 +79,7 @@ Then, for each one:
|
||||
Once every audited destination has `prefetch = 'partial'`, finish in two moves.
|
||||
|
||||
1. **Enable the flag globally.** Set `partialPrefetching: true` in `next.config.ts` (alongside `cacheComponents: true`). Every route is adopted now, so every link is good.
|
||||
2. **Strip the redundant `prefetch = 'partial'` exports.** Run the first-party `remove-partial-prefetch` codemod rather than a text find-and-replace. It removes only `export const prefetch = 'partial'` and leaves any other value (a deliberate `prefetch = 'allow-runtime'`) in place, along with your `TODO(runtime-prefetch)` markers, which wait for step 5.
|
||||
2. **Strip the redundant `prefetch = 'partial'` exports.** Run the first-party `remove-partial-prefetch` codemod rather than a text find-and-replace. It removes only `export const prefetch = 'partial'` and leaves other values such as `prefetch = 'force-disabled'` in place, along with your `TODO(runtime-prefetch)` markers, which wait for step 5.
|
||||
|
||||
Use the `@canary` channel, not `@latest`. The `remove-partial-prefetch` transform isn't in the stable `@next/codemod` release yet, and `@next/codemod@latest` errors with `Invalid transform choice`.
|
||||
|
||||
@@ -87,7 +87,7 @@ Once every audited destination has `prefetch = 'partial'`, finish in two moves.
|
||||
npx @next/codemod@canary remove-partial-prefetch ./app
|
||||
```
|
||||
|
||||
The codemod refuses to run on a dirty working tree. Commit or stash unrelated work first, or pass `--force` to let its edits land alongside your WIP. If the codemod isn't available (older `@next/codemod`, sandboxed environment, offline run), reproduce it by hand by removing `export const prefetch = 'partial'` from every `app/**/{page,layout}.{js,jsx,ts,tsx}` — leave any other `prefetch` value in place, and leave the `TODO(runtime-prefetch)` markers where they are. Don't hand-edit when the codemod can run.
|
||||
The codemod refuses to run on a dirty working tree. Commit or stash unrelated work first, or pass `--force` to let its edits land alongside your WIP. If the codemod isn't available (older `@next/codemod`, sandboxed environment, offline run), reproduce it by hand by removing `export const prefetch = 'partial'` from every `app/**/{page,layout}.{js,jsx,ts,tsx}` — leave other `prefetch` values in place, and leave the `TODO(runtime-prefetch)` markers where they are. Don't hand-edit when the codemod can run.
|
||||
|
||||
## step 3: sweep for URL-data insights (after enabling)
|
||||
|
||||
@@ -97,7 +97,7 @@ Sweep feature by feature. A feature is a single product surface — `app/setting
|
||||
|
||||
If the environment can't finish the whole sweep (slow first compiles, a dev server that falls over under load, no browser at all), take the browser-free work as far as it goes before handing off. Adopt every route you can statically: apply the fix from [`URL data`](https://nextjs.org/docs/messages/instant-shell-url-data) (up to a new `<Suspense>` boundary) and opt the route into `prefetch = 'partial'`, gating on type-check. Work the whole queue in one pass — a larger refactor isn't a reason to defer, and asking whether to continue to the next route or tier isn't a checkpoint; keep going. Stop only for a genuine judgment call, and batch those into the single hand-off report: the routes you statically adopted, the ones still needing a live shell check, and the queue.
|
||||
|
||||
Watch the Insights tab and the dev log for `Next.js encountered … data` lines. The signal this step adds is [`URL data`](https://nextjs.org/docs/messages/instant-shell-url-data): a `params` or `searchParams` read outside `<Suspense>` ties the shared shell to one URL. It can surface even inside an existing `<Suspense>` when the boundary sits above the read. Open its docs page and follow the fix there.
|
||||
Watch the Insights tab and the dev log for `Next.js encountered … data` lines. The signal this step adds is [`URL data`](https://nextjs.org/docs/messages/instant-shell-url-data): a `params` or `searchParams` read too high in the suspended subtree ties the shared shell to one URL. This insight is narrow; it most reliably appears on a `generateStaticParams` route where `params` is already under `<Suspense>`, but still awaited before the URL-specific leaf boundary. If a `blocking-prerender-*` error fires instead, apply the same structural fix.
|
||||
|
||||
Loading a route with the flag on prerenders its App Shell, which validates more of the route than the Cache Components build did. So a route that built cleanly under Cache Components (every route `◐`, no errors) can still surface a `blocking-prerender-*` error here the first time its shell is prerendered — [`runtime data`](https://nextjs.org/docs/messages/blocking-prerender-runtime) (`cookies()`/`headers()`), [`uncached data`](https://nextjs.org/docs/messages/blocking-prerender-dynamic) (an uncached `fetch`/DB call), or sync IO like `Date.now()`/`new Date()`. This doesn't mean the Cache Components adoption was incomplete; it's new validation reaching a path the build never exercised. These aren't Partial Prefetching insights — fix each one the same way you would any blocking-prerender error.
|
||||
|
||||
@@ -107,10 +107,10 @@ These fixes rarely involve the user — each insight names the offending read an
|
||||
|
||||
Checklist before checking in with the user:
|
||||
|
||||
- **An empty sweep is the expected outcome when Cache Components adoption finished cleanly** — the prereq already forced every `params`/`searchParams`/`cookies()` read behind `<Suspense>` (surfaced there as `blocking-prerender-*` errors), so a quiet log is success, not a missing signal. Any entry still in the Insights tab is a deliberate, documented decision. To confirm the signal can still fire, check `partialPrefetching` is on, the version is 16.3 or later, and the dev server was restarted after the config change — or move one URL read back outside `<Suspense>`, watch validation fire, then revert. Expect the probe to surface the Cache Components `blocking-prerender-runtime` error rather than the URL-data insight (the upstream check catches the read first) — either one proves the pipeline is alive.
|
||||
- **An empty sweep is expected when Cache Components adoption finished cleanly.** A quiet log is success, not a missing signal. If you deliberately probe the validation path, use a `generateStaticParams` route with `params` read inside `<Suspense>` but before the URL-specific leaf boundary; other shapes may surface `blocking-prerender-*` instead.
|
||||
- The App Shells are real: for each route you changed, confirm the first paint after a navigation shows the intended shared content, not an empty shell or a stuck fallback. A `<Suspense>` around the whole page body passes validation with an empty shell, which defeats the point.
|
||||
- The insights validate shell _structure_, not that a prefetch actually happened. Confirm on the production run (prefetching is prod-only) that navigating a changed link lands on the shared shell instantly.
|
||||
- **If the app prefetches imperatively** (`router.prefetch(href, { kind: 'full' })`), the insight sweep does not cover it, so an empty sweep is not proof the prefetch survived the flag. On the production run, confirm the prefetch payload for a changed link still carries the route's data, not only the App Shell — compare its `_rsc` prefetch response against a full render of the same route (`PerformanceResourceTiming.decodedBodySize`, or diff the RSC payloads). A shell-only payload means the flag changed what `kind: 'full'` returns; restore the data with `use cache` so it rides the App Shell, or `prefetch = 'allow-runtime'` ([step 5](#step-5-runtime-prefetching-optional)).
|
||||
- **If the app prefetches imperatively**, the insight sweep does not cover it, so an empty sweep is not proof the prefetch survived the flag. Verify the call under `next start`: compare the `_rsc` prefetch response or resource timing before/after, and make sure any intentionally preserved full prefetch still carries the data the old call was warming. If it now returns only the App Shell, migrate that call site using the same decision as the nearest `<Link prefetch={true}>` destination — cache the data, or move runtime-prefetch behavior to a docs-supported `<Link prefetch={true}>`.
|
||||
- **Before blaming a broken route on the flag, reproduce it with `partialPrefetching` off** (or on the pre-flag branch). The flag surfaces existing issues — a fragile request-time auth gate, a rewrite, deployment skew — earlier and more visibly, but rarely causes them. If it breaks flag-off too, it isn't a Partial Prefetching problem; fix it there, not here.
|
||||
- `next build` still passes.
|
||||
|
||||
@@ -127,7 +127,7 @@ Then check in with the user. Speak their language — no insight slugs or step l
|
||||
|
||||
The audit marked the candidates instead of deciding them. Grep for `TODO(runtime-prefetch)` and walk the list with the user in one conversation. The question per route is whether they want the URL-dependent content prefetched ahead of the click, or streaming in after navigation is fine. A runtime prefetch costs a server invocation per prefetchable link — the guide's [per-link prefetching trade-offs](https://nextjs.org/docs/app/guides/runtime-prefetching#per-link-prefetching-trade-offs) section is the checklist. Don't make these calls alone.
|
||||
|
||||
Where the answer is yes, follow the [runtime prefetching guide](https://nextjs.org/docs/app/guides/runtime-prefetching) — add `export const prefetch = 'allow-runtime'` to the route (the codemod in step 2 already stripped the `'partial'` export) and cache the content behind the read using the guide's patterns (`use cache` with the runtime value passed in, or `use cache: private` for per-user data). Each per-link prefetch is a server render, so the [runtime prefetching guide](https://nextjs.org/docs/app/guides/runtime-prefetching#per-link-prefetching-trade-offs) covers the trade-offs, including when to bound it to hover instead of the viewport. Where it's no, delete the marker and leave the route on the default. Either way no `TODO(runtime-prefetch)` marker survives this step. Confirm the opted-in routes against a production run (`next build` and `next start` — the runtime prefetch fires there, not in `next dev`), give the user the same click-through for them, and keep this as its own commit or PR.
|
||||
Where the answer is yes, follow the [runtime prefetching guide](https://nextjs.org/docs/app/guides/runtime-prefetching): keep [`<Link prefetch={true}>`](https://nextjs.org/docs/app/api-reference/components/link#prefetch) on the links that should resolve more than the App Shell, and cache the content behind the URL-data read using the guide's patterns (`use cache` with the runtime value passed in, or `use cache: private` for per-user data). Each per-link prefetch is a server render when the destination needs non-static data, so use the guide's [per-link trade-offs](https://nextjs.org/docs/app/guides/runtime-prefetching#per-link-prefetching-trade-offs) to decide when viewport prefetching is worth it and when [hover-triggered prefetch](https://nextjs.org/docs/app/guides/prefetching#hover-triggered-prefetch) is a better fit. Where it's no, delete the marker and leave the route on the App Shell default. Either way no `TODO(runtime-prefetch)` marker survives this step. Confirm the opted-in links against a production run (`next build` and `next start` — the runtime prefetch fires there, not in `next dev`), give the user the same click-through for them, and keep this as its own commit or PR.
|
||||
|
||||
## further reading
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-codex",
|
||||
"version": "0.20.3",
|
||||
"version": "0.20.4",
|
||||
"description": "oh-my-codex 是 Codex CLI 的多 Agent 编排、结构化工作流、插件级 hooks、MCP 和 HUD 扩展插件。",
|
||||
"author": {
|
||||
"name": "Yeachan Heo",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "oh-my-codex",
|
||||
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
|
||||
"ref": "main",
|
||||
"commit": "435d4a9cc982ffaf83fabbfbb8711ae6c178ffca",
|
||||
"commit": "57f8e682af899b5d0e28d05b238c903c2fdeb913",
|
||||
"adapter": "codex-plugin",
|
||||
"sourcePath": "plugins/oh-my-codex",
|
||||
"syncedAt": "2026-07-19T16:00:01Z"
|
||||
"syncedAt": "2026-07-29T16:00:04Z"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import { extname } from 'node:path';
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const hookDir = dirname(fileURLToPath(import.meta.url));
|
||||
// sync-plugin-mirror verifies this stable marker; runtime behavior is tested separately.
|
||||
const OMX_PLUGIN_HOOK_LAUNCHER_CONTRACT_MARKER = 'omx-plugin-hook-launcher:v1';
|
||||
const OMX_PLUGIN_HOOK_ROUTING_ONLY_MARKER = 'omx-plugin-hook-routing-only:v1';
|
||||
const MAX_WRAPPER_STDIN_BYTES = 1024 * 1024;
|
||||
const RAW_EVENT_SCAN_BYTES = 64 * 1024;
|
||||
const MAX_STOP_STDOUT_BYTES = 1024 * 1024;
|
||||
const MAX_ROUTING_RECORD_BYTES = 4 * 1024;
|
||||
const CODEX_HOOK_EVENT_NAMES = new Set([
|
||||
'SessionStart',
|
||||
'PreToolUse',
|
||||
@@ -144,35 +146,97 @@ function sanitizeLaunchId(value) {
|
||||
return String(value ?? '').trim().replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128);
|
||||
}
|
||||
|
||||
function resolveLaunchClaimPath(payload, launchId) {
|
||||
function resolveLaunchRoutingPath(payload, launchId) {
|
||||
const cwd = typeof payload.cwd === 'string' && payload.cwd.trim() ? payload.cwd : process.cwd();
|
||||
const stateRoot = typeof process.env.OMX_ROOT === 'string' && process.env.OMX_ROOT.trim()
|
||||
? process.env.OMX_ROOT.trim()
|
||||
: join(cwd, '.omx');
|
||||
return join(stateRoot, 'state', 'plugin-hook-launches', `${sanitizeLaunchId(launchId)}.json`);
|
||||
return join(stateRoot, 'state', 'plugin-hook-routing', `${sanitizeLaunchId(launchId)}.json`);
|
||||
}
|
||||
|
||||
function hookPayloadSessionId(input, payload) {
|
||||
const parsedSessionId = typeof payload.session_id === 'string' ? payload.session_id.trim() : '';
|
||||
const candidate = payload?.session_id ?? payload?.sessionId;
|
||||
const parsedSessionId = typeof candidate === 'string' ? candidate.trim() : '';
|
||||
if (parsedSessionId) return parsedSessionId;
|
||||
return extractTopLevelStringField(input.toString('utf8'), ['session_id', 'sessionId'])?.trim() ?? '';
|
||||
return input && typeof input.toString === 'function'
|
||||
? extractTopLevelStringField(input.toString('utf8'), ['session_id', 'sessionId'])?.trim() ?? ''
|
||||
: '';
|
||||
}
|
||||
|
||||
function isOmxLauncherSession(input, payload) {
|
||||
const launchId = process.env.OMX_CODEX_LAUNCH_ID?.trim();
|
||||
function isSafeRoutingSessionId(value) {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= 256;
|
||||
}
|
||||
|
||||
function isChildRoutingSessionStart(payload, ownerSessionId) {
|
||||
if ((payload?.hook_event_name ?? payload?.hookEventName) !== 'SessionStart' || !ownerSessionId) return false;
|
||||
const childSessionId = hookPayloadSessionId(null, payload);
|
||||
const transcriptCandidate = payload?.transcript_path ?? payload?.transcriptPath;
|
||||
const transcriptPath = typeof transcriptCandidate === 'string' ? transcriptCandidate.trim() : '';
|
||||
if (!childSessionId || !transcriptPath) return false;
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(transcriptPath, 'r');
|
||||
const buffer = Buffer.alloc(64 * 1024);
|
||||
const bytes = readSync(fd, buffer, 0, buffer.length, 0);
|
||||
const newline = buffer.subarray(0, bytes).indexOf(0x0a);
|
||||
const firstLine = buffer.subarray(0, newline >= 0 ? newline : bytes).toString('utf8').trim();
|
||||
const record = JSON.parse(firstLine);
|
||||
const metadata = record?.type === 'session_meta' && record?.payload && typeof record.payload === 'object' ? record.payload : null;
|
||||
const spawn = metadata?.source?.subagent?.thread_spawn;
|
||||
const metadataIds = [metadata?.id, metadata?.session_id].filter((value) => value !== undefined);
|
||||
return metadataIds.length > 0
|
||||
&& metadataIds.every((value) => value === childSessionId)
|
||||
&& spawn && typeof spawn === 'object'
|
||||
&& spawn.parent_thread_id === ownerSessionId;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (fd !== undefined) try { closeSync(fd); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function readPinnedRoutingRecord(routingPath) {
|
||||
let fd;
|
||||
try {
|
||||
const initial = lstatSync(routingPath);
|
||||
if (!initial.isFile() || initial.isSymbolicLink() || initial.nlink !== 1 || initial.size <= 0 || initial.size > MAX_ROUTING_RECORD_BYTES) return null;
|
||||
fd = openSync(routingPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
||||
const opened = fstatSync(fd);
|
||||
if (!opened.isFile() || opened.nlink !== 1 || opened.size !== initial.size || opened.dev !== initial.dev || opened.ino !== initial.ino) return null;
|
||||
const buffer = Buffer.alloc(opened.size);
|
||||
if (readSync(fd, buffer, 0, buffer.length, 0) !== buffer.length) return null;
|
||||
const current = lstatSync(routingPath);
|
||||
const final = fstatSync(fd);
|
||||
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || current.size !== opened.size || current.dev !== opened.dev || current.ino !== opened.ino || final.nlink !== 1 || final.size !== opened.size) return null;
|
||||
const routing = JSON.parse(buffer.toString('utf8'));
|
||||
return routing && typeof routing === 'object' && !Array.isArray(routing) ? routing : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (fd !== undefined) try { closeSync(fd); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// This is routing correlation only. It is intentionally unauthenticated and must
|
||||
// never be used as authorization or proof that a session is OMX-owned.
|
||||
function isPluginHookRoutingSession(input, payload) {
|
||||
const launchId = sanitizeLaunchId(process.env.OMX_CODEX_LAUNCH_ID);
|
||||
const entryPath = process.env.OMX_ENTRY_PATH?.trim();
|
||||
const sessionId = hookPayloadSessionId(input, payload);
|
||||
if (!launchId || !entryPath || !sessionId) return false;
|
||||
|
||||
const claimPath = resolveLaunchClaimPath(payload, launchId);
|
||||
if (!launchId || !entryPath || !isSafeRoutingSessionId(sessionId)) return false;
|
||||
|
||||
const routingPath = resolveLaunchRoutingPath(payload, launchId);
|
||||
try {
|
||||
if (existsSync(claimPath)) {
|
||||
const claimed = JSON.parse(readFileSync(claimPath, 'utf8'));
|
||||
return claimed?.sessionId === sessionId;
|
||||
if (existsSync(routingPath)) {
|
||||
const routing = readPinnedRoutingRecord(routingPath);
|
||||
if (!routing) return false;
|
||||
const ownerSessionId = routing.routing === OMX_PLUGIN_HOOK_ROUTING_ONLY_MARKER && isSafeRoutingSessionId(routing.ownerSessionId)
|
||||
? routing.ownerSessionId
|
||||
: '';
|
||||
return ownerSessionId === sessionId || isChildRoutingSessionStart(payload, ownerSessionId);
|
||||
}
|
||||
mkdirSync(dirname(claimPath), { recursive: true });
|
||||
writeFileSync(claimPath, `${JSON.stringify({ sessionId })}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
mkdirSync(dirname(routingPath), { recursive: true });
|
||||
writeFileSync(routingPath, `${JSON.stringify({ routing: OMX_PLUGIN_HOOK_ROUTING_ONLY_MARKER, ownerSessionId: sessionId })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -439,11 +503,11 @@ function parseSingleJsonObjectOutput(raw) {
|
||||
async function main() {
|
||||
const { input, oversized, totalBytes } = await readBoundedStdin({ drainOversized: true });
|
||||
const payload = parseHookPayload(input);
|
||||
const launchedByOmx = isOmxLauncherSession(input, payload);
|
||||
const routedByLaunchCorrelation = isPluginHookRoutingSession(input, payload);
|
||||
const isStop = detectStopHookInput(input);
|
||||
const isCompact = detectCompactHookInput(input);
|
||||
|
||||
if (!launchedByOmx) {
|
||||
if (!routedByLaunchCorrelation) {
|
||||
writePlainCodexNoop(isStop);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,13 +39,14 @@ Autopilot must not run a separate broad expansion/planning/execution/QA/validati
|
||||
- Ground the task with pre-context intake and the deep-interview artifact.
|
||||
- Current ownership rule: Autopilot records `planning_routing` in state before heavy planning. When the Autopilot/main model resolves to a cheap/mini lane (for example `o4-mini`, `*-mini`, `*spark*`, or an explicitly cheap/economy/lite model name), the initial planning/decomposition owner is dedicated `[planner]`; otherwise `[main]` may keep ownership for backward compatibility. A configured `agentModels.planner` is an explicit opt-in that forces dedicated `[planner]` ownership even when `[main]` is not cheap/mini.
|
||||
- Run or resume `$ralplan` to produce/update PRD and test-spec artifacts. If `planning_routing.owner` is `planner`, use the dedicated `[planner]` role for the initial Planner draft/decomposition before the Architect→Critic consensus gates.
|
||||
- PRD/test-spec files alone are not completion evidence. Ralplan may hand off only after durable consensus evidence records a subsequent `Architect` approval first and a subsequent `Critic` approval second.
|
||||
- PRD/test-spec files alone are not completion evidence. Local Architect→Critic approvals are lifecycle evidence, not handoff authority. Ralplan may hand off only after an official host-issued receipt is verified through a documented non-user-mintable host surface; until then retain the reviews with `ralplan_consensus_gate.complete:false` and `blocked_reason:"documented_host_consensus_receipt_unavailable"`.
|
||||
- On every revised planning pass, require subsequent `Architect` approval first and subsequent `Critic` approval second. These ordered reviews remain lifecycle evidence only and never replace the official host receipt.
|
||||
- When returning from a non-clean review or QA pass, include `return_to_ralplan_reason` and the findings as first-class planning input.
|
||||
- If either review is missing, blocked, out of order, or non-approving, remain in `ralplan` or report an explicit blocker/max-iteration outcome; do not progress to `$ultragoal`, `$team`, `$ralph`, or implementation.
|
||||
- Required handoff artifact: an approved plan/test spec plus `ralplan_consensus_gate` evidence suitable for `$ultragoal`.
|
||||
- Required handoff artifact: planning artifacts, lifecycle-only Architect→Critic reviews, and a verified official host receipt authorizing `ralplan_consensus_gate.complete:true`. Without that receipt, remain in `ralplan` and report the host blocker.
|
||||
|
||||
3. **Phase `ultragoal`** — durable implementation + verification loop
|
||||
- Run `$ultragoal` from the approved ralplan artifacts.
|
||||
- Run `$ultragoal` only from ralplan artifacts whose consensus gate is authorized by a verified official host receipt.
|
||||
- Ultragoal owns durable Codex goal handoffs, `.omx/ultragoal` ledger checkpoints, implementation, tests, build/lint/typecheck evidence, cleanup, and final review gate discipline.
|
||||
- Use `$team` only inside an active Ultragoal story when the story clearly benefits from coordinated parallel execution (for example independent file/module lanes, broad test matrix work, or multi-domain implementation). Team remains explicit and leader-owned; Ultragoal keeps the goal/ledger state.
|
||||
- Required handoff artifact: implementation evidence, changed-file summary, verification evidence, and Ultragoal ledger/checkpoint references suitable for `$code-review`.
|
||||
@@ -134,10 +135,17 @@ Required fields:
|
||||
|
||||
- **On start**: `omx state write --input '{"mode":"autopilot","active":true,"current_phase":"deep-interview","iteration":1,"review_cycle":0,"state":{"phase_cycle":["deep-interview","ralplan","ultragoal","code-review","ultraqa"],"handoff_artifacts":{"context_snapshot_path":"<snapshot-path>","deep_interview":null,"ralplan":null,"ralplan_consensus_gate":{"required":true,"sequence":["architect-review","critic-review"],"planning_artifacts_are_not_consensus":true,"required_review_roles":["architect","critic"],"ralplan_architect_review":null,"ralplan_critic_review":null,"complete":false},"ultragoal":null,"code_review":null,"ultraqa":null},"review_verdict":null,"qa_verdict":null,"return_to_ralplan_reason":null}}' --json`
|
||||
- **On deep-interview -> ralplan**: only after a separate gate proves the interview chain is explicitly complete or the user explicitly authorized a skip. For completion, persist `deep_interview_gate:{"status":"complete","rationale":"<why requirements are complete>","handoff_summary":"<summary>"}` (or equivalent non-empty rationale/summary) plus the clarified spec/requirements under `handoff_artifacts.deep_interview`; if a final `omx question` was involved, keep its same-session answered record linked by `question_id`/`satisfied_at`. For skip, persist `deep_interview_gate:{"status":"skipped","skip_authorized_by_user":true,"skip_reason":"<user-authorized reason>","skipped_at":"<timestamp>","source":"user","session_id":"<session>"}`. Do not leave deep-interview merely because the first `omx question` was answered or cleared.
|
||||
- The stable `<!-- OMX:AUTOPILOT:DEEP-INTERVIEW-RALPLAN-HANDOFF:v1 -->` marker immediately precedes the executable completion handoff fence; automation may locate exactly that fence.
|
||||
|
||||
<!-- OMX:AUTOPILOT:DEEP-INTERVIEW-RALPLAN-HANDOFF:v1 -->
|
||||
```bash
|
||||
omx state write --input '{"mode":"autopilot","active":true,"current_phase":"ralplan","session_id":"'"${OMX_SESSION_ID:?authoritative OMX session required}"'","workingDirectory":"'"${PWD:?working directory required}"'","state":{"deep_interview_gate":{"status":"complete","rationale":"requirements are clarified and ready for planning","handoff_summary":"durable deep-interview handoff recorded for ralplan"},"handoff_artifacts":{"deep_interview":".omx/specs/deep-interview-handoff.md"}}}' --json
|
||||
```
|
||||
- The artifact path above must already contain the durable clarified requirements/specification before this command runs. Omit session aliases unless needed; if emitted, `owner_omx_session_id`, `codex_session_id`, and `owner_codex_session_id` must each equal `session_id`.
|
||||
- **Optional execution contract foundation**: when a downstream handoff explicitly sets `execution_contract_required:true`, persist a complete structured `execution_contract` under `handoff_artifacts.deep_interview` before leaving deep-interview. The canonical schema is `version:1`, `execution_stride:"task"|"deliverable"|"milestone"`, `source:"deep-interview"`, `selected_by:"user"|"default"`, `allow_task_shrink:<boolean>`, non-empty `completion_unit`, non-empty `stop_condition`, `acceptance_coverage_scope:"task"|"deliverable"|"milestone"`, and `shrink_policy:"allowed"|"ask_before_shrink"|"deny_unless_blocked"`.
|
||||
- Stride semantics are binding only when `execution_contract_required:true`: `task` means `allow_task_shrink:true`, `acceptance_coverage_scope:"task"`, `shrink_policy:"allowed"`; `deliverable` means `allow_task_shrink:false`, `acceptance_coverage_scope:"deliverable"`, `shrink_policy:"ask_before_shrink"`; `milestone` means `allow_task_shrink:false`, `acceptance_coverage_scope:"milestone"`, `shrink_policy:"deny_unless_blocked"`.
|
||||
- Preserve legacy behavior when `execution_contract_required` is absent or false. Do not infer stride from prose, broadness, phase names, snapshots, or task size; this foundation only validates an explicit structured contract and deliberately uses `milestone` rather than `phase`. New artifacts must write canonical snake_case keys under `handoff_artifacts.deep_interview`; the runtime may read legacy camelCase field/marker aliases and direct/nested `execution_contract` locations only as compatibility input.
|
||||
- **On ralplan -> ultragoal**: only after `ralplan_consensus_gate.complete:true`, with tracker-backed native-subagent `ralplan_architect_review.agent_role:"architect"` and `ralplan_architect_review.verdict:"approve"` recorded before tracker-backed native-subagent `ralplan_critic_review.agent_role:"critic"` and `ralplan_critic_review.verdict:"approve"`; `codex_exec` or artifact-only approvals are trace evidence but not native lane proof. Set `current_phase:"ultragoal"` and persist the plan/test-spec paths under `handoff_artifacts.ralplan`.
|
||||
- **On ralplan -> ultragoal**: only after `ralplan_consensus_gate.complete:true` from an official host-issued receipt verified through a documented non-user-mintable host surface. Native-subagent Architect/Critic lanes, tracker records, `codex_exec`, and artifact approvals are lifecycle or trace evidence only. Until that verifier exists, keep `current_phase:"ralplan"` and persist `blocked_reason:"documented_host_consensus_receipt_unavailable"`.
|
||||
- **On missing ralplan consensus evidence**: keep `current_phase:"ralplan"`, persist `ralplan_consensus_gate.complete:false` with `blocked_reason`, and report an explicit blocker or max-iteration outcome instead of handing off to execution.
|
||||
- **On ultragoal -> code-review**: set `current_phase:"code-review"`, persist implementation/test/ledger evidence under `handoff_artifacts.ultragoal`.
|
||||
- **On code-review -> ultraqa**: set `current_phase:"ultraqa"` only after a real `$code-review` stage/subagent has produced durable evidence; persist the clean review under `handoff_artifacts.code_review` with its source thread/tool/stage reference. Do not author `review_verdict:{clean:true}` from the leader's own summary.
|
||||
@@ -182,7 +190,7 @@ Pipeline state should use `current_phase` values that match the same phase names
|
||||
|
||||
<Final_Checklist>
|
||||
- [ ] Phase `deep-interview` produced/updated clarified requirements or a concise spec
|
||||
- [ ] Phase `ralplan` produced/updated approved planning artifacts and durable sequential evidence from a subsequent `Architect` approval followed by a subsequent `Critic` approval
|
||||
- [ ] Phase `ralplan` produced/updated planning artifacts and preserved subsequent Architect→Critic approvals as lifecycle-only evidence; it advanced only with a verified official host receipt, or remained in `ralplan` with `complete:false` and `blocked_reason:"documented_host_consensus_receipt_unavailable"`.
|
||||
- [ ] Phase `ultragoal` implemented and verified the plan with fresh evidence and durable ledger/checkpoint references
|
||||
- [ ] Phase `rework` was used for implementation-only review fixes when applicable, with findings scoped to a fresh code-review cycle
|
||||
- [ ] `$team` was used only if the active Ultragoal story needed coordinated parallel work, or explicitly recorded as not needed
|
||||
|
||||
@@ -75,78 +75,35 @@ When cancellation targets Ralph state in a scope, completion requires all of the
|
||||
2. Linked Ultrawork/Ecomode in the same scope is also terminal/non-active.
|
||||
4. Unrelated sessions are untouched.
|
||||
|
||||
## Force Clear All
|
||||
## Exact-scope force compatibility
|
||||
|
||||
Use `--force` or `--all` when you need to erase every session plus legacy artifacts, e.g., to reset the workspace entirely.
|
||||
`--force` is a compatibility flag for the same proven current scope as bare cancellation. It does not widen cancellation to other sessions, legacy roots, Team runtimes, or workspace artifacts. Its only additional behavior is exact-session native-stop cleanup after the same ownership checks.
|
||||
|
||||
```
|
||||
`--all` is intentionally unsupported. Workspace-wide destructive cancellation requires a separately reviewed command and authority contract. Unknown flags and mixed flag combinations fail before mutation.
|
||||
|
||||
### Exact canonical recovery under resumed ownership drift
|
||||
|
||||
The native `PreToolUse` exemption accepts an inherited non-empty `NODE_EXTRA_CA_CERTS` only for the exact canonical `omx cancel` command shape (and workflow-supported `--force`). This does not relax `NODE_OPTIONS`, loader/import hooks, `OPENSSL_CONF`, shell startup/function injection, PATH/PATHEXT shadowing, leading assignments, command chaining, noncanonical executable resolution, or unrelated commands.
|
||||
|
||||
For session-scoped cancellation, a stale top-level `owner_codex_session_id` in `skill-active-state.json` may be replaced—not aliased—only inside the existing cancellation transaction after the current pointer, native owner sidecar, canonical target session, and all nested owner/session evidence agree. Displaced owner evidence must be positively absent or stale/dead; live, malformed, foreign, indeterminate, nested-contradictory, or cross-session evidence fails closed. If the skill marker is absent, mode-only cancellation keeps its existing behavior and does not create one.
|
||||
|
||||
The replacement and terminal skill state are serialized as one final skill payload. Cancellation retains all-target prevalidation, `O_NOFOLLOW`, content/identity revalidation, per-file sync, and reverse rollback. These are in-process transaction guarantees, not crash-atomic multi-file visibility; rollback restoration failures are reported rather than presented as successful cleanup.
|
||||
|
||||
```text
|
||||
/cancel
|
||||
/cancel --force
|
||||
```
|
||||
|
||||
```
|
||||
/cancel --all
|
||||
```
|
||||
### Argument contract
|
||||
|
||||
Steps under the hood:
|
||||
1. `state_list_active` enumerates `.omx/state/sessions/{sessionId}/…` to find every known session.
|
||||
2. `state_clear` runs once per session to drop that session’s files.
|
||||
3. A global `state_clear` without `session_id` removes legacy files under `.omx/state/*.json`, `.omx/state/swarm*.db`, and compatibility artifacts (see list).
|
||||
4. Team artifacts (`.omx/state/team/*/`, tmux sessions matching `omx-team-*`) are best-effort cleared as part of the legacy fallback.
|
||||
- no arguments: cancel only the provably current session/root scope;
|
||||
- `--force`: same scope, plus exact-session native-stop cleanup;
|
||||
- `--all`: reject without mutation;
|
||||
- unknown or multiple flags: reject without mutation.
|
||||
|
||||
Every `state_clear` command honors the `session_id` argument, so even force mode still uses the session-aware paths first before deleting legacy files.
|
||||
### State discovery
|
||||
|
||||
Legacy compatibility list (removed only under `--force`/`--all`):
|
||||
- `.omx/state/autopilot-state.json`
|
||||
- `.omx/state/ralph-state.json`
|
||||
- `.omx/state/ralph-plan-state.json`
|
||||
- `.omx/state/ralph-verification.json`
|
||||
- `.omx/state/ultrawork-state.json`
|
||||
- `.omx/state/ecomode-state.json`
|
||||
- `.omx/state/ultraqa-state.json`
|
||||
- `.omx/state/swarm.db`
|
||||
- `.omx/state/swarm.db-wal`
|
||||
- `.omx/state/swarm.db-shm`
|
||||
- `.omx/state/swarm-active.marker`
|
||||
- `.omx/state/swarm-tasks.db`
|
||||
- `.omx/state/ultrapilot-state.json`
|
||||
- `.omx/state/ultrapilot-ownership.json`
|
||||
- `.omx/state/pipeline-state.json`
|
||||
- `.omx/state/plan-consensus.json`
|
||||
- `.omx/state/ralplan-state.json`
|
||||
- `.omx/state/boulder.json`
|
||||
- `.omx/state/hud-state.json`
|
||||
- `.omx/state/subagent-tracking.json`
|
||||
- `.omx/state/subagent-tracker.lock`
|
||||
- `.omx/state/rate-limit-daemon.pid`
|
||||
- `.omx/state/rate-limit-daemon.log`
|
||||
- `.omx/state/checkpoints/` (directory)
|
||||
- `.omx/state/sessions/` (empty directory cleanup after clearing sessions)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
When you invoke this skill:
|
||||
|
||||
### 1. Parse Arguments
|
||||
|
||||
```bash
|
||||
# Check for --force or --all flags
|
||||
FORCE_MODE=false
|
||||
if [[ "$*" == *"--force"* ]] || [[ "$*" == *"--all"* ]]; then
|
||||
FORCE_MODE=true
|
||||
fi
|
||||
```
|
||||
|
||||
### 2. Detect Active Modes
|
||||
|
||||
The skill now relies on the session-aware state contract rather than hard-coded file paths:
|
||||
1. Call `state_list_active` to enumerate `.omx/state/sessions/{sessionId}/…` and discover every active session.
|
||||
2. For each session id, call `state_get_status` to learn which mode is running (`autopilot`, `ralph`, `ultrawork`, etc.) and whether dependent modes exist.
|
||||
3. If a `session_id` was supplied to `/cancel`, skip legacy fallback entirely and operate solely within that session path; otherwise, consult legacy files in `.omx/state/*.json` only if the state tools report no active session. Swarm remains a shared SQLite/marker mode outside session scoping.
|
||||
4. Any cancellation logic in this doc mirrors the dependency order discovered via state tools (autopilot → ralph → …).
|
||||
|
||||
### 3A. Force Mode (if --force or --all)
|
||||
|
||||
Use force mode to clear every session plus legacy artifacts via `state_clear`. Direct file removal is reserved for legacy cleanup when the state tools report no active sessions.
|
||||
Cancellation derives writable targets from the already-proven writable scope. Compatibility discovery may inform status, but never grants write authority. Unrelated session, legacy-root, Team, and run-dir state remains untouched unless it is independently proven as the exact cancellation target.
|
||||
|
||||
### 3B. Smart Cancellation (default)
|
||||
|
||||
@@ -327,21 +284,20 @@ echo " - Ralph (.omx/state/ralph-state.json)"
|
||||
echo " - Ultrawork (.omx/state/ultrawork-state.json)"
|
||||
echo " - UltraQA (.omx/state/ultraqa-state.json)"
|
||||
echo ""
|
||||
echo "Use --force to clear all state files anyway."
|
||||
echo "Use --force for exact-session native-stop cleanup without widening scope."
|
||||
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cancel skill runs as follows:
|
||||
1. Parse the `--force` / `--all` flags, tracking whether cleanup should span every session or stay scoped to the current session id.
|
||||
2. Use `state_list_active` to enumerate known session ids and `state_get_status` to learn the active mode (`autopilot`, `ralph`, `ultrawork`, etc.) for each session.
|
||||
3. When operating in default mode, call `state_clear` with that session_id to remove only the session’s files, then run mode-specific cleanup (autopilot → ralph → …) based on the state tool signals.
|
||||
4. In force mode, iterate every active session, call `state_clear` per session, then run a global `state_clear` without `session_id` to drop legacy files (`.omx/state/*.json`, compatibility artifacts) and report success. Swarm remains a shared SQLite/marker mode outside session scoping.
|
||||
5. Team artifacts (`.omx/state/team/*/`, tmux sessions matching `omx-team-*`) remain best-effort cleanup items invoked during the legacy/global pass.
|
||||
1. Parse arguments strictly. Bare cancellation and a single `--force` are accepted; `--all`, unknown flags, and multiple flags fail before mutation.
|
||||
2. Resolve one writable scope and treat compatibility discovery as read-only.
|
||||
3. Cancel only active state files whose exact scope, ownership fields, and frozen file identity are proven.
|
||||
4. With `--force`, remove only the selected session's native-stop entry after the same proof and revalidation.
|
||||
5. Leave unrelated sessions, legacy compatibility roots, Team artifacts, and tmux sessions untouched.
|
||||
|
||||
State tools always honor the `session_id` argument, so even force mode still clears the session-scoped paths before deleting compatibility-only legacy state.
|
||||
|
||||
Mode-specific subsections below describe what extra cleanup each handler performs after the state-wide operations finish.
|
||||
Mode-specific subsections below describe same-scope dependency ordering only.
|
||||
## Messages Reference
|
||||
|
||||
| Mode | Success Message |
|
||||
@@ -379,21 +335,10 @@ Mode-specific subsections below describe what extra cleanup each handler perform
|
||||
- **Safe**: Only clears linked Ultrawork, preserves standalone Ultrawork
|
||||
- **Local-only**: Clears state files in `.omx/state/` directory
|
||||
- **Resume-friendly**: Autopilot state is preserved for seamless resume
|
||||
- **Team-aware**: Detects tmux-based teams and performs graceful shutdown with force-kill fallback
|
||||
- **Team-aware**: Team cancellation is permitted only when the selected state carries exact same-scope Team authority; unrelated Team artifacts and tmux sessions remain untouched.
|
||||
|
||||
## Tmux Team Cleanup
|
||||
|
||||
When cancelling team mode, the cancel skill should:
|
||||
Cancellation MUST NOT enumerate or kill every `omx-team-*` session and MUST NOT recursively delete `.omx/state/team/`. Team shutdown requires the exact frozen Team root, internal name, session, leader pane, and runtime identity selected by the authorized state transition. When that proof is unavailable or changes, cancellation fails closed without signals, pane actions, overlay edits, or Team-state deletion.
|
||||
|
||||
1. **Kill all team tmux sessions**: `tmux list-sessions -F '#{session_name}' 2>/dev/null | grep '^omx-team-'` and kill each
|
||||
2. **Remove team state directories**: `rm -rf .omx/state/team/*/`
|
||||
3. **Strip AGENTS.md overlay**: Remove content between `<!-- OMX:TEAM:WORKER:START -->` and `<!-- OMX:TEAM:WORKER:END -->`
|
||||
|
||||
### Force Clear Addition
|
||||
|
||||
When `--force` is used, also clean up:
|
||||
```bash
|
||||
rm -rf .omx/state/team/ # All team state
|
||||
# Kill all omx-team-* tmux sessions
|
||||
tmux list-sessions -F '#{session_name}' 2>/dev/null | grep '^omx-team-' | while read s; do tmux kill-session -t "$s" 2>/dev/null; done
|
||||
```
|
||||
`--force` does not widen Team scope. It only enables exact-session native-stop cleanup after the same authority checks.
|
||||
@@ -32,9 +32,9 @@ Complex tasks often fail silently: partial implementations get declared "done",
|
||||
<Execution_Policy>
|
||||
- Fire independent agent calls simultaneously -- never wait sequentially for independent work
|
||||
- Use `run_in_background: true` for long operations (installs, builds, test suites)
|
||||
- Before substantive planning, reviewer delegation, HUD/runtime activation, or adapted role work, inspect the native task surface. Keyword routing state is not authority. When the surface exposes `agent_type`, use typed routing. When it reports `role_routing_unavailable`, run `omx ralplan preflight --json`; on `unsupported_documented_leader_proof`, stop before planner/reviewer work or adapted authority and use a Codex surface with documented root proof or a reviewed alternative workflow. Do not infer root authority from `session_id`, undocumented `thread_id`, session/pointer/transcript/cwd state, absent child data, or prompt labels.
|
||||
- The documented-leader preflight is not a general native-session gate. Run `omx ralplan preflight --json` only when native role routing reports `role_routing_unavailable` and Ralph attempts adapted Ralplan Planner, Architect, or Critic authority, adapted role-intent, or adapted consensus authority. On `unsupported_documented_leader_proof`, stop before that adapted authority and use a Codex surface with documented root proof or a reviewed alternative workflow. Do not infer root authority from `session_id`, undocumented `thread_id`, session/pointer/transcript/cwd state, absent child data, or prompt labels. Ordinary native planning, lifecycle, state, status, health, HUD, runtime, setup, install, sync, and unrelated delegation remain outside this preflight boundary and under their existing controls.
|
||||
- When the native surface exposes `agent_type` role routing, set `agent_type` to an installed OMX role and never omit it for OMX work; use `reasoning_effort` for per-dispatch intensity when needed.
|
||||
- **OMX adapted role-pass protocol:** when native routing is `role_routing_unavailable`, do not fabricate `agent_type`. On documented Codex 0.144.5 the adapted path is unavailable: run `omx ralplan preflight --json`, stop on `unsupported_documented_leader_proof`, and do not use prompt labels, task-name carriers, pending intents, markers, or `omx ralplan role-intent write` as substitutes.
|
||||
- **OMX adapted role-pass protocol:** when native routing is `role_routing_unavailable`, do not fabricate `agent_type`. On documented Codex 0.144.5, only an attempted adapted Ralplan Planner, Architect, Critic, role-intent, or consensus authority path requires `omx ralplan preflight --json` and fails closed on `unsupported_documented_leader_proof`; do not use prompt labels, task-name carriers, pending intents, markers, or `omx ralplan role-intent write` as substitutes.
|
||||
- Preserve legacy Ralph tier intent through native reasoning effort: LOW -> `low`, STANDARD -> `medium`, THOROUGH -> `xhigh`
|
||||
- Deliver the full implementation: no scope reduction, no partial completion, no deleting tests to make them pass
|
||||
- Apply the shared workflow guidance pattern: outcome-first framing, concise visible updates for multi-step execution, local overrides for the active workflow branch, validation proportional to risk, explicit stop rules, and automatic continuation for safe reversible steps. Ask only for material, destructive, credentialed, external-production, or preference-dependent branches.
|
||||
@@ -54,7 +54,7 @@ Complex tasks often fail silently: partial implementations get declared "done",
|
||||
- If an existing relevant snapshot is available, reuse it and record the path in Ralph state.
|
||||
- If request ambiguity is high, gather brownfield facts first. `omx explore` is deprecated; use normal repository inspection tools/subagents for simple read-only repository lookups and `omx sparkshell` only for explicit shell-native read-only evidence. Then run `$deep-interview --quick <task>` to close critical gaps.
|
||||
- Do not begin Ralph execution work (delegation, implementation, or verification loops) until snapshot grounding exists. If forced to proceed quickly, note explicit risk tradeoffs.
|
||||
- When this is a Ralplan-originated handoff and native role routing is unavailable, complete `omx ralplan preflight --json` before intake. On `unsupported_documented_leader_proof`, record the reason in Execution Policy and stop; do not create the snapshot or begin the loop.
|
||||
- A Ralplan-originated handoff alone does not require preflight. Before intake, run `omx ralplan preflight --json` only when native role routing is unavailable and the handoff attempts adapted Ralplan Planner, Architect, Critic, role-intent, or consensus authority. On `unsupported_documented_leader_proof`, record the reason in Execution Policy and stop before that authority; otherwise follow the existing intake controls.
|
||||
1. **Review progress**: Check TODO list and any prior iteration state
|
||||
2. **Continue from where you left off**: Pick up incomplete tasks
|
||||
3. **Delegate in parallel**: Route tasks to specialist native agents with explicit `agent_type` and appropriate `reasoning_effort`
|
||||
@@ -80,7 +80,7 @@ Complex tasks often fail silently: partial implementations get declared "done",
|
||||
- Standard changes: `task(agent_type="architect", reasoning_effort="medium", prompt="...")`
|
||||
- >20 files or security/architectural changes: `task(agent_type="architect", reasoning_effort="xhigh", prompt="...")`
|
||||
- Ralph floor: always run an explicit `architect` native subagent, even for small changes
|
||||
- On `role_routing_unavailable`, do not invoke `omx ralplan role-intent write` or manufacture an Architect identity. On documented Codex 0.144.5, the adapted Architect path is unavailable; surface the leader-proof diagnostic/remediation from Execution Policy and stop. On a future or other surface, use an adapted route only after its documented positive root proof has been reviewed and implemented.
|
||||
- On `role_routing_unavailable`, do not invoke `omx ralplan role-intent write` or manufacture an Architect identity. Run the documented-leader preflight only if Architect verification would attempt adapted Ralplan Architect authority; on documented Codex 0.144.5 that adapted path is unavailable, so surface the leader-proof diagnostic/remediation and stop before it. Ordinary Ralph delegation remains under its existing controls. On a future or other surface, use an adapted route only after its documented positive root proof has been reviewed and implemented.
|
||||
7.5 **Mandatory Deslop Pass**:
|
||||
- After Step 7 passes, run `oh-my-codex:ai-slop-cleaner` on **all files changed during the Ralph session**.
|
||||
- Scope the cleaner to **changed files only**; do not widen the pass beyond Ralph-owned edits.
|
||||
@@ -204,7 +204,7 @@ Why bad: These are independent tasks that should run in parallel, not sequential
|
||||
- [ ] Fresh test run output shows all tests pass
|
||||
- [ ] Fresh build output shows success
|
||||
- [ ] lsp_diagnostics shows 0 errors on affected files
|
||||
- [ ] Architect verification passed: on a routing-capable surface via explicit `task(agent_type="architect", reasoning_effort="medium"...)` minimum. On documented Codex 0.144.5 role-routing-unavailable surfaces, no adapted Architect pass is valid; Ralplan-originated work must have stopped with the leader-proof diagnostic.
|
||||
- [ ] Architect verification passed: on a routing-capable surface via explicit `task(agent_type="architect", reasoning_effort="medium"...)` minimum. On documented Codex 0.144.5 role-routing-unavailable surfaces, no adapted Ralplan Architect pass is valid; when that adapted authority is attempted, preflight must have stopped with the leader-proof diagnostic. Ordinary Ralph work remains subject to its existing controls.
|
||||
- [ ] Codex goal-mode completion audit passed, and `update_goal({status: "complete"})` was called when an active goal exists
|
||||
- [ ] ai-slop-cleaner pass completed on changed files (or --no-deslop specified)
|
||||
- [ ] Post-deslop regression tests pass
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Alias for $plan --consensus
|
||||
|
||||
# Ralplan (Consensus Planning Alias)
|
||||
|
||||
Ralplan is a shorthand alias for `$plan --consensus`. It triggers iterative planning with Planner, Architect, and Critic agents until consensus is reached, with **RALPLAN-DR structured deliberation** (short mode by default, deliberate mode for high-risk work). Scholastic is available as a separate advisory native agent/persona for ontology-heavy planning evidence, but it is not part of the durable consensus gate.
|
||||
Ralplan is a shorthand alias for `$plan --consensus`. It drives Planner, Architect, and Critic planning and records their review lifecycle with **RALPLAN-DR structured deliberation** (short mode by default, deliberate mode for high-risk work). That local lifecycle never authorizes an execution handoff: an official host-issued receipt verified through a documented non-user-mintable host surface is required. Scholastic is an advisory native agent/persona for ontology-heavy planning evidence, not part of the lifecycle.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -20,7 +20,7 @@ $ralplan "task description"
|
||||
|
||||
## Ontology-heavy review
|
||||
|
||||
For requirements semantics, taxonomy, prompt/spec design, policy distinctions, or category-risk architecture, subagent `Scholastic` may be cited as an available advisory ontology reviewer/persona. Its findings can inform the plan or follow-up evidence when explicitly used, but `$ralplan` itself remains the Planner → Architect → Critic consensus workflow and the durable gate remains Architect→Critic only.
|
||||
For requirements semantics, taxonomy, prompt/spec design, policy distinctions, or category-risk architecture, subagent `Scholastic` may be cited as an available advisory ontology reviewer/persona. Its findings can inform the plan or follow-up evidence when explicitly used, but `$ralplan` itself records Architect→Critic lifecycle evidence only; neither those reviews nor Scholastic evidence is a durable execution authorization.
|
||||
|
||||
## Usage with interactive mode
|
||||
|
||||
@@ -49,9 +49,9 @@ The consensus workflow:
|
||||
- If only one viable option remains, explicit invalidation rationale for alternatives
|
||||
- Deliberate mode only: pre-mortem (3 scenarios) + expanded test plan (unit/integration/e2e/observability)
|
||||
2. **User feedback** *(--interactive only)*: If `--interactive` is set, use the structured question UI (`omx question` in attached tmux; native structured input outside tmux when available) to present the draft plan **plus the Principles / Drivers / Options summary** before review (Proceed to review / Request changes / Skip review). Otherwise, automatically proceed to review.
|
||||
**Native role-routing preflight:** Before substantive planning, reviewer delegation, HUD/runtime activation, or adapted-role work, inspect the native task surface. Keyword routing may already have selected Ralplan, but it is not authority. When the surface exposes `agent_type`, use typed native routing and do not run the adapted preflight. When it reports `role_routing_unavailable`, run `omx ralplan preflight --json`; on `unsupported_documented_leader_proof`, stop before planner/reviewer work or adapted authority and use a Codex surface with documented root proof or a reviewed alternative workflow. Do not infer root identity from `session_id`, undocumented `thread_id`, session/pointer/transcript/cwd state, absence of child data, or a prompt label.
|
||||
**Native role-routing preflight:** Keyword routing may already have selected Ralplan, but it is not authority. Run `omx ralplan preflight --json` only when the native task surface reports `role_routing_unavailable` and this workflow attempts adapted Ralplan Planner, Architect, or Critic authority, adapted role-intent, or adapted consensus authority. On `unsupported_documented_leader_proof`, stop before that adapted authority and use a Codex surface with documented root proof or a reviewed alternative workflow. Do not infer root identity from `session_id`, undocumented `thread_id`, session/pointer/transcript/cwd state, absence of child data, or a prompt label. Ordinary native planning, lifecycle, state, status, health, HUD, runtime, setup, install, sync, and unrelated delegation are outside this preflight boundary and remain governed by existing controls.
|
||||
|
||||
**Native role-routing rule:** When the native surface exposes `agent_type` role routing, set `agent_type` to an installed OMX role and never omit it for OMX work. When it does not (`role_routing_unavailable`), do not fabricate `agent_type`. The formerly adapted `omx ralplan role-intent write` path is unavailable on documented Codex 0.144.5 because it lacks documented root proof; do not silently weaken routing with a prompt role label or inferred carrier. Use a Codex surface with documented root proof or a reviewed alternative workflow. A direct `omx ralplan role-intent write` attempt is denied with machine reason `unsupported_documented_leader_proof`.
|
||||
**Native role-routing rule:** When the native surface exposes `agent_type` role routing, set `agent_type` to an installed OMX role and never omit it for OMX work. When it does not (`role_routing_unavailable`), do not fabricate `agent_type`. On documented Codex 0.144.5, adapted Ralplan Planner, Architect, Critic, role-intent, and consensus authority are unavailable because they lack documented root proof; do not silently weaken routing with a prompt role label or inferred carrier. Use a Codex surface with documented root proof or a reviewed alternative workflow for that authority. A direct `omx ralplan role-intent write` attempt is denied with machine reason `unsupported_documented_leader_proof`.
|
||||
|
||||
3. **Architect** reviews for architectural soundness and must provide the strongest steelman antithesis, at least one real tradeoff tension, and (when possible) synthesis — **await completion before step 4**. Launch this as a subsequent role-specific `Architect` subagent and pass the full task statement, context snapshot, PRD/test-spec paths, and relevant prior findings; do not substitute an unvalidated reviewer identity or a short improvised reviewer prompt. In deliberate mode, Architect should explicitly flag principle violations.
|
||||
4. **Critic** evaluates against quality criteria — run only after step 3 completes. Launch this as a subsequent role-specific `Critic` subagent with the full task statement, context snapshot, PRD/test-spec paths, and the completed Architect review; do not ask the Architect subagent to perform the Critic gate and do not substitute an unvalidated reviewer identity or a short improvised reviewer prompt. Critic must enforce principle-option consistency, fair alternatives, risk mitigation clarity, testable acceptance criteria, and concrete verification steps. In deliberate mode, Critic must reject missing/weak pre-mortem or expanded test plan.
|
||||
@@ -62,11 +62,11 @@ The consensus workflow:
|
||||
d. Return to Critic evaluation
|
||||
e. Repeat this loop until Critic returns `APPROVE` or 5 iterations are reached
|
||||
f. If 5 iterations are reached without `APPROVE`, present the best version to the user
|
||||
6. On Critic approval *(--interactive only)*: If `--interactive` is set, use the structured question UI to present the plan with approval options (Approve durable goal execution via ultragoal / Approve and implement via team / Explicit Ralph fallback / Start specialized goal-mode follow-up / Request changes / Reject). Final plan must include ADR (Decision, Drivers, Alternatives considered, Why chosen, Consequences, Follow-ups), an explicit available-agent-types roster, concrete follow-up staffing guidance for `$ultragoal` and `$team`, plus an explicit `$ralph` fallback note when persistent single-owner verification is intentionally selected, suggested reasoning levels by lane, explicit `omx team` / `$team` launch hints, a concrete **team verification** path, and a product-facing **Goal-Mode Follow-up Suggestions** section. Recommend `$ultragoal` by default for goal-mode follow-up, use `$autoresearch-goal` instead when the context is a research project, and use `$performance-goal` instead when the context is an optimization or performance project. Otherwise, output the final plan and stop.
|
||||
7. *(--interactive only)* User chooses: Approve (`$ultragoal` durable goal execution, `$team`, explicit `$ralph` fallback, or a specialized goal-mode follow-up), Request changes, or Reject
|
||||
8. *(--interactive only)* On approval: invoke `$ultragoal` for default durable sequential execution, `$team` for parallel team execution, the selected specialized goal-mode follow-up (`$autoresearch-goal` or `$performance-goal`), or `$ralph` only when the user explicitly selects that fallback with the approved plan and matching success/evaluator context -- never implement directly. Preserve the explicit available-agent-types roster, reasoning-by-lane guidance, role/staffing allocation guidance, launch hints, and verification-path guidance from the approved plan for Ultragoal/team paths and any explicit Ralph fallback.
|
||||
6. On Critic approval *(--interactive only)*: present the plan for review, change requests, rejection, or selection of a **requested future execution lane**. A local Critic approval is lifecycle-only and must not offer or begin an execution handoff while the host receipt verifier is unavailable.
|
||||
7. *(--interactive only)* Record the user's planning disposition and any requested future execution lane; do not invoke `$ultragoal`, `$team`, `$ralph`, or another implementation lane without a verified official host receipt.
|
||||
8. On local Architect→Critic approval, preserve the plan and reviews with `ralplan_consensus_gate.complete:false` and `blocked_reason:"documented_host_consensus_receipt_unavailable"`. Report the host blocker and stop rather than implementing directly.
|
||||
|
||||
> **Important:** Steps 3 and 4 MUST run sequentially as role-specific subagents. Do NOT issue both agent calls in the same parallel batch. Always await the subsequent `Architect` result before invoking the subsequent `Critic`; only a completed, role-specific `Critic` approval can satisfy the durable gate.
|
||||
> **Important:** Steps 3 and 4 MUST run sequentially as role-specific subagents. Do NOT issue both agent calls in the same parallel batch. Always await the subsequent `Architect` result before invoking the subsequent `Critic`; their completed approvals establish local lifecycle evidence only and cannot satisfy the durable execution gate.
|
||||
|
||||
## Planning/Execution Boundary
|
||||
|
||||
@@ -75,7 +75,7 @@ The consensus workflow:
|
||||
The canonical flow is:
|
||||
|
||||
```
|
||||
$ralplan -> durable consensus artifact -> explicit execution lane -> $ultragoal | $team | $ralph
|
||||
$ralplan -> local Architect→Critic lifecycle evidence -> verified official host receipt -> explicit execution lane -> $ultragoal | $team | $ralph
|
||||
```
|
||||
|
||||
Before any execution lane begins, ralplan must emit terminal planning state (complete, paused, failed, or waiting for input) and the durable handoff record below. Do not continue from consensus planning into direct code edits in the same ralplan session.
|
||||
@@ -89,21 +89,22 @@ Before any Autopilot, Pipeline, Ultragoal, Team, Ralph, or implementation handof
|
||||
- `planning_artifacts`: PRD/test-spec paths.
|
||||
- `ralplan_architect_review`: the completed Architect review with an approving verdict.
|
||||
- `ralplan_critic_review`: the completed Critic review with an approving verdict, recorded only after the Architect review.
|
||||
- `ralplan_consensus_gate.complete:true` only when both reviews are present, approving, and in the required Architect→Critic order.
|
||||
- `ralplan_consensus_gate.complete:true` only after an official host-issued receipt is verified through a documented non-user-mintable host surface. Architect/Critic reviews, trackers, artifacts, and local receipt-shaped fields remain lifecycle or trace evidence only; until the verifier exists, persist `complete:false` with `blocked_reason:"documented_host_consensus_receipt_unavailable"`.
|
||||
|
||||
If Architect is missing/blocked, keep the workflow in Architect review or report that blocker. If Critic is missing/blocked/non-approving, keep the workflow in Critic/re-review or report the max-iteration outcome. Do not treat existing plan/test-spec files as permission to skip ralplan or start execution.
|
||||
If Architect is missing/blocked, keep the workflow in Architect review or report that blocker. If Critic is missing/blocked/non-approving, keep the workflow in Critic/re-review or report the max-iteration outcome. Even after both reviews approve, they complete only the local review lifecycle; do not start execution until an official host receipt verifier authorizes the transition. Existing plan/test-spec files and local review artifacts are never permission to skip ralplan or execute.
|
||||
|
||||
Follow the Plan skill's full documentation for consensus mode details.
|
||||
|
||||
## Goal-Mode Follow-up Suggestions
|
||||
|
||||
When ralplan outputs a final handoff or asks the user to choose a next lane, include product-facing goal-mode suggestions alongside the existing Ralph and team options:
|
||||
When a verified official host receipt permits an execution handoff, include product-facing goal-mode suggestions alongside the existing Ralph and team options. Until then, record any requested lane as non-executing planning guidance and keep `ralplan_consensus_gate.complete:false` with `blocked_reason:"documented_host_consensus_receipt_unavailable"`.
|
||||
|
||||
- `$ultragoal` — **default goal-mode follow-up** for implementation or general goal-oriented follow-up plans that should become durable Codex/OMX goals with sequential completion tracking.
|
||||
- `$autoresearch-goal` — research-project follow-up when the plan centers on a question, literature/reference gathering, evaluator-backed research, or a professor/critic-style research deliverable.
|
||||
- `$performance-goal` — optimization/performance follow-up when the plan centers on speed, latency, throughput, memory, benchmark, or other measurable performance work.
|
||||
|
||||
Keep `$team` as a first-class execution option and keep `$ralph` available only as an explicit fallback where appropriate: use Ultragoal as the default durable goal-mode follow-up, Team for coordinated parallel implementation, and Ralph only for intentionally selected persistent single-owner completion/verification pressure. For parallelizable durable-goal delivery, recommend `$ultragoal` + `$team` together: Ultragoal remains the leader-owned `.omx/ultragoal` ledger/Codex-goal wrapper while Team runs parallel lanes and returns checkpoint-ready evidence. Do not present Ralph as the recommended follow-up when durable goal tracking is needed; present Ultragoal as the superseding default, with Team for parallel delivery and Ralph only as an explicit fallback when its narrow persistence loop is specifically desired.
|
||||
Use the available-agent-types roster to produce explicit role/staffing allocation, reasoning-by-lane guidance, concrete launch hints (including `omx team` when parallel delivery is justified), and team verification responsibilities for any future receipt-authorized execution path.
|
||||
|
||||
## Pre-context Intake
|
||||
|
||||
@@ -183,11 +184,8 @@ The gate auto-passes when it detects **any** concrete signal. You do not need al
|
||||
- **Planner** creates initial plan (which files, what auth method, what tests)
|
||||
- **Architect** reviews for soundness
|
||||
- **Critic** validates quality and testability
|
||||
5. On consensus approval, user chooses execution path:
|
||||
- **ultragoal**: default durable follow-up for sequential goal execution with ledger checkpoints
|
||||
- **team**: coordinated parallel execution for stories that need multiple lanes, with evidence ready for Ultragoal checkpoints
|
||||
- **ralph**: explicit single-owner fallback only when the user intentionally wants a persistent verification/completion loop instead of the default durable goal ledger
|
||||
6. Execution begins with a clear, bounded plan through the selected handoff path
|
||||
5. Architect and Critic approval completes the local planning lifecycle only. Persist `ralplan_consensus_gate.complete:false` with `blocked_reason:"documented_host_consensus_receipt_unavailable"` and report the host blocker.
|
||||
6. Execution does not begin until a verified official host receipt authorizes the selected handoff path.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -64,8 +64,9 @@ Loop until `omx ultragoal status` reports all goals complete:
|
||||
`omx ultragoal checkpoint --goal-id <id> --status complete --evidence "<evidence>" --codex-goal-json <get_goal-json-or-path> [--quality-gate-json <quality-gate-json-or-path>]`
|
||||
9. If blocked or failed, checkpoint failure:
|
||||
`omx ultragoal checkpoint --goal-id <id> --status failed --evidence "<blocker/evidence>"`
|
||||
10. For legacy per-story completed-goal blockers, preserve the non-terminal blocker with:
|
||||
`omx ultragoal checkpoint --goal-id <id> --status blocked --evidence "<completed legacy Codex goal blocks create_goal in this thread>" --codex-goal-json <get_goal-json-or-path>`
|
||||
10. For non-terminal blockers, use blocked checkpoints:
|
||||
- legacy different completed goal: `omx ultragoal checkpoint --goal-id <id> --status blocked --evidence "<completed legacy Codex goal blocks create_goal in this thread>" --codex-goal-json <get_goal-json-or-path>`
|
||||
- matching native Codex `blocked` status: `omx ultragoal checkpoint --goal-id <id> --status blocked --evidence "<blocker evidence>" --codex-goal-json <matching-blocked-get_goal-json-or-path>`
|
||||
11. Resume failed goals with `omx ultragoal complete-goals --retry-failed`.
|
||||
|
||||
## Dynamic steering
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "cbb6bf9917efb18d787e510fc41a5e9e388bbdf8",
|
||||
"commit": "dd6c503df8c247b6544dadf2313c4cceff6b0281",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/ppt-master",
|
||||
"syncedAt": "2026-07-28T15:59:58Z"
|
||||
"syncedAt": "2026-07-29T16:00:04Z"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ before the page plan is frozen, not only when a deck is already exported.
|
||||
|---|---|---|
|
||||
| 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, 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 |
|
||||
| A static full-bleed page that should stop looking frozen | Consider slow `path_*` motion on a visually subordinate image or atmospheric layer; §4.1 gives one starting recipe | Post-processing; §4.1 |
|
||||
| Carousel, counting numerals, parallax depth, click-to-reveal flip card | Four recurring recipes assembled from the mechanisms above | §4.2 — the carousel and odometer both need paired pages |
|
||||
| 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 |
|
||||
@@ -50,9 +50,11 @@ 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 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.
|
||||
post-processing stage when the project already carries `animations.json`, when
|
||||
the user explicitly asks to tune animation order/effects/timing/object-level
|
||||
reveals, or when the effective Custom Animations outcome in
|
||||
`design_spec.md §I` is enabled. A §IX `Motion suggestion` remains Strategist
|
||||
advice and informs an active pass, but never triggers the stage alone.
|
||||
|
||||
**Hard rule — semantic anchors before object-targeted sidecar entries**: when
|
||||
object animation is in scope, derive reveal units from page meaning and
|
||||
@@ -73,19 +75,13 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project>
|
||||
python3 skills/ppt-master/scripts/svg_to_pptx.py <project>
|
||||
```
|
||||
|
||||
Single-slide sidecar excerpt (repeat the complete slide block for every SVG in `svg_output/`):
|
||||
Sparse sidecar excerpt (unlisted slides inherit resolved defaults):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"defaults": {
|
||||
"transition": { "effect": "fade", "duration": 0.4 },
|
||||
"animation": { "effect": "auto", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" }
|
||||
},
|
||||
"slides": {
|
||||
"03_market": {
|
||||
"transition": { "effect": "fade", "duration": 0.4 },
|
||||
"animation": { "effect": "auto", "duration": 0.4, "stagger": 0.5, "trigger": "after-previous" },
|
||||
"groups": {
|
||||
"title": { "effect": "entrance_fade", "order": 1 },
|
||||
"chart": { "effect": "entrance_wipe", "effect_options": { "direction": "left" }, "order": 2, "duration": 0.6 },
|
||||
@@ -143,14 +139,14 @@ Rules:
|
||||
- An explicit sidecar group may override the legacy chrome-name heuristic, but it cannot override `data-pptx-layer` or an explicit static role/placeholder marker.
|
||||
- Unknown effects, modes, or triggers and invalid numeric/order fields fail validation; no fallback effect is substituted.
|
||||
|
||||
**Inheritance**: the sidecar is optional. Sparse legacy slides inherit
|
||||
`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.
|
||||
**Inheritance**: the sidecar and its `defaults` block are optional. Unlisted
|
||||
slides and omitted slide fields inherit `defaults.transition` /
|
||||
`defaults.animation`, then CLI/exporter resolution. Explicit CLI flags override
|
||||
the corresponding sidecar default/slide fields; explicit group overrides remain
|
||||
unless `-a none` hard-disables all object motion. Groups inherit the resolved
|
||||
slide duration, timing modifiers, after-effect, and sound. `effect_options`
|
||||
remains coupled to an explicit effect; `trigger_shape` is never inherited;
|
||||
omitted `order`/`delay` use exporter defaults.
|
||||
|
||||
### 2.1 Deterministic Morph Object Pairing
|
||||
|
||||
@@ -163,22 +159,13 @@ The generated names follow Microsoft's
|
||||
```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": {
|
||||
@@ -370,17 +357,26 @@ Flags: `-a/--animation` selects effect/mode; `--animation-trigger` selects Start
|
||||
`--animation-config` selects a sidecar; `--no-animations` disables page/object
|
||||
motion but preserves narration audio and recorded advance timing.
|
||||
|
||||
> Note: `--recorded-narration` rejects `on-click` and `trigger_shape`; use its default `narration_animations.json`, pass `--animation-config animations.json` for the canonical presentation animation, or pass `--no-animations`.
|
||||
> Note: `--recorded-narration` rejects `on-click` and `trigger_shape`. When either animation sidecar exists, narrated export selects `narration_animations.json`; canonical `animations.json` without that derived file remains a synchronization error. Without sidecars, pass `--inherit-motion-from <base_postflight_report>` for the base deck motion. Pass `--animation-config animations.json` for canonical animation, or `--no-animations` to remove page and object motion.
|
||||
|
||||
### 4.1 Slow ambient motion — the page that breathes
|
||||
|
||||
Most object animation exists to reveal content on click. There is a second, quieter use: **one long, slow motion on the background that never waits for a click**, so a static page stops looking frozen. It is the highest-impact-per-effort motion available and it costs one sidecar entry.
|
||||
**Reference — not a constraint**: ambient motion can keep a static page from
|
||||
feeling frozen when it remains visually subordinate to the message. A common
|
||||
starting recipe is `path_left` or `path_right` on a background image, started
|
||||
`with-previous` and paced much more slowly than a content reveal. The same
|
||||
principle may suit another atmospheric or non-information-bearing layer. Choose
|
||||
duration, distance, and moving-object count from the composition and delivery
|
||||
context.
|
||||
|
||||
The recipe: a `path_left` (or `path_right`) motion on the **background image group only**, `with-previous` so it starts unprompted, and a duration of **4–10 s** — an order of magnitude longer than the reveal default. Set the travel distance so the image is still fully covering the canvas at both ends; a background that drifts past its own edge exposes the slide beneath it.
|
||||
Keep a full-bleed moving image covering the canvas at both endpoints; exposing
|
||||
the slide beneath it is a visible failure.
|
||||
|
||||
It pairs naturally with a fixed foreground: with image-layout-patterns `#90`, the scrim and its cut contour stay locked while the world moves behind the cuts, which reads as looking through windows rather than as a sliding photo. The same logic applies to `#82` and `#12`.
|
||||
|
||||
**Restraint is the whole technique**: one moving object per page, background only, never body copy or data. Two ambient motions on one page cancel each other out and the page reads as unstable.
|
||||
Motion remains subordinate: avoid competing ambient paths or movement that
|
||||
reduces the readability of body copy or data. Multiple coordinated layers are
|
||||
valid when they express one intentional depth or atmosphere relationship.
|
||||
|
||||
### 4.2 Recurring recipes
|
||||
|
||||
@@ -389,7 +385,7 @@ mechanisms already defined above — none needs a new capability.
|
||||
|
||||
**Carousel** (Morph, §2.1 and §3.1) — hold a fixed row of card frames and rotate the *content* through them: on each page every image advances one position, so the card at centre changes while the frames stay put. Explicitly pair each moving content unit across adjacent pages; the fixed frames stay static and need no pair. Scales to any number of images with one page each.
|
||||
|
||||
**Odometer / counting numerals** (morph or motion path) — build a vertical strip of digits 0–9 and show one through a fixed window formed by background-filled rectangles above and below ([`image-layout-patterns.md`](./image-layout-patterns.md) `#95`). Shift the strip so the target digit lands in the window, then either morph between two pages or run a `path_up` motion on the strip. 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. A small stagger, such as `0.1s`, can make digit columns settle in sequence; synchronized motion is also valid when it fits the intended rhythm.
|
||||
|
||||
**Parallax depth** (morph) — move a background layer a *short* distance and a foreground layer a longer one between two pages. The differing travel is read as depth. Keep both layers' z-order identical on both pages; a layer that changes stacking between pages breaks the tween and the transition jumps.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Global artifact ownership rules for PPT Master projects.
|
||||
| `analysis/<stem>.identity.json` | Native deck identity facts | Canvas, theme palette/fonts, observed usage | Read selectively when detailed identity facts are needed |
|
||||
| `analysis/<stem>.slide_library.json` | Native PPTX structure facts | Text slots, geometry, native tables, native chart caches, SmartArt nodes/connections | Direct PPTX workflows use as native fill/structure contract |
|
||||
| `analysis/image_analysis.csv` | Regenerated image fact view | Measured facts about the current `images/` folder | Re-run `analyze_images.py` before reading image facts after changes |
|
||||
| `design_spec.md` | Strategist design authority | Human-readable design intent, page brief, rationale, resources, and production mechanics | Consume final confirmation once, write/audit here, and apply enabled refinement to this same artifact. After Gate 1 plus conditional approval, later roles read it instead of `result.json`; §IX owns Executor page content. |
|
||||
| `design_spec.md` | Strategist design authority | Human-readable design intent, page brief, rationale, resources, and production mechanics | Consume final confirmation once, write/audit here, and apply enabled refinement to this same artifact. §I records effective Speaker Notes, Custom Animations, and Narration Audio outcomes plus provenance; a newer explicit user instruction updates only its owning outcome without reopening Confirm UI. After Gate 1 plus conditional approval, later roles read this file instead of `result.json`; §IX owns Executor page content. |
|
||||
| `spec_lock.md` | Execution anchor and routing contract | Machine-readable stable color/type roles, icons, images, page rhythm, charts, `template_reuse_scope`, and the route's PowerPoint structure mode; mirror/layout template routes additionally own input prototypes, the Master roster, and the complete page-to-Master/Layout mapping | Strategist authors the route-specific anchors from the audited Design Spec plus current project/page/template context. Executor retains the complete lock once per valid execution context; local uncertainty consults that retained copy before the owning Design Spec fragment. Sparse page-local color/font garnish needs no lock row; a recurring semantic role or new adaptive Layout identity requires Strategist repair before reuse. |
|
||||
| `project_manager.py page-context` stdout | Derived on-demand page context | Read-only model-facing anchor set + current-page delta + fingerprints for large references | Use only for explicit diagnostics/telemetry or an unresolved page/template/chart path-SHA projection. Never edit or persist it as a replacement source of truth, and never run it as a routine pre-page gate. `global` is a bounded anchor set, not a whitelist. `reference_set` carries path/SHA/load policy but never appends reference payloads. |
|
||||
| `analysis/page-context/P<NN>.usage.json` | Derived optional context telemetry | Measured on-demand page-context size plus hashes of owning inputs/references | `page-context --record-usage` deterministically replaces only the invoked page's snapshot; `page-context-report` summarizes existing snapshots. Telemetry may be partial. Use token data to evaluate context cost, never as content or an execution contract. |
|
||||
@@ -36,14 +36,14 @@ Global artifact ownership rules for PPT Master projects.
|
||||
| `confirm_ui/recommendations.stage1.json`, `.stage2.json`, `.stage3.json` | Confirmation proposals | One Strategist-authored payload per confirmation stage | Confirm UI selects the active file from `result.json`. The active, unconfirmed stage may be overwritten when the user requests a new recommendation; normal progression writes the next stage file and leaves confirmed earlier stages intact. Legacy `recommendations.json` is read only when no stage-specific file exists. |
|
||||
| `confirm_ui/result.json` | Confirmation result | Persisted user-confirmed input evidence | Generate Step 4 reads the final object once into active context; Strategist consumes it completely into `design_spec.md`. Normal downstream work does not reopen it; fresh recovery may read it once when no retained final state exists. |
|
||||
| `svg_output/` | Page-design author source | Main-agent handwritten SVG pages containing the complete visible design | Quality checker and native PPTX export read this as the canonical visual/page-layout source; templates and locks do not add missing visible objects at export |
|
||||
| `notes/total.md` | Speaker-note source | Complete notes before splitting | Step 6 writes; Step 7.1 splits |
|
||||
| `notes/slide_*.md` | Split notes | Per-slide notes generated from `total.md` | Derived by `total_md_split.py` |
|
||||
| `notes/total.md` | Conditional speaker-note source | Complete notes before splitting | Step 6 writes only when the effective Speaker Notes outcome is enabled; Step 7.1 splits |
|
||||
| `notes/slide_*.md` | Conditional split notes | Per-slide notes generated from `total.md` | Derived by `total_md_split.py` only when speaker notes are enabled |
|
||||
| `svg_final/` | Derived visual preview | Self-contained post-processed SVGs that may be opened directly or inserted as SVG pictures | Rebuild from `svg_output/` with `finalize_svg.py`; do not use as a supported PPTX source |
|
||||
| `validation/svg_quality_report.json` | Quality provenance | Final SVG gate split into blocking / introduced / inherited / source-import categories, bound to the checked SVG bytes by SHA-256 | `svg_quality_checker.py --stage final --json` writes before export; the exporter reads it programmatically and links it only when the export-source fingerprint matches. Agents use successful command output and do not load the full JSON except for targeted failure/audit reads. |
|
||||
| `validation/<output_stem>.report.json` | Published-package audit | PPTX package/resource postflight status, part counts, and quality-gate linkage | Step 7.3 writes after the PPTX passes package validation and emits a compact `[POSTFLIGHT]` receipt. Agents use the receipt on routine success and keep the full JSON cold unless targeted failure/audit evidence is required. |
|
||||
| `exports/` | Delivery artifacts | Native DrawingML PPTX and explicit native-object/narration variants | Step 7.3 writes only final deliverables from `svg_output/`. |
|
||||
| `backup/<timestamp>/svg_output/` | Frozen author-source archive | Re-export source without re-running LLM | `svg_to_pptx.py` writes a snapshot during export |
|
||||
| `animations.json` | Optional animation config | Page-transition and object-animation sidecar | Created only when the conditional animation workflow activates; normal export never creates it |
|
||||
| `animations.json` | Optional animation config | Page-transition and object-animation sidecar | Existing files activate intent resolution: Stage 3 `false` preserves, explicit objects-off exports `-a none`, and all-motion-off bypasses with `--no-animations`. Creation requires explicit instruction or enabled outcome; §IX advice never activates it |
|
||||
|
||||
---
|
||||
|
||||
@@ -68,6 +68,7 @@ Global artifact ownership rules for PPT Master projects.
|
||||
| Export source | The only supported generated-PPTX route reads `svg_output/` through the project SVG-to-DrawingML converter. A diagnostic `-s final` override does not change ownership or create a supported release route. |
|
||||
| Shape-conversion boundary | PowerPoint's manual Convert-to-Shape operation on `svg_final/` is outside the project compatibility contract. |
|
||||
| Confirmation | Final UI/chat confirmation overrides recommendations and is consumed once into `design_spec.md`. Enabled refinement applies arbitrary revisions there and requires approval; only then may active-decision fidelity release lock authoring. |
|
||||
| Proactive production outcomes | Resolve notes/animation/narration from explicit instruction → Stage 3 → defaults, then persist outcomes/provenance only in Design Spec §I. Explicit notes-off/audio-on applies Generate's dependency gate; Stage 3 animation `false` does not suppress a sidecar. |
|
||||
|
||||
**Forbidden - mixed ownership**: Do not copy chart values from Markdown into `analysis/` by hand, do not edit `svg_final/` as the source of a fix, do not edit imported lossless SVGs instead of their authoring IR, and do not treat `design_spec.md` prose as a replacement for `spec_lock.md`.
|
||||
|
||||
@@ -79,7 +80,7 @@ Global artifact ownership rules for PPT Master projects.
|
||||
|---|---|---|
|
||||
| `analysis/image_analysis.csv` | Current `images/` | `python3 ${SKILL_DIR}/scripts/analyze_images.py <project_path>/images` |
|
||||
| `<import_workspace>/authoring-svg/authoring_summary.json` | Current authoring SVGs plus tool-only manifest roster | `python3 ${SKILL_DIR}/scripts/svg_authoring_view.py <import_workspace>/authoring-svg --refresh-summary`; in-place vector/picture extraction refreshes it automatically |
|
||||
| `notes/slide_*.md` | `notes/total.md` | `python3 ${SKILL_DIR}/scripts/total_md_split.py <project_path>` |
|
||||
| `notes/slide_*.md` | `notes/total.md`, when speaker notes are enabled | `python3 ${SKILL_DIR}/scripts/total_md_split.py <project_path>` |
|
||||
| `svg_final/` | `svg_output/` plus project assets | `python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>` |
|
||||
| `validation/svg_quality_report.json` | `svg_output/`, locks, template provenance | `python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> --stage final --json` |
|
||||
| Native PPTX + `validation/<output_stem>.report.json` | `svg_output/` plus notes/assets and final quality report | `python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>` |
|
||||
|
||||
@@ -9,9 +9,9 @@ Always-loaded Executor authority for flat SVG page authoring and behavior shared
|
||||
| `pptx_structure.mode: structured` | [`executor-structured.md`](./executor-structured.md) |
|
||||
| Any data chart, chart catalog selection, or text-grid table | [`executor-chart.md`](./executor-chart.md) |
|
||||
| A page will use a preset pattern fill or evaluate native chart/table replacement | [`native-data-interface.md`](./native-data-interface.md) before deciding eligibility or emitting metadata |
|
||||
| Any image or formula resource, including template-bundled images | [`executor-image.md`](./executor-image.md) + [`image-layout-patterns.md`](./image-layout-patterns.md) |
|
||||
| Any image/formula; a cited `#<id>` or optional composition recall also triggers the library | [`executor-image.md`](./executor-image.md); conditionally [`image-layout-patterns.md`](./image-layout-patterns.md) |
|
||||
| Any `Status: Sourced` web image | [`executor-web-image.md`](./executor-web-image.md), after `executor-image.md` |
|
||||
| Speaker notes generation after all SVG pages pass | [`executor-notes.md`](./executor-notes.md) |
|
||||
| Effective Speaker Notes outcome is enabled after all SVG pages pass | [`executor-notes.md`](./executor-notes.md) |
|
||||
|
||||
> Narrative skeleton and visual aesthetic come from this deck's locked files under [`modes/`](./modes/_index.md) and [`visual-styles/`](./visual-styles/_index.md). Technical constraints are in [`shared-standards-core.md`](./shared-standards-core.md).
|
||||
|
||||
@@ -40,7 +40,7 @@ Always-loaded Executor authority for flat SVG page authoring and behavior shared
|
||||
|
||||
**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 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.
|
||||
**Default — resolve active cross-page geometry here, while pages are still being authored (may override when the deck has no continuous action to express)**: object effects, page transitions, and Morph pair keys are post-processing decisions, but the two visible endpoint states are not. Apply this preparation only when an explicit user motion instruction, an enabled effective Custom Animations outcome, or an existing `animations.json` activates motion; a §IX Motion suggestion alone remains non-operative advice. An active sequence that should read as one continuous action (slide-in, flip, camera push-in, progressive reveal, camera pan) must be authored as consecutive pages in `svg_output/` now. Give each continuing endpoint a compatible direct-root group; source and destination ids or geometry may differ because the later motion stage can bind them explicitly through `animations.json`. A deck that reaches export without both states cannot gain the motion by adding a flag. Adding pages is a §IX roster change and returns to Strategist for Design Spec repair first.
|
||||
|
||||
---
|
||||
|
||||
@@ -81,14 +81,14 @@ Use named lock roles literally when that role applies, and use optional `Templat
|
||||
| `consumption_mode` | Page execution |
|
||||
|---|---|
|
||||
| `text` | Make the visible page independently understandable. Preserve complete prose, explicit labels / captions / sources, tables, and necessary detail; use bullets only for genuinely parallel or ordered items. |
|
||||
| `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. |
|
||||
| `balanced` | Keep the primary claim and its evidence on the page; when notes are enabled, let them 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; when notes are enabled, put explanation and transitions there instead of creating paragraph dumps or compressed bullet prose. When notes are disabled, rely only on the confirmed presenter/page channels and never omit required content on the assumption that notes will carry it. |
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
- **Hard rule — one paragraph, one text frame**: use one `<text>` per prose paragraph, never sibling `<text>` elements for its visual lines. Keep the first authored line as direct text; later lines use direct `<tspan>` children that repeat parent `x`, retain effective font size, and use positive relative `dy`. An all-`<tspan>` form may start at `dy="0"`. Default retains these breaks without PowerPoint wrapping; `--reflow-text` enables reflow. Choose positive line spacing for the typeface, size, density, and reading distance; no fixed ratio overrides legibility or the selected style.
|
||||
- **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.
|
||||
|
||||
@@ -119,7 +119,7 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P<NN>`
|
||||
|
||||
| Tag | Layout discipline |
|
||||
|-----|-------------------|
|
||||
| `anchor` | Structural page (cover / chapter / TOC / ending). With `template_reuse_scope: mirror`, follow the selected prototype verbatim except visible text values. With `layout`, retain the selected structure system while realizing the page's §IX intent. With `style` or free design, realize §IX directly — for the cover deliver its `Cover impact` and for a closing page its `Closing impact`, never a default centered title + subtitle or generic "Thank you" sign-off. |
|
||||
| `anchor` | Structural page (cover / chapter / TOC / ending). `mirror` follows its prototype; `layout` retains its structure system. `style` / free design preserves the §IX cover hook or closing takeaway but may adapt the recommended composition. Avoid an information-empty generic cover/sign-off unless content, user direction, or template requires it. |
|
||||
| `dense` | Information-heavy. Card grids, multi-column layouts, KPI dashboards, tables, and charts are all permitted. This is the baseline behavior. |
|
||||
| `breathing` | Low-density impact page. Avoid **multi-card grid layouts** — do not organize content as multiple parallel rounded containers (3-card row, 4-card KPI grid, 2×2 matrix rendered as cards). Use naked text blocks, dividers, whitespace, or full-bleed imagery as the content structure. Single rounded visual elements (hero image corners, callouts, tags, one emphasis block) are fine — the rule is about grid structure, not about the `rx` attribute. Proportions follow information weight (not a preset ratio). Typical forms: hero quote, single large number with one-line interpretation, full-bleed image with floating caption, section transition. |
|
||||
|
||||
@@ -142,7 +142,7 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P<NN>`
|
||||
- **Template structure**: inherit the native visual framework only for `template_reuse_scope: mirror|layout`; `style` uses the flat route
|
||||
- **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity
|
||||
- **Generation rhythm**: P01 → first-page gate → uninterrupted remaining pages → final gate, in one context without batches or mid-run checker calls.
|
||||
- **Fact provenance**: when a §IX page lists `Fact IDs`, resolve each ID from `sources/*.facts.json` and keep the claim/value unchanged. Render a compact source footnote using the source name and a short URL/domain when space permits; 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.
|
||||
- **Fact provenance**: when a §IX page lists `Fact IDs`, resolve each ID from `sources/*.facts.json` and keep the claim/value unchanged. Render a compact source footnote using the source name and a short URL/domain when space permits; when speaker notes are enabled, state the attribution naturally there too. When §IX says `Data class: scenario`, place a visible localized `Scenario data` / `情景数据` label adjacent to the affected KPI/chart and, when notes are enabled, state naturally there that the number is illustrative. Never attach an external fact ID to scenario data or let an unlabeled invented KPI look factual.
|
||||
- **Default — stage each page with the style's composition geometry (may override when the content genuinely calls for a plain grid)**: an SVG page is a canvas, not a DOM. Before defaulting to stacked rounded-rect cards or uniform equal columns, pick one page-scale move from the locked visual style's §1 `Composition geometry` (a bleed shape, diagonal split, oversized numeral, orbit rings, …) to stage the page's primary zone. Card grids are one option among many, not the house layout.
|
||||
- **Containers are structural**: cards and grids express grouping, hierarchy, or capacity, not a house style. Preserve meaningful template frames; restyle radius, fill, stroke, and depth from the active Design Spec and `spec_lock.md`. Chart-catalog adaptation is owned by [`executor-chart.md`](./executor-chart.md); preview effects never override project styling or structural roles.
|
||||
- **Reference — prefer semantic geometry over preset stacks**: for relationships such as ascending, converging, breaking through, or stacking, first seek a basic primitive, one exact preset, or a clear Boolean result. Only when none can faithfully express the relationship should one page-specific polygon/path replace a stack of generic arrows.
|
||||
@@ -150,7 +150,7 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P<NN>`
|
||||
- **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. **Logic Construction Phase (conditional)**: after SVGs pass the quality check, batch-generate speaker notes for narrative continuity only when the effective Speaker Notes outcome is enabled.
|
||||
|
||||
### 3.0 Native Shape Selection
|
||||
|
||||
@@ -281,9 +281,9 @@ test -f "<project_path>/icons/<lib>/<name>.svg"
|
||||
|
||||
## 5. Font Usage
|
||||
|
||||
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`.
|
||||
Read typography from `spec_lock.md`: `<role>_family` → `title_family` / `body_family` → legacy `font_family`; sparse accents follow §2.1 and LaTeX stays PNG.
|
||||
|
||||
**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.
|
||||
**Default — locked-stack realization (may vary treatment)**: Express the Design Spec Character Reference through scale, weight, spacing, color, and composition; keep the locked family. Put the common stack on root `<svg>`, omit matching descendants, and override at the nearest clear `<g>`, `<text>`, or `<tspan>`.
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -293,11 +293,15 @@ Typography comes from `spec_lock.md`: `<role>_family` wins; otherwise titles use
|
||||
|
||||
## 6. Completion Routing
|
||||
|
||||
After every SVG page passes the final quality check, load [`executor-notes.md`](./executor-notes.md) and complete its notes contract before entering the route's Step 7.
|
||||
After every SVG page passes the final quality check, load
|
||||
[`executor-notes.md`](./executor-notes.md) and complete its notes contract only
|
||||
when the effective Speaker Notes outcome in `design_spec.md §I` is enabled.
|
||||
When disabled, proceed directly to the route's conditional motion handling and
|
||||
Step 7.
|
||||
|
||||
## 7. Next Steps After Completion
|
||||
|
||||
> **Auto-continuation**: After Visual Construction Phase (all SVG pages) and Logic Construction Phase (all notes) are complete, the Executor proceeds directly to the post-processing pipeline.
|
||||
> **Auto-continuation**: After Visual Construction Phase and any enabled Logic Construction Phase are complete, the Executor proceeds directly to the post-processing pipeline.
|
||||
|
||||
**Post-processing & Export**: Follow [`generate-pptx.md`](../workflows/generate-pptx.md)
|
||||
Step 7. That workflow owns the serial commands, gates, success criteria, and
|
||||
|
||||
@@ -23,21 +23,23 @@ 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/`.
|
||||
|
||||
**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 — layout catalog is optional recall**: Load [`image-layout-patterns.md`](./image-layout-patterns.md) only when an active suggestion cites `#<id>` or inspiration is useful; resolve only cited ids. Free-form suggestions need no catalog lookup. Adapt or decline the suggestion when the page communicates better, while preserving resource role/source, must-use, crop/content, and explicit user/template constraints. Expression-only changes need no upstream rewrite.
|
||||
|
||||
**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.
|
||||
**Hard rule — narrow visual-inspection scope**: Start with §VIII `Reference` plus dimensions. If one `Existing`/`Sourced` asset still leaves focal-safe crop, overlay contrast, or a quiet region ambiguous, inspect only that asset/review copy for placement. This cannot reopen selection, change identity/must-use, infer provenance, substitute the asset, or invent focus; never routinely read back `Generated` images. If `adaptive` focus stays uncertain, use `meet`; conflicting binding constraints return upstream.
|
||||
|
||||
**Placeholder**: Dashed border `<rect stroke-dasharray="8,4" .../>` + description text
|
||||
|
||||
**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
|
||||
**Hard rule — same-source addressable crops, only when adopted**: A layout
|
||||
suggestion, including pattern `#100`, never activates this transport. Apply it
|
||||
only when the chosen composition uses independent same-source crops or an
|
||||
explicit editable/Morph requirement needs them. Once active, reuse one exact
|
||||
`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
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
|
||||
Conditional late-stage authority for generating the complete speaker-notes document.
|
||||
|
||||
**Trigger**: load only after all SVG pages pass the final quality check.
|
||||
**Trigger**: load only after all SVG pages pass the final quality check and the
|
||||
effective Speaker Notes outcome in `design_spec.md §I` is enabled. A missing
|
||||
legacy outcome uses compatibility default `enabled`; effective Narration Audio
|
||||
enabled also requires Speaker Notes enabled. When notes are disabled, do not
|
||||
load this branch or create `notes/total.md`.
|
||||
|
||||
## 1. Complete Speaker-notes Document
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ Whenever the slide uses an image with `Status: Sourced`, look up the correspondi
|
||||
| `license_tier` | Action on this slide |
|
||||
|---|---|
|
||||
| `no-attribution` | Embed the `<image>` element only. **No credit element needed.** |
|
||||
| `attribution-required` | Embed the `<image>` element **plus** a small inline `<text>` credit element per the visual spec in [image-searcher.md §7](./image-searcher.md). |
|
||||
| `attribution-required` | Embed the `<image>` element **plus** a visible inline credit that preserves the asset-specific legal content in [image-searcher.md §7](./image-searcher.md). |
|
||||
| `manual` | Embed the `<image>` element only. **No credit element** — a user-supplied `--from-url` replacement; verifying usage rights / any required credit is the user's responsibility. |
|
||||
|
||||
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.
|
||||
The credit is **not** rendered by post-processing or export — it must be present in the SVG you produce. Preserve that asset's author, source/provider, and CC BY / CC BY-SA license facts. Size, position, color, per-image versus combined treatment, labels, and any contrast scrim/gradient are Executor-owned as long as the credit stays readable and unambiguously bound to the correct image.
|
||||
|
||||
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.
|
||||
Use `attribution_text` from the manifest entry as the **starting point**. You may omit the filename and full URL when the visible source/provider remains clear, but retain that image's author and CC BY / CC BY-SA license so the quality checker can bind the credit to the referenced asset. For CC0/PD images that landed in the `attribution-required` tier only because of upstream metadata quirks (rare), credits are still safe to render.
|
||||
|
||||
`svg_quality_checker.py` treats a missing image-specific author + license credit as an **error**; one generic CC token does not cover multiple files. An unreadable/missing manifest or missing per-file provenance is also blocking. Fix the manifest or SVG before post-processing.
|
||||
|
||||
|
||||
@@ -74,9 +74,9 @@ After all rows reach terminal status:
|
||||
|
||||
**Hard rule**: acquisition failures MUST NOT halt the pipeline.
|
||||
|
||||
1. Try once
|
||||
2. On recoverable failure (network, no candidates, license rejection, rate limit), retry once with broadened parameters
|
||||
3. On second failure, set `Status: Needs-Manual`, log the reason in conversation, continue
|
||||
1. Run the selected path's initial strategy
|
||||
2. On recoverable failure (network, no candidates, license rejection, rate limit), continue through materially different strategies that remain inside that path's confirmed permissions; never loop an already exhausted strategy
|
||||
3. When the path-specific query/provider/license-stage or backend/retry strategy is exhausted, set `Status: Needs-Manual`, log the reason in conversation, and continue
|
||||
4. After the phase completes, summarize all `Needs-Manual` rows for the user — list filenames, where prompts live (`images/image_prompts.md` paste-ready blocks for ai rows; refresh via `image_gen.py --render-md` if stale), and where to place generated files (`project/images/<filename>`). For `slice` rows, list the parent sheet filename and target element names; the user places the sheet, then the agent reruns `slice_images.py`.
|
||||
|
||||
`Needs-Manual` is also the entry status for **Offline Manual Mode** (no `IMAGE_BACKEND` configured, no host-native image tool in use). Affected ai rows are marked `Needs-Manual` from the start without a failed attempt — see [`image-generator.md`](./image-generator.md) §7 Offline Manual Mode.
|
||||
|
||||
@@ -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 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 |
|
||||
| **Type** | Optional recall for a local structural infographic's internal skeleton (infographic / flowchart / framework / matrix / cycle / funnel / pyramid / comparison / timeline / map / scene). Use it when one template fits; otherwise omit type and write the composition directly in §4.1 E prose. Local single-subject/portrait and `hero_page` images also omit type. | Per image |
|
||||
|
||||
> Rendering decides *how the image is drawn* (line quality, texture, depth). Color instructions begin from the deck roles: background / secondary background usually dominate, primary carries main forms, and accents stay scarce. Adjust proportions and derive coherent lighting/material/tint transitions for the image context; do not replace the deck's identity with an unrelated image-only palette.
|
||||
|
||||
@@ -122,15 +122,15 @@ 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 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.
|
||||
3. **Determine type or free composition** — an Illustration Sheet omits manifest `type` and follows §4.3's grid composition. For another local structural infographic, use one of the 11 types only when the `_index.md` offers a real match; otherwise omit type and author the intended structure directly with §4.1 E. A local single-subject/portrait image omits type and uses §4.1 A/B inside its actual region. A `hero_page` omits type and uses §4.1 A/B/C/D/E.
|
||||
4. `read_file references/image-type-templates/<type>.md` only when a type was selected (and only if not already read).
|
||||
5. **Assemble the prompt** by combining:
|
||||
- The rendering's style paragraph (from Step 2)
|
||||
- Color-role instructions anchored by the deck HEX values and refined for the image context (from Step 2)
|
||||
- The selected type's structural layout, or the no-type composition prose (from Step 3)
|
||||
- The image's specific `Reference` intent (from `design_spec.md §VIII`)
|
||||
- The 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)
|
||||
- Container sizing from the selected type file, or the row's Dimensions for no-type prose
|
||||
- The hard rules from §5 below (HEX-not-as-text, rendering-aligned human depiction and likeness authorization, text policy)
|
||||
|
||||
The assembled prompt is **one cohesive paragraph**, not a bulleted list of tags. See §4 for the assembly template.
|
||||
|
||||
@@ -165,7 +165,7 @@ This produces generic, model-average output. The model is not weighting your tag
|
||||
|
||||
### 4.1 No-type composition primitives
|
||||
|
||||
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.
|
||||
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. E authors any custom hero or local composition, including a structural infographic that does not genuinely match one of the 11 type templates.
|
||||
|
||||
**Primitive A — single dominant subject (product / object / concept hero)**
|
||||
|
||||
@@ -177,7 +177,7 @@ Use for: product reveal, concept introduction, chapter-opener visual, brand stat
|
||||
|
||||
> 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, or executive intro, including a local bio region. 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. Let the chosen rendering and Reference determine photographic, editorial, painterly, graphic, or other figure treatment; see §5.2.
|
||||
|
||||
**Primitive C — typographic hero (the text *is* the image)**
|
||||
|
||||
@@ -189,7 +189,7 @@ Use with `text_policy: embedded`. Must obey the §5.3 rule — text that is part
|
||||
|
||||
> 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 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.
|
||||
**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/E 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.
|
||||
|
||||
@@ -197,13 +197,13 @@ Use for: cover background, chapter divider background, breathing-page background
|
||||
|
||||
When none of A/B/C/D describe the page's intended layout (triptych, asymmetric multi-focal, narrative diorama, etc.), write the composition description directly into the prompt's composition sentence — same paragraph slot A/B/C/D occupy, but in your own words. No new field; the freedom is in the prose.
|
||||
|
||||
**Hard rule — custom composition prose**:
|
||||
**Default — concise custom composition prose (may override for subject accuracy)**:
|
||||
|
||||
| Rule | Value |
|
||||
|---|---|
|
||||
| Length | One paragraph, 2-5 sentences, replacing A/B/C/D's opening paragraph |
|
||||
| Required content | subject count, layout structure, where breathing room sits, where SVG overlay can claim canvas |
|
||||
| Forbidden | Naming a competing primitive ("like A but two subjects") |
|
||||
| Content | State enough subject count and layout structure to make the composition executable; include breathing room or an SVG-overlay region only when the page composition actually needs it |
|
||||
| Clarity | Describe the actual geometry; a primitive name alone is not a substitute |
|
||||
|
||||
Example opening for a triptych hero:
|
||||
|
||||
@@ -246,9 +246,9 @@ Example opening for a triptych hero:
|
||||
|
||||
### 4.3 Illustration sheets — one generation, many spot elements
|
||||
|
||||
When a deck wants several small **spot illustrations** scattered as decorative accessories across pages, do **not** generate them one image per slot. Generate one sheet and slice it. One call buys identical rendering, deck-color treatment, and line quality—the same consistency protected by `deck_rendering` + `color_scheme`.
|
||||
An illustration sheet can produce several small **spot illustrations** in one generation and preserve closely matched rendering, deck-color treatment, and line quality before slicing.
|
||||
|
||||
**When to use**: the §VIII image resource plan needs ≥3 small spot illustrations from the same family across the deck. For a single hero/local image, stay with the normal one-row-per-image flow (§4.1). Use sheets only where decorative illustration genuinely lifts the page; an unused element costs nothing, but a deck papered in decoration reads cheap.
|
||||
**Default — one sheet for a compatible spot family (may override when separate generation serves the assets better)**: Prefer a sheet when several elements share similar proportions, detail, quality, and semantic precision. Generate elements separately when those needs differ materially; quantity alone neither requires nor forbids a sheet. A single hero/local image stays with the normal one-row-per-image flow (§4.1).
|
||||
|
||||
**Hard rule**: a spot sheet is a generation source, not a slide asset. The sheet row is never listed in `spec_lock.md images` and never referenced from SVG. Only the sliced element rows are placed.
|
||||
|
||||
@@ -279,11 +279,11 @@ 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-recommended decorative-cutout Layout pattern rather than a boxed-container recommendation; Executor owns the actual placement — 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 carries a Strategist layout recommendation; Executor may realize it as a direct cutout or inside an appropriate container while preserving the resource and crop/content constraints.
|
||||
|
||||
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):
|
||||
**Slice** with [`slice_images.py`](../scripts/slice_images.py) — cells are cut row-major into individual files in `images/`. With `--alpha` they become transparent elements suitable for direct cutout placement or for composition inside a card, evidence frame, label, or other container. 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 can sit on any slide color or container):
|
||||
|
||||
```bash
|
||||
python3 scripts/slice_images.py <project>/images/illus_sheet.png --grid 2x3 \
|
||||
@@ -293,10 +293,10 @@ python3 scripts/slice_images.py <project>/images/illus_sheet.png --grid 2x3 \
|
||||
**Three constraints that decide whether it looks good**:
|
||||
|
||||
1. **Flat background, matched to the slide.** `image_gen.py` has no transparent-background mode, so the cut element carries whatever was behind it. A flat sheet background (= deck background HEX) is what `--alpha` keys out and what makes non-keyed pieces blend.
|
||||
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.
|
||||
2. **Clean grid, or it cuts ugly.** State the exact row/column structure and cell shape so the model does not invent a square matrix; `--trim` absorbs smaller placement variance. Do not generate several sheets or read them back merely to choose a favorite; re-roll only when user/live-preview feedback exposes an unusable slice.
|
||||
3. **Generate only as large as needed.** Each cell is a fraction of the sheet. Pick the smallest sheet size that keeps each sliced cell at least **1.5-2x** the intended display size. `1K` is usually enough for small 80-160px decorative spots; use `2K` for medium 180-320px placements; reserve `4K` for large, cropped, or potentially enlarged elements.
|
||||
|
||||
**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)).
|
||||
**Reference — sliced-asset placement is not a constraint**: A transparent slice may remain an unboxed cutout or enter a card, evidence frame, label, panel, or other suitable container. Strategist's layout text is an expression recommendation; Executor owns the actual geometry and treatment while preserving the resource role and crop/content constraints.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -314,13 +314,13 @@ Image generation models occasionally paint color names and HEX values as **visib
|
||||
|
||||
> Color values (HEX codes like #1E3A5F) and color names are rendering guidance only — do NOT display HEX codes, color names, or palette labels as visible text anywhere in the image.
|
||||
|
||||
### 5.2 Simplified human figures, no realistic faces
|
||||
### 5.2 Human depiction follows the selected rendering
|
||||
|
||||
When the image contains people:
|
||||
|
||||
> Human figures appear as simplified stylized silhouettes or symbolic representations — no photorealistic faces, no detailed anatomy, no celebrity likeness. Express role/emotion through posture, attire, and simple gestures.
|
||||
> Match facial detail, anatomy, texture, and realism to the selected rendering and the row's Reference. A silhouette, detailed illustration, painterly figure, editorial photograph, or another treatment is valid when it belongs to that rendering.
|
||||
|
||||
Exception: when the chosen rendering is `corporate-photo`, photorealism is intentional — replace the above with: `Diverse, professionally attired subjects. Editorial photography style, natural composition`.
|
||||
**Hard rule — likeness authorization**: Do not request an identifiable real-person or celebrity likeness unless the Reference explicitly names a user-authorized subject/source. Generic or fictional people remain free to follow the selected rendering.
|
||||
|
||||
### 5.3 Text policy — two-layer ownership
|
||||
|
||||
@@ -449,7 +449,7 @@ 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 | 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[].type` | no | Step 3 per-image | Optional one-of-11 internal-composition type for a local structural infographic when a template genuinely fits. Omit it for custom §4.1 E prose, `hero_page`, an Illustration Sheet, and local single-subject/portrait prose. |
|
||||
| `items[].page_role` | yes | Step 3 per-image | `local` (default — region block on SVG page) or `hero_page` (image is page's main voice; SVG overlay minimal or empty) |
|
||||
| `items[].text_policy` | yes | Step 3 per-image | `none` (image carries no text — explicit visual rule) or `embedded` (image contains stable artistic lettering, hand-lettered keywords, or visual identifiers like axis labels / subplot letters / unit symbols). AI judges per image; no global default bias — see §5.3. |
|
||||
| `items[].aspect_ratio` | yes | Container sizing | Passed to `image_gen.py --aspect_ratio` |
|
||||
@@ -461,12 +461,12 @@ Write `project/images/image_prompts.json` with this shape:
|
||||
| `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 omit `type` for hero pages and local single-subject / portrait prose.
|
||||
> **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 also omit `type` for custom §4.1 E prose, 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. New manifests follow the field table; hero pages and local single-subject / portrait prose omit `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; custom §4.1 E prose, 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`.
|
||||
|
||||
@@ -645,7 +645,7 @@ Diagnose the failure category, adjust the **one specific dimension** responsible
|
||||
| Garbled letters in supposedly text-free image | `text_policy: none` rule too weak | Strengthen with explicit list: "no letters, no numbers, no words, no signs, no labels, no captions, no watermarks" |
|
||||
| SVG text overlay clashes with busy image area | Page design needs negative space the prompt didn't request | Add a composition cue like "leave the {center / left third / lower band} relatively calm for text overlay" — only when the page actually overlays text on top of the image |
|
||||
| Subject vague | Reference field too abstract | Rewrite reference with concrete nouns (verbs + objects) |
|
||||
| Faces too realistic / uncanny | §5.2 rule omitted, or rendering is photo-incompatible | Either append §5.2, or switch rendering to a non-photo family |
|
||||
| Human depiction conflicts with the selected style or intent | §5.2 rendering/Reference cues were diluted | Restate the selected rendering's facial detail, anatomy, texture, and realism cues without changing the locked rendering |
|
||||
|
||||
**Variant workflow**:
|
||||
|
||||
|
||||
+11
-11
@@ -1,10 +1,10 @@
|
||||
# Image-Text Layout Patterns
|
||||
# Image-Text Layout Pattern Library
|
||||
|
||||
A vocabulary registry of ways images can be placed on a slide. The point of this file is to **expand the mental list of options** so that when you reach for an image layout, you do not default to the same three patterns (left/right, top/bottom, full-bleed cover). Start at **High-Yield Patterns** below: it routes the common page situations to the constructions that most visibly raise a deck's quality, at no asset cost.
|
||||
An optional vocabulary library for ways images can be placed on a slide. Open it when a page would benefit from more composition ideas; ordinary natural-language layout suggestions remain valid without consulting or citing the library. When using it, start at **High-Yield Patterns** below.
|
||||
|
||||
Every entry has a name plus a short technical hint. Common techniques get a single line. Less obvious or easily forgotten techniques get a short paragraph — not a full tutorial, but enough that a model unfamiliar with the project can implement it without guessing. This is a registry, not a teaching document; no use-case prescriptions, no decision tables.
|
||||
Every entry has a name plus a short technical hint. Common techniques get a single line. Less obvious or easily forgotten techniques get a short paragraph — not a full tutorial, but enough that a model unfamiliar with the project can implement it without guessing. This is an inspiration library, not a legality boundary or teaching document; it sets no usage, id, family, or coverage quota.
|
||||
|
||||
> **Numbers are stable 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.
|
||||
> **Numbers are stable optional identifiers, not sequence.** The file is split into **Part 1 — Primary Structures** (#1–#19, #38–#56, #73–#81, #88, #92–#94) and **Part 2 — Modifier Layers** (#20–#37, #57–#72, #82–#87, #89–#91, #95–#100). Numbers jump within each Part because Primary structures were grouped first; existing references to `#38`, `#48`, etc. anywhere in the project still resolve correctly. **High-Yield Patterns** is a router over those same numbers; use it when entering by page situation.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,11 +20,11 @@ Anything that must remain editable, numerically or semantically exact, or styled
|
||||
|
||||
---
|
||||
|
||||
## High-Yield Patterns — Open Here
|
||||
## High-Yield Patterns — Optional Starting Points
|
||||
|
||||
The patterns below are what separate a deck that looks designed from a deck that looks assembled. Nearly all of them are **one `<image>` plus geometry** — no extra asset, no generation cost, no second render — and they are the SVG equivalents of what PowerPoint users reach for under Merge Shapes. They sit late in the file only because the numbering is historical; they are the first place to look, not the last.
|
||||
The patterns below are efficient ways to expand a page beyond familiar splits. Nearly all of them are **one `<image>` plus geometry** — no extra asset, no generation cost, no second render — and they are the SVG equivalents of what PowerPoint users reach for under Merge Shapes.
|
||||
|
||||
**Default — resolve each page against this table before falling back to the plain structures in Part 1 (may override when the content genuinely wants a plain split, an equal grid, or bare whitespace):**
|
||||
**Reference — not a constraint**: use this router when it adds a useful composition option. A plain split, equal grid, bare whitespace, or an unlisted free-form construction remains valid when it serves the content.
|
||||
|
||||
| Page situation | Reach for | Produces |
|
||||
|---|---|---|
|
||||
@@ -42,13 +42,13 @@ The patterns below are what separate a deck that looks designed from a deck that
|
||||
| A subject should escape its container | `#85` subject breaking out + `#96` | Depth with no shadow at all |
|
||||
| One place should be recognized across consecutive pages | `#87` one image panned across pages | The deck reads as one continuous scene; `-t morph` uses heuristic matching, while explicit `morph.pairs` makes the camera pan deterministic |
|
||||
|
||||
**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.
|
||||
**Reference — not a constraint**: when citing a modifier-only result, also name the content-appropriate Primary that supplies the page bones. Free-form suggestions may describe the complete relationship without catalog ids.
|
||||
|
||||
**Hard rule — registration is what makes this family work**: in `#82`, `#85`, `#87`, `#89`, `#96`, `#97`, and `#100`, the image stays anchored to the *union* of its containers, or the copies share one source coordinate system. A few pixels of drift reads as a printing error. `#84` alone breaks registration on purpose.
|
||||
|
||||
**Prepared-asset gate**: select `#96` only when a registered cutout PNG already exists, `#97` only when its blurred crop exists, and `#99` only when its desaturated copy exists. If not, keep the original asset and fall back to a native-shape treatment such as `#30` / `#29`; do not invent an image-processing step during execution.
|
||||
|
||||
**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`.
|
||||
**Reference — not a constraint**: repeated plain splits may be a reason to consult this library, but are not evidence of failure. Neither §VIII nor the final deck must cite an id or cover a pattern family.
|
||||
|
||||
Each entry above is specified in full at its own number below; the table routes, it does not restate.
|
||||
|
||||
@@ -262,7 +262,7 @@ 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`).
|
||||
> **Default — focal-safe text contrast (may override when the image and treatment demonstrably remain legible).** `preserveAspectRatio="xMidYMid slice"` center-crops whatever the source aspect ratio does not cover, so estimate the crop before placing text. Keep copy clear of the focal subject and maintain readable contrast across its full area. A gradient transition is valid when those conditions hold; use an opaque plateau or solid panel only when the image and softer treatment cannot guarantee them. When subject position is unresolved, prefer the opaque treatment rather than guessing.
|
||||
|
||||
27. **Linear gradient scrim for text legibility** — `<linearGradient>` in `<defs>` (set `x1/y1/x2/y2` for direction) + overlay `<rect fill="url(#grad)">`. Most common is top-to-bottom darkening on full-bleed cover images.
|
||||
|
||||
@@ -419,7 +419,7 @@ Combine freely. The "AI-default" failure mode is the opposite: defaulting to bar
|
||||
| Benefits with one dominant proof image | `#80` |
|
||||
| Light promotional page without photos | `#81` |
|
||||
|
||||
**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.
|
||||
**Reference — not a constraint**: before adding another photo, consider whether one prepared image plus #82–#100 can express the idea more clearly. Registration and prepared-asset boundaries remain mandatory when the chosen technique depends on them.
|
||||
|
||||
**Cross-page through-line (recurring motif).** The patterns above are per-page, but a deck reads as *designed* when one illustration motif family recurs across pages—a cover anchor, section dividers repeating the motif (`#75`), and small `#63` spots threaded through the body. Keep one family (shared rendering / locked deck colors / subject world), vary scale and placement, and never turn recurrence into a quota.
|
||||
|
||||
|
||||
@@ -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-recommended catalog pattern; preferred composition, Executor-owned realization |
|
||||
| `Layout pattern` | Non-empty Strategist layout suggestion in free-form prose, optionally citing catalog ids; Executor-owned realization |
|
||||
| `Crop Policy` | `no-crop` requires one complete instance; `adaptive` lets Executor choose `meet` or focal-safe `slice` |
|
||||
| `Reference` | Optional calculated image/text rectangles, focal notes, and composition intent |
|
||||
| `spec_lock.md images` value | `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-crop>`; source/crop exactly project §VIII, while pattern preserves its ordered catalog ids (or normalized custom prose) as a recommendation, not a geometry/realization lock |
|
||||
| `spec_lock.md images` value | `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-crop>`; source/crop exactly project §VIII, while pattern preserves the normalized free-form suggestion and any optional catalog ids as a recommendation, not a geometry/realization lock |
|
||||
|
||||
For SVG `<image>` syntax, path rules, `preserveAspectRatio`, external refs, and Base64 embedding: see [`svg-image-embedding.md`](svg-image-embedding.md).
|
||||
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
# Rendering: corporate-photo
|
||||
|
||||
Editorial photography style — real subjects, natural composition, professional grading. The only rendering where photorealism is intentional. Used for team photos, lifestyle shots, product photography, real-world scenarios.
|
||||
Editorial photography style — real subjects, natural composition, professional grading. Used for team photos, lifestyle shots, product photography, and real-world scenarios.
|
||||
|
||||
## 1. Style paragraph (paste-ready, 100 words)
|
||||
|
||||
@@ -20,13 +20,13 @@ Editorial photography style — real subjects, natural composition, professional
|
||||
|
||||
---
|
||||
|
||||
## 3. Exception to global hard rule §5.2
|
||||
## 3. Human treatment
|
||||
|
||||
corporate-photo **overrides** the global "simplified silhouettes / no realistic faces" rule (image-generator.md §5.2). For this rendering, append instead:
|
||||
Apply [`image-generator.md`](../image-generator.md) §5.2 through this rendering's photographic grammar:
|
||||
|
||||
> Diverse, professionally attired subjects rendered photorealistically. Editorial photography composition, natural light or soft studio lighting. Subjects appear authentic and contemporary — no posed stock-photo stiffness, no exaggerated expressions, no fashion-shoot artificiality.
|
||||
|
||||
All other renderings keep the simplified-figure rule.
|
||||
This rendering does not create a photorealism whitelist; other renderings choose their own anatomy, facial detail, texture, and realism under the same global rule.
|
||||
|
||||
## 4. Using the deck's HEX values
|
||||
|
||||
@@ -43,4 +43,4 @@ corporate-photo's relationship to HEX is **color grading**, not color fills:
|
||||
|
||||
**Snippet A — team workplace photo, text_policy: none**
|
||||
|
||||
> Editorial photography of a small modern team collaborating around a laptop in a contemporary office. Three to four diverse, professionally attired adults — varied ethnicities, varied ages — engaged in genuine conversation, no posed stock-photo stiffness. Natural window light from the side, soft shadows. Color grading is cool-corporate — image is subtly graded toward deep blue tones echoing the deck's primary `#1E3A5F`, with the background carrying soft light gray `#F8F9FA` walls and a single accent of warm wood or amber from a desk lamp echoing the accent `#D4AF37`. Shallow depth of field — subjects sharp, background gently blurred. Composed as a 1200×600 hero band with 8% inner padding. Diverse, professionally attired subjects rendered photorealistically. Editorial photography composition. Authentic, not stock-photo artificial. Color values are rendering guidance — HEX codes themselves do not appear in the image as text.
|
||||
> Editorial photography of a small modern team collaborating around a laptop in a contemporary office. Three to four diverse, professionally attired adults — varied ethnicities, varied ages — engaged in genuine conversation, no posed stock-photo stiffness. Natural window light from the side, soft shadows. Color grading is cool-corporate — image is subtly graded toward deep blue tones echoing the deck's primary `#1E3A5F`, with the background carrying soft light gray `#F8F9FA` walls and a single accent of warm wood or amber from a desk lamp echoing the accent `#D4AF37`. Shallow depth of field — subjects sharp, background gently blurred. Composed as a 1200×600 hero band with 8% inner padding. Diverse, professionally attired subjects rendered photorealistically. Editorial photography composition. Authentic, not stock-photo artificial. Color values are rendering guidance — HEX codes themselves do not appear in the image as text.
|
||||
|
||||
@@ -59,7 +59,7 @@ Default chain (when `--provider` is unset):
|
||||
|
||||
Keyed providers without an API key are silently skipped — not an error.
|
||||
|
||||
**Validation**: For polished visual decks, configure at least one keyed provider before using `Acquire Via: web`.
|
||||
**Default — keyed providers for broader stock coverage (may override when zero-config sources fit)**: Configure Pexels or Pixabay when their stock-photo coverage serves the brief. Their absence is not a validation failure; Openverse and Wikimedia remain valid zero-config acquisition paths.
|
||||
|
||||
---
|
||||
|
||||
@@ -70,11 +70,11 @@ Keep two layers distinct:
|
||||
| Layer | Owner and grammar |
|
||||
|---|---|
|
||||
| Design Spec §VIII `Reference` | Strategist's complete visual intent: exact subject, desired view/mood, focal or quiet region, and crop-safety constraints. Positive quality cues are valid here. |
|
||||
| `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. |
|
||||
| `image_queries.json.items[].query` / positional query | Image_Searcher's concrete entity/identity keyword string. Start with the shortest phrase that preserves identity; keep exact multi-word names and necessary disambiguators even when they exceed four words. Omit mood, quality, composition, HEX, and negative wording. |
|
||||
|
||||
Web APIs match metadata, not semantic intent. 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.
|
||||
Web APIs match metadata, not semantic intent. Providers try the original query first, then progressively simplified four/three/two/one-word variants. A pipeline manifest should therefore use a concise query without pre-truncating exact names. For Chinese landmarks, use the precise Chinese name with Wikimedia; for stock providers, use compact English identity terms when they retain the subject.
|
||||
|
||||
Image_Searcher consumes the locked Reference and never rewrites `design_spec.md` or `spec_lock.md`. A candidate either satisfies that existing subject/focal/crop intent, or the role re-queries once and then marks `Needs-Manual`.
|
||||
Image_Searcher consumes the locked Reference and never rewrites `design_spec.md` or `spec_lock.md`. A candidate either satisfies that existing subject/focal/crop intent, or the role tries materially different query/provider/permitted-license strategies until no untried strategy remains, then marks `Needs-Manual`. Never loosen `required_terms`, the license policy, or the locked intent to manufacture a match.
|
||||
|
||||
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.
|
||||
|
||||
@@ -183,14 +183,14 @@ Never treat a generic `required_terms` pass as acceptance. For example, matching
|
||||
|
||||
**Replacement ladder when a best match is not right** (any reviewer):
|
||||
|
||||
1. refine the query and re-run that row once;
|
||||
1. refine the query and re-run that row while each revision tests a materially different identity phrase or disambiguator; do not repeat a semantically exhausted query;
|
||||
2. **manual URL replace (universal, model-agnostic)** — the user finds a better image anywhere and gives its URL; download and swap it in:
|
||||
```bash
|
||||
python3 scripts/image_search.py --from-url <image-url> --filename <name>.jpg -o <project_path>/images
|
||||
```
|
||||
Recorded with `license_tier: manual` — verifying usage rights is the user's call. Human replacement is a legitimate outcome, not a failure. It updates the image and `image_sources.json` but does **not** rewrite `image_queries.json`, so a row fixed this way may still read `Needs-Manual` in the batch manifest — harmless: the file is present, so export proceeds ([`executor-web-image.md`](./executor-web-image.md) §1);
|
||||
3. (opt-in) `--save-candidates` to pull auto-alternatives with their own `source_page_url`s, then `--promote` the best (below);
|
||||
4. if nothing fits, mark the row `Needs-Manual`.
|
||||
4. when the query variants, configured provider chain, and permitted license stages are exhausted and no user-confirmed manual URL is available, mark the row `Needs-Manual`.
|
||||
|
||||
Web search is far cheaper than AI generation, so this review pass is well worth it.
|
||||
|
||||
@@ -265,40 +265,23 @@ Every successful download appends or replaces one entry keyed on `filename`:
|
||||
|
||||
---
|
||||
|
||||
## 7. On-Slide Attribution — Visual Specification
|
||||
## 7. On-Slide Attribution Contract
|
||||
|
||||
Applied by Executor when an image's `license_tier == "attribution-required"`. Three layouts depending on the page.
|
||||
Applied by Executor when an image's `license_tier == "attribution-required"`.
|
||||
|
||||
### 7.1 Single-image page
|
||||
**Hard rule — legal content and binding**: Every slide that uses the asset carries a visible, readable credit bound unambiguously to that asset. Preserve its author, source/provider, and CC BY / CC BY-SA license facts from `attribution_text`; do not invent, merge away, or drop identity.
|
||||
|
||||
- **Position**: bottom-right of the image's container, hugging the image edge (within ~8 px)
|
||||
- **Font size**: 6–8pt equivalent (≈ 0.7–1 % of canvas short edge)
|
||||
- **Color**: `fill="#999999"` on light/photo backgrounds; `fill="#FFFFFF" fill-opacity="0.6"` on dark/photo
|
||||
- **Content**: `© {author} / {provider_short} / {license_short}`
|
||||
- `provider_short`: `Openverse` / `Wikimedia` / `Pexels` / `Pixabay`
|
||||
- `license_short`: `CC BY 4.0` / `CC BY-SA 4.0` / `Public Domain`
|
||||
- Drop empty fields (CC0 with no author → `via Openverse`)
|
||||
**Reference — visual treatment is not a constraint**: Position, size, color, line structure, per-image versus combined credits, labels, and contrast treatment belong to the page composition. Use any treatment that stays readable and preserves the asset-to-credit binding; a scrim or gradient is optional, not required.
|
||||
|
||||
**Forbidden — fields that break the visual line**: full URLs, `attribution_text` verbatim, "License:" prefix.
|
||||
**Reference — attribution treatments, not constraints**:
|
||||
|
||||
### 7.2 Multi-image page (≥ 2 attribution-required)
|
||||
| Page situation | Possible treatment |
|
||||
|---|---|
|
||||
| One credited image | Place a compact credit near the image edge or in a page footnote area |
|
||||
| Several credited images | Use per-image credits or one combined source line with labels when needed for unambiguous mapping |
|
||||
| Hero / full-bleed image | Place the credit in an available quiet region; add a scrim or gradient only when contrast otherwise fails |
|
||||
|
||||
Combine into one source line at the page bottom rather than scattering credits:
|
||||
|
||||
```
|
||||
Sources: a, b via Wikimedia (CC BY); c via Openverse (CC BY-SA)
|
||||
```
|
||||
|
||||
Use single-letter labels (a/b/c) only when needed for disambiguation.
|
||||
|
||||
### 7.3 Hero / full-bleed image
|
||||
|
||||
- Bottom 1.5 cm gradient overlay: `stop-color="#000000" stop-opacity="0"` → `stop-color="#000000" stop-opacity="0.5"`
|
||||
- 7pt text with `fill="#FFFFFF" fill-opacity="0.6"` inside the overlay band, right-aligned ~24 px from edge
|
||||
|
||||
### 7.4 Source for the credit text
|
||||
|
||||
Use `attribution_text` from the manifest as the **starting point**. Compress for the small-text constraint:
|
||||
Use `attribution_text` from the manifest as the **starting point**. Compress when the chosen page treatment needs a shorter line, without dropping the required facts:
|
||||
|
||||
| Manifest | Slide credit |
|
||||
|---|---|
|
||||
@@ -313,7 +296,7 @@ Extends [`image-base.md`](./image-base.md) §6.
|
||||
|
||||
| Situation | Behavior |
|
||||
|---|---|
|
||||
| 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. |
|
||||
| No candidates from any provider in either stage | Mark row `Needs-Manual`. Suggest a more precise query or another configured provider; rerun without `--strict-no-attribution` only when the confirmed page may carry visible credit. |
|
||||
| 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. |
|
||||
@@ -327,7 +310,7 @@ CLI exit: `0` when all attempted rows resolve; `1` while any row remains `Failed
|
||||
|
||||
Reference field is **intent description**, not a query. See [`image-base.md`](./image-base.md) §8 for the rule.
|
||||
|
||||
Keep it intact as the acceptance contract. Derive a separate 1–4 word provider query; do not pass the Reference verbatim or rewrite it after search.
|
||||
Keep it intact as the acceptance contract. Derive a separate concise provider query that preserves exact names and necessary disambiguation; do not pass the Reference verbatim or rewrite it after search.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Type Templates — Index
|
||||
|
||||
A **type** describes the **internal geometric composition skeleton** of a local infographic image block — what the layout looks like *inside the rectangle the model paints*. Type is decided **per image**, not per deck (one deck typically uses 2-4 different types).
|
||||
A **type** optionally describes the **internal geometric composition skeleton** of a local infographic image block — what the layout looks like *inside the rectangle the model paints*. Type is decided **per image**, not per deck.
|
||||
|
||||
## What Type *is* and *is not*
|
||||
|
||||
@@ -12,7 +12,7 @@ A **type** describes the **internal geometric composition skeleton** of a local
|
||||
- *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** — 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.
|
||||
**Reference — when to skip type**: 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. A local structural infographic with no genuine catalog match omits type and uses custom Primitive E prose. The 11 types below are recall tools, not a closed set.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,7 +38,7 @@ Each type has its own file with: composition skeleton (LAYOUT / ELEMENTS / NEGAT
|
||||
|
||||
## 2. Auto-selection — per-image `Purpose` → type
|
||||
|
||||
For each row in `design_spec.md §VIII Image Resource List` where `page_role: local`, match `Purpose` against this table.
|
||||
**Reference — not a constraint**: For a `page_role: local` row in `design_spec.md §VIII Image Resource List`, use this table when `Purpose` genuinely matches. Otherwise omit `type` and write the intended composition directly.
|
||||
|
||||
| `Purpose` keyword | Type |
|
||||
|---|---|
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ Atmospheric environment with narrative — a moment, a place, a situation render
|
||||
```
|
||||
|
||||
| LAYOUT | Three-layer composition: foreground (subject + immediate context), middle ground (supporting environment), background (atmospheric depth). The viewer reads the scene like a small story |
|
||||
| ELEMENTS | One or more figures (simplified silhouettes unless `corporate-photo`) in an environment. Environmental elements (sun, lamp, tree, desk) support the narrative |
|
||||
| ELEMENTS | One or more figures treated according to the selected rendering and Reference, inside an environment. Environmental elements (sun, lamp, tree, desk) support the narrative |
|
||||
| NEGATIVE SPACE | Atmospheric perspective creates breathing room — background paler than foreground |
|
||||
| ATMOSPHERE | Lighting direction, color temperature, and mood are deliberate (golden-hour, evening light, morning haze, etc.) |
|
||||
|
||||
@@ -43,4 +43,4 @@ Two valid cases:
|
||||
|
||||
**Snippet A — warm-scene + warm-earth personal story, text_policy: none, 1200×600**
|
||||
|
||||
> Atmospheric scene illustration with golden-hour cinematic lighting. The composition is a three-layer narrative: foreground left — a softly rendered simplified figure silhouette walking along a path with warm long shadows cast toward the right; middle ground — the warm path winds into a stylized hillside with a few suggested trees; background — atmospheric perspective creates pale warm distance, sky transitioning from amber `#D97706` at the horizon to soft cream `#FEF3C7` at the top. Lighting is warm golden-hour from the upper right. Foreground in deeper warm primary `#9A3412`; small accent gold `#D4AF37` highlights on sunlit surfaces. No hard outlines — forms emerge from light and shadow. Subtle film grain at 8% opacity. Composed as a 1200×600 hero scene with 10% inner padding. Simplified silhouette figure only — no realistic face. NO text or labels. Color values are rendering guidance only.
|
||||
> Atmospheric scene illustration with golden-hour cinematic lighting. The composition is a three-layer narrative: foreground left — a softly rendered simplified figure silhouette walking along a path with warm long shadows cast toward the right; middle ground — the warm path winds into a stylized hillside with a few suggested trees; background — atmospheric perspective creates pale warm distance, sky transitioning from amber `#D97706` at the horizon to soft cream `#FEF3C7` at the top. Lighting is warm golden-hour from the upper right. Foreground in deeper warm primary `#9A3412`; small accent gold `#D4AF37` highlights on sunlit surfaces. No hard outlines — forms emerge from light and shadow. Subtle film grain at 8% opacity. Composed as a 1200×600 hero scene with 10% inner padding. Simplified silhouette figure only — no realistic face. NO text or labels. Color values are rendering guidance only.
|
||||
|
||||
@@ -12,7 +12,7 @@ Each mode has its own file with: narrative skeleton, page-structure tendencies,
|
||||
|
||||
| Mode | Narrative skeleton | Best for |
|
||||
|---|---|---|
|
||||
| [`pyramid`](./pyramid.md) | Conclusion first; MECE arguments; every datum carries a comparison | Decision support, analysis, strategy, board / exec reports |
|
||||
| [`pyramid`](./pyramid.md) | Conclusion first; structured arguments; data contextualized with supported comparisons where useful | Decision support, analysis, strategy, board / exec reports |
|
||||
| [`narrative`](./narrative.md) | Story arc — situation → tension → resolution; suspense and turns | Pitches, case studies, brand journeys, fundraising |
|
||||
| [`instructional`](./instructional.md) | Concept decomposition; step-by-step; parallel exposition | Training, tutorials, explainers, knowledge sharing |
|
||||
| [`showcase`](./showcase.md) | Visual-led impact; big imagery / numbers; emotional rhythm | Launches, brand reveals, event / promo decks |
|
||||
@@ -40,12 +40,12 @@ Each mode has its own file with: narrative skeleton, page-structure tendencies,
|
||||
|
||||
| Torn between | …the first when | …the second when |
|
||||
|---|---|---|
|
||||
| `pyramid` / `briefing` | it must land a recommendation — conclusion-first, every number compared | it must inform completely without arguing — topic titles, even weight |
|
||||
| `pyramid` / `briefing` | it must land a recommendation — conclusion-first, figures contextualized toward a decision | it must inform completely without arguing — topic titles, even weight |
|
||||
| `narrative` / `pyramid` | the point lands through a story arc, tension → resolution | the point lands as a conclusion stated up front, then supported |
|
||||
| `narrative` / `showcase` | an argument travels through the story | presence leads — minimal copy, one big visual per page |
|
||||
| `narrative` / `showcase` | an argument travels through the story | presence leads — concise copy and a clear visual focus |
|
||||
| `instructional` / `briefing` | the goal is to build understanding step by step | the goal is to lay out a complete reference to scan |
|
||||
|
||||
> "Keynote-style" is a *mode* request, not a visual style — it means showcase pacing (one big idea per page, full-bleed hero, reveal rhythm), skinned by whatever visual style fits the brand (`swiss-minimal` clean, `dark-tech` dramatic, `glassmorphism` premium). Don't reach for a "keynote" visual style — there isn't one, by design.
|
||||
> "Keynote-style" is a *mode* request, not a visual style — it means showcase pacing (a clear primary idea, hero-scale visual treatment, reveal rhythm), skinned by whatever visual style fits the brand (`swiss-minimal` clean, `dark-tech` dramatic, `glassmorphism` premium). Don't reach for a "keynote" visual style — there isn't one, by design.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-3
@@ -8,11 +8,11 @@ Teaching-led exposition. Decompose a concept into ordered, digestible parts and
|
||||
|
||||
**Decompose, then sequence**: break the subject into parts and present them in a deliberate order (simple → complex, prerequisite → dependent, overview → detail).
|
||||
|
||||
**One concept per page**: each page teaches a single idea well; do not stack unrelated concepts.
|
||||
**Focused learning unit**: center each page on a coherent teaching step; related concepts may share a page when their relationship is what the learner needs to understand.
|
||||
|
||||
**Parallel exposition**: sibling concepts get parallel structure — same shape, same depth — so the audience can compare and map them.
|
||||
|
||||
**Show, then tell**: lead with a concrete example or analogy, then state the principle. A worked example beats an abstract definition.
|
||||
**Ground abstraction**: use a concrete example or analogy when it clarifies the principle; sequence example and explanation according to the learner's prerequisite needs.
|
||||
|
||||
**Signpost**: orient the learner — what we covered, what comes next.
|
||||
|
||||
@@ -24,7 +24,7 @@ Titles state what the page teaches ("How attention weights are computed") — cl
|
||||
|
||||
- Numbered steps / ordered flows for processes; parallel cards for sibling concepts.
|
||||
- Diagrams that build incrementally; annotate the part currently being explained.
|
||||
- A concrete example anchors each abstract point.
|
||||
- Concrete examples anchor abstract points when they improve transfer or comprehension.
|
||||
|
||||
> Step / flow / diagram geometry lives in [`templates/charts/`](../../templates/charts/); this mode decides *the learning order and granularity*.
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ Story-arc persuasion. Carry the audience through situation → tension → resol
|
||||
|
||||
## 1. Narrative skeleton
|
||||
|
||||
**Arc, per deck and per page**: scenario → conflict → resolution. Set a stake, raise a tension, resolve it — then bridge to the next beat.
|
||||
**Arc across the deck and within suitable beats**: scenario → conflict → resolution. Set a stake, raise tension, resolve it, and bridge to the next beat without forcing the complete arc onto every page.
|
||||
|
||||
**Suspense and payoff**: pose a question at the right moment, answer it on the next page. Let curiosity pull the audience forward.
|
||||
**Suspense and payoff**: pose a question at the right moment and answer it at the beat where the evidence lands. Let curiosity pull the audience forward.
|
||||
|
||||
**Human framing**: anchor abstract points in a protagonist, a moment, a concrete stake ("a team that shipped in two weeks instead of three months").
|
||||
|
||||
**At least one turn**: a reframe, a reveal, a "but here's what changed". Flat exposition is not narrative.
|
||||
**Narrative movement**: use a reframe, reveal, or change in stakes where the material supports it. The sequence should move rather than merely relabel flat exposition.
|
||||
|
||||
Titles read as beats that advance the arc ("Then the numbers stopped adding up"), not as labels.
|
||||
|
||||
@@ -20,7 +20,7 @@ Titles read as beats that advance the arc ("Then the numbers stopped adding up")
|
||||
|
||||
## 2. Page-structure tendencies
|
||||
|
||||
- Pages alternate rhythm: a dense beat followed by a breathing page (single image / quote / turn) to prevent fatigue.
|
||||
- Vary dense and breathing beats according to tension, payoff, and audience fatigue; do not enforce a mechanical alternation.
|
||||
- Visual weight guides the eye through each beat (hero image, one focal number, a pull quote).
|
||||
- Continuity within a chapter, variation between chapters.
|
||||
|
||||
@@ -30,7 +30,7 @@ Titles read as beats that advance the arc ("Then the numbers stopped adding up")
|
||||
|
||||
## 3. Speaker-notes register
|
||||
|
||||
Conversational narration — like talking with the audience, not reading a report. Scenario-conflict-resolution per page. Metaphors make the abstract tangible ("like adding a turbocharger"). Plain rhetorical questions create suspense; bridge each page from the prior one. Conversational data ("nearly a third", "more than doubled"). (Common framework: [`executor-notes.md`](../executor-notes.md) §1.)
|
||||
Conversational narration — like talking with the audience, not reading a report. Use scenario-conflict-resolution within a page when that beat benefits from it. Metaphors make the abstract tangible ("like adding a turbocharger"). Plain rhetorical questions create suspense; bridge each page from the prior one. Conversational data ("nearly a third", "more than doubled"). (Common framework: [`executor-notes.md`](../executor-notes.md) §1.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Mode: pyramid
|
||||
|
||||
Conclusion-first argumentation. State the answer, then support it with mutually-exclusive, collectively-exhaustive evidence — every claim earns its place, every number carries a comparison. For audiences who want the result before the process: executives, boards, investors, decision-makers.
|
||||
Conclusion-first argumentation. State the answer, then support it with structured evidence — every claim earns its place and data is contextualized without inventing comparisons. For audiences who want the result before the process: executives, boards, investors, decision-makers.
|
||||
|
||||
---
|
||||
|
||||
@@ -12,10 +12,10 @@ SCQA opening, pyramid body:
|
||||
|
||||
| Stage | Role | Where |
|
||||
|---|---|---|
|
||||
| Situation | establish shared context | cover / first 1-2 pages |
|
||||
| Situation | establish shared context | opening pages |
|
||||
| Complication | the tension / problem | early pages |
|
||||
| Question | the implicit question to resolve | transition |
|
||||
| Answer | the recommendation, developed MECE | all body pages |
|
||||
| Answer | the recommendation, developed through structured evidence; use MECE when the source supports it | body pages |
|
||||
|
||||
**Assertion titles** — write the finding, not the topic:
|
||||
|
||||
@@ -25,16 +25,16 @@ SCQA opening, pyramid body:
|
||||
| "Challenges" | "Three structural contradictions block scaled deployment" |
|
||||
| "Our Solution" | "Three-phase path: Focus, Expand, Scale" |
|
||||
|
||||
**Data never stands alone** — every figure pairs with a comparison (prior period / benchmark / competitor / target / rank) and a "so what". A bare number is an incomplete thought in this mode.
|
||||
**Contextualize data** — pair a figure with a supported comparison when that comparison changes the decision; otherwise state its direct relevance or implication. Never invent a prior period, benchmark, competitor, target, or rank to satisfy the mode.
|
||||
|
||||
**MECE** — when decomposing (drivers, segments, options), branches are mutually exclusive and collectively exhaustive; parts sum to the whole (or label "Other").
|
||||
**Structured decomposition** — when decomposing drivers, segments, or options, make branches meaningfully distinct and cover what the decision needs. Preserve real overlap or an acknowledged gap instead of fabricating a perfect MECE partition.
|
||||
|
||||
---
|
||||
|
||||
## 2. Page-structure tendencies
|
||||
|
||||
- Title (the conclusion) → one-line takeaway → supporting evidence beneath.
|
||||
- Each body page answers one question and states its own one-sentence conclusion.
|
||||
- Title (the conclusion) → concise takeaway → supporting evidence beneath.
|
||||
- Each body page centers on a decision-relevant question and states a clear conclusion; supporting subquestions may share the page when their relationship matters.
|
||||
- Decomposition pages (driver tree / MECE breakdown / 2×2 matrix) carry the analytical load.
|
||||
- Source attribution on every data page.
|
||||
|
||||
@@ -44,7 +44,7 @@ SCQA opening, pyramid body:
|
||||
|
||||
## 3. Speaker-notes register
|
||||
|
||||
Conclusion-driven: the first sentence of each page's notes is the takeaway, then 2-3 supporting facts in flowing prose. Composed, authoritative. Every number paired with its comparison in the same sentence ("23% — nearly double the industry's 12%"). Spell percentages as words where the spoken form reads more naturally. (Common framework: [`executor-notes.md`](../executor-notes.md) §1.)
|
||||
Conclusion-driven: open each page's notes with the takeaway, then support it with a compact set of facts in flowing prose. Composed, authoritative. Pair a number with a supported comparison when it materially clarifies the takeaway ("23% — nearly double the industry's 12%"); otherwise explain its implication directly. Spell percentages as words where the spoken form reads more naturally. (Common framework: [`executor-notes.md`](../executor-notes.md) §1.)
|
||||
|
||||
---
|
||||
|
||||
@@ -53,6 +53,6 @@ Conclusion-driven: the first sentence of each page's notes is the takeaway, then
|
||||
```
|
||||
Title: "Retention, not acquisition, now drives growth" ← the conclusion
|
||||
Takeaway: one line — "CAC up 40% YoY, yet repurchase lifted 60% of revenue growth"
|
||||
Body: 3 MECE arguments, each with one contextualized datum
|
||||
Body: supporting arguments with contextualized data
|
||||
Footer: Source: … | page #
|
||||
```
|
||||
|
||||
@@ -6,11 +6,11 @@ Visual-led impact. Let imagery, scale, and rhythm carry the message; minimize co
|
||||
|
||||
## 1. Narrative skeleton
|
||||
|
||||
**Image / number leads, words support**: each page has one dominant visual element — a hero image, a single huge number, a short phrase — not a paragraph.
|
||||
**Image / number leads, words support**: establish a dominant visual hierarchy through a hero image, large number, or short phrase; supporting elements may share the page without competing for focus.
|
||||
|
||||
**Emotional rhythm**: build and release — a run of bold pages punctuated by a quiet one. Pace for feeling, not density.
|
||||
|
||||
**One idea per page, stated big**: reduce each page to a single takeaway expressed at scale.
|
||||
**Primary idea stated big**: center each page on a clear takeaway expressed at scale; retain supporting context when the audience needs it to understand or trust that takeaway.
|
||||
|
||||
**Reveal structure**: hold back, then reveal (the product, the result, the tagline) for maximum effect.
|
||||
|
||||
@@ -20,11 +20,11 @@ Titles are short and evocative — a phrase, not a sentence.
|
||||
|
||||
## 2. Page-structure tendencies
|
||||
|
||||
- Full-bleed imagery with overlay text; a single focal hero number / phrase.
|
||||
- Generous negative space; the page breathes around one element.
|
||||
- Full-bleed imagery with overlay text; a focal hero number or phrase.
|
||||
- Generous negative space around the primary visual relationship.
|
||||
- Bold use of the deck's theme color for atmosphere (cover / chapter pages).
|
||||
|
||||
> Hero / full-bleed / breathing-page geometry lives in [`executor-base.md`](../executor-base.md) and [`image-layout-patterns.md`](../image-layout-patterns.md); this mode decides *what single thing each page presents*.
|
||||
> Hero / full-bleed / breathing-page geometry lives in [`executor-base.md`](../executor-base.md) and the optional [`image-layout-patterns.md`](../image-layout-patterns.md) library; this mode decides *what each page makes primary*.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-5
@@ -110,11 +110,10 @@ and visible-stroke rects also remain ordinary objects.
|
||||
| `object` | one text, image, basic SVG shape, or validated compact authored-preset `<g>` marked as carrier; alternatively the slot group declares `binding="proxy"` | `obj` |
|
||||
| `media` | one `<image>` or supported imported crop `<svg>`, marked as carrier | `media` |
|
||||
|
||||
**Text slot carrier**: A multiline text placeholder must remain one
|
||||
native text frame. Use the default paragraph merge; `--no-merge` cannot supply
|
||||
several line shapes as one
|
||||
PowerPoint placeholder prototype/binding. Leave strict-line text Slide-local
|
||||
when separate frames are the required result.
|
||||
**Text slot carrier**: A multiline text placeholder must remain one native text
|
||||
frame. Default export and `--reflow-text` do; `--no-merge` cannot supply several
|
||||
line shapes as one PowerPoint placeholder prototype/binding. Leave strict-line
|
||||
text Slide-local when separate frames are required.
|
||||
|
||||
For a materialized mirror, an imported text carrier may additionally keep the
|
||||
source shape's positive `data-pptx-frame="x y width height"`. That frame owns
|
||||
|
||||
+1
-1
@@ -577,7 +577,7 @@ These forms are needed only when the stated PPT behavior matters:
|
||||
|
||||
| Desired behavior | Required form |
|
||||
|---|---|
|
||||
| One editable PPT text frame with mixed inline formatting or wrapped prose | Keep one logical paragraph in one `<text>`. Use non-positional `<tspan>` children for inline runs. Keep the first wrapped line as direct text and put each later line in a direct positioned `<tspan>` that repeats the parent `x` and uses positive relative `dy`; an all-`<tspan>` form may start with `dy="0"`. Same-size, evenly stacked lines flow in the current paragraph; a font-size change, list marker, or larger accepted gap starts another paragraph in that frame. Sibling `<text>` elements are forbidden as line breaks for one paragraph; they remain valid for semantically independent frames. |
|
||||
| One editable PPT text frame with mixed formatting or multiline prose | Use one `<text>` per logical paragraph and non-positional `<tspan>` children for inline runs. Keep the first authored line as direct text; later lines use direct positioned `<tspan>` children that repeat parent `x` with positive relative `dy`; an all-`<tspan>` form may start at `dy="0"`. Default retains these breaks without PowerPoint wrapping; `--reflow-text` may join eligible lines. A font-size change, list marker, or larger accepted gap starts another paragraph. Sibling `<text>` elements are forbidden as one paragraph's line breaks; they remain valid for independent frames. |
|
||||
| Stable object grouping or object-level animation anchor | Wrap the intended object in `<g id="...">`. Content grouping is **mandatory** per §4.3 — a top-level `<g id>` is also the animation anchor; it is not an optional convenience. |
|
||||
| Native PowerPoint background promotion | Outside structured mode, the first eligible visual layer may be a direct full-canvas `<rect>` or one inside a simple single-child group. Its fill must have a registered native mapping (solid, linear/radial gradient, or preset pattern), and it must have no transform, filter, clip, rounding, or visible stroke. Export writes the fill as Slide `p:bg`; image elements remain pictures. Structured routes use the narrower explicit solid-background ownership contract in [`pptx-structure-interface.md`](./pptx-structure-interface.md). |
|
||||
| Free-design / brand-only PowerPoint structure | Use `pptx_structure.mode: flat`. Keep every represented object Slide-local; export materializes one clean project-owned Master plus one Blank Layout from the current lock, removes stock content placeholders/Layout inventory, and retains only the standard date/footer/slide-number capability hooks. Do not author Master/Layout identities, layers, or placeholder slots. Quick-test uses the same flat object ownership but converter-default theme scaffolding because no lock exists. |
|
||||
|
||||
@@ -16,7 +16,7 @@ For illustration, apply this precedence: confirmed `none` → explicit user inte
|
||||
|
||||
**Context-first understanding for provided assets**: Do not visually scan `images/`. First infer identity, role, and crop / focus needs from source position and surrounding prose, captions / alt / titles, filename, user notes / confirmed `image_notes`, existing resource records, and CSV geometry. Inspect only one specific image when a remaining ambiguity would change selection, factual identity, page role, crop safety, or focal placement. Never inspect for inspiration, bulk-open the folder, or infer external facts / provenance from pixels. Record the result in §VIII. Leave an optional unresolved asset unused; route an unresolved must-use asset through failure recovery.
|
||||
|
||||
For ≥3 AI-generated same-family spots, plan one unplaced `ai` Illustration Sheet row plus one placed `slice` row per used element; only slice rows enter `spec_lock.md images`. State the intended placement shape family in the sheet reference and use separate sheets for incompatible shapes. [`image-generator.md`](./image-generator.md) §4.3 owns grid, ratio, slicing, and execution details. Stage 3 chooses the AI execution path under `image-generator.md` §7; do not pre-empt or re-pick it here.
|
||||
**Default — one coherent sheet for compatible same-family spots (may override when aspect, detail, quality, or semantic needs differ)**: prefer one Illustration Sheet when several AI-generated spots can share a useful cell shape and production treatment; generate them independently when forcing one sheet would weaken a planned element. When a sheet is chosen, plan one unplaced `ai` Illustration Sheet row plus one placed `slice` row per used element; only slice rows enter `spec_lock.md images`. State the intended placement shape family in the sheet reference and use separate sheets for incompatible shapes. [`image-generator.md`](./image-generator.md) §4.3 owns grid, ratio, slicing, and execution details. Stage 3 chooses the AI execution path under `image-generator.md` §7; do not pre-empt or re-pick it here.
|
||||
|
||||
## 2. AI Image Strategy — propose before Stage 2; lock only for confirmed `ai`
|
||||
|
||||
@@ -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, 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.
|
||||
Add §VIII rows for the image resources actually planned from the confirmed source boundary and for every selected formula; a formula-only plan contains only formula rows. A permitted but unused source needs no row. Author each row's filename, dimensions/ratio, preferred layout suggestion, crop policy, purpose/type, acquisition, status, reference, and conditional AI fields as part of the complete Design Spec. `Acquire Via` is `ai`, `web`, `user`, `formula`, `placeholder`, or `slice`; status follows [`svg-image-embedding.md`](./svg-image-embedding.md). When a planned or explicitly required asset is not yet available, retain its row as `Pending` or `Needs-Manual`; never remove the row or change `Acquire Via` to make the Design Spec look complete. After §VIII passes final confirmation, project every placed row into `spec_lock.md images` as `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-crop>` and omit unplaced Illustration Sheets. `source` and `crop` preserve the exact confirmed §VIII text; `pattern` preserves the non-empty free-form suggestion, including any optional catalog ids, while remaining preferred expression rather than locked geometry. References describe visual intent: AI uses subject + intent + composition without repeating rendering or HEX; web records exact subject, view/mood, focal/quiet region, and crop safety with positive quality cues; Image_Searcher later derives a separate short, specific provider query without rewriting this locked intent, while complete entity names or necessary disambiguation may use more words; formula preserves source LaTeX and placement intent.
|
||||
|
||||
**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**: 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.
|
||||
**Mandatory**: write one concise, non-empty, executable `Layout pattern` value per non-formula row in ordinary language. It may cite stable ids from [`image-layout-patterns.md`](./image-layout-patterns.md), but reading the library or using ids is not required. Preserve any cited id accurately; otherwise describe the composition without inventing one.
|
||||
|
||||
**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.
|
||||
**Reference — not a constraint**: open [`image-layout-patterns.md`](./image-layout-patterns.md) only when its vocabulary would expand the current options. Techniques needing a cutout, blurred crop, or desaturated copy require that prepared asset. Executor may adapt, replace, or decline the suggestion while preserving resource role, file/source, must-use status, crop boundary, content, and explicit user/template constraints; layout-only changes need no upstream rewrite.
|
||||
|
||||
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`.
|
||||
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ When the communication contract conflicts with the workspace, choose and state t
|
||||
|
||||
> Internal note: `content_divergence` controls source reorganization; the AI-derived `template_reuse_scope` records the reused layer; `template_adherence` records whether a structured plan keeps or extends existing Layout identities.
|
||||
|
||||
**Template design precedence**: User overrides win. Otherwise template colors and title/body stacks are fixed anchors, not industry defaults. Each of ≥3 Stage-2 directions still carries all six palette roles and complete font objects: repeat fixed values and vary only template-open roles. Keep declared icon and image constraints.
|
||||
**Template design precedence**: User overrides win. Otherwise template colors and title/body stacks are fixed anchors, not industry defaults. Each of ≥3 Stage-2 directions carries all six palette roles and complete fonts: repeat fixed values with `typography.fixed: true`; vary only template-open roles. Keep declared icon and image constraints.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -28,17 +28,17 @@ As a top-tier AI presentation strategist, receive source documents, perform cont
|
||||
|
||||
| Stage | Items | Role |
|
||||
|---|---|---|
|
||||
| **1 — communication contract** | `c` audience · open-ended communication intent · audience outcome · core message / delivery context (primary + optional secondary) / artifact afterlife · `content_divergence` (all prose fields may be blank) · `a` canvas | confirmed first |
|
||||
| **1 — communication contract** | `primary_language` · `c` audience · open-ended communication intent · audience outcome · core message / delivery context (primary + optional secondary) / artifact afterlife · `content_divergence` (all prose fields may be blank) · `a` canvas | confirmed first |
|
||||
| **2 — complete deck solution** (authored once from the user's *actual* Stage 1) | reading mode (`delivery_purpose`, PPT only) · `d` mode + visual style · `b` page count · `e` color · `f` icon · `g` typography · `h` image source + generated-image rendering · conditional natural-language template application | derived from the confirmed contract; internal template exporter modes remain hidden |
|
||||
| **3 — resources / production** (authored once from the user's *actual* Stage 1 + Stage 2) | formula policy · conditional AI-image acquisition path · generation mode · refine-spec toggle | derived from the confirmed solution |
|
||||
| **3 — resources / production** (authored once from the user's *actual* Stage 1 + Stage 2) | formula policy · conditional AI-image acquisition path · generation mode · refine-spec toggle · proactive speaker notes / custom animations / narration audio | derived from the confirmed solution |
|
||||
|
||||
Do not force communication intent into one catalog label; Stage 1 records composite intent in prose. Editable prose fields are recommendation drafts, not required inputs: confirmation preserves current text and blanks; never repopulate a cleared field. Stage 2 confirms narrative spine, reading density, page budget, visual system, and image direction. With a template, inspect its actual prototypes/content, present one editable application plan, and keep exporter reuse/adherence internal. Present ≥3 coordinated safe / shifted / bold directions so color, type, icons, and generated-image rendering begin coherent; the user may override each component. Generated images inherit deck colors—there is no second image palette. Stage 3 covers production. Author each stage once; same-stage edits update only visible browser state through documented deterministic dependencies, without another AI/backend recommendation. Launch/derive/wait mechanics live in [`generate-pptx.md`](../workflows/generate-pptx.md) Step 4; item specs keep `a`–`h`.
|
||||
Do not force communication intent into one catalog label; Stage 1 records composite intent in prose. Editable prose fields are recommendation drafts, not required inputs: confirmation preserves current text and blanks; never repopulate a cleared field. Stage 2 confirms narrative spine, reading density, page budget, visual system, and image direction. With a template, inspect its actual prototypes/content, present one editable application plan, and keep exporter reuse/adherence internal. Present ≥3 coordinated safe / shifted / bold directions so color, type, icons, and generated-image rendering begin coherent; the user may override each component. Generated images inherit deck colors—there is no second image palette. Stage 3 covers production. Its proactive defaults are speaker notes `true`, custom animations `false`, and narration audio `false`; a prior explicit user instruction overrides the matching recommendation, and effective narration audio requires effective speaker notes. Author each stage once; same-stage edits update only visible browser state through documented deterministic dependencies, without another AI/backend recommendation. Launch/derive/wait mechanics live in [`generate-pptx.md`](../workflows/generate-pptx.md) Step 4; item specs keep `a`–`h`.
|
||||
|
||||
> **Execution discipline**: This is the last always-on BLOCKING checkpoint. After confirmation, proceed without another pause unless spec refinement is enabled.
|
||||
>
|
||||
> **One opt-in exception**: present the refinement line with the split-mode note ([`generate-pptx.md`](../workflows/generate-pptx.md) Step 4). Only explicit opt-in runs [`refine-spec`](../workflows/stages/refine-spec.md): write the Design Spec once, pass Gate 1, then stop before the lock for unrestricted chat revision. Never enter it unprompted.
|
||||
|
||||
> **Default presentation surface — Confirm UI.** Use `<project>/confirm_ui/recommendations.stage1.json`, `.stage2.json`, and `.stage3.json` at their documented handoffs and launch per Generate Step 4. The active, unconfirmed stage may be overwritten when the user asks for a new recommendation; normal progression writes the next stage file and leaves confirmed earlier stages intact. Stage 2 carries ≥3 safe / shifted / bold `design_directions`; each bundles visual style, a six-role HEX palette, CJK + Latin heading/body typography, icons, and conditional image rendering. Also print the recommendations + URL in chat as fallback context. Skip launch only for an explicit chat-only request; a chat-question tool is not a substitute. Generate Step 4 reads the final confirmed `result.json` once and retains that object for Design Spec authoring. [`confirm_ui.md`](../scripts/docs/confirm_ui.md) owns schema and lifecycle.
|
||||
> **Default presentation surface — Confirm UI.** Use `<project>/confirm_ui/recommendations.stage1.json`, `.stage2.json`, and `.stage3.json`; launch per Generate Step 4. Stage 1 writes canonical BCP-47 `primary_language` apart from UI `lang`; the server normalizes legacy English/Chinese/Japanese/Korean names, rejecting `und` and Chinese without script/region; Strategist projects it through Design Spec §I to lock communication. Replace only the active unconfirmed stage; preserve confirmed files. Stage 2 carries ≥3 safe / shifted / bold `design_directions`; each bundles visual style, a six-role HEX palette, primary-language heading/body typography plus an English companion only for non-English decks, icons, and conditional image rendering. Print the URL, Stage-1 summary, and `confirm_ui.md` chat fallback; this is not confirmation. Skip launch only for explicit chat-only use; chat-question tools are no substitute. Step 4 reads final confirmed `result.json` once for Design Spec authoring. [`confirm_ui.md`](../scripts/docs/confirm_ui.md) owns schema and lifecycle.
|
||||
|
||||
**Confirmed-value semantics**: confirmation preserves both the value and the owning field's semantic type. Apply the type to the affected property, not automatically to the whole object:
|
||||
|
||||
@@ -85,7 +85,7 @@ Seed the following as open-prose recommendations when the source and user reques
|
||||
|
||||
The contract is not the narrative mode. `communication_intent` says what change is needed; `mode` is one Stage-2 strategy for organizing the argument. Several intents may share one dominant mode, and one intent may support several possible modes.
|
||||
|
||||
**Reading mode** (PPT only) is a closed Stage-2 information-carriage axis: `text` (read-close) / `balanced` (business, default) / `presentation`. Keep the existing `recommend.delivery_purpose` / `result.json.delivery_purpose` key for compatibility, but label and reason about it as reading mode—never as communication purpose. It decides how meaning is divided among the page, visuals, presenter, and notes, driving page grammar, granularity, density / rhythm, and the §b page-count recommendation. The §g body baseline is a downstream typography default, not the label or definition shown in the reading-mode control.
|
||||
**Reading mode** (PPT only) is a closed Stage-2 information-carriage axis: `text` (read-close) / `balanced` (business, default) / `presentation`. Keep the existing `recommend.delivery_purpose` / `result.json.delivery_purpose` key for compatibility, but label and reason about it as reading mode—never as communication purpose. It decides how meaning is divided among the page, visuals, presenter, and, when enabled, notes, driving page grammar, granularity, density / rhythm, and the §b page-count recommendation. The §g body baseline is a downstream typography default, not the label or definition shown in the reading-mode control.
|
||||
|
||||
**Material divergence** — a **free-text** source-treatment intent in the Stage-1 delivery section: in their own words, how closely the deck should follow the source vs how freely it may reshape it. This is the user's own call — a free prose field (`content_divergence`), **not** a fixed set of options and **not** something you recommend from analyzing the source. Surface the question plainly (in the confirm UI it appears after the delivery-context fields); leave it for the user to fill. Blank = a balanced default.
|
||||
|
||||
@@ -212,12 +212,12 @@ See [`../templates/icons/README.md`](../templates/icons/README.md) for the curre
|
||||
|
||||
**Family selection**:
|
||||
|
||||
- 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 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`.
|
||||
- User/template typography is authoritative. When it fixes the stacks, repeat them and set `typography.fixed: true` on every Stage-2 direction. Otherwise ≥3 directions use different concrete heading/body combinations spanning concord and contrast; no extra font round.
|
||||
- Every Stage-2 direction carries `heading` / `body` `primary`, `css`, and positive `body_size`; add `english` only when the deck's main language is not English.
|
||||
- Use concrete, target-installed PowerPoint faces. The Confirm UI font catalog supplies additional manual dropdown choices, not a recommendation whitelist.
|
||||
- Keep stacks to four families or fewer. A brand/web face may lead only after user-confirmed target installation/approved install; PPT Master does not embed fonts. Otherwise export a safe face and keep the unavailable face as Design Spec reference.
|
||||
- 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.
|
||||
- Choose by locked style and vary the axis: 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.
|
||||
|
||||
@@ -280,12 +280,17 @@ 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` |
|
||||
| Image composition | Image-as-canvas, editorial crop, collage, cutout, or meaningful focus / comparison / evidence units carry the page better than an adjacent rectangle | Propose a permitted source; when selected, load [`strategist-image.md`](./strategist-image.md), record a concise §VIII `Layout pattern` suggestion, and describe page-level image/overlay relationships in §IX `Layout` / `Images` |
|
||||
| Native paint / overlay | Gradient, translucency, scrim, vignette, or wash supports focus, hierarchy, depth, legibility, or image integration | Record purpose/layering in §IX `Layout`, plus `Images` when imagery participates; no new field or type/stops/opacity/coordinates—Executor chooses realization |
|
||||
| Native shape / Merge Shapes | A literal Office symbol, a stock bent/curved relationship contour, or a compound silhouette, negative-space cutout, overlap-only region, or meaningful fragmentation strengthens the visual idea | Add an optional §IX `Native shape suggestion` with the semantic result plus a candidate preset/Connector family or Boolean operation/operands |
|
||||
| Page transition | A section/state change, spatial continuity, recorded/self-running flow, or the same semantic object changing position, scale, crop, or state across adjacent pages benefits from motion | Add an optional §IX `Motion suggestion` describing the communication job and any continuing object's start/end semantic states; leave effect, ids, pairing names, and timing to Executor |
|
||||
| Object animation | Progressive reveal clarifies sequence, causality, comparison, hierarchy, narration order, full-view → detail, atmosphere → evidence, or hotspot/annotation order | Add an optional §IX `Motion suggestion` describing semantic units/order and any visible image-state relationship; leave group ids, effect, and timing to Executor |
|
||||
|
||||
Write useful motion advice regardless of the effective Custom Animations outcome.
|
||||
The suggestion remains non-binding and never activates custom-animation
|
||||
execution by itself; only an explicit motion requirement or an enabled outcome
|
||||
may require visible endpoint/reveal-state preparation.
|
||||
|
||||
Review planned pages through two lenses:
|
||||
|
||||
| Lens | Content shapes |
|
||||
@@ -293,7 +298,7 @@ Review planned pages through two lenses:
|
||||
| Numeric / data | comparisons, trends, proportions, KPIs, financials, rankings, distributions, funnels |
|
||||
| Structural information | rosters, agendas, principles, phases, journeys, capability maps, OKR cascades, roadmaps, strategic frameworks |
|
||||
|
||||
**Per-page recall**: For every page whose information structure may benefit from a visualization, restate the content shape as 3–8 concise English semantic tags. Translate source-language and industry terms into structure before recall. Run:
|
||||
**Reference — not a constraint**: use catalog recall when it would help. Its CLI accepts 3–8 distinct English content-shape tags; a page already planned as a custom visualization or table needs no recall. Run:
|
||||
|
||||
```bash
|
||||
python3 skills/ppt-master/scripts/chart_recall.py recall \
|
||||
@@ -309,7 +314,7 @@ The command returns a bounded shortlist plus `no-template-match`. Read it unfilt
|
||||
**Selection**:
|
||||
|
||||
1. Choose the most relevant candidate as a reference for that page. It does not lock the final visualization type or geometry and never applies to another page without its own row.
|
||||
2. After the fallback gate above, retain `no-template-match` when no reference fits: data content falls back to a table, permitted conceptual content to an AI image, and structural content to a custom layout. Record the fallback only in the page's §IX `Visualization` / `Layout`; never serialize it into §VII.
|
||||
2. Retain `no-template-match` when no reference fits. Choose a custom visualization, table, diagram/layout, or permitted image from the content semantics, communication job, and editability need. Record the choice only in §IX; never serialize `no-template-match` into §VII.
|
||||
3. Validate all selected keys before writing the lock:
|
||||
|
||||
```bash
|
||||
@@ -340,11 +345,22 @@ 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)
|
||||
### Speaker Notes Requirements
|
||||
|
||||
- File naming: Recommended to match SVG names (`01_cover.svg` → `notes/01_cover.md`), also compatible with `notes/slide01.md`
|
||||
- Fill in the Design Spec: total presentation duration, notes style (formal / conversational / interactive), presentation purpose (inform / persuade / inspire / instruct / report)
|
||||
- Split note files must NOT contain `#` heading lines (`notes/total.md` master document MUST use `#` heading lines)
|
||||
Resolve the effective Speaker Notes outcome from the latest explicit user
|
||||
instruction, then Stage 3 `proactive_speaker_notes`, then compatibility default
|
||||
`true`. Effective Narration Audio `enabled` requires Speaker Notes `enabled`
|
||||
without changing the raw proactive preference; when that dependency changes the
|
||||
notes outcome, its provenance names enabled Narration Audio.
|
||||
|
||||
| Effective outcome | Design Spec §X |
|
||||
|---|---|
|
||||
| `enabled` | Record filename policy, content/source handling, total duration, notes style, and presentation purpose |
|
||||
| `disabled` | Keep §X and write `Generation: disabled`; do not invent note requirements |
|
||||
|
||||
When enabled, match SVG names where possible (`01_cover.svg` →
|
||||
`notes/01_cover.md`); `notes/slide01.md` remains compatible. Split files contain
|
||||
no `#` heading lines; `notes/total.md` uses `#` headings.
|
||||
|
||||
---
|
||||
|
||||
@@ -392,9 +408,9 @@ Free-design patterns are starting points, not quotas. Adjust composition, spacin
|
||||
|
||||
### 6.1 Content Planning Strategy
|
||||
|
||||
Content-outline and speaker-notes strategy follow the deck's locked **mode** — see [`modes/_index.md`](./modes/_index.md), then the locked preset file or every listed custom reference plus its behavior. The guidance below applies within any mode:
|
||||
Content-outline strategy and, when enabled, speaker-notes strategy follow the deck's locked **mode** — see [`modes/_index.md`](./modes/_index.md), then the locked preset file or every listed custom reference plus its behavior. The guidance below applies within any mode:
|
||||
|
||||
**Reading mode controls information carriage, not communication intent.** `result.json delivery_purpose` is retained as the compatibility key for `text` (read-close) / `balanced` (business, default) / `presentation`, confirmed with the complete deck solution in Stage 2. It decides how meaning is divided among the page, visuals, presenter, and notes. The body baseline (§g) is one consequence, not the definition:
|
||||
**Reading mode controls information carriage, not communication intent.** `result.json delivery_purpose` is retained as the compatibility key for `text` (read-close) / `balanced` (business, default) / `presentation`, confirmed with the complete deck solution in Stage 2. It decides how meaning is divided among the page, visuals, presenter, and enabled notes. The body baseline (§g) is one consequence, not the definition:
|
||||
|
||||
| Reading mode | Primary carrier | §IX page grammar | Granularity / rhythm | Speaker notes |
|
||||
|---|---|---|---|---|
|
||||
@@ -402,11 +418,14 @@ Content-outline and speaker-notes strategy follow the deck's locked **mode** —
|
||||
| `balanced` · business (default) | page + presenter | one primary claim with concise explanation, structured evidence, or a necessary list | moderate granularity; mixed rhythm | interpretation and transitions |
|
||||
| `presentation` | presenter + visuals | one claim per page, keywords / short phrases, a large visual or hero number; no paragraph dumps or prose compressed into bullet fragments | more, sparser pages; leans `anchor` / `breathing` | carries explanation, transitions, and supporting detail |
|
||||
|
||||
**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.
|
||||
When Speaker Notes is disabled, the final column is unavailable: keep every
|
||||
required meaning in the visible page and confirmed presenter channel.
|
||||
|
||||
**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.
|
||||
**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 enabled notes, appendix pages, captions, and visible sources instead of crowding every slide.
|
||||
|
||||
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.
|
||||
**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 enabled notes rather than turning every sentence into a fragment; when notes are disabled, keep the necessary explanation in the visible page or confirmed presenter channel. 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 enabled 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 enabled notes, never by rephrasing or re-paginating.
|
||||
|
||||
> Note: §IX is the complete page brief; Executor retains it with the lock until context invalidation, then reloads both once.
|
||||
|
||||
@@ -414,8 +433,15 @@ 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.
|
||||
|
||||
After final confirmation, a newer explicit notes/animation/narration instruction
|
||||
updates only affected §I outcomes/provenance and resumes their owner; never
|
||||
reopen Confirm UI or add them to `spec_lock.md`. Before editing, apply
|
||||
Generate's notes/audio dependency gate. Record animation provenance as
|
||||
Stage 3 `false`, explicit objects-off, or explicit all-motion-off; only the last
|
||||
includes transitions.
|
||||
|
||||
1. Use the retained complete final-confirmation state already read once by Generate Step 4, then read `templates/design_spec_reference.md`.
|
||||
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.
|
||||
2. Compose the whole Design Spec in active context before touching the target path. Create `design_spec.md` once from the schema marker through §X; do not copy a scaffold into the project or patch placeholder fields. Record production mechanics in §I, including one effective outcome plus provenance for Speaker Notes, Custom Animations, and Narration Audio. Resolve them from latest explicit user instruction → matching Stage 3 proactive value → compatibility default `enabled` / `disabled` / `disabled`; Narration Audio enabled requires Speaker Notes enabled without rewriting the raw proactive evidence, and a dependency-driven notes outcome records that provenance. In §IX, create the complete ordered roster; each entry carries layout, title, core message, **Audience move**, complete preferred wording, applicable capability recommendations, visualization/image references, sourced `Fact IDs`, and `Data class: scenario` for invented demo data. After Gate 1 plus conditional refine approval, roster ids/count/order and semantic content are authoritative; non-literal wording, block texture, layout, cover/closing composition, capability recommendations, and image/chart patterns remain References unless promoted.
|
||||
3. Compare `design_spec.md` against the final confirmation field by field. Repair every omission or deviation before entering an enabled refine-spec review or authoring `spec_lock.md`.
|
||||
4. If enabled, run [`refine-spec`](../workflows/stages/refine-spec.md) after Gate 1; edit only that Design Spec and create no lock before explicit approval.
|
||||
5. Read `templates/spec_lock_reference.md`. From the approved Design Spec plus context, create the lock once or resynchronize stale derived state. Retain identity/refinements, select stable roles/routing, omit unnamed page-local values, and do not reopen evidence. This is implementation judgment, not another recommendation.
|
||||
@@ -427,17 +453,18 @@ Generate Step 4 owns this sequence. `design_spec.md` is the complete human-reada
|
||||
| Communication contract and `content_divergence` | §I records the confirmed contract; §IX realizes every stated purpose, outcome, priority, and source-treatment constraint |
|
||||
| Canvas, reading mode, and page count | §I records the confirmed input and exact resolved count; §IX contains that many ordered pages. Executor produces exactly one output slide per entry, in order |
|
||||
| Mode, visual style, palette, and generated-image rendering | §I and §III record the selected direction as identity anchors; named core roles stay stable while page-local expression remains contextual |
|
||||
| Typography, including Strategist-derived recurring family overrides and every visible role size | §IV records the confirmed heading/body stacks, any recurring support-role stacks justified by §IX, and exact `body`, `title`, `subtitle`, and `annotation` anchor values; never discard a declared role override or re-derive a confirmed anchor |
|
||||
| Typography, including Strategist-derived recurring family overrides and every visible role size | §IV records Character/upgrade References, resolved heading/body stacks, recurring support-role stacks justified by §IX, and exact `body`, `title`, `subtitle`, and `annotation` anchors; never discard a declared role override or re-derive a confirmed anchor |
|
||||
| Icons | §VI uses the confirmed library or confirmed no-icon/custom path |
|
||||
| Confirmed image-source set, `image_notes`, and AI strategy | §VIII uses only permitted sources and includes every explicitly required source, asset, or page role; a permitted but unused source needs no row |
|
||||
| Natural-language template application | §I records it and the relevant layout/prototype choices realize it without silently dropping a requested use or exclusion |
|
||||
| Formula policy, AI-image acquisition path, generation mode, refine-spec toggle | §I records them as production mechanics; their owning Generate stage consumes the Design Spec, and formula policy also shapes §VIII when formula-worthy content exists |
|
||||
| Proactive speaker notes, custom animations, and narration audio | §I records the three resolved effective outcomes with provenance, while §X records enabled note requirements or `Generation: disabled`; they remain outside `spec_lock.md`. §IX Motion suggestions remain optional advice regardless of the animation outcome |
|
||||
|
||||
⛔ **GATE 1 — active-decision fidelity.** Do not create `spec_lock.md` until the initial Design Spec passes the comparison above and any enabled refinement is explicitly approved. Before Gate 2, every requested revision must be present and every unaffected decision intact. Missing/substituted values, unapplied revisions, or silently changed semantic types block despite schema validity; bounded Reference adaptation and unused Permission remain valid.
|
||||
|
||||
⛔ **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, 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.
|
||||
**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, layout suggestion, 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 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.
|
||||
|
||||
@@ -446,9 +473,9 @@ Generate Step 4 owns this sequence. `design_spec.md` is the complete human-reada
|
||||
- **page_rhythm is mandatory**: Based on the page list in §IX Content Outline, assign each page one of `anchor` / `dense` / `breathing`. This is what breaks the uniform "every page is a card grid" feel. New locks may not omit the section; consumer omission behavior is owned by [`executor-base.md`](executor-base.md) §2.1.
|
||||
- **Fact IDs and scenario labels are mandatory when applicable**: Read any `sources/*.facts.json`. For each §IX page, list the stable IDs actually used; never cite an ID whose claim is absent from the page. Mark invented KPIs/targets/internal ratios as `Data class: scenario` and state which values are scenario data. Executor carries external sources into notes/footnotes and renders a visible scenario label for scenario figures.
|
||||
- **Rhythm follows narrative, not quota**: `breathing` pages mark natural pauses — chapter transitions, standalone emphasis (hero quote / big number), SCQA bridges. Dense decks may legitimately be all `dense`. **Do NOT invent filler pages** ("Thank you", empty dividers) to pad rhythm — every `breathing` page must say something independent. Consumption mode biases the overall lean (`presentation` toward more `anchor` / `breathing`, `text` toward `dense`; see §6.1) — a bias, never a quota.
|
||||
- **Cover impact is mandatory**: Page `P01` is the deck's first visual contract, not a generic title slide. In `design_spec.md §IX`, add a `Cover impact` line for `P01` that names one concrete hook and one concrete composition strategy. Use the source's strongest available signal: a provocative core claim, object / scene metaphor, hero number, founder / product / audience moment, or a distilled conflict. Pair it with one concrete composition strategy — such as `full-bleed image + floating title`, `typographic poster`, `hero object`, `data hook`, `editorial scene`, `high-contrast abstract geometry`, or a fresh composition the deck's subject suggests (these are starting points, not the allowed set). If no external or AI image is available, still specify a native-SVG visual hook; do not fall back to "title + subtitle + decorative background". (Beautify / template-fill keep the source cover verbatim — this rule does not apply on those preservation paths.)
|
||||
- **Cover rhythm lock**: `P01` remains `anchor` in `spec_lock.md page_rhythm`, but its §IX `Cover impact` must prevent content-page patterns. Do not plan multi-card grids, agenda-like bullets, or equal-weight columns on the cover unless a template explicitly requires that structure, or a preservation path (beautify / template-fill) is transcribing the source cover verbatim.
|
||||
- **Closing impact (only when the deck closes)**: the deck's last page is its final visual contract — the strongest impression after the cover. When the deck genuinely lands on a conclusion / call-to-action / final-takeaway page, give it a `Closing impact` line in §IX: name the one thing the audience should leave with (a distilled takeaway, a forward call, a memorable restatement of the core claim) + one composition that delivers it — never a generic "Thank you" / contact-only slide or a centered-title reprise of the cover. **Do NOT invent a closing page to satisfy this** — the filler-page ban above still holds; apply it only to the page where the deck actually resolves. Same exemptions as the cover: skip on template / beautify / template-fill preservation paths.
|
||||
- **Cover impact is mandatory**: In `design_spec.md §IX`, give `P01` one concrete hook from the source's strongest claim, metaphor, number, moment, or conflict plus a recommended composition. The hook binds; Executor may adapt the composition to prepared assets and explicit constraints. With no suitable image, recommend a native-SVG hook instead of a generic title treatment. Beautify / template-fill preservation paths are exempt.
|
||||
- **Cover rhythm lock**: `P01` remains `anchor`. Default away from generic content-page templates; a card grid, agenda, or equal-weight columns remains valid when content, user direction, or the template makes it the clearest cover.
|
||||
- **Closing impact (only when the deck closes)**: For a genuine conclusion / CTA / final takeaway, name the binding takeaway plus a recommended composition; Executor may adapt the latter. Do not default to an information-empty "Thank you", contact-only slide, or cover reprise; an explicit contact/event CTA may serve the purpose. **Do NOT invent a closing page to satisfy this**. Preservation paths are exempt.
|
||||
- **pptx_structure is mandatory**: Free-design, brand-only, and `template_reuse_scope: style` routes write `mode: flat`; a style-reference route may also record `template_reuse_scope: style` but omits every structure mapping and `template_adherence`. `template_reuse_scope: mirror|layout` writes `mode: structured` plus `template_adherence: strict|adaptive`. Do not write legacy `baseline`, `template`, `preserve`, `layout_strategy`, or Layout-kind rows into a new project.
|
||||
- **Flat-route boundary**: With `mode: flat`, omit `pptx_masters`, `pptx_layouts`, `page_pptx_layouts`, and `page_layouts`. Do not plan native Master/Layout families or reusable placeholder slots. Every generated SVG object remains Slide-local: omit root Master/Layout identity, `data-pptx-layer`, and `data-pptx-placeholder*` metadata. Export materializes one clean project-owned Master plus one Blank Layout from the current color/typography lock, removes stock content placeholders/Layout inventory, and retains only the standard date/footer/slide-number capability hooks.
|
||||
- **Structured template route**: When [`strategist-template.md`](./strategist-template.md) is active and reuse is `mirror|layout`, follow its complete Master/Layout/slot/prototype mapping rules.
|
||||
|
||||
+8
-7
@@ -24,7 +24,7 @@ Defined in the Design Specification & Content Outline; each image carries an `Ac
|
||||
| **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) |
|
||||
| **Sourced** | Web-sourced file exists at expected path | Reference from `../images/`; check `image_sources.json` for `license_tier` — if `attribution-required`, render an inline credit element on the slide (see [`executor-web-image.md`](./executor-web-image.md) §1 and [`image-searcher.md`](./image-searcher.md) §7 for the attribution contract) |
|
||||
| **Rendered** | Deterministic formula PNG exists at expected path (`Acquire Via: formula`) | Reference from `../images/`; use a legal anchor with `meet` for the complete placement (centered default: `xMidYMid meet`) and do not crop |
|
||||
| **Needs-Manual** | Automatic acquisition is unavailable/exhausted or the confirmed path requires manual fulfillment; for `slice`, the parent sheet is unavailable | Dashed placeholder unless the user has supplied the expected file. For `slice` rows, supply the parent sheet and rerun `slice_images.py`; do not hand-place individual element files |
|
||||
| **Existing** | User already has image (`Acquire Via: user`) | Place in `images/`, reference with `<image>` |
|
||||
@@ -149,14 +149,15 @@ aliases only when invoked through `finalize_svg.py --only`.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Image Optimization
|
||||
### Native PPTX Image Export
|
||||
|
||||
Compress before embedding to reduce file size:
|
||||
**Default — preserve unmodified image bytes**: `svg_to_pptx.py` uses `--image-sizing cap`. It keeps original bytes when an image needs neither resizing nor EXIF geometry normalization, and re-encodes only images that require one of those transformations. Use the explicit compact command only when a compact export is requested.
|
||||
|
||||
```bash
|
||||
convert input.png -quality 85 -resize 1920x1080\> output.png # ImageMagick
|
||||
pngquant --quality=65-80 input.png -o output.png # pngquant (recommended)
|
||||
```
|
||||
| Need | Command |
|
||||
|---|---|
|
||||
| Normal native export | `python3 scripts/svg_to_pptx.py <project_path>` |
|
||||
| Explicit compact export | `python3 scripts/svg_to_pptx.py <project_path> --image-sizing display --image-scale 2 --image-quality 85` |
|
||||
| Force original bytes | `python3 scripts/svg_to_pptx.py <project_path> --no-image-optimize` |
|
||||
|
||||
### File Organization
|
||||
|
||||
|
||||
+3
-3
@@ -9,19 +9,19 @@ Brutalist editorial newspaper. Wall-to-wall small type, irregular column widths,
|
||||
- Shape language: hard rectangles and ruled boxes; thick black borders / cell frames; visible column dividers. Corner radius `rx="0"` — never rounded.
|
||||
- Composition geometry: a masthead numeral or headline set so large it crosses column rules; one grid cell inverted to solid ink (light type on dark) as the focal cell; full-bleed rule bars slicing sections; a single rotated stamp-box breaking the grid at one deliberate point.
|
||||
- Decoration: the grid itself is the decoration — masthead bars, rule lines, boxed pull-quotes, halftone fills. No gradients, no soft cards, no shadows.
|
||||
- Whitespace: tight and deliberate — narrow margins, dense columns, a newspaper's packed rhythm. Density is the point; one or two breathing zones per page keep it readable, not airy.
|
||||
- Whitespace: tight and deliberate — narrow margins, dense columns, a newspaper's packed rhythm. Density is the point; retain enough breathing room for hierarchy and readability without imposing a per-page count.
|
||||
- Irregular multi-column layout (mixed column widths) over a uniform grid; asymmetry is intentional.
|
||||
|
||||
## 2. Typography character
|
||||
|
||||
- Three-family hard contrast: a heavy display sans for headlines (poster-black weight), a serif for column body, monospace for figures / data — the collision is the look.
|
||||
- Strong role contrast: a heavy display sans for headlines (poster-black weight), a serif character for column body, and monospace for figures / data can create the collision this style needs without imposing a family count.
|
||||
- Small body size, high density; strong size jump between masthead headline and body. Flush-left columns, tight leading.
|
||||
|
||||
> Families are chosen at confirmation `g`; this style asks for a display-black × serif-body × monospace-data *character*.
|
||||
|
||||
## 3. Using the deck's colors
|
||||
|
||||
- Near-monochrome: ink-dark structure and type on a paper-light field; a single spot accent appears rarely (a masthead rule, one key figure, a stamp) — a few percent of canvas at most.
|
||||
- Near-monochrome: ink-dark structure and type on a paper-light field; use a localized spot accent as punctuation, such as a masthead rule, key figure, or stamp.
|
||||
- Color as punctuation, not fill. No color blocking, no gradients — the accent earns attention by scarcity.
|
||||
|
||||
> HEX values come from confirmation `e`; this style only governs how sparingly the accent is used — it names no colors.
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ Dark canvas, luminous accents, geometric precision. For tech, AI, dev tools, dat
|
||||
|
||||
## 1. Shape & decoration
|
||||
|
||||
- Shape language: crisp geometry; thin glowing rules; hexagon / circuit / grid motifs used sparingly. Slight rounding (`rx` 4-8) or sharp.
|
||||
- Shape language: crisp geometry; thin glowing rules; hexagon / circuit / grid motifs used sparingly. Corners may be sharp or only slightly rounded.
|
||||
- Composition geometry: a glowing diagonal circuit trace splitting the field; concentric orbit rings staging a central metric; a hexagon node cluster mapping components; an oversized low-opacity numeral or glyph floating behind the content layer; thin bracket frames zoning the page.
|
||||
- Decoration: glow accents, fine grid backgrounds, monospace labels, node / connector lines. Restrained — precision over clutter.
|
||||
- Whitespace: dark negative space reads as depth; let elements float on it.
|
||||
@@ -20,8 +20,8 @@ Dark canvas, luminous accents, geometric precision. For tech, AI, dev tools, dat
|
||||
|
||||
## 3. Using the deck's colors
|
||||
|
||||
- Dark background; one or two luminous accents carry focus (glowing figures, active nodes); everything else low-key.
|
||||
- The accent does the work of attention — few points, high contrast.
|
||||
- Dark background; a restrained set of luminous accents carries focus through glowing figures or active nodes while everything else stays low-key.
|
||||
- The accent does the work of attention through localized, high-contrast use rather than a fixed point count.
|
||||
|
||||
> HEX values come from confirmation `e`; this style only governs the dark-field, luminous-accent discipline — it names no colors.
|
||||
|
||||
|
||||
+3
-3
@@ -6,8 +6,8 @@ Magazine-grade hierarchy. Columns, hairline rules, a serif / sans interplay, str
|
||||
|
||||
## 1. Shape & decoration
|
||||
|
||||
- Shape language: rectilinear; thin rules and column dividers instead of cards. Minimal rounding (`rx` 0-4).
|
||||
- Composition geometry: an oversized drop cap or numeral anchoring the page; a pull quote breaking across two columns; a full-height vertical rule the content hangs from; an asymmetric 2:1 column split instead of even columns; one figure deliberately crossing a column edge.
|
||||
- Shape language: rectilinear; thin rules and column dividers instead of cards. Rounding, when used, stays visually minimal.
|
||||
- Composition geometry: an oversized drop cap or numeral anchoring the page; a pull quote breaking across columns; a full-height vertical rule the content hangs from; an asymmetric column split instead of mechanically even columns; a figure deliberately crossing a column edge.
|
||||
- Decoration: hairline rules, kickers / eyebrows, pull quotes, drop-style emphasis — typographic, not graphic. Sparing.
|
||||
- Whitespace: structured by columns and baseline rhythm; comfortable but information-rich.
|
||||
- Multi-column text flow where content suits.
|
||||
@@ -21,7 +21,7 @@ Magazine-grade hierarchy. Columns, hairline rules, a serif / sans interplay, str
|
||||
|
||||
## 3. Using the deck's colors
|
||||
|
||||
- Mostly monochrome text on a light field; one accent for emphasis (a rule, a highlighted figure, a kicker).
|
||||
- Mostly monochrome text on a light field; a restrained accent may emphasize a rule, highlighted figure, or kicker.
|
||||
- Restraint — color marks structure and emphasis, not decoration.
|
||||
|
||||
> HEX values come from confirmation `e`; this style only governs the monochrome-with-structural-accent discipline — it names no colors.
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ Frosted-glass SaaS — translucent layered panels, flowing gradient light, float
|
||||
|
||||
## 1. Shape & decoration
|
||||
|
||||
- Shape language: rounded translucent glass panels (low fill-opacity over the dark field) with bright hairline edges; layered, floating cards that imply blur and frost; rounded corners (`rx` 12-20).
|
||||
- Shape language: visibly rounded translucent glass panels (low fill-opacity over the dark field) with bright hairline edges; layered, floating cards that imply blur and frost.
|
||||
- Composition geometry: one hero glass panel set off-axis over a radial bloom; overlapping translucent discs building the focal cluster; a large glass ring encircling the key metric; panels stepped in depth to imply sequence; a diagonal light beam crossing the dark field.
|
||||
- Decoration: soft radial light blooms in the background; thin luminous edge highlights along panels; restrained — the glass material is the decoration, not added ornament. Realize the radial bloom / glow halo as a `<circle>` / `<ellipse>` with a `<radialGradient>` fill, never a `rect rx=w/2` standing in for it.
|
||||
- Whitespace: dark negative space reads as depth; let panels float on it with room to breathe.
|
||||
@@ -19,7 +19,7 @@ Frosted-glass SaaS — translucent layered panels, flowing gradient light, float
|
||||
|
||||
## 3. Using the deck's colors
|
||||
|
||||
- Dark field; the deck's colors read as luminous gradients flowing across panels and titles, low-opacity glass tints, and a neon accent at ~10%. Color behaves like light through glass, not flat fill.
|
||||
- Dark field; the deck's colors read as luminous gradients flowing across panels and titles, low-opacity glass tints, and a subordinate neon accent. Color behaves like light through glass, not flat fill.
|
||||
- Depth and hierarchy come from how brightly the glass glows, not from heavy saturation.
|
||||
|
||||
> HEX values come from confirmation `e`; this style only governs the translucent-glass, luminous-gradient discipline — it names no colors.
|
||||
|
||||
+2
-2
@@ -20,8 +20,8 @@ Whiteboard-ink minimalism — a pale field, confident black hand-ink line work,
|
||||
|
||||
## 3. Using the deck's colors
|
||||
|
||||
- Near-monochrome: ink-dark line work on a pale field does ~85% of the work; the deck's accent appears only as a semantic mark (risk / positive / highlight) under ~10% of canvas.
|
||||
- Color carries meaning, not decoration — one or two accents, used where they signify.
|
||||
- Near-monochrome: ink-dark line work on a pale field carries the composition; the deck's accent remains a sparse semantic mark for risk, positive state, or emphasis.
|
||||
- Color carries meaning, not decoration; use only the accents that signify something on the current page.
|
||||
|
||||
> HEX values come from confirmation `e`; this style only governs the monochrome-with-semantic-accent discipline — it names no colors.
|
||||
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ Memphis / Pop — clashing color blocks, geometric confetti, bold outlines, 80s-
|
||||
|
||||
## 1. Shape & decoration
|
||||
|
||||
- Shape language: bold geometric primitives — circles, triangles, zigzags, squiggles, blobs — with thick dark outlines (2-4px). Mixed corner radii allowed; playful inconsistency is on-brand.
|
||||
- Shape language: bold geometric primitives — circles, triangles, zigzags, squiggles, blobs — with thick dark outlines. Mixed corner radii allowed; playful inconsistency is on-brand.
|
||||
- Composition geometry: a giant primitive bleeding off one edge as the backdrop; a diagonal two-color field split; a zigzag or squiggle band as the section divider; props scattered at angles around one dominant rotated frame or badge at the focal point.
|
||||
- Decoration: scattered geometric confetti, color-block backings, pattern fills (dots / stripes), oversized punctuation. Generous decoration — but composed, not chaotic.
|
||||
- Whitespace: energetic asymmetry; props float at angles. Still leave the focal content room to read against the noise.
|
||||
@@ -20,7 +20,7 @@ Memphis / Pop — clashing color blocks, geometric confetti, bold outlines, 80s-
|
||||
|
||||
## 3. Using the deck's colors
|
||||
|
||||
- Multi-accent clash is the signature — but bounded: the clashing colors stay a minority of canvas (≈40% cap), and any one page fronts only two or three of them, not the whole set.
|
||||
- Multi-accent clash is the signature, but the light field and dark outlines remain visually dominant. Front a curated subset of the deck accents on each page instead of displaying the whole set by quota.
|
||||
- A light field carries the noise; dark outlines anchor every shape so the clash reads as composed, not muddy. Disciplined exuberance, never rainbow soup.
|
||||
|
||||
> HEX values come from confirmation `e`; this style governs how many accents appear and how boldly — it names no colors.
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ Layered paper-craft — scissor-cut shapes stacked in tactile layers, soft shado
|
||||
|
||||
## 4. Texture / elevation
|
||||
|
||||
- Real layered depth — a soft 8-12% drop shadow under each cut layer is core here (the one style where layered shadow is the point, not a violation). Matte paper grain on each sheet.
|
||||
- Real layered depth — a soft, low-opacity drop shadow under cut layers is core here (the style where layered shadow is the point, not a violation). Matte paper grain on each sheet.
|
||||
|
||||
## 5. Paired image-rendering
|
||||
|
||||
|
||||
+2
-2
@@ -6,7 +6,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.
|
||||
- Shape language: visibly rounded rectangles, pill tags, and soft containers. Keep the radius family coherent deck-wide.
|
||||
- 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.
|
||||
@@ -27,7 +27,7 @@ Approachable and modern. Rounded cards, gentle elevation, friendly rhythm. For p
|
||||
|
||||
## 4. Texture / elevation
|
||||
|
||||
- Gentle elevation: soft shadows on floating cards (resting tier), subtle tints, optional same-hue gradients. Two-tier elevation max; keep peer-grid cards flat.
|
||||
- Gentle elevation: soft shadows on floating cards, subtle tints, and optional same-hue gradients. Keep the elevation hierarchy shallow and coherent; peer-grid cards stay flat.
|
||||
|
||||
## 5. Paired image-rendering
|
||||
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ Strict Swiss-grid discipline. Modular grid, sharp geometry, aggressive whitespac
|
||||
|
||||
## 1. Shape & decoration
|
||||
|
||||
- Shape language: sharp rectangles, true circles, single-weight rules. Corner radius `rx="0"` by default; if rounding at all, ≤4.
|
||||
- Shape language: sharp rectangles, true circles, single-weight rules. Use square corners by default; any rounding stays barely perceptible.
|
||||
- Composition geometry: one oversized geometric plane — a full-height color column, a giant disc, a heavy bar — zoning the page; an asymmetric split with content flush to one axis; a hero numeral at architectural scale; a single diagonal rule or type line as the deliberate grid break. Few, large, exact — geometry at poster scale is Swiss, clutter is not.
|
||||
- Decoration: none. No gradient fills, no decorative blocks, no badges — structure carries the page.
|
||||
- Whitespace: vast and deliberate; negative space carries as much weight as content. Wide margins, generous gutters.
|
||||
@@ -21,7 +21,7 @@ Strict Swiss-grid discipline. Modular grid, sharp geometry, aggressive whitespac
|
||||
|
||||
## 3. Using the deck's colors
|
||||
|
||||
- One color dominates a deliberate grid zone; the field stays near-white; the accent appears at a single point — never more than a few percent of canvas.
|
||||
- A dominant color may define a deliberate grid zone while the field stays near-white; keep accent color localized enough that it reads as punctuation rather than decoration.
|
||||
- Color as conceptual zone, not decoration. No gradients.
|
||||
|
||||
> HEX values come from confirmation `e`; this style only governs how sparingly they are applied — it names no colors.
|
||||
|
||||
@@ -8,7 +8,7 @@ Risograph zine / DIY poster — misregistered color layers, halftone dots, a tig
|
||||
|
||||
- Shape language: cut-and-paste blocks, offset color shapes, rough frames; outlines in a near-black ink tone. Corner radius low or zero — print-flat, not soft-digital.
|
||||
- Composition geometry: rotated pasted-on blocks (slight skew) building the collage stage; a torn-strip band as the section divider; an oversized halftone shape bleeding behind the content; a photocopy frame or taped corner anchoring the focal block; column scraps at mixed widths.
|
||||
- Decoration: the riso print artifacts — 1-3px color-layer misregistration, halftone-dot `<pattern>` texture, overlapping color blocks that imply a third color where they cross. Texture is the decoration.
|
||||
- Decoration: the riso print artifacts — subtle visible color-layer misregistration, halftone-dot `<pattern>` texture, overlapping color blocks that imply another color where they cross. Texture is the decoration.
|
||||
- Whitespace: poster-like — bold focal blocks with raw margins; deliberate roughness over clean alignment.
|
||||
|
||||
## 2. Typography character
|
||||
@@ -20,8 +20,8 @@ Risograph zine / DIY poster — misregistered color layers, halftone dots, a tig
|
||||
|
||||
## 3. Using the deck's colors
|
||||
|
||||
- A strictly limited spot palette (riso logic) on a warm paper field; two ink layers do most of the work and a third spot color appears rarely (<5%).
|
||||
- Color is laid as flat spot fills, not gradients; overlap and offset of the same few inks create depth and the third-color illusion. Scarcity and overlap, not variety.
|
||||
- A tightly limited spot palette (riso logic) sits on a warm paper field; a dominant ink pairing does most of the work and any additional spot color appears selectively.
|
||||
- Color is laid as flat spot fills, not gradients; overlap and offset of the same small ink set create depth and extra-color illusions. Scarcity and overlap, not variety.
|
||||
|
||||
> HEX values come from confirmation `e`; this style governs the flat-spot, misregistered-overlay discipline — it names no colors.
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ python3 scripts/update_repo.py
|
||||
| PPTX animations | `pptx_animations.py`, `animation_config.py` | [docs/pptx-animations.md](./docs/pptx-animations.md) |
|
||||
| Spec maintenance | `update_spec.py`, `chart_recall.py` | [docs/update_spec.md](./docs/update_spec.md); [docs/chart-recall.md](./docs/chart-recall.md) |
|
||||
| Image tools | `image_gen.py`, `latex_render.py`, `analyze_images.py`, `gemini_watermark_remover.py` | [docs/image.md](./docs/image.md) |
|
||||
| Maintenance smokes | Inline temporary-project commands | [advanced image and motion](./docs/advanced-image-motion-smoke.md); [mask and gradient](./docs/mask-gradient-smoke.md); [multilingual text](./docs/multilingual-text-smoke.md) |
|
||||
| Repo maintenance | `update_repo.py` | README install/update section |
|
||||
| Troubleshooting | validation, preview, export, dependency issues | [docs/troubleshooting.md](./docs/troubleshooting.md) |
|
||||
|
||||
@@ -174,7 +175,7 @@ python3 scripts/template_fill_pptx.py apply <project_path>/sources/<source.pptx>
|
||||
python3 scripts/template_fill_pptx.py validate <project_path>
|
||||
```
|
||||
|
||||
`apply` requires `fill_plan.json` to have top-level `"status": "confirmed"` unless `--force` is passed. It automatically writes `filled_YYYYMMDD_HHMMSS.pptx` unless the output stem already ends with a timestamp. It applies a `fade` page transition by default; `--transition <effect>` accepts a canonical effect in the shared native gallery registry documented by [`docs/pptx-transitions.md`](docs/pptx-transitions.md), while old names remain accepted only as compatibility inputs, and `--transition-duration <seconds>` changes its duration. `--transition none` removes the visual effect, `--transition keep` preserves the source transitions, and a per-slide `transition` field in the plan overrides whatever the CLI selects. The object form accepts effect-specific native `effect_options`.
|
||||
`apply` requires `fill_plan.json` to have top-level `"status": "confirmed"` unless `--force` is passed. It automatically writes `filled_YYYYMMDD_HHMMSS.pptx` unless the output stem already ends with a timestamp. It preserves source page transitions by default; `--transition <effect>` accepts a canonical effect in the shared native gallery registry documented by [`docs/pptx-transitions.md`](docs/pptx-transitions.md), while old names remain accepted only as compatibility inputs, and `--transition-duration <seconds>` changes a replacement effect's duration. `--transition none` removes the visual effect, `--transition keep` states the preservation policy explicitly, and a per-slide `transition` field in the plan overrides whatever the CLI selects. The object form accepts effect-specific native `effect_options`.
|
||||
|
||||
Native existing-PPTX enhancement (direct PPTX, no SVG conversion):
|
||||
|
||||
@@ -250,12 +251,17 @@ the existing references without progressively wrapping more parent geometry.
|
||||
Post-processing and export:
|
||||
|
||||
```bash
|
||||
# Run only when the Design Spec's effective Speaker Notes outcome is enabled.
|
||||
python3 scripts/total_md_split.py <project_path>
|
||||
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`: 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.
|
||||
When Speaker Notes is disabled, skip `total_md_split.py` and append
|
||||
`--no-notes` to `svg_to_pptx.py` so stale files under `notes/` cannot be
|
||||
embedded.
|
||||
|
||||
`finalize_svg.py` optimizes ordinary raster images by default using `2x` display pixels and max `2560px`; validated nested crop transports retain source pixel dimensions because their inner `1×1` image is source-unit geometry rather than a rendered-pixel budget. Native `svg_to_pptx.py` defaults to `--image-sizing cap`: images that need neither resizing nor EXIF geometry normalization retain their original bytes, while oversized single-frame raster sources are re-encoded after resizing toward `2560px`. 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 --image-quality 85` for an explicit compact export, or `--no-image-optimize` to force 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.
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
validate = subparsers.add_parser(
|
||||
'validate',
|
||||
help='validate animations.json references against svg_output/*.svg',
|
||||
help='validate animations.json values and project-local references',
|
||||
)
|
||||
validate.add_argument('project_path', help='Project directory')
|
||||
validate.add_argument('-c', '--config', default=None, help='Config path; default: <project>/animations.json')
|
||||
@@ -115,7 +115,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
except Exception as exc:
|
||||
print(f'Error: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
if not config:
|
||||
if config is None:
|
||||
print('No animations.json found; default animation policy will be used.')
|
||||
return 0
|
||||
errors = list(dict.fromkeys(
|
||||
|
||||
@@ -296,7 +296,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
if not directories:
|
||||
parser.print_help()
|
||||
return 0
|
||||
print(
|
||||
"\n[ERROR] Provide at least one directory or pass --all.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Validate each directory
|
||||
for directory in directories:
|
||||
@@ -305,6 +309,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
else:
|
||||
print(f"[WARN] Skipping non-existent directory: {directory}\n")
|
||||
|
||||
if validator.summary['total'] == 0:
|
||||
print(
|
||||
"[ERROR] No projects were found in the requested directories.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Print summary
|
||||
validator.print_summary()
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ Examples:
|
||||
python3 scripts/confirm_ui/server.py projects/my-project
|
||||
python3 scripts/confirm_ui/server.py projects/my-project --port 5051
|
||||
python3 scripts/confirm_ui/server.py projects/my-project --no-browser
|
||||
python3 scripts/confirm_ui/server.py projects/my-project --daemon --wait
|
||||
python3 scripts/confirm_ui/server.py projects/my-project --daemon
|
||||
python3 scripts/confirm_ui/server.py projects/my-project --wait-only --wait-stage stage1
|
||||
|
||||
Dependencies:
|
||||
flask>=3.0.0
|
||||
@@ -54,6 +55,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 language_tags import ( # noqa: E402
|
||||
LanguageTagError,
|
||||
language_base,
|
||||
normalize_language_tag,
|
||||
)
|
||||
from server_common import ( # noqa: E402
|
||||
claim_lock as _claim_lock,
|
||||
clear_lock as _clear_lock,
|
||||
@@ -464,7 +470,8 @@ def _stage_skip_error(confirm_dir: Path) -> Optional[str]:
|
||||
return None
|
||||
expected = _stage_name(_result_stage_number(result_stage) + 1)
|
||||
reattach = (
|
||||
'--daemon --wait' if expected == 'stage1'
|
||||
'--wait-only --wait-stage stage1'
|
||||
if expected == 'stage1'
|
||||
else '--wait-only --wait-stage stage2'
|
||||
)
|
||||
expected_file = RECOMMENDATION_STAGE_NAMES[
|
||||
@@ -550,17 +557,99 @@ def _positive_number(value: object) -> bool:
|
||||
return number > 0 and number != float('inf')
|
||||
|
||||
|
||||
def _typography_error(typography: object, label: str, *, require_sizes: bool) -> Optional[str]:
|
||||
def _is_english_language(language: object) -> bool:
|
||||
"""Return whether a recommendation language is an English locale."""
|
||||
if not isinstance(language, str):
|
||||
return False
|
||||
try:
|
||||
return language_base(language) == 'en'
|
||||
except LanguageTagError:
|
||||
return False
|
||||
|
||||
|
||||
def _recommendation_language(recommendations: dict) -> object:
|
||||
"""Return the deck's main language without conflating it with UI ``lang``."""
|
||||
value = (
|
||||
recommendations.get('primary_language')
|
||||
or recommendations.get('content_language')
|
||||
or recommendations.get('language')
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
return value.get('value') or value.get('id') or value.get('code') or ''
|
||||
return value
|
||||
|
||||
|
||||
def _primary_language_error(recommendations: dict) -> Optional[str]:
|
||||
"""Require and canonicalize the staged content-language source of truth."""
|
||||
return _canonicalize_primary_language(recommendations, required=True)
|
||||
|
||||
|
||||
def _canonicalize_primary_language(
|
||||
recommendations: dict,
|
||||
*,
|
||||
required: bool,
|
||||
) -> Optional[str]:
|
||||
"""Write a canonical primary language into one recommendation/result object."""
|
||||
value = _recommendation_language(recommendations)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
if required:
|
||||
return (
|
||||
'Stage 1 recommendations must declare a valid primary_language '
|
||||
'BCP-47 tag; lang controls only the Confirm UI language'
|
||||
)
|
||||
return None
|
||||
try:
|
||||
recommendations['primary_language'] = normalize_language_tag(value)
|
||||
except LanguageTagError as exc:
|
||||
return f'invalid primary_language: {exc}'
|
||||
return None
|
||||
|
||||
|
||||
def _typography_font_value(
|
||||
font: dict,
|
||||
field: str,
|
||||
*,
|
||||
english_primary: bool,
|
||||
) -> object:
|
||||
"""Return a canonical typography font value, accepting its language-aware alias."""
|
||||
legacy_field = 'latin' if field == 'english' or english_primary else 'cjk'
|
||||
value = font.get(field)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
value = font.get(legacy_field)
|
||||
return value
|
||||
|
||||
|
||||
def _typography_error(
|
||||
typography: object,
|
||||
label: str,
|
||||
*,
|
||||
require_sizes: bool,
|
||||
main_language: object = '',
|
||||
) -> Optional[str]:
|
||||
"""Validate one complete user-facing typography recommendation or choice."""
|
||||
if not isinstance(typography, dict):
|
||||
return f'{label} must be an object'
|
||||
english_primary = _is_english_language(main_language)
|
||||
for role in ('heading', 'body'):
|
||||
font = typography.get(role)
|
||||
if not isinstance(font, dict):
|
||||
return f'{label}.{role} must be an object'
|
||||
for field in ('cjk', 'latin', 'css'):
|
||||
if not isinstance(font.get(field), str) or not font[field].strip():
|
||||
return f'{label}.{role}.{field} must be non-empty'
|
||||
fields = (('primary', 'latin' if english_primary else 'cjk'),)
|
||||
if not english_primary:
|
||||
fields += (('english', 'latin'),)
|
||||
for field, legacy_field in fields:
|
||||
value = _typography_font_value(
|
||||
font,
|
||||
field,
|
||||
english_primary=english_primary,
|
||||
)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return (
|
||||
f'{label}.{role}.{field} '
|
||||
f'(or legacy {legacy_field}) must be non-empty'
|
||||
)
|
||||
if not isinstance(font.get('css'), str) or not font['css'].strip():
|
||||
return f'{label}.{role}.css must be non-empty'
|
||||
if not _positive_number(typography.get('body_size')):
|
||||
return f'{label}.body_size must be a positive number'
|
||||
if not require_sizes:
|
||||
@@ -574,6 +663,72 @@ def _typography_error(typography: object, label: str, *, require_sizes: bool) ->
|
||||
return None
|
||||
|
||||
|
||||
def _typography_signature(
|
||||
typography: dict,
|
||||
*,
|
||||
main_language: object,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return the language-relevant font choices that distinguish one candidate."""
|
||||
english_primary = _is_english_language(main_language)
|
||||
fields = ('primary',) if english_primary else ('primary', 'english')
|
||||
values = []
|
||||
for role in ('heading', 'body'):
|
||||
font = typography[role]
|
||||
for field in fields:
|
||||
value = _typography_font_value(
|
||||
font,
|
||||
field,
|
||||
english_primary=english_primary,
|
||||
)
|
||||
values.append(str(value).strip().casefold())
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def _typography_candidates_distinct_error(
|
||||
candidates: list,
|
||||
labels: list[str],
|
||||
*,
|
||||
main_language: object,
|
||||
) -> Optional[str]:
|
||||
"""Require every candidate to offer a different relevant font combination."""
|
||||
fixed = [
|
||||
isinstance(candidate, dict) and candidate.get('fixed') is True
|
||||
for candidate in candidates
|
||||
]
|
||||
if any(fixed):
|
||||
if not all(fixed):
|
||||
return 'typography.fixed must be true on every candidate or omitted'
|
||||
signatures = [
|
||||
_typography_signature(
|
||||
candidate,
|
||||
main_language=main_language,
|
||||
)
|
||||
for candidate in candidates
|
||||
]
|
||||
if any(signature != signatures[0] for signature in signatures[1:]):
|
||||
return 'fixed typography candidates must repeat the same font combination'
|
||||
return None
|
||||
seen = {}
|
||||
for index, candidate in enumerate(candidates):
|
||||
signature = _typography_signature(
|
||||
candidate,
|
||||
main_language=main_language,
|
||||
)
|
||||
if signature in seen:
|
||||
previous = seen[signature]
|
||||
combination = (
|
||||
'heading/body primary'
|
||||
if _is_english_language(main_language)
|
||||
else 'heading/body primary+english'
|
||||
)
|
||||
return (
|
||||
f'{labels[index]} repeats {labels[previous]}; '
|
||||
f'{combination} combinations must differ'
|
||||
)
|
||||
seen[signature] = index
|
||||
return None
|
||||
|
||||
|
||||
def _candidate_list(spec: object) -> list:
|
||||
"""Return candidates from the current or legacy recommendation shape."""
|
||||
if not isinstance(spec, dict):
|
||||
@@ -584,13 +739,19 @@ def _candidate_list(spec: object) -> list:
|
||||
return candidates if isinstance(candidates, list) else []
|
||||
|
||||
|
||||
def _stage2_design_directions_error(recommendations: dict) -> Optional[str]:
|
||||
def _stage2_design_directions_error(
|
||||
recommendations: dict,
|
||||
*,
|
||||
main_language: object = '',
|
||||
) -> Optional[str]:
|
||||
"""Require three complete coordinated Stage 2 design systems."""
|
||||
main_language = main_language or _recommendation_language(recommendations)
|
||||
directions = recommendations.get('design_directions')
|
||||
if isinstance(directions, dict):
|
||||
candidates = _candidate_list(directions)
|
||||
if len(candidates) < 3:
|
||||
return 'Stage 2 design_directions must include at least 3 candidates'
|
||||
typography_candidates = []
|
||||
for index, candidate in enumerate(candidates, start=1):
|
||||
label = f'design_directions.candidates[{index - 1}]'
|
||||
if not isinstance(candidate, dict):
|
||||
@@ -607,16 +768,25 @@ def _stage2_design_directions_error(recommendations: dict) -> Optional[str]:
|
||||
candidate.get('typography'),
|
||||
f'{label}.typography',
|
||||
require_sizes=False,
|
||||
main_language=main_language,
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
typography_candidates.append(candidate['typography'])
|
||||
if _uses_ai_images(recommendations):
|
||||
image_strategy = candidate.get('image_strategy')
|
||||
if not isinstance(image_strategy, dict) or not str(
|
||||
image_strategy.get('rendering') or ''
|
||||
).strip():
|
||||
return f'{label}.image_strategy.rendering must be non-empty'
|
||||
return None
|
||||
return _typography_candidates_distinct_error(
|
||||
typography_candidates,
|
||||
[
|
||||
f'design_directions.candidates[{index}].typography'
|
||||
for index in range(len(typography_candidates))
|
||||
],
|
||||
main_language=main_language,
|
||||
)
|
||||
|
||||
# Legacy staged files remain readable, but they must still provide three
|
||||
# complete color combinations and at least one complete typography choice.
|
||||
@@ -630,14 +800,26 @@ def _stage2_design_directions_error(recommendations: dict) -> Optional[str]:
|
||||
typography = _candidate_list(recommendations.get('typography'))
|
||||
if not typography:
|
||||
return 'Stage 2 recommendations must include typography candidates'
|
||||
if main_language and len(typography) < 3:
|
||||
return 'Stage 2 recommendations must include 3 typography candidates'
|
||||
for index, candidate in enumerate(typography):
|
||||
error = _typography_error(
|
||||
candidate,
|
||||
f'typography.candidates[{index}]',
|
||||
require_sizes=False,
|
||||
main_language=main_language,
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
if main_language:
|
||||
return _typography_candidates_distinct_error(
|
||||
typography,
|
||||
[
|
||||
f'typography.candidates[{index}]'
|
||||
for index in range(len(typography))
|
||||
],
|
||||
main_language=main_language,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -701,7 +883,28 @@ def _submission_stage_error(
|
||||
return 'legacy single-pass recommendations accept only a final submission'
|
||||
return None
|
||||
|
||||
if rec_stage_number == 1:
|
||||
language_error = _primary_language_error(recommendations)
|
||||
if language_error:
|
||||
return language_error
|
||||
|
||||
if rec_stage_number == 2:
|
||||
try:
|
||||
previous_result = _read_json_object(confirm_dir / RESULT_NAME)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
previous_result = {}
|
||||
language_source = (
|
||||
previous_result
|
||||
if _recommendation_language(previous_result)
|
||||
else recommendations
|
||||
)
|
||||
language_error = _canonicalize_primary_language(
|
||||
language_source,
|
||||
required=True,
|
||||
)
|
||||
if language_error:
|
||||
return language_error
|
||||
main_language = _recommendation_language(language_source)
|
||||
recommendation_error = _template_stage2_error(
|
||||
recommendations,
|
||||
template_required=template_required,
|
||||
@@ -711,7 +914,10 @@ def _submission_stage_error(
|
||||
recommendation_error = _stage2_custom_candidates_error(recommendations)
|
||||
if recommendation_error:
|
||||
return recommendation_error
|
||||
recommendation_error = _stage2_design_directions_error(recommendations)
|
||||
recommendation_error = _stage2_design_directions_error(
|
||||
recommendations,
|
||||
main_language=main_language,
|
||||
)
|
||||
if recommendation_error:
|
||||
return recommendation_error
|
||||
|
||||
@@ -762,7 +968,11 @@ def _custom_selection_error(result: dict) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _stage2_solution_error(result: dict) -> Optional[str]:
|
||||
def _stage2_solution_error(
|
||||
result: dict,
|
||||
*,
|
||||
main_language: object = '',
|
||||
) -> Optional[str]:
|
||||
"""Reject a Stage 2/final payload with an incomplete design system."""
|
||||
color = result.get('color')
|
||||
color_error = _palette_error(color, 'color')
|
||||
@@ -779,19 +989,9 @@ def _stage2_solution_error(result: dict) -> Optional[str]:
|
||||
typography,
|
||||
'typography',
|
||||
require_sizes=True,
|
||||
main_language=main_language,
|
||||
)
|
||||
typography_custom = (
|
||||
isinstance(typography, dict)
|
||||
and typography.get('name') == 'custom'
|
||||
and str(typography.get('custom') or '').strip()
|
||||
and _positive_number(typography.get('body_size'))
|
||||
and isinstance(typography.get('sizes'), dict)
|
||||
and all(
|
||||
_positive_number(typography['sizes'].get(role))
|
||||
for role in _TYPOGRAPHY_SIZE_ROLES
|
||||
)
|
||||
)
|
||||
if typography_error and not typography_custom:
|
||||
if typography_error:
|
||||
return typography_error
|
||||
return None
|
||||
|
||||
@@ -971,9 +1171,49 @@ _PRODUCTION_RECOMMEND_KEYS = (
|
||||
'image_ai_path',
|
||||
'generation_mode',
|
||||
)
|
||||
_PROACTIVE_EXECUTION_DEFAULTS = {
|
||||
'proactive_speaker_notes': True,
|
||||
'proactive_custom_animations': False,
|
||||
'proactive_narration_audio': False,
|
||||
}
|
||||
_LOCKED_RECOMMENDATIONS_KEY = '_locked_recommendations'
|
||||
|
||||
|
||||
def _resolve_proactive_execution_values(
|
||||
source: dict,
|
||||
) -> tuple[dict[str, bool], Optional[str]]:
|
||||
"""Resolve proactive-execution booleans with backward-compatible defaults."""
|
||||
values = {}
|
||||
for key, default in _PROACTIVE_EXECUTION_DEFAULTS.items():
|
||||
if key not in source:
|
||||
values[key] = default
|
||||
continue
|
||||
raw_value = source[key]
|
||||
if isinstance(raw_value, dict):
|
||||
if 'value' not in raw_value or not isinstance(raw_value['value'], bool):
|
||||
return {}, f'{key}.value must be a boolean'
|
||||
raw_value = raw_value['value']
|
||||
elif not isinstance(raw_value, bool):
|
||||
return {}, f'{key} must be a boolean'
|
||||
values[key] = raw_value
|
||||
return values, None
|
||||
|
||||
|
||||
def _normalize_proactive_execution_result(
|
||||
result: dict,
|
||||
defaults: dict[str, bool],
|
||||
) -> Optional[str]:
|
||||
"""Write the independent raw confirmation booleans to the final result."""
|
||||
values = {}
|
||||
for key, default in defaults.items():
|
||||
value = result.get(key, default)
|
||||
if not isinstance(value, bool):
|
||||
return f'{key} must be a boolean'
|
||||
values[key] = value
|
||||
result.update(values)
|
||||
return None
|
||||
|
||||
|
||||
def _merge_confirmed_choices(data: dict, result_file: Path) -> None:
|
||||
"""Fold already-confirmed choices into later-stage recommendations."""
|
||||
try:
|
||||
@@ -983,6 +1223,14 @@ def _merge_confirmed_choices(data: dict, result_file: Path) -> None:
|
||||
recommend = data.setdefault('recommend', {})
|
||||
if not isinstance(recommend, dict):
|
||||
recommend = data['recommend'] = {}
|
||||
main_language = _recommendation_language(res)
|
||||
if main_language:
|
||||
try:
|
||||
data['primary_language'] = normalize_language_tag(main_language)
|
||||
except LanguageTagError:
|
||||
# Keep the invalid legacy value visible to the API boundary below,
|
||||
# which returns a user-facing contract error instead of hiding it.
|
||||
data['primary_language'] = main_language
|
||||
for key in _CONTRACT_RECOMMEND_KEYS:
|
||||
if res.get(key) not in (None, ''):
|
||||
recommend[key] = res[key]
|
||||
@@ -1044,6 +1292,8 @@ def _merge_confirmed_choices(data: dict, result_file: Path) -> None:
|
||||
recommend[key] = res[key]
|
||||
if 'refine_spec' in res:
|
||||
data['refine_spec'] = {'value': bool(res.get('refine_spec'))}
|
||||
for key, default in _PROACTIVE_EXECUTION_DEFAULTS.items():
|
||||
data[key] = {'value': res.get(key, default)}
|
||||
|
||||
|
||||
def _apply_locked_recommendations(
|
||||
@@ -1062,6 +1312,8 @@ def _apply_locked_recommendations(
|
||||
previous_locks = previous.get(_LOCKED_RECOMMENDATIONS_KEY)
|
||||
if isinstance(previous_locks, dict):
|
||||
locked_values.update(previous_locks)
|
||||
for key in _PROACTIVE_EXECUTION_DEFAULTS:
|
||||
locked_values.pop(key, None)
|
||||
|
||||
try:
|
||||
recommendations = _read_json_object(recommendations_file)
|
||||
@@ -1078,10 +1330,12 @@ def _apply_locked_recommendations(
|
||||
):
|
||||
locked_values = {}
|
||||
for key, field in recommendations.items():
|
||||
if key in _PROACTIVE_EXECUTION_DEFAULTS:
|
||||
continue
|
||||
if not isinstance(field, dict) or field.get('locked') is not True:
|
||||
continue
|
||||
if 'value' in field:
|
||||
locked_values[key] = field.get('value') or ''
|
||||
locked_values[key] = field['value']
|
||||
for key, value in locked_values.items():
|
||||
result[key] = value
|
||||
return locked_values
|
||||
@@ -1095,7 +1349,7 @@ def _wait_only_for_result(
|
||||
) -> int:
|
||||
"""Attach to an already-running confirm server and wait for a target stage.
|
||||
|
||||
No child is launched here: the page is still open from the first ``--wait``
|
||||
No child is launched here: the page is open from the preceding ``--daemon``
|
||||
launch, so liveness is tracked via the recorded pid, not a ``proc`` handle.
|
||||
Only the stage guard is used (no mtime gate), because intermediate submits
|
||||
may happen before this wait command is issued.
|
||||
@@ -1444,6 +1698,18 @@ def create_app(
|
||||
rec_stage_number = _recommendation_stage(data)
|
||||
if rec_stage_number >= 2 and result_file.exists():
|
||||
_merge_confirmed_choices(data, result_file)
|
||||
if rec_stage_number > 0:
|
||||
language_error = _canonicalize_primary_language(
|
||||
data,
|
||||
required=True,
|
||||
)
|
||||
if language_error:
|
||||
return jsonify({'error': language_error}), 409
|
||||
else:
|
||||
# Legacy single-pass files remain permissive. Canonicalize only
|
||||
# aliases/tags the shared helper already understands; an old prose
|
||||
# value must never prevent the compatibility UI from opening.
|
||||
_canonicalize_primary_language(data, required=False)
|
||||
if rec_stage_number == 2:
|
||||
recommendation_error = _template_stage2_error(
|
||||
data,
|
||||
@@ -1460,6 +1726,14 @@ def create_app(
|
||||
recommendation_error = _stage2_design_directions_error(data)
|
||||
if recommendation_error:
|
||||
return jsonify({'error': recommendation_error}), 409
|
||||
if rec_stage_number in {0, 3}:
|
||||
proactive_values, proactive_error = (
|
||||
_resolve_proactive_execution_values(data)
|
||||
)
|
||||
if proactive_error:
|
||||
return jsonify({'error': proactive_error}), 409
|
||||
for key, value in proactive_values.items():
|
||||
data[key] = {'value': value}
|
||||
# Template application is authored by Strategist from the installed
|
||||
# workspace and current content. Never expose legacy mode fields as
|
||||
# user-facing confirmation controls.
|
||||
@@ -1505,16 +1779,67 @@ def create_app(
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
rec_file = _active_recommendations_path(confirm_dir)
|
||||
current_recommendations = {}
|
||||
if stage == 'stage2' or _recommendation_stage(current_recommendations) == 3:
|
||||
solution_error = _stage2_solution_error(result)
|
||||
rec_stage_number = _recommendation_stage(current_recommendations)
|
||||
previous_result = {}
|
||||
if rec_stage_number >= 2:
|
||||
try:
|
||||
previous_result = _read_json_object(result_file)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
main_language = None
|
||||
if rec_stage_number > 0:
|
||||
language_source = (
|
||||
previous_result
|
||||
if _recommendation_language(previous_result)
|
||||
else current_recommendations
|
||||
)
|
||||
if not _recommendation_language(language_source):
|
||||
language_source = result
|
||||
language_error = _canonicalize_primary_language(
|
||||
language_source,
|
||||
required=True,
|
||||
)
|
||||
if language_error:
|
||||
return jsonify({'error': language_error}), 409
|
||||
main_language = _recommendation_language(language_source)
|
||||
if main_language:
|
||||
result['primary_language'] = main_language
|
||||
else:
|
||||
result.pop('primary_language', None)
|
||||
if stage == 'stage2' or rec_stage_number == 3:
|
||||
solution_error = _stage2_solution_error(
|
||||
result,
|
||||
main_language=main_language,
|
||||
)
|
||||
if solution_error:
|
||||
return jsonify({'error': solution_error}), 400
|
||||
if stage not in {'stage1', 'stage2'}:
|
||||
proactive_defaults, proactive_recommendation_error = (
|
||||
_resolve_proactive_execution_values(current_recommendations)
|
||||
)
|
||||
if proactive_recommendation_error:
|
||||
return jsonify({
|
||||
'error': proactive_recommendation_error,
|
||||
}), 409
|
||||
proactive_result_error = _normalize_proactive_execution_result(
|
||||
result,
|
||||
proactive_defaults,
|
||||
)
|
||||
if proactive_result_error:
|
||||
return jsonify({'error': proactive_result_error}), 400
|
||||
_normalize_custom_selections(result)
|
||||
locked_values = _apply_locked_recommendations(
|
||||
result,
|
||||
rec_file,
|
||||
result_file,
|
||||
)
|
||||
if stage not in {'stage1', 'stage2'}:
|
||||
proactive_result_error = _normalize_proactive_execution_result(
|
||||
result,
|
||||
proactive_defaults,
|
||||
)
|
||||
if proactive_result_error:
|
||||
return jsonify({'error': proactive_result_error}), 400
|
||||
result.pop('template_reuse_scope', None)
|
||||
result.pop('template_adherence', None)
|
||||
# Staged flow: Stage 1 / Stage 2 submits record intermediate choices but do
|
||||
@@ -1569,13 +1894,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
'dead server on the recorded/default port so browser polling can resume.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--wait-stage', default='final', metavar='{stage2,final}',
|
||||
'--wait-stage', default='final', metavar='{stage1,stage2,final}',
|
||||
help='With --wait-only, wait for this result.json stage (default: final). '
|
||||
'Use stage2 for the direction handoff in the three-stage flow.',
|
||||
'Use stage1 after the initial daemon launch and chat handoff; use '
|
||||
'stage2 for the direction handoff.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--wait-timeout', type=int, default=WAIT_TIMEOUT_DEFAULT,
|
||||
help=f'Seconds the --wait parent blocks before returning (default: {WAIT_TIMEOUT_DEFAULT}; '
|
||||
help=f'Seconds the wait caller blocks before returning (default: {WAIT_TIMEOUT_DEFAULT}; '
|
||||
'0 = no limit). Kept under the caller\'s tool timeout; the detached server lives on.',
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -1606,8 +1932,8 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
logger.error('%s is not a directory', project_path)
|
||||
return 1
|
||||
wait_stage = _stage_key(args.wait_stage)
|
||||
if wait_stage not in {'stage2', 'final'}:
|
||||
logger.error('--wait-stage must be stage2 or final')
|
||||
if wait_stage not in {'stage1', 'stage2', 'final'}:
|
||||
logger.error('--wait-stage must be stage1, stage2, or final')
|
||||
return 2
|
||||
|
||||
# Step 4 cleanup: stop any lingering confirm server and exit. Independent of
|
||||
@@ -1615,7 +1941,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
if args.shutdown:
|
||||
return _shutdown_existing(project_path / LOCK_FILE_NAME)
|
||||
|
||||
# Staged wait: attach to the server launched by the first --wait and block
|
||||
# Staged wait: attach to the server launched by --daemon and block
|
||||
# until the page writes the requested intermediate or final result.json.
|
||||
if args.wait_only:
|
||||
lock_file = project_path / LOCK_FILE_NAME
|
||||
|
||||
+631
-109
File diff suppressed because it is too large
Load Diff
+819
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "Enumerable (finite) option universe for the confirm page. Served by Flask static at /static/catalogs.json; the front-end prefers /api/catalogs when the server provides it. These fields list ALL options and the AI marks one as recommended via the active recommendation stage's `recommend` object. Open/generative fields (color, typography, generated-image style) are NOT here — the AI authors >=3 candidates each for those (creative recommendations always offer real choice). 'canvas' mirrors scripts/config.py CANVAS_FORMATS — keep in sync. User-facing catalog text supports label_zh/label_en/label_ja, desc_zh/desc_en/desc_ja, group_zh/group_en/group_ja.",
|
||||
"_comment": "Enumerable (finite) option universe for the confirm page. Served by Flask static at /static/catalogs.json; the front-end prefers /api/catalogs when the server provides it. These fields list fixed options and the AI marks one as recommended via the active recommendation stage's `recommend` object. Color, coordinated typography recommendations, and generated-image style remain generative. `fonts` supplies the manual typography dropdowns outside those recommendations; users may still enter another installed font. 'canvas' mirrors scripts/config.py CANVAS_FORMATS — keep in sync. User-facing catalog text supports label_zh/label_en/label_ja, desc_zh/desc_en/desc_ja, group_zh/group_en/group_ja.",
|
||||
"canvas": [
|
||||
{
|
||||
"id": "ppt169",
|
||||
@@ -98,6 +98,824 @@
|
||||
"use_ja": "印刷/PDF出力"
|
||||
}
|
||||
],
|
||||
"fonts": [
|
||||
{
|
||||
"id": "Microsoft YaHei",
|
||||
"label": "Microsoft YaHei",
|
||||
"label_zh": "微软雅黑",
|
||||
"label_en": "Microsoft YaHei",
|
||||
"label_ja": "Microsoft YaHei",
|
||||
"locales": ["zh-CN", "zh-SG", "zh-Hans"],
|
||||
"scripts": ["Hans"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "DengXian",
|
||||
"label": "DengXian",
|
||||
"label_zh": "等线",
|
||||
"label_en": "DengXian",
|
||||
"label_ja": "DengXian",
|
||||
"locales": ["zh-CN", "zh-SG", "zh-Hans"],
|
||||
"scripts": ["Hans"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "STXihei",
|
||||
"label": "STXihei",
|
||||
"label_zh": "华文细黑",
|
||||
"label_en": "STXihei",
|
||||
"label_ja": "STXihei",
|
||||
"locales": ["zh-CN", "zh-SG", "zh-Hans"],
|
||||
"scripts": ["Hans"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-cloud"
|
||||
},
|
||||
{
|
||||
"id": "SimHei",
|
||||
"label": "SimHei",
|
||||
"label_zh": "黑体",
|
||||
"label_en": "SimHei",
|
||||
"label_ja": "SimHei",
|
||||
"locales": ["zh-CN", "zh-SG", "zh-Hans"],
|
||||
"scripts": ["Hans"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "SimSun",
|
||||
"label": "SimSun",
|
||||
"label_zh": "宋体",
|
||||
"label_en": "SimSun",
|
||||
"label_ja": "SimSun",
|
||||
"locales": ["zh-CN", "zh-SG", "zh-Hans"],
|
||||
"scripts": ["Hans"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "NSimSun",
|
||||
"label": "NSimSun",
|
||||
"label_zh": "新宋体",
|
||||
"label_en": "NSimSun",
|
||||
"label_ja": "NSimSun",
|
||||
"locales": ["zh-CN", "zh-SG", "zh-Hans"],
|
||||
"scripts": ["Hans"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "STZhongsong",
|
||||
"label": "STZhongsong",
|
||||
"label_zh": "华文中宋",
|
||||
"label_en": "STZhongsong",
|
||||
"label_ja": "STZhongsong",
|
||||
"locales": ["zh-CN", "zh-SG", "zh-Hans"],
|
||||
"scripts": ["Hans"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "office-cloud"
|
||||
},
|
||||
{
|
||||
"id": "FangSong",
|
||||
"label": "FangSong",
|
||||
"label_zh": "仿宋",
|
||||
"label_en": "FangSong",
|
||||
"label_ja": "FangSong",
|
||||
"locales": ["zh-CN", "zh-SG", "zh-Hans"],
|
||||
"scripts": ["Hans"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "STFangsong",
|
||||
"label": "STFangsong",
|
||||
"label_zh": "华文仿宋",
|
||||
"label_en": "STFangsong",
|
||||
"label_ja": "STFangsong",
|
||||
"locales": ["zh-CN", "zh-SG", "zh-Hans"],
|
||||
"scripts": ["Hans"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "office-cloud"
|
||||
},
|
||||
{
|
||||
"id": "KaiTi",
|
||||
"label": "KaiTi",
|
||||
"label_zh": "楷体",
|
||||
"label_en": "KaiTi",
|
||||
"label_ja": "KaiTi",
|
||||
"locales": ["zh-CN", "zh-SG", "zh-Hans"],
|
||||
"scripts": ["Hans"],
|
||||
"category": "calligraphic",
|
||||
"css": "serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "STKaiti",
|
||||
"label": "STKaiti",
|
||||
"label_zh": "华文楷体",
|
||||
"label_en": "STKaiti",
|
||||
"label_ja": "STKaiti",
|
||||
"locales": ["zh-CN", "zh-SG", "zh-Hans"],
|
||||
"scripts": ["Hans"],
|
||||
"category": "calligraphic",
|
||||
"css": "serif",
|
||||
"availability": "office-cloud"
|
||||
},
|
||||
{
|
||||
"id": "STXingkai",
|
||||
"label": "STXingkai",
|
||||
"label_zh": "华文行楷",
|
||||
"label_en": "STXingkai",
|
||||
"label_ja": "STXingkai",
|
||||
"locales": ["zh-CN", "zh-SG", "zh-Hans"],
|
||||
"scripts": ["Hans"],
|
||||
"category": "calligraphic",
|
||||
"css": "serif",
|
||||
"availability": "office-cloud"
|
||||
},
|
||||
{
|
||||
"id": "STXinwei",
|
||||
"label": "STXinwei",
|
||||
"label_zh": "华文新魏",
|
||||
"label_en": "STXinwei",
|
||||
"label_ja": "STXinwei",
|
||||
"locales": ["zh-CN", "zh-SG", "zh-Hans"],
|
||||
"scripts": ["Hans"],
|
||||
"category": "calligraphic",
|
||||
"css": "serif",
|
||||
"availability": "office-cloud"
|
||||
},
|
||||
{
|
||||
"id": "Microsoft JhengHei",
|
||||
"label": "Microsoft JhengHei",
|
||||
"label_zh": "微软正黑体",
|
||||
"label_en": "Microsoft JhengHei",
|
||||
"label_ja": "Microsoft JhengHei",
|
||||
"locales": ["zh-TW", "zh-HK", "zh-Hant"],
|
||||
"scripts": ["Hant"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "PMingLiU",
|
||||
"label": "PMingLiU",
|
||||
"label_zh": "新细明体",
|
||||
"label_en": "PMingLiU",
|
||||
"label_ja": "PMingLiU",
|
||||
"locales": ["zh-TW", "zh-HK", "zh-Hant"],
|
||||
"scripts": ["Hant"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "MingLiU",
|
||||
"label": "MingLiU",
|
||||
"label_zh": "细明体",
|
||||
"label_en": "MingLiU",
|
||||
"label_ja": "MingLiU",
|
||||
"locales": ["zh-TW", "zh-HK", "zh-MO", "zh-Hant"],
|
||||
"scripts": ["Hant"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "MingLiU_HKSCS",
|
||||
"label": "MingLiU_HKSCS",
|
||||
"label_zh": "细明体 HKSCS",
|
||||
"label_en": "MingLiU HKSCS",
|
||||
"label_ja": "MingLiU HKSCS",
|
||||
"locales": ["zh-HK", "zh-Hant"],
|
||||
"scripts": ["Hant"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "office-cloud"
|
||||
},
|
||||
{
|
||||
"id": "DFKai-SB",
|
||||
"label": "DFKai-SB",
|
||||
"label_zh": "标楷体",
|
||||
"label_en": "DFKai-SB",
|
||||
"label_ja": "DFKai-SB",
|
||||
"locales": ["zh-TW", "zh-HK", "zh-MO", "zh-Hant"],
|
||||
"scripts": ["Hant"],
|
||||
"category": "calligraphic",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Yu Gothic",
|
||||
"label": "Yu Gothic",
|
||||
"label_zh": "游哥特体",
|
||||
"label_en": "Yu Gothic",
|
||||
"label_ja": "游ゴシック",
|
||||
"locales": ["ja-JP"],
|
||||
"scripts": ["Jpan"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Meiryo",
|
||||
"label": "Meiryo",
|
||||
"label_zh": "Meiryo",
|
||||
"label_en": "Meiryo",
|
||||
"label_ja": "メイリオ",
|
||||
"locales": ["ja-JP"],
|
||||
"scripts": ["Jpan"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "BIZ UDGothic",
|
||||
"label": "BIZ UDGothic",
|
||||
"label_zh": "BIZ UD哥特体",
|
||||
"label_en": "BIZ UDGothic",
|
||||
"label_ja": "BIZ UDゴシック",
|
||||
"locales": ["ja-JP"],
|
||||
"scripts": ["Jpan"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "BIZ UDPGothic",
|
||||
"label": "BIZ UDPGothic",
|
||||
"label_zh": "BIZ UDP哥特体",
|
||||
"label_en": "BIZ UDPGothic",
|
||||
"label_ja": "BIZ UDPゴシック",
|
||||
"locales": ["ja-JP"],
|
||||
"scripts": ["Jpan"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "MS Gothic",
|
||||
"label": "MS Gothic",
|
||||
"label_zh": "MS 哥特体",
|
||||
"label_en": "MS Gothic",
|
||||
"label_ja": "MS ゴシック",
|
||||
"locales": ["ja-JP"],
|
||||
"scripts": ["Jpan"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "MS PGothic",
|
||||
"label": "MS PGothic",
|
||||
"label_zh": "MS P哥特体",
|
||||
"label_en": "MS PGothic",
|
||||
"label_ja": "MS Pゴシック",
|
||||
"locales": ["ja-JP"],
|
||||
"scripts": ["Jpan"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Yu Mincho",
|
||||
"label": "Yu Mincho",
|
||||
"label_zh": "游明朝体",
|
||||
"label_en": "Yu Mincho",
|
||||
"label_ja": "游明朝",
|
||||
"locales": ["ja-JP"],
|
||||
"scripts": ["Jpan"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "BIZ UDMincho Medium",
|
||||
"label": "BIZ UDMincho Medium",
|
||||
"label_zh": "BIZ UD明朝 Medium",
|
||||
"label_en": "BIZ UDMincho Medium",
|
||||
"label_ja": "BIZ UD明朝 Medium",
|
||||
"locales": ["ja-JP"],
|
||||
"scripts": ["Jpan"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "BIZ UDPMincho Medium",
|
||||
"label": "BIZ UDPMincho Medium",
|
||||
"label_zh": "BIZ UDP明朝 Medium",
|
||||
"label_en": "BIZ UDPMincho Medium",
|
||||
"label_ja": "BIZ UDP明朝 Medium",
|
||||
"locales": ["ja-JP"],
|
||||
"scripts": ["Jpan"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "MS Mincho",
|
||||
"label": "MS Mincho",
|
||||
"label_zh": "MS 明朝体",
|
||||
"label_en": "MS Mincho",
|
||||
"label_ja": "MS 明朝",
|
||||
"locales": ["ja-JP"],
|
||||
"scripts": ["Jpan"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "MS PMincho",
|
||||
"label": "MS PMincho",
|
||||
"label_zh": "MS P明朝体",
|
||||
"label_en": "MS PMincho",
|
||||
"label_ja": "MS P明朝",
|
||||
"locales": ["ja-JP"],
|
||||
"scripts": ["Jpan"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Malgun Gothic",
|
||||
"label": "Malgun Gothic",
|
||||
"label_zh": "Malgun Gothic",
|
||||
"label_en": "Malgun Gothic",
|
||||
"label_ja": "Malgun Gothic",
|
||||
"locales": ["ko-KR"],
|
||||
"scripts": ["Kore"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Microsoft GothicNeo",
|
||||
"label": "Microsoft GothicNeo",
|
||||
"label_zh": "Microsoft GothicNeo",
|
||||
"label_en": "Microsoft GothicNeo",
|
||||
"label_ja": "Microsoft GothicNeo",
|
||||
"locales": ["ko-KR"],
|
||||
"scripts": ["Kore"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-cloud"
|
||||
},
|
||||
{
|
||||
"id": "Dotum",
|
||||
"label": "Dotum",
|
||||
"label_zh": "Dotum",
|
||||
"label_en": "Dotum",
|
||||
"label_ja": "Dotum",
|
||||
"locales": ["ko-KR"],
|
||||
"scripts": ["Kore"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Gulim",
|
||||
"label": "Gulim",
|
||||
"label_zh": "Gulim",
|
||||
"label_en": "Gulim",
|
||||
"label_ja": "Gulim",
|
||||
"locales": ["ko-KR"],
|
||||
"scripts": ["Kore"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Batang",
|
||||
"label": "Batang",
|
||||
"label_zh": "Batang",
|
||||
"label_en": "Batang",
|
||||
"label_ja": "Batang",
|
||||
"locales": ["ko-KR"],
|
||||
"scripts": ["Kore"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Gungsuh",
|
||||
"label": "Gungsuh",
|
||||
"label_zh": "Gungsuh",
|
||||
"label_en": "Gungsuh",
|
||||
"label_ja": "Gungsuh",
|
||||
"locales": ["ko-KR"],
|
||||
"scripts": ["Kore"],
|
||||
"category": "calligraphic",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "BatangChe",
|
||||
"label": "BatangChe",
|
||||
"label_zh": "BatangChe",
|
||||
"label_en": "BatangChe",
|
||||
"label_ja": "BatangChe",
|
||||
"locales": ["ko-KR"],
|
||||
"scripts": ["Kore"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "DotumChe",
|
||||
"label": "DotumChe",
|
||||
"label_zh": "DotumChe",
|
||||
"label_en": "DotumChe",
|
||||
"label_ja": "DotumChe",
|
||||
"locales": ["ko-KR"],
|
||||
"scripts": ["Kore"],
|
||||
"category": "monospace",
|
||||
"css": "monospace",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "GulimChe",
|
||||
"label": "GulimChe",
|
||||
"label_zh": "GulimChe",
|
||||
"label_en": "GulimChe",
|
||||
"label_ja": "GulimChe",
|
||||
"locales": ["ko-KR"],
|
||||
"scripts": ["Kore"],
|
||||
"category": "monospace",
|
||||
"css": "monospace",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "GungsuhChe",
|
||||
"label": "GungsuhChe",
|
||||
"label_zh": "GungsuhChe",
|
||||
"label_en": "GungsuhChe",
|
||||
"label_ja": "GungsuhChe",
|
||||
"locales": ["ko-KR"],
|
||||
"scripts": ["Kore"],
|
||||
"category": "calligraphic",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Arabic Typesetting",
|
||||
"label": "Arabic Typesetting",
|
||||
"label_zh": "Arabic Typesetting",
|
||||
"label_en": "Arabic Typesetting",
|
||||
"label_ja": "Arabic Typesetting",
|
||||
"locales": ["ar", "fa", "ur"],
|
||||
"scripts": ["Arab"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Sakkal Majalla",
|
||||
"label": "Sakkal Majalla",
|
||||
"label_zh": "Sakkal Majalla",
|
||||
"label_en": "Sakkal Majalla",
|
||||
"label_ja": "Sakkal Majalla",
|
||||
"locales": ["ar", "fa", "ur"],
|
||||
"scripts": ["Arab"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Urdu Typesetting",
|
||||
"label": "Urdu Typesetting",
|
||||
"label_zh": "Urdu Typesetting",
|
||||
"label_en": "Urdu Typesetting",
|
||||
"label_ja": "Urdu Typesetting",
|
||||
"locales": ["ur", "ar", "fa"],
|
||||
"scripts": ["Arab"],
|
||||
"category": "calligraphic",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Gisha",
|
||||
"label": "Gisha",
|
||||
"label_zh": "Gisha",
|
||||
"label_en": "Gisha",
|
||||
"label_ja": "Gisha",
|
||||
"locales": ["he"],
|
||||
"scripts": ["Hebr"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "David",
|
||||
"label": "David",
|
||||
"label_zh": "David",
|
||||
"label_en": "David",
|
||||
"label_ja": "David",
|
||||
"locales": ["he"],
|
||||
"scripts": ["Hebr"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "FrankRuehl",
|
||||
"label": "FrankRuehl",
|
||||
"label_zh": "FrankRuehl",
|
||||
"label_en": "FrankRuehl",
|
||||
"label_ja": "FrankRuehl",
|
||||
"locales": ["he"],
|
||||
"scripts": ["Hebr"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Nirmala UI",
|
||||
"label": "Nirmala UI",
|
||||
"label_zh": "Nirmala UI",
|
||||
"label_en": "Nirmala UI",
|
||||
"label_ja": "Nirmala UI",
|
||||
"locales": ["hi", "mr", "ne"],
|
||||
"scripts": ["Deva"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Mangal",
|
||||
"label": "Mangal",
|
||||
"label_zh": "Mangal",
|
||||
"label_en": "Mangal",
|
||||
"label_ja": "Mangal",
|
||||
"locales": ["hi", "mr", "ne"],
|
||||
"scripts": ["Deva"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Aparajita",
|
||||
"label": "Aparajita",
|
||||
"label_zh": "Aparajita",
|
||||
"label_en": "Aparajita",
|
||||
"label_ja": "Aparajita",
|
||||
"locales": ["hi", "mr", "ne"],
|
||||
"scripts": ["Deva"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Kokila",
|
||||
"label": "Kokila",
|
||||
"label_zh": "Kokila",
|
||||
"label_en": "Kokila",
|
||||
"label_ja": "Kokila",
|
||||
"locales": ["hi", "mr", "ne"],
|
||||
"scripts": ["Deva"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Leelawadee UI",
|
||||
"label": "Leelawadee UI",
|
||||
"label_zh": "Leelawadee UI",
|
||||
"label_en": "Leelawadee UI",
|
||||
"label_ja": "Leelawadee UI",
|
||||
"locales": ["th"],
|
||||
"scripts": ["Thai"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Angsana New",
|
||||
"label": "Angsana New",
|
||||
"label_zh": "Angsana New",
|
||||
"label_en": "Angsana New",
|
||||
"label_ja": "Angsana New",
|
||||
"locales": ["th"],
|
||||
"scripts": ["Thai"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Cordia New",
|
||||
"label": "Cordia New",
|
||||
"label_zh": "Cordia New",
|
||||
"label_en": "Cordia New",
|
||||
"label_ja": "Cordia New",
|
||||
"locales": ["th"],
|
||||
"scripts": ["Thai"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "windows-language-feature"
|
||||
},
|
||||
{
|
||||
"id": "Aptos",
|
||||
"label": "Aptos",
|
||||
"label_zh": "Aptos",
|
||||
"label_en": "Aptos",
|
||||
"label_ja": "Aptos",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Arial",
|
||||
"label": "Arial",
|
||||
"label_zh": "Arial",
|
||||
"label_en": "Arial",
|
||||
"label_ja": "Arial",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek", "Arab", "Hebr"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Calibri",
|
||||
"label": "Calibri",
|
||||
"label_zh": "Calibri",
|
||||
"label_en": "Calibri",
|
||||
"label_ja": "Calibri",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek", "Arab", "Hebr"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Segoe UI",
|
||||
"label": "Segoe UI",
|
||||
"label_zh": "Segoe UI",
|
||||
"label_en": "Segoe UI",
|
||||
"label_ja": "Segoe UI",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek", "Arab", "Hebr"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Verdana",
|
||||
"label": "Verdana",
|
||||
"label_zh": "Verdana",
|
||||
"label_en": "Verdana",
|
||||
"label_ja": "Verdana",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Tahoma",
|
||||
"label": "Tahoma",
|
||||
"label_zh": "Tahoma",
|
||||
"label_en": "Tahoma",
|
||||
"label_ja": "Tahoma",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek", "Arab", "Hebr", "Thai"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Trebuchet MS",
|
||||
"label": "Trebuchet MS",
|
||||
"label_zh": "Trebuchet MS",
|
||||
"label_en": "Trebuchet MS",
|
||||
"label_ja": "Trebuchet MS",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Candara",
|
||||
"label": "Candara",
|
||||
"label_zh": "Candara",
|
||||
"label_en": "Candara",
|
||||
"label_ja": "Candara",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Corbel",
|
||||
"label": "Corbel",
|
||||
"label_zh": "Corbel",
|
||||
"label_en": "Corbel",
|
||||
"label_ja": "Corbel",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Bahnschrift",
|
||||
"label": "Bahnschrift",
|
||||
"label_zh": "Bahnschrift",
|
||||
"label_en": "Bahnschrift",
|
||||
"label_ja": "Bahnschrift",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek"],
|
||||
"category": "sans-serif",
|
||||
"css": "sans-serif",
|
||||
"availability": "windows"
|
||||
},
|
||||
{
|
||||
"id": "Georgia",
|
||||
"label": "Georgia",
|
||||
"label_zh": "Georgia",
|
||||
"label_en": "Georgia",
|
||||
"label_ja": "Georgia",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Times New Roman",
|
||||
"label": "Times New Roman",
|
||||
"label_zh": "Times New Roman",
|
||||
"label_en": "Times New Roman",
|
||||
"label_ja": "Times New Roman",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek", "Arab", "Hebr"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Cambria",
|
||||
"label": "Cambria",
|
||||
"label_zh": "Cambria",
|
||||
"label_en": "Cambria",
|
||||
"label_ja": "Cambria",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Constantia",
|
||||
"label": "Constantia",
|
||||
"label_zh": "Constantia",
|
||||
"label_en": "Constantia",
|
||||
"label_ja": "Constantia",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek"],
|
||||
"category": "serif",
|
||||
"css": "serif",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Consolas",
|
||||
"label": "Consolas",
|
||||
"label_zh": "Consolas",
|
||||
"label_en": "Consolas",
|
||||
"label_ja": "Consolas",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek"],
|
||||
"category": "monospace",
|
||||
"css": "monospace",
|
||||
"availability": "office-safe"
|
||||
},
|
||||
{
|
||||
"id": "Courier New",
|
||||
"label": "Courier New",
|
||||
"label_zh": "Courier New",
|
||||
"label_en": "Courier New",
|
||||
"label_ja": "Courier New",
|
||||
"locales": ["en", "und-Latn"],
|
||||
"scripts": ["Latn", "Cyrl", "Grek", "Arab", "Hebr"],
|
||||
"category": "monospace",
|
||||
"css": "monospace",
|
||||
"availability": "office-safe"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
{
|
||||
"id": "pyramid",
|
||||
|
||||
+45
-8
@@ -557,7 +557,44 @@ textarea.text-input { resize: vertical; line-height: 1.5; }
|
||||
}
|
||||
.font-sample-heading { font-size: 24px; font-weight: 700; line-height: 1.3; }
|
||||
.font-sample-body { font-size: 14px; color: #333; margin-top: 4px; }
|
||||
.custom-typography-input { margin-top: 8px; min-height: 58px; resize: vertical; }
|
||||
.custom-typography-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.font-picker-head {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.font-picker-head .subfield-label { margin-bottom: 0; }
|
||||
.font-picker-hint { grid-column: 1 / -1; }
|
||||
.font-custom-status {
|
||||
display: none;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 11.5px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.custom-typography-legacy { grid-column: 1 / -1; }
|
||||
.custom-typography-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.font-select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
.font-select:focus { outline: none; border-color: var(--accent); }
|
||||
.other-font-input { margin-top: 2px; }
|
||||
.custom-color-input { margin-top: 8px; min-height: 58px; resize: vertical; }
|
||||
.image-strategy-custom-input { margin-top: 8px; min-height: 72px; resize: vertical; }
|
||||
.image-usage-notes-input { min-height: 64px; resize: vertical; }
|
||||
@@ -702,13 +739,13 @@ textarea.text-input { resize: vertical; line-height: 1.5; }
|
||||
var(--card);
|
||||
}
|
||||
|
||||
/* ---- typography CJK / Latin previews ---- */
|
||||
/* ---- typography primary-language / English previews ---- */
|
||||
.font-sample-line { display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; }
|
||||
.font-sample-heading-box .fs-cjk { font-size: 24px; font-weight: 700; }
|
||||
.font-sample-heading-box .fs-latin { font-size: 22px; font-weight: 700; }
|
||||
.font-sample-heading-box .fs-primary { font-size: 24px; font-weight: 700; }
|
||||
.font-sample-heading-box .fs-english { font-size: 22px; font-weight: 700; }
|
||||
.font-sample-body-box { margin-top: 6px; }
|
||||
.font-sample-body-box .fs-cjk,
|
||||
.font-sample-body-box .fs-latin { font-size: 14px; color: #333; }
|
||||
.font-sample-body-box .fs-primary,
|
||||
.font-sample-body-box .fs-english { font-size: 14px; color: #333; }
|
||||
.font-card-meta { display: block; margin-top: 4px; }
|
||||
|
||||
/* ---- confirmed overlay ---- */
|
||||
@@ -769,7 +806,7 @@ textarea.text-input { resize: vertical; line-height: 1.5; }
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sp-title-lat { margin-left: 10px; opacity: 0.92; }
|
||||
.sp-title-english { margin-left: 10px; opacity: 0.92; }
|
||||
.sp-body { display: flex; align-items: stretch; gap: 10px; margin-top: 6px; }
|
||||
.sp-accent-bar { flex: 0 0 4px; border-radius: 2px; }
|
||||
.sp-body-wrap {
|
||||
@@ -780,7 +817,7 @@ textarea.text-input { resize: vertical; line-height: 1.5; }
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sp-body-lat { margin-left: 10px; opacity: 0.92; }
|
||||
.sp-body-english { margin-left: 10px; opacity: 0.92; }
|
||||
.sp-content {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
# Advanced Image and Motion Maintenance Smoke
|
||||
|
||||
Run this manual smoke from the repository root after changing nested image
|
||||
crop finalization, native picture export, deterministic Morph pairing, authored
|
||||
PowerPoint presets, or their planning contracts. It builds one temporary
|
||||
two-slide project under the gitignored `projects/_smoke_*` namespace and follows
|
||||
the inline-smoke convention from
|
||||
[`code-style.md`](../../../../docs/rules/code-style.md) §11; do not turn it into
|
||||
a test file or public example deck.
|
||||
|
||||
The fixture deliberately closes the full planning and execution chain:
|
||||
|
||||
- `design_spec.md` carries `Motion suggestion`, `Native shape suggestion`, one
|
||||
current §VIII image row, and `Crop Policy`;
|
||||
- `spec_lock.md` projects that row with optional layout pattern `#100`;
|
||||
- both pages reuse one raster through ordinary, ellipse-preset, and custom-path
|
||||
independent nested crops;
|
||||
- `animations.json` pairs the main crop across adjacent Morph pages;
|
||||
- one helper-authored `rightArrow` verifies native preset discovery and export.
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
project = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix="_smoke_advanced_image_motion_",
|
||||
dir="projects",
|
||||
)
|
||||
)
|
||||
scripts = Path("skills/ppt-master/scripts")
|
||||
images = project / "images"
|
||||
svg_output = project / "svg_output"
|
||||
images.mkdir()
|
||||
svg_output.mkdir()
|
||||
(project / "README.md").write_text(
|
||||
"# Advanced image and motion maintenance smoke\n",
|
||||
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()
|
||||
|
||||
|
||||
scene = Image.new("RGB", (1280, 720), "#E2E8F0")
|
||||
draw = ImageDraw.Draw(scene)
|
||||
draw.rectangle((0, 0, 420, 720), fill="#2563EB")
|
||||
draw.rectangle((420, 0, 860, 720), fill="#F97316")
|
||||
draw.rectangle((860, 0, 1280, 720), fill="#0F172A")
|
||||
draw.ellipse((460, 150, 820, 510), fill="#F8FAFC")
|
||||
scene.save(images / "scene.png")
|
||||
|
||||
preset = run_tool(
|
||||
"preset_shape_svg.py",
|
||||
"render",
|
||||
"rightArrow",
|
||||
"--id",
|
||||
"native-arrow",
|
||||
"--frame",
|
||||
"500",
|
||||
"570",
|
||||
"280",
|
||||
"80",
|
||||
"--fill",
|
||||
"#2563EB",
|
||||
"--stroke",
|
||||
"none",
|
||||
)
|
||||
preset = (
|
||||
'<g id="native-arrow-module" data-pptx-bounds="500 570 280 80">'
|
||||
+ preset
|
||||
+ "</g>"
|
||||
)
|
||||
|
||||
(project / "design_spec.md").write_text(
|
||||
"""<!-- ppt-master-schema: design-spec/v1 -->
|
||||
# Advanced Image and Motion Smoke - Design Spec
|
||||
|
||||
## I. Project Information
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Project Name | advanced-image-motion-smoke |
|
||||
| Canvas Format | PPT 16:9 |
|
||||
| Page Count | 2 |
|
||||
|
||||
## II. Canvas Specification
|
||||
|
||||
- ViewBox: `0 0 1280 720`
|
||||
- Format: `ppt169`
|
||||
|
||||
## III. Visual Theme
|
||||
|
||||
- Direction: restrained technical maintenance fixture
|
||||
- Colors: blue, orange, slate, and white
|
||||
|
||||
## IV. Typography System
|
||||
|
||||
- Title: Arial, sans-serif at 36px
|
||||
- Body: Arial, sans-serif at 20px
|
||||
|
||||
## V. Layout Principles
|
||||
|
||||
- Reuse one source image through independently editable crop objects.
|
||||
- Keep every visible crop inside the slide canvas.
|
||||
|
||||
## VI. Icon Usage Specification
|
||||
|
||||
- No icons.
|
||||
|
||||
## VIII. Image Resource List
|
||||
|
||||
| Filename | Dimensions | Ratio | Purpose | Type | Layout pattern | Crop Policy | Acquire Via | Status | Reference | text_policy | page_role |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| scene.png | 1280x720 | 16:9 | Morph crop continuity | Photo | #100 same-source independent crops with a shaped detail | adaptive | user | Existing | Synthetic three-band scene for crop and Morph verification | none | local |
|
||||
|
||||
## IX. Content Outline
|
||||
|
||||
### Part 1: Crop continuity
|
||||
|
||||
#### Slide 01 - Overview
|
||||
|
||||
- **Audience move**: See separate source views as one editable image system.
|
||||
- **Layout**: Ordinary crop, shaped crop, and one native directional preset.
|
||||
- **Title**: Overview crop state
|
||||
- **Core message**: One source can support independent native picture objects.
|
||||
- **Content**: Show the wide crop and shaped detail together.
|
||||
- **Images**: Use `scene.png` for both visible crop objects.
|
||||
- **Native shape suggestion**: Use the PowerPoint `rightArrow` preset to indicate continuation.
|
||||
- **Motion suggestion**: Continue the primary crop into Slide 02 as one deterministic Morph object.
|
||||
|
||||
#### Slide 02 - Detail
|
||||
|
||||
- **Audience move**: Recognize the same image after a controlled crop and position change.
|
||||
- **Layout**: Enlarged primary crop plus a second shaped detail.
|
||||
- **Title**: Detail crop state
|
||||
- **Core message**: Morph identity is independent from the picture crop geometry.
|
||||
- **Content**: Move and zoom the primary crop while preserving the shared source.
|
||||
- **Images**: Reuse `scene.png`; do not generate or replace the source.
|
||||
- **Motion suggestion**: Pair the primary crop with Slide 01 and use Morph by object.
|
||||
|
||||
## X. Speaker Notes Requirements
|
||||
|
||||
- Notes are disabled for this maintenance smoke.
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
(project / "spec_lock.md").write_text(
|
||||
"""<!-- ppt-master-schema: spec-lock/v1 -->
|
||||
# Execution Lock
|
||||
|
||||
## canvas
|
||||
- viewBox: 0 0 1280 720
|
||||
- format: ppt169
|
||||
## communication
|
||||
- audience: PPT Master maintainers
|
||||
- objective: Verify advanced image and motion contracts end to end.
|
||||
- core_message: One raster remains editable across native crops and Morph.
|
||||
## mode
|
||||
- mode: showcase
|
||||
## visual_style
|
||||
- visual_style: swiss-minimal
|
||||
## 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
|
||||
## images
|
||||
- scene: images/scene.png | source=user | pattern=#100 same-source independent crops with a shaped detail | crop=adaptive
|
||||
## page_rhythm
|
||||
- P01: dense
|
||||
- P02: dense
|
||||
## pptx_structure
|
||||
- mode: flat
|
||||
## forbidden
|
||||
- Unsupported SVG constructs
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
slide_1 = f"""<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1280 720" data-pptx-page-role="content">
|
||||
<defs>
|
||||
<clipPath id="overview-shape" clipPathUnits="userSpaceOnUse">
|
||||
<ellipse cx="0.725" cy="0.5" rx="0.275" ry="0.5"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect id="background" data-pptx-role="background"
|
||||
x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>
|
||||
<g id="hero-overview" data-pptx-bounds="80 140 448 360">
|
||||
<svg id="overview-crop" x="80" y="140" width="448" height="360"
|
||||
viewBox="0 0 0.7 1" preserveAspectRatio="none" overflow="hidden">
|
||||
<image href="../images/scene.png" x="0" y="0" width="1" height="1"
|
||||
preserveAspectRatio="none"/>
|
||||
</svg>
|
||||
</g>
|
||||
<g id="shaped-overview" data-pptx-bounds="760 170 330 340">
|
||||
<svg id="overview-shaped-crop" data-pptx-crop="1"
|
||||
x="760" y="170" width="330" height="340"
|
||||
viewBox="0.45 0 0.55 1" preserveAspectRatio="none" overflow="hidden">
|
||||
<image href="../images/scene.png" x="0" y="0" width="1" height="1"
|
||||
preserveAspectRatio="none" clip-path="url(#overview-shape)"/>
|
||||
</svg>
|
||||
</g>
|
||||
{preset}
|
||||
</svg>
|
||||
"""
|
||||
|
||||
slide_2 = """<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1280 720" data-pptx-page-role="content">
|
||||
<defs>
|
||||
<clipPath id="detail-shape" clipPathUnits="userSpaceOnUse">
|
||||
<path d="M 0.5 1 L 0.75 0 L 1 1 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect id="background" data-pptx-role="background"
|
||||
x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>
|
||||
<g id="hero-detail" data-pptx-bounds="0 0 1280 720">
|
||||
<svg id="detail-crop" x="0" y="0" width="1280" height="720"
|
||||
viewBox="0.15 0.1 0.36 0.36"
|
||||
preserveAspectRatio="none" overflow="hidden">
|
||||
<image href="../images/scene.png" x="0" y="0" width="1" height="1"
|
||||
preserveAspectRatio="none"/>
|
||||
</svg>
|
||||
</g>
|
||||
<g id="shaped-detail" data-pptx-bounds="820 160 320 360">
|
||||
<svg id="detail-shaped-crop" data-pptx-crop="1"
|
||||
x="820" y="160" width="320" height="360"
|
||||
viewBox="0.5 0 0.5 1" preserveAspectRatio="none" overflow="hidden">
|
||||
<image href="../images/scene.png" x="0" y="0" width="1" height="1"
|
||||
preserveAspectRatio="none" clip-path="url(#detail-shape)"/>
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
(svg_output / "01_overview.svg").write_text(slide_1, encoding="utf-8")
|
||||
(svg_output / "02_detail.svg").write_text(slide_2, encoding="utf-8")
|
||||
(project / "animations.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"slides": {
|
||||
"02_detail": {
|
||||
"transition": {
|
||||
"effect": "morph",
|
||||
"effect_options": {"morph_by": "object"},
|
||||
"duration": 0.8,
|
||||
},
|
||||
"morph": {
|
||||
"from": "01_overview",
|
||||
"pairs": {
|
||||
"hero-image": {
|
||||
"from": "hero-overview",
|
||||
"to": "hero-detail",
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
run_tool("project_manager.py", "validate", project)
|
||||
run_tool("svg_quality_checker.py", project, "--format", "ppt169")
|
||||
run_tool("animation_config.py", "validate", project)
|
||||
|
||||
|
||||
def finalized_image_sizes():
|
||||
sizes = []
|
||||
for svg_path in sorted((project / "svg_final").glob("*.svg")):
|
||||
root = ET.parse(svg_path).getroot()
|
||||
for image in root.iter("{http://www.w3.org/2000/svg}image"):
|
||||
href = image.get("href") or image.get(
|
||||
"{http://www.w3.org/1999/xlink}href"
|
||||
)
|
||||
assert href and href.startswith("data:image/"), href
|
||||
payload = base64.b64decode(href.split(",", 1)[1], validate=True)
|
||||
with Image.open(io.BytesIO(payload)) as embedded:
|
||||
sizes.append(embedded.size)
|
||||
return sizes
|
||||
|
||||
|
||||
finalized_sizes = []
|
||||
for finalize_args in ((), ("--no-compress",)):
|
||||
run_tool("finalize_svg.py", project, *finalize_args)
|
||||
embedded_sizes = finalized_image_sizes()
|
||||
assert len(embedded_sizes) == 4, embedded_sizes
|
||||
assert all(width > 100 and height > 100 for width, height in embedded_sizes)
|
||||
finalized_sizes.append(embedded_sizes)
|
||||
|
||||
pptx = project / "advanced-image-motion.pptx"
|
||||
run_tool("svg_to_pptx.py", project, "--no-notes", "-o", pptx)
|
||||
with zipfile.ZipFile(pptx) as archive:
|
||||
slide_names = sorted(
|
||||
name
|
||||
for name in archive.namelist()
|
||||
if re.fullmatch(r"ppt/slides/slide[12]\.xml", name)
|
||||
)
|
||||
slide_xml = [archive.read(name) for name in slide_names]
|
||||
assert len(slide_xml) == 2, slide_names
|
||||
assert all(b'!!hero-image' in xml for xml in slide_xml)
|
||||
assert b'morph' in slide_xml[1]
|
||||
assert sum(xml.count(b"<a:srcRect") for xml in slide_xml) >= 4
|
||||
assert b'<a:prstGeom prst="ellipse">' in slide_xml[0]
|
||||
assert b'<a:prstGeom prst="rightArrow">' in slide_xml[0]
|
||||
assert b"<a:custGeom>" in slide_xml[1]
|
||||
|
||||
readback = project / "readback"
|
||||
run_tool(
|
||||
"pptx_to_svg.py",
|
||||
pptx,
|
||||
"-o",
|
||||
readback,
|
||||
"--inheritance-mode",
|
||||
"flat",
|
||||
"--strict",
|
||||
)
|
||||
readback_svg = "\n".join(
|
||||
path.read_text(encoding="utf-8")
|
||||
for path in sorted((readback / "svg").glob("slide_*.svg"))
|
||||
)
|
||||
assert readback_svg.count('data-pptx-crop="1"') >= 2
|
||||
assert readback_svg.count('data-pptx-shape-name="!!hero-image"') == 2
|
||||
|
||||
print(
|
||||
"Advanced image and motion smoke: passed "
|
||||
f"(embedded sizes={finalized_sizes}; project={project})"
|
||||
)
|
||||
PY
|
||||
```
|
||||
|
||||
Every command must exit successfully. The finalized data URIs must retain
|
||||
useful source pixels rather than collapsing to the nested child’s unit
|
||||
coordinates; the package must contain four native picture crops, the authored
|
||||
`rightArrow`, one Morph transition, and exactly one `!!hero-image` target on
|
||||
each adjacent slide. The strict PPTX-to-SVG readback must restore both shaped
|
||||
crop markers and both forced-Morph object names.
|
||||
|
||||
The printed project is intentionally retained for manual Microsoft PowerPoint
|
||||
inspection. Morph must continue the primary picture without a visible
|
||||
cross-fade, sharpness jump, or media re-decoding flash. That Office-only visual
|
||||
check is evidence gathering; to stress differing media payloads, replace
|
||||
`scene.png` with a `7680×4320` source and re-export. Do not trigger media
|
||||
deduplication changes from package structure alone.
|
||||
@@ -16,15 +16,29 @@
|
||||
|
||||
**Hard rule**: Keep detailed Confirm UI behavior here. The Generate route may summarize orchestration, but it should not duplicate the full JSON schema, catalog behavior, or launcher lifecycle.
|
||||
|
||||
**Fallback rule**: The page is default. Use chat only on explicit chat-only request or launch failure/timeout after one `result.json` re-check; a chat-question tool is not a launch failure. Preserve all three stages and keep Stage-1 prompts open-ended.
|
||||
**Fallback rule**: The page is default. Use chat on an explicit chat-only request, when the user answers the always-on handoff in chat, or after launch failure/timeout and one `result.json` re-check; a chat-question tool alone is not a launch failure. Preserve all three stages and keep Stage-1 prompts open-ended.
|
||||
|
||||
**Always-on Stage-1 chat handoff**: Launch the healthy daemon without `--wait`.
|
||||
After it returns, immediately post its actual URL plus one compact, localized
|
||||
summary of the current Stage-1 recommendations: audience, communication intent,
|
||||
audience outcome, core message, delivery context, artifact afterlife,
|
||||
`content_divergence`, and canvas. Show a blank as “not specified” without
|
||||
changing its value. End with an explicit localized line saying that, if the
|
||||
page did not open or cannot be reached, the user may reply “continue with these
|
||||
recommendations” or revise the same items directly in chat; the same three-stage
|
||||
flow will continue. Only then run `--wait-only --wait-stage stage1`. A chat
|
||||
reply selects the chat path without waiting for timeout. The handoff is context,
|
||||
not confirmation, and silence confirms nothing. After launch failure/timeout
|
||||
and the required result re-check, present the same items as open Stage-1 chat
|
||||
questions and wait for an explicit response.
|
||||
|
||||
## `confirm_ui/server.py`
|
||||
|
||||
```bash
|
||||
python3 scripts/confirm_ui/server.py <project_path> --daemon --wait # launch + wait for Stage 1
|
||||
python3 scripts/confirm_ui/server.py <project_path> --daemon # healthy launch; return for chat handoff
|
||||
python3 scripts/confirm_ui/server.py <project_path> --wait-only --wait-stage stage1 # Stage 1
|
||||
python3 scripts/confirm_ui/server.py <project_path> --wait-only --wait-stage stage2 # Stage 2: wait for the direction handoff
|
||||
python3 scripts/confirm_ui/server.py <project_path> --wait-only # Stage 3: wait for the final result
|
||||
python3 scripts/confirm_ui/server.py <project_path> --daemon
|
||||
python3 scripts/confirm_ui/server.py <project_path> --daemon --port 5051
|
||||
python3 scripts/confirm_ui/server.py <project_path> --no-browser
|
||||
python3 scripts/confirm_ui/server.py <project_path> --timeout 0 # disable idle auto-shutdown
|
||||
@@ -34,8 +48,8 @@ python3 scripts/confirm_ui/server.py <project_path> --shutdown # Step 4 clean
|
||||
- Binds `127.0.0.1:5050` by default — or the next free port if another project already holds it (the launch log prints the actual URL) — and auto-opens the browser (suppress with `--no-browser`). `--port <other>` forces a specific port.
|
||||
- In `--daemon` mode the launcher starts the child server with browser opening suppressed, waits for `GET /api/health` to prove the server is accepting requests, then opens the printed `http://127.0.0.1:<port>` URL. If health never becomes reachable, the command fails before presenting a dead page.
|
||||
- **Shares port 5050 with the live preview server** (`svg_editor/server.py`). The two never run at once: confirm is Step 4, live preview is Step 6, and Step 4 always shuts this server down on exit (see `--shutdown`) so the port is free. One port = one forward rule for the whole pipeline. They still keep **separate processes and locks** (`.confirm_ui.lock` vs `.live_preview.lock`).
|
||||
- `--daemon` starts the Flask process in the background; add `--wait` in the main pipeline so the parent command returns only after the page writes a fresh `result.json`. The `--wait` budget defaults to **590 s** (`--wait-timeout`), kept under the typical 600 s tool ceiling — run the launch with a long tool timeout (≈600000 ms). On timeout the parent returns non-zero but the detached server keeps running, so the caller must re-check `result.json` once before the chat fallback (a slow user may confirm just after the wait returns).
|
||||
- `--wait-only` attaches to the page already running from the first `--daemon --wait` and blocks until the page writes the requested stage. If that stage is already persisted, it returns before attempting server recovery, so a fast final confirmation cannot reopen Stage 3 after the page shuts itself down. Otherwise, if the recorded server died, it automatically restarts on the recorded/default port so polling reconnects. Use `--wait-stage stage2` for the complete-solution handoff, then the default `--wait-stage final` for Stage 3. It keys on stage alone (no mtime gate), because a user may submit before the wait command starts.
|
||||
- `--daemon` starts the Flask process in the background and returns after the health check. Generate uses it without `--wait` so the Stage-1 chat handoff appears before blocking. `--daemon --wait` remains a combined compatibility form. The wait budget defaults to **590 s** (`--wait-timeout`); on timeout the detached server remains live, and the caller re-checks `result.json` once before chat fallback.
|
||||
- `--wait-only` attaches to the page opened by `--daemon` and blocks until the requested stage. If that stage is already persisted, it returns before recovery, so a fast submit between launch, chat handoff, and wait is not lost. Otherwise, if the recorded server died, it restarts on the recorded/default port. Use `stage1` only for a fresh Stage-1 launch, `stage2` for the complete-solution handoff, and `final` for Stage 3. It keys on stage alone; resume chooses the target from the persisted result instead of restarting Stage 1.
|
||||
- `--shutdown` stops a confirm server left running for this project and exits — **idempotent** (a no-op when nothing is running). Tries a graceful `/api/shutdown`, falls back to killing the recorded pid, then clears the lock. Generate Step 4 runs this on every path (page-confirm or chat-fallback) so the page never lingers on the shared port before live preview starts.
|
||||
- Refuses to start unless the recommendation file expected from `result.json` exists (initially `<project_path>/confirm_ui/recommendations.stage1.json`; `--shutdown` needs no recommendations).
|
||||
- Per-project lock at `<project_path>/.confirm_ui.lock` — duplicate launches are refused; stale locks (dead pid) are overwritten.
|
||||
@@ -54,6 +68,7 @@ pip install flask
|
||||
- **Visual examples for hard-to-name choices** — the full-screen confirmation page loads real SVG page samples from `static/style_previews/` for `visual_style`, and renders real sample SVGs from `templates/icons` for `icons`. These thumbnails make style and icon-library choices visually comparable before the user locks them. Preview copy is fixed role text (big title / section title / body / points), not project content from recommendation files, so users compare visual treatment rather than copywriting. These previews are a confirmation aid only: they do not add fields to recommendation stage files or `result.json`, and they do not replace the later Step 6 live preview.
|
||||
- **Image usage multi-select** — image sources are selected as one or more catalog ids: `ai` = AI-generated, `web` = Web-sourced, `provided` = User-provided, `placeholder` = Placeholder, `none` = No images. `none` is exclusive. A confirmed non-`none` set is the allowed acquisition-source boundary, not a requirement to use every selected source; only explicit `image_notes` wording can require a source, asset, or page role. Recommendation and result values may be a legacy single string, but new files should use an array. When several sources are recommended, write the source ids to `recommend.image_usage` and write the actual usage strategy to `image_notes`, not a custom prose value.
|
||||
- **Closed enumerable** — PPT reading mode (`delivery_purpose` compatibility key), formula policy / generation mode / refine spec, plus AI source only when image usage includes `ai`. These have no Custom box; out-of-catalog values snap back to the recommended option.
|
||||
- **Proactive execution booleans** — Stage 3 carries top-level `proactive_speaker_notes`, `proactive_custom_animations`, and `proactive_narration_audio` values. Defaults are `true`, `false`, and `false`, respectively. They control what the Agent does proactively only when the user has not explicitly instructed otherwise; the latest explicit user instruction always wins. These three values are raw confirmation evidence: the UI and server neither couple nor rewrite them, and every boolean combination is valid. When narration audio is enabled, Strategist later resolves the effective Speaker Notes outcome to enabled and records `Narration Audio dependency` as its Design Spec provenance. Disabling proactive custom animation does not suppress the Strategist's advisory motion recommendations.
|
||||
- **Open prose** — `audience`, `communication_intent`, `audience_outcome`, `core_message`, `delivery_context`, `artifact_afterlife`, `content_divergence`, and `page_count`. `communication_intent` may carry several purposes plus priority / sequence; common paths appear only as help text. `delivery_context` states one primary presenter-led / reader-led / hybrid / recorded-self-running context plus optional secondary use; a hybrid recommendation names which context leads. `content_divergence` is the source-treatment axis. `page_count` may be a range here; Strategist resolves the exact §IX roster, leaving Executor no pagination latitude.
|
||||
- **Coordinated generative directions** — `design_directions` carries ≥3 safe / shifted / bold candidates. Each candidate bundles visual style, color, typography, icon id, and conditional generated-image rendering. The page can still render legacy top-level `color`, `typography`, and `image_strategy` candidates, but new staged recommendations use the coordinated bundle.
|
||||
|
||||
@@ -77,9 +92,9 @@ The page runs as a **three-stage wizard in one browser session**. Each stage has
|
||||
|
||||
| Recommendation file | Declared stage | Page renders | Button | On submit |
|
||||
|---|---|---|---|---|
|
||||
| `recommendations.stage1.json` | `"stage1"` | communication contract — audience; open `communication_intent`; audience outcome; core message / primary delivery context + optional secondary use / artifact afterlife / `content_divergence` (all prose fields may be blank); canvas | **Confirm contract & continue** | writes `result.json` `{ stage: "stage1", status: "stage1-confirmed", <communication contract> }`; the page stays open and polls |
|
||||
| `recommendations.stage1.json` | `"stage1"` | communication contract — content language; audience; open `communication_intent`; audience outcome; core message / primary delivery context + optional secondary use / artifact afterlife / `content_divergence` (all prose fields may be blank); canvas | **Confirm contract & continue** | writes `result.json` `{ stage: "stage1", status: "stage1-confirmed", <communication contract> }`; the page stays open and polls |
|
||||
| `recommendations.stage2.json` | `"stage2"` | complete deck solution — conditional natural-language template application, reading mode, mode, page count, visual direction, color, icons, typography, image usage, generated-image rendering | **Confirm solution & continue** | writes `result.json` `{ stage: "stage2", status: "stage2-confirmed", <contract + solution> }`; the page stays open and polls |
|
||||
| `recommendations.stage3.json` | `"stage3"` | production only — confirmed image-source summary, conditional AI acquisition path, formula policy, generation mode, Design Spec review toggle | **Confirm** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
|
||||
| `recommendations.stage3.json` | `"stage3"` | production only — confirmed image-source summary, conditional AI acquisition path, formula policy, proactive notes / custom-animation / narration-audio toggles, generation mode, Design Spec review toggle | **Confirm** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
|
||||
| `recommendations.json` | stage or legacy no-stage payload | read-only compatibility when no stage-specific file exists | matching legacy behavior | preserves the former staged or single-pass behavior; new projects never create this file |
|
||||
|
||||
The AI launches Stage 1, authors the complete Stage-2 solution once from the user's actual contract, then authors Stage-3 production mechanics once from the confirmed solution. An edit inside the current stage never requests another recommendation. The page preserves earlier answers across transitions. `GET /api/session` is the waiting-state endpoint; `GET /api/recommendations` is `no-store`, and the server folds confirmed earlier-stage choices back into later payloads so refresh / reopen restores the user's actual values—including Stage-2 color, typography, icon, image-source, and rendering choices. Once any stage-specific file exists, the server ignores legacy `recommendations.json` to prevent mixed lifecycles.
|
||||
@@ -92,6 +107,7 @@ The AI launches Stage 1, authors the complete Stage-2 solution once from the use
|
||||
{
|
||||
"stage": "stage1",
|
||||
"lang": "zh",
|
||||
"primary_language": "zh-CN",
|
||||
"recommend": {
|
||||
"canvas": "ppt169"
|
||||
},
|
||||
@@ -115,7 +131,7 @@ The AI launches Stage 1, authors the complete Stage-2 solution once from the use
|
||||
}
|
||||
```
|
||||
|
||||
All seven Stage-1 prose values may be blank and none blocks confirmation. The values shown in the boxes are editable recommendations; the submitted current values are authoritative, so clearing a box writes and retains `""`. A preservation profile may lock an open field, for example `"content_divergence": { "value": "keep source wording and page structure verbatim", "locked": true }`; the browser renders it read-only. The server carries that lock through the intermediate results and restores the value on every staged submit; the internal carry-over marker is removed from the final `result.json`.
|
||||
All seven Stage-1 prose values may be blank. `primary_language` is required canonical BCP-47. The server normalizes legacy English / Chinese / Japanese / Korean aliases, rejects `und` and Chinese without script/region, and carries it forward; `lang` is UI-only. Prose submits verbatim, including `""`. A profile's `{ "locked": true }` value is read-only, persisted, and stripped of that marker in final `result.json`.
|
||||
|
||||
The common paths — inform / explain / persuade / decide / align / teach / report and account / mobilize / record and hand off — appear only as help text for `communication_intent`. They are not catalog ids and must not be emitted as a `primary_job` field.
|
||||
|
||||
@@ -167,9 +183,9 @@ After Stage 1 is confirmed, create `recommendations.stage2.json` with the comple
|
||||
"secondary_accent": "#4A7BB5", "body_text": "#1D2430"
|
||||
} },
|
||||
"typography": {
|
||||
"name_zh": "清晰无衬线",
|
||||
"heading": { "cjk": "Microsoft YaHei", "latin": "Arial", "css": "sans-serif" },
|
||||
"body": { "cjk": "Microsoft YaHei", "latin": "Arial", "css": "sans-serif" },
|
||||
"name_zh": "微软雅黑 + Arial",
|
||||
"heading": { "primary": "Microsoft YaHei", "english": "Arial", "css": "sans-serif" },
|
||||
"body": { "primary": "Microsoft YaHei", "english": "Arial", "css": "sans-serif" },
|
||||
"body_size": 24
|
||||
},
|
||||
"image_strategy": {
|
||||
@@ -197,34 +213,39 @@ After Stage 2 is confirmed, create `recommendations.stage3.json` with production
|
||||
"formula_policy": "mixed",
|
||||
"generation_mode": "continuous"
|
||||
},
|
||||
"proactive_speaker_notes": { "value": true },
|
||||
"proactive_custom_animations": { "value": false },
|
||||
"proactive_narration_audio": { "value": false },
|
||||
"refine_spec": { "value": false }
|
||||
}
|
||||
```
|
||||
|
||||
- `recommend.*` names each recommended id. New mode / style values use a catalog id or literal `custom`; arbitrary prose values are legacy-only. Use `recommend.image_strategy: "custom"` only when an explicit user-supplied image direction should start selected. Missing recommendations fall back to the normal preset. Legacy aliases remain accepted; new files write canonical ids.
|
||||
- The three proactive-execution fields are top-level boolean `{ "value": ... }` objects, not catalog ids. Omitted fields use `true / false / false` for notes / custom animation / narration audio. These are absence-of-instruction defaults, not permission to override the user's latest explicit request. Preserve all three raw values independently through `result.json`; do not couple or rewrite them. Strategist derives effective Speaker Notes as enabled when audio is `true` and records `Narration Audio dependency` as provenance in the Design Spec. `proactive_custom_animations: false` leaves Strategist animation suggestions unchanged; it only prevents unrequested custom-animation execution.
|
||||
- `custom_candidates` is recommendation-only. Mode / style carry localized `name` + `behavior`; conditional image strategy also carries `rendering: "custom"`, `visual`, and `mood`. When a proposal combines or borrows existing catalog entries, the visible behavior names every exact id and Strategist reads every corresponding file before authoring it; a genuinely novel proposal names none. The server rejects missing required candidates; the UI shows full copy, edits it only after selection, rejects a selected blank, and omits unselected candidates from `result.json`. Template-backed proposals obey inherited identity, prototype capacity, and `template_application`.
|
||||
- `audience`, `communication_intent`, `audience_outcome`, and `delivery_context` are load-bearing Stage-1 reasoning inputs, so seed concrete recommendations when the evidence supports them; they are not required user inputs. Every Stage-1 prose field may be blank after confirmation. The complete six-field contract stays in `result.json` and `design_spec.md`; `spec_lock.md communication` receives only the compact `audience` / `objective` / `core_message` execution projection plus the applicable reading mode. `communication_intent` may preserve several purposes plus priority / sequence; never add a `primary_job` enum.
|
||||
- Seed `audience`, `communication_intent`, `audience_outcome`, and `delivery_context` when evidence supports them; users need not supply them, and every Stage-1 prose field may end blank. The contract and `primary_language` stay in `result.json` and `design_spec.md`; `spec_lock.md communication` receives `primary_language`, compact `audience` / `objective` / `core_message`, and reading mode. `communication_intent` may preserve multiple purposes and priority/sequence; never add a `primary_job` enum.
|
||||
- Do not write `recommend.template_reuse_scope` or `recommend.template_adherence`. Strategist records those internal exporter values later in `spec_lock.md` after inspecting the actual template and current content.
|
||||
- For an active template workspace, write one editable prose field as top-level `template_application.value`. It summarizes actual page/prototype use and preservation/reorganization decisions. Omit it for free design. The UI returns the current string through Stage 2, Stage 3, and final confirmation; Strategist then persists the final effective plan as `- **Template Application**: ...` in `design_spec.md §I`, which Executor reads from the retained Design Spec. Never replace it with internal reuse/adherence ids or a fixed option menu.
|
||||
- `recommend.image_usage` should be an array of source ids when more than one source applies, e.g. `["ai", "provided"]`. A single string is still accepted for backward compatibility. Do not write bare `"custom"` and do not encode a mixed-source plan as prose here; write the prose to top-level `image_notes.value`.
|
||||
- `image_notes` is the initial strategy note shown under the image source chips. Use it for page-role guidance and constraints: which source applies where, what to avoid, which user assets are authoritative, how realistic / abstract the imagery should be, and what can remain as placeholders. It is intent guidance, not a separate finite option.
|
||||
- When confirmed Stage-2 `image_usage` includes `ai`, Stage 3 sets `recommend.image_ai_path` to one of `auto` / `api` / `host-native` / `manual`. Stage 2 never asks for the acquisition mechanism while the user is still deciding the image role.
|
||||
- **Color candidates carry the user-facing core `palette`**: `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, and `body_text`. The page renders every role as a labelled swatch with its HEX value visible, and offers per-role override inputs for precise single-role edits, plus a **Custom color card with a free-text box** (parallel to the custom typography box) — the user can describe the palette in words or paste HEX values instead of filling each role; this writes `color: { "name": "custom", "custom": "<text>" }` to `result.json` for the AI to interpret. Legacy `text` is accepted as an alias for `body_text`, but new files should write `body_text`. Strategist derives secondary text, borders, state colors, and visual-style neutral tiers while writing `design_spec.md`, then projects the machine values to `spec_lock.md`; those are not user-facing confirmation choices.
|
||||
- **Color candidates carry the user-facing core `palette`**: `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, and `body_text`. The page renders every role as a labelled swatch with its HEX value visible, and offers per-role override inputs for precise single-role edits, plus a **Custom color card with a free-text box** — the user can describe the palette in words or paste HEX values instead of filling each role; this writes `color: { "name": "custom", "custom": "<text>" }` to `result.json` for the AI to interpret. Legacy `text` is accepted as an alias for `body_text`, but new files should write `body_text`. Strategist derives secondary text, borders, state colors, and visual-style neutral tiers while writing `design_spec.md`, then projects the machine values to `spec_lock.md`; those are not user-facing confirmation choices.
|
||||
- **Candidate display text may be multilingual**: color / typography candidates can provide `name_zh` / `name_en` / `name_ja` and `note_zh` / `note_en` / `note_ja`; the page falls back to legacy `name` / `note`. Labels resolve in the page language first, then fall back across the others (a `ja` page: ja → en → zh; zh/en pages keep their zh↔en fallback and try `_ja` last), so when `lang` is `ja` always include the `_ja` variants — otherwise the candidate labels render in English.
|
||||
- **Typography candidates split CJK and Latin** for both `heading` and `body`; `css` is the fallback preview stack. Each candidate includes topic-matched sample text. Stage 2 is authored once with a reading-mode baseline of `text` 20 · `balanced` 24 · `presentation` 32 px on PPT. Font cards choose family / character and preserve the current sizing state; they do not introduce a competing size recommendation. The page writes px directly. `delivery_purpose` remains the compatibility key only.
|
||||
- **Typography candidates** use concrete heading/body `primary`; non-English decks also use `english`, while English-primary decks omit it. `cjk` / `latin` remain legacy aliases. Localized `name` labels the pair and `css` only previews. Three generated pairs differ; user/template-fixed pairs repeat only with `fixed: true`. Catalog `fonts` supplies language-filtered dropdowns plus Other without limiting recommendations; edits mark Custom and refresh the preview. Include topic samples. PPT baselines are `text` 20 · `balanced` 24 · `presentation` 32 px; cards preserve sizes and submit px.
|
||||
- **Per-role size override** (parallel to color's per-role HEX override): besides `body_size`, the page exposes editable inputs for `title` / `subtitle` / `annotation`. The browser applies one documented deterministic dependency chain: `reading mode → body baseline → unpinned role sizes` (role ramp: `body ×` the §g ratios). Changing reading mode updates the body and all unpinned roles locally; changing body updates unpinned roles locally. Editing body or a role pins that value, so later reading-mode changes do not overwrite it. Font / direction-card selection preserves all current sizes. This is a browser-only state update: it performs no fetch, asks the backend to author no new recommendations, and a re-render preserves exactly what the user sees. Each role input is labelled as px and shows an approximate pt equivalent (`1px = 0.75pt`) for orientation. The final values are written to `result.json` as `typography.sizes: { "title", "subtitle", "annotation" }` in **px** — every canvas, no pt and no `sizes_pt` provenance. These confirmed values are Strategist input anchors: the completed page plan may add recurring roles, and downstream execution owns bounded per-occurrence treatment. Candidate `sizes` remain accepted for compatibility, but the fresh Stage-2 baseline is normalized through the same local ramp before first render.
|
||||
- **`delivery_purpose` compatibility key / Reading mode** (enumerable, PPT only) decides where meaning is carried, not merely how large type is: `text` makes pages self-contained with complete sentences, short prose, captions, tables, and necessary detail; `balanced` shares explanation between page and presenter; `presentation` uses one idea, concise claims, and visual evidence while speech / notes carry the detail. It therefore governs page grammar, granularity, density / rhythm, and note burden. Reading-mode cards intentionally show **no px value**; the typography section owns the separately visible body / role sizes and applies any local default. It is surfaced in Stage 2 beside the visual system, separate from communication intent. `recommend.delivery_purpose` pre-selects one; `result.json` retains the key, while `spec_lock.md` uses canonical `consumption_mode`. Non-PPT canvases omit it.
|
||||
- **Combined style preview** — a compact live "overall impression" strip sits just above the color section and is **sticky**: it pins under the topbar so it stays visible while the user scrolls through the color / icon / typography sections, keeping the picking controls and their combined effect on screen together. It applies the currently selected color palette **and** typography (heading sample in `primary` over `background`, body sample in `body_text`, an `accent` bar, a `secondary_bg` chip) and repaints on every color / HEX-override / font / `body_size` change. It does not replace the per-candidate swatches or font samples (those stay for picking); it is deliberately an abstract style chip, **not** a slide-layout preview — page layout preview remains the live-preview server's job (Step 6). No schema field; it derives entirely from the existing color + typography selections.
|
||||
- **Generated-image direction** appears only for `image_usage: ai`: up to three preset cards plus one full-width AI custom proposal. Custom has no preset dropdown; selection makes it editable and submits `rendering: "custom"` + `behavior`. If that behavior uses catalog renderings, it visibly names their exact ids; Strategist retains that confirmed basis as optional `image_rendering_references` in `spec_lock.md`. A genuinely novel behavior produces no reference row. The live preview follows the selection. No image palette is written; deck colors remain authoritative, and legacy `image_strategy.palette` is ignored.
|
||||
- **`design_directions`** is the canonical Stage-2 spectrum: ≥3 safe / shifted / bold bundles with localized copy, style, icons, conditional image strategy, complete CJK/Latin typography, and HEX `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, `body_text`. Selection applies the bundle; component controls override it. `result.json` stores components, not a direction id.
|
||||
- **`design_directions`** is the canonical Stage-2 spectrum: ≥3 safe / shifted / bold bundles with localized copy, style, icons, conditional image strategy, complete language-aware typography, and HEX `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, `body_text`. Selection applies the bundle; component controls override it. `result.json` stores components, not a direction id.
|
||||
- `recommend.generation_mode` and `refine_spec` mirror [`generate-pptx`](../../workflows/generate-pptx.md) Step 4. `split` / `true` are explicit opt-ins. Refinement adds no UI stage: after Gate 1 it stops before the lock for unrestricted chat revision until approval.
|
||||
- `content_divergence` is a **free-text** Stage-1 source-treatment field. Blank means a balanced default; facts stay sourced at every level. Strategist consumes it while authoring §IX and records it in `design_spec.md §I`; it is not written to `spec_lock.md`. Beautify sends `{ "value": "keep source wording and page structure verbatim", "locked": true }`, so the UI displays it read-only and the server restores it on every staged submit. Template-fill does not use this confirmation flow and does not surface it.
|
||||
- `lang` is a soft default (`zh` / `en` / `ja` — the page UI supports all three); an explicit user language choice in the page (persisted to `localStorage`) wins.
|
||||
- `lang` is the soft UI-language default (`zh` / `en` / `ja`); the persisted user choice wins. It never sets `primary_language`.
|
||||
|
||||
### Output — `result.json` (written on submit, read by the AI)
|
||||
|
||||
```json
|
||||
{
|
||||
"primary_language": "zh-CN",
|
||||
"canvas": "ppt169",
|
||||
"page_count": "12-15",
|
||||
"audience": "...",
|
||||
@@ -239,13 +260,16 @@ After Stage 2 is confirmed, create `recommendations.stage3.json` with production
|
||||
"visual_style": "swiss-minimal",
|
||||
"color": { "name": "...", "palette": { "background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..." } },
|
||||
"icons": "tabler-outline",
|
||||
"typography": { "name": "...", "heading": { "cjk": "...", "latin": "...", "css": "..." }, "body": { "cjk": "...", "latin": "...", "css": "..." }, "body_size": 24, "body_size_unit": "px", "sizes": { "title": 42, "subtitle": 32, "annotation": 18 } },
|
||||
"typography": { "name": "...", "heading": { "primary": "...", "english": "...", "css": "..." }, "body": { "primary": "...", "english": "...", "css": "..." }, "body_size": 24, "body_size_unit": "px", "sizes": { "title": 42, "subtitle": 32, "annotation": 18 } },
|
||||
"delivery_purpose": "balanced",
|
||||
"formula_policy": "mixed",
|
||||
"image_usage": ["ai", "provided"],
|
||||
"image_notes": "封面和章节页用 AI 主视觉;产品页优先用户素材,缺口页可用占位符。",
|
||||
"image_ai_path": "auto",
|
||||
"image_strategy": { "name": "方案 A", "rendering": "vector-illustration", "visual": "...", "mood": "..." },
|
||||
"proactive_speaker_notes": true,
|
||||
"proactive_custom_animations": false,
|
||||
"proactive_narration_audio": false,
|
||||
"generation_mode": "continuous",
|
||||
"refine_spec": false,
|
||||
"stage": "final",
|
||||
@@ -254,13 +278,13 @@ After Stage 2 is confirmed, create `recommendations.stage3.json` with production
|
||||
}
|
||||
```
|
||||
|
||||
The shape above is final. Selected custom values use `mode: custom` + `mode_behavior`, `visual_style: custom` + `visual_style_behavior`, or `image_strategy.rendering: custom` + `behavior`. During Design Spec and lock authoring, Strategist projects optional `mode_references`, `visual_style_references`, or `image_rendering_references` only when that confirmed behavior actually uses named catalog sources; genuinely novel custom behavior has no reference list. Intermediate writes retain accumulated fields; legacy tier names remain read-compatible.
|
||||
The shape above is final. The proactive-execution values are independent flat booleans in `result.json`; old recommendations and results that omit them resolve to `true / false / false`. They remain raw evidence even when `proactive_speaker_notes` is `false` and `proactive_narration_audio` is `true`; Strategist owns the effective dependency resolution described above. Selected custom values use `mode: custom` + `mode_behavior`, `visual_style: custom` + `visual_style_behavior`, or `image_strategy.rendering: custom` + `behavior`. During Design Spec and lock authoring, Strategist projects optional `mode_references`, `visual_style_references`, or `image_rendering_references` only when that confirmed behavior actually uses named catalog sources; genuinely novel custom behavior has no reference list. Intermediate writes retain accumulated fields; legacy tier names remain read-compatible.
|
||||
|
||||
**Final-result consumption contract.** A final result is the user-confirmed input contract for the Strategist's Design Spec, not another recommendation input. After the final wait, Generate Step 4 reads the complete final object exactly once and retains it while Strategist writes and audits `design_spec.md` against every explicitly present field. Normal lock authoring and downstream execution do not reopen `result.json`; the completed Design Spec is the durable authority. Only after that audit passes does Strategist author `spec_lock.md` from the Design Spec plus current execution context, selecting stable anchors and routing rather than copying every field or enumerating every legal color/font. Every value must be consumed at the semantic type owned by [`strategist.md`](../../references/strategist.md) §1 and its field owner: do not omit or substitute it, and do not silently strengthen or weaken its type. If a confirmed requirement cannot be honored, the owning workflow reports or pauses under failure recovery; it never deletes the requirement to keep the pipeline moving.
|
||||
|
||||
- Bespoke mode / style prose lives only in the required behavior sibling; image custom prose lives in `image_strategy.behavior`. Canvas / icons retain free-text edge cases, color / typography retain `name: "custom"`, and image usage remains a source-id array plus `image_notes`.
|
||||
- `image_ai_path` and `image_strategy` appear only with `image_usage: ai` and remain confirmed downstream. The page is default; explicit/failure chat fallback keeps identical fields. `image_ai_path` selects the Step 5 path, and [`strategist-image.md`](../../references/strategist-image.md) §2 retains the selected rendering or custom behavior as the deck-level image identity anchor; individual prompts still adapt subject, composition, and atmosphere within it.
|
||||
- After the user clicks the **final Confirm** (Stage 3, or single-pass), the page saves `result.json` and shuts the server down (auto-close). Stage-1 **Confirm contract & continue** and Stage-2 **Confirm solution & continue** keep the page open while it polls for the downstream stage file. In the default flow, the first `--daemon --wait` returns on the stage-1 result, `--wait-only --wait-stage stage2` returns on the stage-2 result, and the final `--wait-only` returns on the final result; the AI reads each immediately — no extra chat confirmation is required. Chat fallback shows the same initially-unselected custom proposals. Either way, Step 4 ends with a `--shutdown` cleanup so a never-confirmed page cannot keep holding port 5050 ahead of the Step 6 live preview.
|
||||
- After the user clicks the **final Confirm** (Stage 3, or single-pass), the page saves `result.json` and shuts the server down (auto-close). Stage-1 **Confirm contract & continue** and Stage-2 **Confirm solution & continue** keep the page open while it polls for the downstream stage file. The default flow is `--daemon` → Stage-1 chat handoff → `--wait-only --wait-stage stage1` → Stage-2 wait → final wait; the AI reads each result immediately. Chat fallback shows the same initially-unselected custom proposals. Either way, Step 4 ends with `--shutdown` so a never-confirmed page cannot hold port 5050 ahead of Step 6 live preview.
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ 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.
|
||||
Use this as the default inventory and geometry source; it does not perform semantic image understanding. Generate planning follows the Strategist's context-first boundary: source context, captions / alt text / titles, filenames, user notes, and existing resource records come first. Only an already-selected provided/web asset whose focal-safe crop, overlay contrast, or quiet region remains materially ambiguous may be inspected for that placement; this never reopens selection or provenance, never bulk-opens the image folder, and never restores routine readback of AI-generated images.
|
||||
|
||||
## `image_search.py`
|
||||
|
||||
@@ -190,18 +190,18 @@ Providers (Pexels / Pixabay are tried first when keyed; Openverse and Wikimedia
|
||||
| `openverse` | zero-config | fallback aggregator: Wikimedia + Flickr + museums + rawpixel |
|
||||
| `wikimedia` | zero-config | educational, scientific, geographic, historical |
|
||||
|
||||
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.
|
||||
Default search chain (when `--provider` is unset): configured Pexels, configured Pixabay, Openverse, then Wikimedia. Missing keyed credentials are silently skipped. Keyed providers broaden stock-photo coverage but are optional; zero-config providers remain valid.
|
||||
|
||||
`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.
|
||||
Keep the Design Spec §VIII `Reference` as the full visual/crop intent; write a separate concise provider query for this CLI. Start with the shortest phrase that preserves identity, but retain exact multi-word names and necessary disambiguators beyond four words.
|
||||
|
||||
| Case | Pattern |
|
||||
|---|---|
|
||||
| Generic stock concept | `boardroom meeting` |
|
||||
| China-specific landmark | 1–4 official place/identity words |
|
||||
| China-specific landmark | Precise official place/identity name plus necessary geography |
|
||||
| Avoid | Negative prompt wording such as `not tourist snapshot` |
|
||||
|
||||
License filter:
|
||||
@@ -240,7 +240,7 @@ Output:
|
||||
|
||||
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.
|
||||
|
||||
The full role-level reference (intent → query translation, on-slide attribution visual specification) is in [`references/image-searcher.md`](../../references/image-searcher.md).
|
||||
The full role-level reference (intent → query translation, on-slide attribution contract) is in [`references/image-searcher.md`](../../references/image-searcher.md).
|
||||
|
||||
## `gemini_watermark_remover.py`
|
||||
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
# Multilingual Text Maintenance Smoke
|
||||
|
||||
Run this manual smoke from the repository root after changing Confirm UI
|
||||
language handling, DrawingML text export, native tables/charts, notes, or
|
||||
document metadata. It uses only temporary files and does not add an automated
|
||||
test suite.
|
||||
|
||||
```bash
|
||||
PYTHONPATH="skills/ppt-master/scripts:skills/ppt-master/scripts/confirm_ui" python3 - <<'PY'
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from pptx import Presentation
|
||||
|
||||
from confirm_ui.server import create_app
|
||||
from language_tags import (
|
||||
LanguageTagError,
|
||||
language_uses_rtl,
|
||||
normalize_language_tag,
|
||||
)
|
||||
from svg_to_pptx.drawingml.context import ConvertContext
|
||||
from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes
|
||||
from svg_to_pptx.native_objects import _build_native_chart
|
||||
from svg_to_pptx.native_objects.table import _build_native_table
|
||||
from svg_to_pptx.pptx_package.builder import create_pptx_with_native_svg
|
||||
from svg_to_pptx.pptx_package.cli import _declared_primary_language
|
||||
from svg_to_pptx.pptx_package.notes import create_notes_slide_xml
|
||||
|
||||
|
||||
def reject_language(value):
|
||||
try:
|
||||
normalize_language_tag(value)
|
||||
except LanguageTagError:
|
||||
return
|
||||
raise AssertionError(f"invalid language accepted: {value}")
|
||||
|
||||
|
||||
canonical = {
|
||||
"ES_mx": "es-MX",
|
||||
"RU_ru": "ru-RU",
|
||||
"AR_sa": "ar-SA",
|
||||
"HE_il": "he-IL",
|
||||
"HI_in": "hi-IN",
|
||||
"TH_th": "th-TH",
|
||||
"KO_kr": "ko-KR",
|
||||
"fil_ph": "fil-PH",
|
||||
"zh_hans": "zh-Hans",
|
||||
"de-CH-1901": "de-CH-1901",
|
||||
"en-u-nu-latn": "en-u-nu-latn",
|
||||
}
|
||||
for raw, expected in canonical.items():
|
||||
assert normalize_language_tag(raw) == expected
|
||||
for raw in ("und", "zh", "en--US", "Arabic", "x-private"):
|
||||
reject_language(raw)
|
||||
assert language_uses_rtl("ar-Arab-SA")
|
||||
assert not language_uses_rtl("ar-Latn-SA")
|
||||
assert language_uses_rtl("en-Arab-US")
|
||||
|
||||
samples = {
|
||||
"en-US": "Summary 2026",
|
||||
"zh-Hans": "年度总结 2026",
|
||||
"ja-JP": "年間まとめ 2026",
|
||||
"ko-KR": "연간 요약 2026",
|
||||
"es-ES": "Resumen 2026",
|
||||
"ru-RU": "Итоги 2026",
|
||||
"ar-SA": "ملخص 2026",
|
||||
"he-IL": "סיכום 2026",
|
||||
"hi-IN": "सारांश 2026",
|
||||
"th-TH": "สรุป 2026",
|
||||
}
|
||||
rtl_languages = {"ar-SA", "he-IL"}
|
||||
|
||||
with TemporaryDirectory(prefix="ppt-master-multilingual-smoke-") as tmp:
|
||||
root = Path(tmp)
|
||||
|
||||
# Confirm UI canonicalizes Stage 1 and persists the same project language.
|
||||
project = root / "confirm-project"
|
||||
confirm = project / "confirm_ui"
|
||||
confirm.mkdir(parents=True)
|
||||
recommendation = {
|
||||
"stage": "stage1",
|
||||
"lang": "en",
|
||||
"primary_language": "AR_sa",
|
||||
"audience": {"value": "Team"},
|
||||
"communication_intent": {"value": "Explain"},
|
||||
"audience_outcome": {"value": "Understand"},
|
||||
"core_message": {"value": "Result"},
|
||||
"delivery_context": {"value": "Meeting"},
|
||||
"artifact_afterlife": {"value": ""},
|
||||
"content_divergence": {"value": ""},
|
||||
"recommend": {"canvas": "ppt169"},
|
||||
}
|
||||
(confirm / "recommendations.stage1.json").write_text(
|
||||
json.dumps(recommendation),
|
||||
encoding="utf-8",
|
||||
)
|
||||
app = create_app(str(project), idle_timeout=0)
|
||||
app.testing = True
|
||||
client = app.test_client()
|
||||
response = client.get("/api/recommendations")
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["primary_language"] == "ar-SA"
|
||||
response = client.post(
|
||||
"/api/confirm",
|
||||
json={
|
||||
"stage": "stage1",
|
||||
"primary_language": "ar-SA",
|
||||
"canvas": "ppt169",
|
||||
"audience": "Team",
|
||||
"communication_intent": "Explain",
|
||||
"audience_outcome": "Understand",
|
||||
"core_message": "Result",
|
||||
"delivery_context": "Meeting",
|
||||
"artifact_afterlife": "",
|
||||
"content_divergence": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = json.loads((confirm / "result.json").read_text(encoding="utf-8"))
|
||||
assert result["primary_language"] == "ar-SA"
|
||||
|
||||
# The execution lock is the only export-time project-language source.
|
||||
lock_project = root / "lock-project"
|
||||
lock_project.mkdir()
|
||||
lock_template = """# Execution Lock
|
||||
|
||||
## communication
|
||||
{language}- audience: team
|
||||
- objective: explain
|
||||
- core_message: result
|
||||
"""
|
||||
lock = lock_project / "spec_lock.md"
|
||||
lock.write_text(
|
||||
lock_template.format(language="- primary_language: ES_mx\n"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _declared_primary_language(lock_project) == "es-MX"
|
||||
lock.write_text(lock_template.format(language=""), encoding="utf-8")
|
||||
assert _declared_primary_language(lock_project) is None
|
||||
|
||||
for index, (language, sample) in enumerate(samples.items(), 1):
|
||||
stem = f"slide-{index}"
|
||||
svg = root / f"{stem}.svg"
|
||||
svg.write_text(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||
'viewBox="0 0 1280 720">'
|
||||
'<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>'
|
||||
f'<text x="100" y="140" font-size="42" '
|
||||
f'font-family="Arial" fill="#111111">{sample}</text>'
|
||||
"</svg>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
slide_xml, *_ = convert_svg_to_slide_shapes(
|
||||
svg,
|
||||
index,
|
||||
verbose=False,
|
||||
primary_language=language,
|
||||
)
|
||||
assert f'lang="{language}"' in slide_xml
|
||||
assert ("rtl=\"1\"" in slide_xml) == (language in rtl_languages)
|
||||
assert ("<a:rtl val=\"1\"/>" in slide_xml) == (
|
||||
language in rtl_languages
|
||||
)
|
||||
|
||||
ascii_svg = root / f"{stem}-ascii.svg"
|
||||
ascii_svg.write_text(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||
'viewBox="0 0 1280 720">'
|
||||
'<text x="100" y="140" font-size="42">2026 AI</text>'
|
||||
"</svg>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
ascii_xml, *_ = convert_svg_to_slide_shapes(
|
||||
ascii_svg,
|
||||
index + 20,
|
||||
verbose=False,
|
||||
primary_language=language,
|
||||
)
|
||||
assert f'lang="{language}"' in ascii_xml
|
||||
assert 'rtl="1"' not in ascii_xml
|
||||
assert "<a:rtl val=\"1\"/>" not in ascii_xml
|
||||
|
||||
context = ConvertContext(primary_language=language)
|
||||
marker = ET.Element("g")
|
||||
table = _build_native_table(
|
||||
marker,
|
||||
context,
|
||||
{
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"width": 600,
|
||||
"height": 180,
|
||||
"columns": [sample, "2026 AI"],
|
||||
"rows": [["A", "B"]],
|
||||
"style": {"font_family": "Arial"},
|
||||
},
|
||||
)
|
||||
assert f'lang="{language}"' in table.xml
|
||||
assert all(slot in table.xml for slot in ("<a:latin ", "<a:ea ", "<a:cs "))
|
||||
|
||||
chart_context = ConvertContext(primary_language=language)
|
||||
_build_native_chart(
|
||||
marker,
|
||||
chart_context,
|
||||
{
|
||||
"x": 10,
|
||||
"y": 220,
|
||||
"width": 600,
|
||||
"height": 300,
|
||||
"type": "column",
|
||||
"title": sample,
|
||||
"categories": ["A", "B"],
|
||||
"series": [{"name": sample, "values": [1, 2]}],
|
||||
"style": {"font_family": "Arial"},
|
||||
"show_legend": True,
|
||||
},
|
||||
)
|
||||
chart_xml = next(
|
||||
value.decode("utf-8")
|
||||
for part, value in chart_context.package_files.items()
|
||||
if part.startswith("ppt/charts/chart") and not part.endswith(".rels")
|
||||
)
|
||||
assert f'<c:lang val="{language}"/>' in chart_xml
|
||||
assert f'lang="{language}"' in chart_xml
|
||||
|
||||
notes_xml = create_notes_slide_xml(
|
||||
1,
|
||||
sample + "\n2026 AI",
|
||||
language,
|
||||
)
|
||||
assert f'lang="{language}"' in notes_xml
|
||||
assert ("rtl=\"1\"" in notes_xml) == (language in rtl_languages)
|
||||
|
||||
output = root / f"{index}.pptx"
|
||||
assert create_pptx_with_native_svg(
|
||||
[svg],
|
||||
output,
|
||||
canvas_format="ppt169",
|
||||
verbose=False,
|
||||
transition=None,
|
||||
notes={stem: sample + "\n2026 AI"},
|
||||
pptx_structure="flat",
|
||||
structure_name="multilingual-smoke",
|
||||
primary_language=language,
|
||||
)
|
||||
with zipfile.ZipFile(output) as archive:
|
||||
assert archive.testzip() is None
|
||||
packaged_slide = archive.read(
|
||||
"ppt/slides/slide1.xml"
|
||||
).decode("utf-8")
|
||||
packaged_notes = archive.read(
|
||||
"ppt/notesSlides/notesSlide1.xml"
|
||||
).decode("utf-8")
|
||||
core = archive.read("docProps/core.xml").decode("utf-8")
|
||||
assert f'lang="{language}"' in packaged_slide
|
||||
assert f'lang="{language}"' in packaged_notes
|
||||
assert f"<dc:language>{language}</dc:language>" in core
|
||||
assert len(Presentation(str(output)).slides) == 1
|
||||
|
||||
# A legacy lock with no language field keeps the previous per-run path.
|
||||
legacy_svg = root / "legacy.svg"
|
||||
legacy_svg.write_text(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||
'viewBox="0 0 1280 720">'
|
||||
'<text x="100" y="140" font-size="42">한국어 2026</text>'
|
||||
"</svg>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
legacy_output = root / "legacy.pptx"
|
||||
assert create_pptx_with_native_svg(
|
||||
[legacy_svg],
|
||||
legacy_output,
|
||||
canvas_format="ppt169",
|
||||
verbose=False,
|
||||
transition=None,
|
||||
pptx_structure="flat",
|
||||
structure_name="legacy-smoke",
|
||||
)
|
||||
with zipfile.ZipFile(legacy_output) as archive:
|
||||
assert archive.testzip() is None
|
||||
legacy_slide = archive.read(
|
||||
"ppt/slides/slide1.xml"
|
||||
).decode("utf-8")
|
||||
assert 'lang="ko-KR"' in legacy_slide
|
||||
|
||||
print("Multilingual text smoke: passed")
|
||||
PY
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
Multilingual text smoke: passed
|
||||
```
|
||||
|
||||
The RTL contract is paragraph `a:pPr rtl="1"` plus run-level `a:rtl` only
|
||||
when the run contains strong RTL characters. Do not use `rtlCol`; it controls
|
||||
column order, not paragraph direction.
|
||||
@@ -190,6 +190,11 @@ the next `after-previous` row.
|
||||
|
||||
## 6. Validation and Read-Back
|
||||
|
||||
Before export, `animation_config.py validate` uses the writer's effect-behavior
|
||||
test for `bounce_end` and resolves declared sound paths against the project
|
||||
root. Missing paths, non-files, and unsupported audio extensions fail this
|
||||
project-level preflight; field-only validation remains filesystem-independent.
|
||||
|
||||
Generated export reads every slide back before packaging and compares each
|
||||
requested row with the serialized result:
|
||||
|
||||
|
||||
@@ -234,13 +234,13 @@ continue to preserve existing object names and transition XML.
|
||||
|---|---|---|---|
|
||||
| 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 | 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 | preserve source | preserve source | explicit effects replace; legacy advance_after maps to both |
|
||||
| Native Enhance | Confirmed global/per-slide plan effect | Confirmed timing module | With audio off, an enabled global transition or explicit global `none` applies to all pages; with audio on, the scope flag controls non-narrated pages |
|
||||
|
||||
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.
|
||||
Template Fill changes source transitions only when its CLI or per-slide plan
|
||||
selects a replacement, removal, or timed advance. Native Enhance uses its
|
||||
confirmed plan. The public `create_pptx_with_native_svg` Python API retains its
|
||||
legacy 0.5s default; the generated-deck CLI explicitly passes 0.4s.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -468,6 +468,8 @@ Do not confuse this tool with `extract_svg_assets.py`:
|
||||
Run these steps one at a time. Wait for each command to exit successfully before
|
||||
starting the next command.
|
||||
|
||||
When the effective Speaker Notes outcome in `design_spec.md §I` is enabled, run:
|
||||
|
||||
```bash
|
||||
python3 scripts/total_md_split.py <project_path>
|
||||
```
|
||||
@@ -484,6 +486,10 @@ After `finalize_svg.py` exits successfully, run:
|
||||
python3 scripts/svg_to_pptx.py <project_path>
|
||||
```
|
||||
|
||||
When Speaker Notes is disabled, skip `total_md_split.py` and use
|
||||
`python3 scripts/svg_to_pptx.py <project_path> --no-notes` for the final
|
||||
command. This prevents stale files under `notes/` from being embedded.
|
||||
|
||||
Do not start another post-processing command while the current command is still
|
||||
running. The canonical gates and success criteria are owned by
|
||||
[`generate-pptx.md`](../../workflows/generate-pptx.md) Step 7.
|
||||
@@ -506,6 +512,10 @@ Convert project SVGs into PPTX.
|
||||
|
||||
```bash
|
||||
python3 scripts/svg_to_pptx.py <project_path>
|
||||
# Explicit compact image export:
|
||||
python3 scripts/svg_to_pptx.py <project_path> --image-sizing display --image-scale 2 --image-quality 85
|
||||
# Force original image bytes:
|
||||
python3 scripts/svg_to_pptx.py <project_path> --no-image-optimize
|
||||
python3 scripts/svg_to_pptx.py <project_path> --native-charts-and-tables
|
||||
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure structured # deck/layout template override
|
||||
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure flat # free-design/brand-only override
|
||||
@@ -517,12 +527,19 @@ python3 scripts/svg_to_pptx.py <project_path> --no-notes
|
||||
python3 scripts/svg_to_pptx.py <project_path> -t none
|
||||
python3 scripts/svg_to_pptx.py <project_path> --auto-advance 3
|
||||
python3 scripts/svg_to_pptx.py <project_path> --animation mixed --animation-duration 0.8
|
||||
python3 scripts/svg_to_pptx.py <project_path> --no-merge # strict line-fidelity mode (see below)
|
||||
python3 scripts/svg_to_pptx.py <project_path> --reflow-text # opt-in PowerPoint reflow
|
||||
python3 scripts/svg_to_pptx.py <project_path> --no-merge # one text frame per visual line
|
||||
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio
|
||||
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio --animation-config animations.json
|
||||
python3 scripts/svg_to_pptx.py <project_path> --recorded-narration audio --no-animations
|
||||
```
|
||||
|
||||
Native image export defaults to `--image-sizing cap`: it preserves source bytes
|
||||
when no resize or EXIF geometry normalization is required, and re-encodes only
|
||||
images that require one of those transformations. The `display` command above
|
||||
is an explicit compact export; `--no-image-optimize` disables all native image
|
||||
optimization and forces original bytes.
|
||||
|
||||
The normal command reads `pptx_structure.mode` from `spec_lock.md`. For legacy
|
||||
projects whose lock exists but predates that field, export emits one compatibility
|
||||
warning and uses `flat`; no SVG regeneration is required. A missing `spec_lock.md`,
|
||||
@@ -565,11 +582,11 @@ Behavior:
|
||||
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:
|
||||
- Default: mergeable paragraph blocks (same x, dy clustered around one base line-height) collapse into one editable text frame. Equal effective font sizes may join as flowing prose; a font-size change, list marker, or accepted larger gap starts a new `<a:p>` with precise `<a:lnSpc>` / `<a:spcBef>`. Resizing the box reflows text inside it without erasing those paragraph boundaries.
|
||||
- With `--no-merge`: every dy-stacked `<tspan>` becomes its own text frame — exact SVG line layout is preserved but a 12-line paragraph is 12 separate textboxes
|
||||
- Side effect: PowerPoint may wrap merged paragraphs to a different line count than the SVG source. Long body text (abstracts, multi-paragraph sections, reference lists) usually benefits from the default; pages with tight typographic alignment (covers, charts, tables) usually want `--no-merge`
|
||||
- Mergeable detection is conservative: only fires when the children form a clean paragraph block; mixed-layout `<text>` falls through to the default per-line path
|
||||
- Multiline text export modes:
|
||||
- Default: one editable frame retains authored breaks and disables PowerPoint wrapping. An ordinary generated frame uses PowerPoint's native resize-shape-to-fit-text behavior, so deleting a retained break expands the frame instead of leaving text outside it; imported exact frames and structured multiline placeholder carriers retain fixed-size behavior.
|
||||
- `--reflow-text`: eligible same-size lines become flowing prose that PowerPoint may rewrap; a font-size change, list marker, or accepted larger gap remains a paragraph boundary. Legacy `--merge-paragraphs` aliases this mode.
|
||||
- `--no-merge`: each dy-stacked line becomes an independent frame with its own placement.
|
||||
- Detection is conservative: mixed-layout `<text>` falls back to per-line frames. Use `--reflow-text` only for resizable body copy and `--no-merge` only for independent line objects or absolute line positions.
|
||||
- Native release export reads `svg_output/`. `-s final` is an explicit diagnostic override for comparing conversion behavior against post-processed SVGs; it does not change artifact ownership or create a supported release path.
|
||||
- `svg_final/` may be opened directly or inserted into PowerPoint as an SVG picture. PowerPoint's manual Convert-to-Shape operation is outside the compatibility contract.
|
||||
- On every SVG-authoring route, each file in `svg_output/` is the complete visible
|
||||
@@ -604,9 +621,10 @@ Behavior:
|
||||
- Narration text is read strictly from the matching `notes/*.md` file; the script only skips Markdown heading lines (`# ...`) and does not summarize, rewrite, or filter delivery notes
|
||||
- `--recorded-narration audio` prepares PowerPoint's "recorded timings and narrations": every slide must have matching `m4a` / `mp3` / `wav` audio, `ffprobe` must read every duration, and `--animation-trigger on-click` is rejected
|
||||
- `--recorded-narration audio` keeps speaker notes, embeds each matching audio file, and writes slide auto-advance timings from audio duration
|
||||
- Narrated export defaults to `<project>/narration_animations.json`; pass `--animation-config animations.json` for the canonical presentation animation, or `--no-animations` to remove object animations and page-transition motion while retaining narration and slide timings
|
||||
- When either animation sidecar exists, narrated export defaults to `<project>/narration_animations.json`; a canonical `animations.json` without that derived file remains a blocking synchronization error
|
||||
- Without animation sidecars, Generate narration reads base-report deck motion via `--inherit-motion-from`; direct low-level omission keeps legacy `fade` / no object builds. Use `--animation-config animations.json` for canonical animation, or `--no-animations` to remove object/page motion while retaining narration timings
|
||||
- Non-narrated export keeps the existing optional `<project>/animations.json` default
|
||||
- Narration timing is merged into the existing slide timing DOM; object-animation rows and the resolved page transition are preserved rather than regenerated
|
||||
- Narration timing merges into the existing slide timing DOM. While motion remains enabled, object-animation rows and the resolved page transition are preserved rather than regenerated; inherited `-a none` suppresses object rows, and `--no-animations` removes both motion layers
|
||||
- `--narration-audio-dir audio` is the lower-level embedding path: it embeds whatever files match and allows partial audio coverage
|
||||
- Either narration flag names the default-flow export `<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports in the same directory
|
||||
- This is intended for direct PowerPoint video export with "Use recorded timings and narrations"
|
||||
|
||||
@@ -35,9 +35,12 @@ Processing options:
|
||||
flatten-text - Convert <tspan> to independent <text> (for special renderers)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import shutil
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
from xml.etree import ElementTree as ET
|
||||
@@ -64,6 +67,14 @@ from svg_to_pptx.use_expander import (
|
||||
)
|
||||
|
||||
|
||||
class FlattenTextResult(Enum):
|
||||
"""Describe whether flattening changed a file, skipped it, or failed."""
|
||||
|
||||
CHANGED = "changed"
|
||||
UNCHANGED = "unchanged"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
def safe_print(text: str, *, file: TextIO | None = None) -> None:
|
||||
"""Print text while tolerating Windows terminal encoding limits."""
|
||||
stream = file or sys.stdout
|
||||
@@ -84,11 +95,13 @@ def safe_print(text: str, *, file: TextIO | None = None) -> None:
|
||||
print(text, file=stream)
|
||||
|
||||
|
||||
def process_flatten_text(svg_file: Path, verbose: bool = False) -> bool:
|
||||
"""Flatten text in a single SVG file (in-place modification)"""
|
||||
def process_flatten_text(
|
||||
svg_file: Path,
|
||||
verbose: bool = False,
|
||||
) -> FlattenTextResult:
|
||||
"""Flatten text in one SVG and report changed, unchanged, or error."""
|
||||
try:
|
||||
from svg_finalize.flatten_tspan import flatten_text_with_tspans
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
tree = ET.parse(str(svg_file))
|
||||
changed = flatten_text_with_tspans(tree)
|
||||
@@ -97,11 +110,213 @@ def process_flatten_text(svg_file: Path, verbose: bool = False) -> bool:
|
||||
tree.write(str(svg_file), encoding='unicode', xml_declaration=False)
|
||||
if verbose:
|
||||
safe_print(f" [OK] {svg_file.name}: text flattened")
|
||||
return changed
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
safe_print(f" [ERROR] {svg_file.name}: {e}")
|
||||
return False
|
||||
return FlattenTextResult.CHANGED
|
||||
return FlattenTextResult.UNCHANGED
|
||||
except Exception as exc:
|
||||
safe_print(
|
||||
f" [ERROR] {svg_file.name}: text flattening failed: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return FlattenTextResult.ERROR
|
||||
|
||||
|
||||
def _path_lexists(path: Path) -> bool:
|
||||
"""Return whether a path or dangling symlink occupies the target name."""
|
||||
return os.path.lexists(path)
|
||||
|
||||
|
||||
def _publish_candidate_directory(candidate_dir: Path, output_dir: Path) -> None:
|
||||
"""Publish one staged directory and restore the previous output on failure."""
|
||||
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}")
|
||||
|
||||
transaction_dir = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f".{output_dir.name}.publish-",
|
||||
dir=output_dir.parent,
|
||||
)
|
||||
)
|
||||
backup_dir = transaction_dir / "previous"
|
||||
preserve_backup = False
|
||||
|
||||
try:
|
||||
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 svg_final and restore the previous "
|
||||
"directory; 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 _process_candidate_directory(
|
||||
candidate_dir: Path,
|
||||
*,
|
||||
options: dict[str, bool],
|
||||
quiet: bool,
|
||||
compress: bool,
|
||||
max_dimension: int | None,
|
||||
image_scale: float,
|
||||
icons_dir: Path,
|
||||
icons_fallback_dir: Path | None,
|
||||
) -> bool:
|
||||
"""Run every selected finalization pass against one unpublished candidate."""
|
||||
# Core normalization: downstream image/rect processors read XML geometry.
|
||||
geometry_count = 0
|
||||
for svg_file in candidate_dir.glob('*.svg'):
|
||||
try:
|
||||
geometry_count += materialize_inline_geometry_in_file(svg_file)
|
||||
except (OSError, ET.ParseError, GeometryStyleError) as exc:
|
||||
safe_print(
|
||||
f"[ERROR] {svg_file.name}: inline geometry materialization failed: {exc}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Step 1: Expand project icons, then standard same-document use references.
|
||||
if options.get('embed_icons'):
|
||||
if not quiet:
|
||||
safe_print("[1/3] Expanding icons + local use references...")
|
||||
icons_count = 0
|
||||
for svg_file in candidate_dir.glob('*.svg'):
|
||||
count = embed_icons_in_file(
|
||||
svg_file,
|
||||
icons_dir,
|
||||
dry_run=False,
|
||||
verbose=False,
|
||||
fallback_dir=icons_fallback_dir,
|
||||
)
|
||||
icons_count += count
|
||||
for svg_file in candidate_dir.glob('*.svg'):
|
||||
try:
|
||||
geometry_count += materialize_inline_geometry_in_file(svg_file)
|
||||
except (OSError, ET.ParseError, GeometryStyleError) as exc:
|
||||
safe_print(
|
||||
f"[ERROR] {svg_file.name}: expanded icon geometry "
|
||||
f"materialization failed: {exc}"
|
||||
)
|
||||
return False
|
||||
local_use_count = 0
|
||||
for svg_file in candidate_dir.glob('*.svg'):
|
||||
try:
|
||||
local_use_count += expand_local_use_references_in_file(svg_file)
|
||||
except (OSError, ET.ParseError, UseExpansionError) as exc:
|
||||
safe_print(
|
||||
f"[ERROR] {svg_file.name}: local <use> expansion failed: {exc}"
|
||||
)
|
||||
return False
|
||||
if not quiet:
|
||||
if icons_count > 0:
|
||||
safe_print(f" {icons_count} icon(s) embedded")
|
||||
else:
|
||||
safe_print(" No icons")
|
||||
if local_use_count > 0:
|
||||
safe_print(f" {local_use_count} local use reference(s) expanded")
|
||||
else:
|
||||
safe_print(" No local use references")
|
||||
|
||||
if not quiet and geometry_count:
|
||||
safe_print(
|
||||
f"[PREP] {geometry_count} inline geometry declaration(s) materialized"
|
||||
)
|
||||
|
||||
# Step 2: Align (slice/meet) and Base64-embed all <image> in one pass.
|
||||
# Replaces the former crop-images / fix-aspect / embed-images trio: the
|
||||
# spatial transform (slice → crop, meet → fit-box) and the asset embed
|
||||
# are mutually exclusive branches per image, sequenced together so each
|
||||
# SVG is only parsed and serialized once and each bitmap is only read
|
||||
# from disk once.
|
||||
if options.get('align_images'):
|
||||
if not quiet:
|
||||
safe_print("[2/3] Aligning + embedding images...")
|
||||
img_count = 0
|
||||
img_errors = 0
|
||||
office_vector_count = 0
|
||||
for svg_file in candidate_dir.glob('*.svg'):
|
||||
office_vector_count += count_office_vector_refs_in_svg(svg_file)
|
||||
count, errs = align_and_embed_images_in_svg(
|
||||
svg_file,
|
||||
dry_run=False,
|
||||
verbose=False,
|
||||
compress=compress,
|
||||
max_dimension=max_dimension,
|
||||
image_scale=image_scale,
|
||||
)
|
||||
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,
|
||||
)
|
||||
return False
|
||||
if not quiet:
|
||||
if img_count > 0:
|
||||
msg = f" {img_count} image(s) aligned + embedded"
|
||||
safe_print(msg)
|
||||
if office_vector_count:
|
||||
safe_print(
|
||||
f" {office_vector_count} Office vector(s) left external "
|
||||
"for native PPTX passthrough"
|
||||
)
|
||||
elif office_vector_count:
|
||||
safe_print(
|
||||
f" {office_vector_count} Office vector(s) left external "
|
||||
"for native PPTX passthrough"
|
||||
)
|
||||
else:
|
||||
safe_print(" No images")
|
||||
|
||||
# Step 3: Flatten text.
|
||||
if options.get('flatten_text'):
|
||||
if not quiet:
|
||||
safe_print("[3/3] Flattening text...")
|
||||
flatten_count = 0
|
||||
flatten_errors = 0
|
||||
for svg_file in candidate_dir.glob('*.svg'):
|
||||
result = process_flatten_text(svg_file, verbose=False)
|
||||
if result is FlattenTextResult.CHANGED:
|
||||
flatten_count += 1
|
||||
elif result is FlattenTextResult.ERROR:
|
||||
flatten_errors += 1
|
||||
if flatten_errors:
|
||||
safe_print(
|
||||
f"[ERROR] Text flattening failed for {flatten_errors} file(s); "
|
||||
"svg_final was not published",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
if not quiet:
|
||||
if flatten_count > 0:
|
||||
safe_print(f" {flatten_count} file(s) processed")
|
||||
else:
|
||||
safe_print(" No processing needed")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def finalize_project(
|
||||
@@ -149,133 +364,45 @@ def finalize_project(
|
||||
safe_print("[PREVIEW] Preview mode, no operations will be performed")
|
||||
return True
|
||||
|
||||
# Step 1: Copy directory
|
||||
if svg_final.exists():
|
||||
shutil.rmtree(svg_final)
|
||||
shutil.copytree(svg_output, svg_final)
|
||||
|
||||
if not quiet:
|
||||
print()
|
||||
|
||||
# Core normalization: downstream image/rect processors read XML geometry.
|
||||
geometry_count = 0
|
||||
for svg_file in svg_final.glob('*.svg'):
|
||||
try:
|
||||
geometry_count += materialize_inline_geometry_in_file(svg_file)
|
||||
except (OSError, ET.ParseError, GeometryStyleError) as exc:
|
||||
safe_print(
|
||||
f"[ERROR] {svg_file.name}: inline geometry materialization failed: {exc}"
|
||||
)
|
||||
return False
|
||||
# Step 2: Expand project icons, then standard same-document use references.
|
||||
if options.get('embed_icons'):
|
||||
if not quiet:
|
||||
safe_print("[1/3] Expanding icons + local use references...")
|
||||
icons_count = 0
|
||||
for svg_file in svg_final.glob('*.svg'):
|
||||
count = embed_icons_in_file(
|
||||
svg_file,
|
||||
icons_dir,
|
||||
dry_run=False,
|
||||
verbose=False,
|
||||
fallback_dir=icons_fallback_dir,
|
||||
)
|
||||
icons_count += count
|
||||
for svg_file in svg_final.glob('*.svg'):
|
||||
try:
|
||||
geometry_count += materialize_inline_geometry_in_file(svg_file)
|
||||
except (OSError, ET.ParseError, GeometryStyleError) as exc:
|
||||
safe_print(
|
||||
f"[ERROR] {svg_file.name}: expanded icon geometry "
|
||||
f"materialization failed: {exc}"
|
||||
)
|
||||
return False
|
||||
local_use_count = 0
|
||||
for svg_file in svg_final.glob('*.svg'):
|
||||
try:
|
||||
local_use_count += expand_local_use_references_in_file(svg_file)
|
||||
except (OSError, ET.ParseError, UseExpansionError) as exc:
|
||||
safe_print(
|
||||
f"[ERROR] {svg_file.name}: local <use> expansion failed: {exc}"
|
||||
)
|
||||
return False
|
||||
if not quiet:
|
||||
if icons_count > 0:
|
||||
safe_print(f" {icons_count} icon(s) embedded")
|
||||
else:
|
||||
safe_print(" No icons")
|
||||
if local_use_count > 0:
|
||||
safe_print(f" {local_use_count} local use reference(s) expanded")
|
||||
else:
|
||||
safe_print(" No local use references")
|
||||
|
||||
if not quiet and geometry_count:
|
||||
safe_print(
|
||||
f"[PREP] {geometry_count} inline geometry declaration(s) materialized"
|
||||
candidate_dir = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f".{svg_final.name}.candidate-",
|
||||
dir=svg_final.parent,
|
||||
)
|
||||
|
||||
# Step 3: Align (slice/meet) and Base64-embed all <image> in one pass.
|
||||
# Replaces the former crop-images / fix-aspect / embed-images trio: the
|
||||
# spatial transform (slice → crop, meet → fit-box) and the asset embed
|
||||
# are mutually exclusive branches per image, sequenced together so each
|
||||
# SVG is only parsed and serialized once and each bitmap is only read
|
||||
# from disk once.
|
||||
if options.get('align_images'):
|
||||
if not quiet:
|
||||
safe_print("[2/3] Aligning + embedding images...")
|
||||
img_count = 0
|
||||
img_errors = 0
|
||||
office_vector_count = 0
|
||||
for svg_file in svg_final.glob('*.svg'):
|
||||
office_vector_count += count_office_vector_refs_in_svg(svg_file)
|
||||
count, errs = align_and_embed_images_in_svg(
|
||||
svg_file,
|
||||
dry_run=False,
|
||||
verbose=False,
|
||||
)
|
||||
try:
|
||||
try:
|
||||
shutil.copytree(svg_output, candidate_dir, dirs_exist_ok=True)
|
||||
candidate_ready = _process_candidate_directory(
|
||||
candidate_dir,
|
||||
options=options,
|
||||
quiet=quiet,
|
||||
compress=compress,
|
||||
max_dimension=max_dimension,
|
||||
image_scale=image_scale,
|
||||
icons_dir=icons_dir,
|
||||
icons_fallback_dir=icons_fallback_dir,
|
||||
)
|
||||
img_count += count
|
||||
img_errors += errs
|
||||
if img_errors:
|
||||
except Exception as exc:
|
||||
safe_print(
|
||||
f"[ERROR] Image alignment/embedding failed for "
|
||||
f"{img_errors} image(s); svg_final was not published",
|
||||
f"[ERROR] SVG finalization failed before publish: {exc}",
|
||||
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"
|
||||
safe_print(msg)
|
||||
if office_vector_count:
|
||||
safe_print(
|
||||
f" {office_vector_count} Office vector(s) left external "
|
||||
"for native PPTX passthrough"
|
||||
)
|
||||
elif office_vector_count:
|
||||
safe_print(
|
||||
f" {office_vector_count} Office vector(s) left external "
|
||||
"for native PPTX passthrough"
|
||||
)
|
||||
else:
|
||||
safe_print(" No images")
|
||||
|
||||
# Step 4: Flatten text
|
||||
if options.get('flatten_text'):
|
||||
if not quiet:
|
||||
safe_print("[3/3] Flattening text...")
|
||||
flatten_count = 0
|
||||
for svg_file in svg_final.glob('*.svg'):
|
||||
if process_flatten_text(svg_file, verbose=False):
|
||||
flatten_count += 1
|
||||
if not quiet:
|
||||
if flatten_count > 0:
|
||||
safe_print(f" {flatten_count} file(s) processed")
|
||||
else:
|
||||
safe_print(" No processing needed")
|
||||
if not candidate_ready:
|
||||
return False
|
||||
|
||||
try:
|
||||
_publish_candidate_directory(candidate_dir, svg_final)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
safe_print(
|
||||
f"[ERROR] svg_final publish failed: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
finally:
|
||||
shutil.rmtree(candidate_dir, ignore_errors=True)
|
||||
|
||||
# Done
|
||||
if not quiet:
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Language Tag Helpers
|
||||
|
||||
Normalize and inspect the project primary-language tag shared by confirmation
|
||||
and export tooling.
|
||||
|
||||
Usage:
|
||||
Import ``normalize_language_tag`` from another script.
|
||||
|
||||
Examples:
|
||||
from language_tags import normalize_language_tag
|
||||
normalize_language_tag("Chinese")
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
class LanguageTagError(ValueError):
|
||||
"""Report an invalid or ambiguous project language tag."""
|
||||
|
||||
|
||||
_LANGUAGE_RE = re.compile(r"^[A-Za-z]{2,8}$")
|
||||
_EXTLANG_RE = re.compile(r"^[A-Za-z]{3}$")
|
||||
_SCRIPT_RE = re.compile(r"^[A-Za-z]{4}$")
|
||||
_REGION_RE = re.compile(r"^(?:[A-Za-z]{2}|[0-9]{3})$")
|
||||
_VARIANT_RE = re.compile(r"^(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3})$")
|
||||
_SINGLETON_RE = re.compile(r"^[0-9A-WY-Za-wy-z]$")
|
||||
_EXTENSION_RE = re.compile(r"^[A-Za-z0-9]{2,8}$")
|
||||
_PRIVATE_RE = re.compile(r"^[A-Za-z0-9]{1,8}$")
|
||||
|
||||
|
||||
def _alias_key(value: str) -> str:
|
||||
"""Return a whitespace-stable key for legacy natural-language aliases."""
|
||||
return " ".join(value.strip().casefold().split())
|
||||
|
||||
|
||||
_LANGUAGE_ALIASES = {
|
||||
_alias_key(alias): tag
|
||||
for tag, aliases in {
|
||||
"en-US": (
|
||||
"English",
|
||||
"英文",
|
||||
"英语",
|
||||
"英語",
|
||||
),
|
||||
"zh-Hans": (
|
||||
"Chinese",
|
||||
"Chinese Simplified",
|
||||
"Chinese (Simplified)",
|
||||
"Simplified Chinese",
|
||||
"Mandarin",
|
||||
"中文",
|
||||
"汉语",
|
||||
"漢語",
|
||||
"普通话",
|
||||
"普通話",
|
||||
"简体",
|
||||
"簡體",
|
||||
"简体中文",
|
||||
"簡體中文",
|
||||
"简中",
|
||||
),
|
||||
"zh-Hant": (
|
||||
"Chinese Traditional",
|
||||
"Chinese (Traditional)",
|
||||
"Traditional Chinese",
|
||||
"繁体",
|
||||
"繁體",
|
||||
"繁体中文",
|
||||
"繁體中文",
|
||||
"繁中",
|
||||
),
|
||||
"ja-JP": (
|
||||
"Japanese",
|
||||
"日本語",
|
||||
"日文",
|
||||
"日语",
|
||||
"日語",
|
||||
),
|
||||
"ko-KR": (
|
||||
"Korean",
|
||||
"한국어",
|
||||
"조선말",
|
||||
"韩语",
|
||||
"韓語",
|
||||
"朝鲜语",
|
||||
"朝鮮語",
|
||||
),
|
||||
}.items()
|
||||
for alias in aliases
|
||||
}
|
||||
|
||||
_NATURAL_LANGUAGE_NAMES = {
|
||||
"arabic",
|
||||
"bengali",
|
||||
"czech",
|
||||
"danish",
|
||||
"dutch",
|
||||
"farsi",
|
||||
"finnish",
|
||||
"french",
|
||||
"georgian",
|
||||
"german",
|
||||
"greek",
|
||||
"hebrew",
|
||||
"hindi",
|
||||
"hungarian",
|
||||
"indonesian",
|
||||
"italian",
|
||||
"malay",
|
||||
"marathi",
|
||||
"nepali",
|
||||
"norwegian",
|
||||
"persian",
|
||||
"polish",
|
||||
"portuguese",
|
||||
"romanian",
|
||||
"russian",
|
||||
"slovak",
|
||||
"spanish",
|
||||
"swedish",
|
||||
"tamil",
|
||||
"telugu",
|
||||
"thai",
|
||||
"turkish",
|
||||
"ukrainian",
|
||||
"urdu",
|
||||
"vietnamese",
|
||||
}
|
||||
_RTL_BASE_LANGUAGES = {
|
||||
"ar",
|
||||
"dv",
|
||||
"fa",
|
||||
"he",
|
||||
"iw",
|
||||
"ps",
|
||||
"sd",
|
||||
"ug",
|
||||
"ur",
|
||||
"yi",
|
||||
}
|
||||
_RTL_SCRIPTS = {
|
||||
"Adlm",
|
||||
"Arab",
|
||||
"Hebr",
|
||||
"Mand",
|
||||
"Nkoo",
|
||||
"Rohg",
|
||||
"Samr",
|
||||
"Syrc",
|
||||
"Thaa",
|
||||
}
|
||||
|
||||
|
||||
def normalize_language_tag(value: str) -> str:
|
||||
"""Return one canonical, unambiguous BCP-47 language tag.
|
||||
|
||||
The helper accepts the four historical natural-language aliases used by
|
||||
Confirm UI. Other language names stay invalid: callers must provide a real
|
||||
tag such as ``es-ES``, ``ru-RU``, or ``ar-SA``.
|
||||
"""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise LanguageTagError("primary language must be a non-empty BCP-47 tag")
|
||||
|
||||
raw = value.strip()
|
||||
alias = _LANGUAGE_ALIASES.get(_alias_key(raw))
|
||||
if alias:
|
||||
raw = alias
|
||||
else:
|
||||
raw = raw.replace("_", "-")
|
||||
|
||||
parts = raw.split("-")
|
||||
if any(not part for part in parts):
|
||||
raise LanguageTagError(f"invalid BCP-47 language tag: {value!r}")
|
||||
|
||||
language = parts[0]
|
||||
if not _LANGUAGE_RE.fullmatch(language):
|
||||
raise LanguageTagError(
|
||||
"use a BCP-47 language tag such as es-ES, ru-RU, ar-SA, or zh-Hans"
|
||||
)
|
||||
language = language.lower()
|
||||
if language in _NATURAL_LANGUAGE_NAMES:
|
||||
raise LanguageTagError(
|
||||
"use a BCP-47 language tag such as es-ES, ru-RU, ar-SA, or zh-Hans"
|
||||
)
|
||||
if language == "und":
|
||||
raise LanguageTagError(
|
||||
"und is not a usable primary language; choose the presentation language"
|
||||
)
|
||||
|
||||
normalized = [language]
|
||||
index = 1
|
||||
|
||||
extlang_count = 0
|
||||
while (
|
||||
len(language) <= 3
|
||||
and index < len(parts)
|
||||
and extlang_count < 3
|
||||
and _EXTLANG_RE.fullmatch(parts[index])
|
||||
):
|
||||
normalized.append(parts[index].lower())
|
||||
index += 1
|
||||
extlang_count += 1
|
||||
|
||||
has_script = False
|
||||
if index < len(parts) and _SCRIPT_RE.fullmatch(parts[index]):
|
||||
normalized.append(parts[index].title())
|
||||
index += 1
|
||||
has_script = True
|
||||
|
||||
has_region = False
|
||||
if index < len(parts) and _REGION_RE.fullmatch(parts[index]):
|
||||
region = parts[index]
|
||||
normalized.append(region.upper() if region.isalpha() else region)
|
||||
index += 1
|
||||
has_region = True
|
||||
|
||||
variants = set()
|
||||
while index < len(parts) and _VARIANT_RE.fullmatch(parts[index]):
|
||||
variant = parts[index].lower()
|
||||
if variant in variants:
|
||||
raise LanguageTagError(f"duplicate BCP-47 variant: {parts[index]!r}")
|
||||
variants.add(variant)
|
||||
normalized.append(variant)
|
||||
index += 1
|
||||
|
||||
singletons = set()
|
||||
while index < len(parts) and _SINGLETON_RE.fullmatch(parts[index]):
|
||||
singleton = parts[index].lower()
|
||||
if singleton in singletons:
|
||||
raise LanguageTagError(f"duplicate BCP-47 extension: {parts[index]!r}")
|
||||
singletons.add(singleton)
|
||||
normalized.append(singleton)
|
||||
index += 1
|
||||
start = index
|
||||
while index < len(parts) and _EXTENSION_RE.fullmatch(parts[index]):
|
||||
normalized.append(parts[index].lower())
|
||||
index += 1
|
||||
if index == start:
|
||||
raise LanguageTagError(
|
||||
f"BCP-47 extension {singleton!r} requires a following subtag"
|
||||
)
|
||||
|
||||
if index < len(parts) and parts[index].casefold() == "x":
|
||||
normalized.append("x")
|
||||
index += 1
|
||||
start = index
|
||||
while index < len(parts) and _PRIVATE_RE.fullmatch(parts[index]):
|
||||
normalized.append(parts[index].lower())
|
||||
index += 1
|
||||
if index == start:
|
||||
raise LanguageTagError("BCP-47 private use requires a following subtag")
|
||||
|
||||
if index != len(parts):
|
||||
raise LanguageTagError(f"invalid BCP-47 subtag: {parts[index]!r}")
|
||||
if language == "zh" and not (has_script or has_region):
|
||||
raise LanguageTagError(
|
||||
"Chinese must declare a script or region, for example zh-Hans, "
|
||||
"zh-Hant, zh-CN, or zh-TW"
|
||||
)
|
||||
return "-".join(normalized)
|
||||
|
||||
|
||||
def language_base(value: str) -> str:
|
||||
"""Return the lower-case primary language subtag."""
|
||||
return normalize_language_tag(value).split("-", 1)[0]
|
||||
|
||||
|
||||
def language_uses_rtl(value: str) -> bool:
|
||||
"""Return whether the canonical language or explicit script is RTL."""
|
||||
tag = normalize_language_tag(value)
|
||||
parts = tag.split("-")
|
||||
index = 1
|
||||
if len(parts[0]) <= 3:
|
||||
while index < len(parts) and _EXTLANG_RE.fullmatch(parts[index]):
|
||||
index += 1
|
||||
if index < len(parts) and _SCRIPT_RE.fullmatch(parts[index]):
|
||||
return parts[index] in _RTL_SCRIPTS
|
||||
return parts[0] in _RTL_BASE_LANGUAGES
|
||||
@@ -9,7 +9,7 @@ global or per-slide transitions while keeping the stable workflow command.
|
||||
Usage:
|
||||
python3 scripts/native_enhance_pptx.py init <source.pptx> [--name project_name]
|
||||
python3 scripts/native_enhance_pptx.py plan <project_path>
|
||||
python3 scripts/native_enhance_pptx.py validate <project_path>
|
||||
python3 scripts/native_enhance_pptx.py validate <project_path> [--materials {all,notes}]
|
||||
python3 scripts/native_enhance_pptx.py apply <project_path>
|
||||
|
||||
Examples:
|
||||
|
||||
+98
-28
@@ -12,7 +12,7 @@ slide auto-advance timings, and optional global or per-slide page transitions.
|
||||
Usage:
|
||||
python3 scripts/native_enhance_pptx.py init <source.pptx> [--name project_name]
|
||||
python3 scripts/native_enhance_pptx.py apply <project_path> [--output output.pptx]
|
||||
python3 scripts/native_enhance_pptx.py validate <project_path>
|
||||
python3 scripts/native_enhance_pptx.py validate <project_path> [--materials {all,notes}]
|
||||
|
||||
Examples:
|
||||
python3 scripts/native_enhance_pptx.py init projects/source.pptx --name fire_station
|
||||
@@ -624,10 +624,19 @@ def _audio_path(audio_dir: Path, index: int) -> Path | None:
|
||||
f"slide{index}",
|
||||
]
|
||||
for stem in stems:
|
||||
for ext in NARRATION_EXTENSIONS:
|
||||
candidate = audio_dir / f"{stem}{ext}"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
matches = [
|
||||
audio_dir / f"{stem}{ext}"
|
||||
for ext in NARRATION_EXTENSIONS
|
||||
if (audio_dir / f"{stem}{ext}").exists()
|
||||
]
|
||||
if len(matches) > 1:
|
||||
names = ", ".join(path.name for path in matches)
|
||||
raise ValueError(
|
||||
f"ambiguous audio stem {stem!r}: {names}; "
|
||||
"keep exactly one supported extension"
|
||||
)
|
||||
if matches:
|
||||
return matches[0]
|
||||
return None
|
||||
|
||||
|
||||
@@ -636,11 +645,16 @@ def _collect_material_readiness(
|
||||
notes_dir: Path,
|
||||
audio_dir: Path,
|
||||
modules: set[str],
|
||||
*,
|
||||
required_modules: set[str] | None = None,
|
||||
) -> MaterialReadiness:
|
||||
"""Inspect enabled-module inputs once for both validation and application."""
|
||||
notes_required = "notes" in modules
|
||||
audio_required = "audio" in modules or "timings" in modules
|
||||
timings_enabled = "timings" in modules
|
||||
material_modules = modules if required_modules is None else required_modules
|
||||
notes_required = "notes" in material_modules
|
||||
audio_required = (
|
||||
"audio" in material_modules or "timings" in material_modules
|
||||
)
|
||||
timings_enabled = "timings" in material_modules
|
||||
note_paths: dict[int, Path] = {}
|
||||
audio_paths: dict[int, Path] = {}
|
||||
audio_durations: dict[int, float] = {}
|
||||
@@ -668,7 +682,12 @@ def _collect_material_readiness(
|
||||
elif notes_required:
|
||||
invalid_notes[slide.index] = f"{note.name} has no spoken text"
|
||||
|
||||
audio = _audio_path(audio_dir, slide.index)
|
||||
try:
|
||||
audio = _audio_path(audio_dir, slide.index)
|
||||
except ValueError as exc:
|
||||
if audio_required:
|
||||
invalid_audio[slide.index] = str(exc)
|
||||
continue
|
||||
if audio is None:
|
||||
if audio_required:
|
||||
missing_audio.append(slide.index)
|
||||
@@ -1207,8 +1226,8 @@ def _build_enhancement_plan(
|
||||
existing_plan: dict | None = None,
|
||||
) -> dict:
|
||||
previous = existing_plan or {}
|
||||
notes_enabled = _preserved_enabled(previous, "notes", True)
|
||||
audio_enabled = _preserved_enabled(previous, "audio", True)
|
||||
notes_enabled = _preserved_enabled(previous, "notes", True) or audio_enabled
|
||||
timings_enabled = _preserved_enabled(previous, "timings", True)
|
||||
previous_timings = _module_config(previous, "timings")
|
||||
raw_padding: object
|
||||
@@ -1367,7 +1386,11 @@ def _resolve_slide_enter(
|
||||
)
|
||||
|
||||
|
||||
def _validate_plan_modules(plan: dict) -> None:
|
||||
def _validate_plan_modules(
|
||||
plan: dict,
|
||||
*,
|
||||
allow_legacy_audio_without_notes: bool = False,
|
||||
) -> None:
|
||||
if plan and plan.get("schema") != PLAN_SCHEMA:
|
||||
raise ValueError(
|
||||
f"unsupported enhancement plan schema: {plan.get('schema')!r}"
|
||||
@@ -1411,6 +1434,25 @@ def _validate_plan_modules(plan: dict) -> None:
|
||||
raise ValueError(
|
||||
f"enhancement plan module {name}.enabled must be a boolean"
|
||||
)
|
||||
notes_config = modules_cfg.get("notes")
|
||||
audio_config = modules_cfg.get("audio")
|
||||
legacy_audio_without_notes = (
|
||||
allow_legacy_audio_without_notes
|
||||
and isinstance(notes_config, dict)
|
||||
and notes_config.get("enabled") is False
|
||||
)
|
||||
if (
|
||||
isinstance(audio_config, dict)
|
||||
and audio_config.get("enabled") is True
|
||||
and not legacy_audio_without_notes
|
||||
and (
|
||||
not isinstance(notes_config, dict)
|
||||
or notes_config.get("enabled") is not True
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
"enhancement plan audio requires notes.enabled: true"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_transition_plan(
|
||||
@@ -1505,6 +1547,18 @@ def _resolve_transition_plan(
|
||||
apply_without_audio = (
|
||||
cli_apply_without_audio or raw_apply_without_audio
|
||||
)
|
||||
if (
|
||||
"audio" not in modules
|
||||
and (
|
||||
"transitions" in modules
|
||||
or cli_effect is not None
|
||||
or global_enter.policy == "none"
|
||||
)
|
||||
):
|
||||
# A confirmed global transition is independently actionable. The
|
||||
# narrated-only scope switch matters only while audio is enabled.
|
||||
# Explicit none remains an action even though the module is disabled.
|
||||
apply_without_audio = True
|
||||
|
||||
raw_slides = transitions_cfg.get("slides", {})
|
||||
if not isinstance(raw_slides, dict):
|
||||
@@ -1536,17 +1590,6 @@ def _resolve_transition_plan(
|
||||
slide_index=slide_index,
|
||||
)
|
||||
|
||||
if (
|
||||
("transitions" in modules or cli_effect is not None)
|
||||
and "audio" not in modules
|
||||
and not apply_without_audio
|
||||
and not slide_enters
|
||||
):
|
||||
raise ValueError(
|
||||
"transitions is enabled, but no slides are reachable: enable audio, "
|
||||
"set apply_without_audio=true, or add transitions.slides entries"
|
||||
)
|
||||
|
||||
return ResolvedTransitionPlan(
|
||||
global_enter=global_enter,
|
||||
slide_enters=slide_enters,
|
||||
@@ -1751,7 +1794,10 @@ def plan_project(args: argparse.Namespace) -> int:
|
||||
|
||||
existing_plan = _load_enhancement_plan(project_path)
|
||||
try:
|
||||
_validate_plan_modules(existing_plan)
|
||||
_validate_plan_modules(
|
||||
existing_plan,
|
||||
allow_legacy_audio_without_notes=True,
|
||||
)
|
||||
readiness = _collect_material_readiness(
|
||||
slides,
|
||||
notes_dir,
|
||||
@@ -2139,6 +2185,7 @@ def validate_project(args: argparse.Namespace) -> int:
|
||||
source_pptx, notes_dir, audio_dir, _exports_dir = _project_paths(project_path)
|
||||
plan = _load_enhancement_plan(project_path)
|
||||
modules = _enabled_modules(plan)
|
||||
material_modules = {"notes"} if args.materials == "notes" else modules
|
||||
source_delivery = audit_pptx_delivery(source_pptx)
|
||||
validation_dir = project_path / "validation"
|
||||
validation_dir.mkdir(exist_ok=True)
|
||||
@@ -2149,6 +2196,7 @@ def validate_project(args: argparse.Namespace) -> int:
|
||||
plan,
|
||||
modules,
|
||||
status="failed",
|
||||
material_scope=args.materials,
|
||||
fatal_delivery_errors=fatal_delivery_messages,
|
||||
delivery_check=source_delivery,
|
||||
)
|
||||
@@ -2215,6 +2263,7 @@ def validate_project(args: argparse.Namespace) -> int:
|
||||
notes_dir,
|
||||
audio_dir,
|
||||
modules,
|
||||
required_modules=material_modules,
|
||||
)
|
||||
hard_failure = bool(
|
||||
source_errors
|
||||
@@ -2238,9 +2287,12 @@ def validate_project(args: argparse.Namespace) -> int:
|
||||
plan,
|
||||
modules,
|
||||
status=status,
|
||||
material_scope=args.materials,
|
||||
slide_count=len(slides),
|
||||
notes_required="notes" in modules,
|
||||
audio_required="audio" in modules or "timings" in modules,
|
||||
notes_required="notes" in material_modules,
|
||||
audio_required=(
|
||||
"audio" in material_modules or "timings" in material_modules
|
||||
),
|
||||
plan_errors=plan_errors,
|
||||
transition_errors=transition_errors,
|
||||
transition_override_count=transition_slide_count,
|
||||
@@ -2280,7 +2332,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
init.add_argument(
|
||||
"--apply-transition-without-audio",
|
||||
action="store_true",
|
||||
help="draft the plan with page transitions for slides without audio",
|
||||
help=(
|
||||
"when audio is enabled, draft transitions for slides without audio "
|
||||
"as well"
|
||||
),
|
||||
)
|
||||
init.set_defaults(func=init_project)
|
||||
|
||||
@@ -2309,7 +2364,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"--apply-transition-without-audio",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="include page transitions for slides without audio",
|
||||
help=(
|
||||
"when audio is enabled, include page transitions for slides "
|
||||
"without audio"
|
||||
),
|
||||
)
|
||||
plan.set_defaults(func=plan_project)
|
||||
|
||||
@@ -2332,7 +2390,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
apply.add_argument(
|
||||
"--apply-transition-without-audio",
|
||||
action="store_true",
|
||||
help="also write page transitions on slides that do not have audio",
|
||||
help=(
|
||||
"when audio is enabled, also write page transitions on slides "
|
||||
"without audio"
|
||||
),
|
||||
)
|
||||
apply.set_defaults(func=apply_project)
|
||||
|
||||
@@ -2341,6 +2402,15 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="check source integrity, plan semantics, and material readiness",
|
||||
)
|
||||
validate.add_argument("project_path", help="native enhancement project directory")
|
||||
validate.add_argument(
|
||||
"--materials",
|
||||
choices=("all", "notes"),
|
||||
default="all",
|
||||
help=(
|
||||
"required material scope: all enabled modules, or notes only "
|
||||
"before narration audio exists (default: all)"
|
||||
),
|
||||
)
|
||||
validate.set_defaults(func=validate_project)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -44,6 +46,7 @@ from tts_backends import (
|
||||
configure_utf8_stdio()
|
||||
|
||||
DEFAULT_EDGE_CONCURRENCY = 3
|
||||
SUPPORTED_AUDIO_EXTENSIONS = frozenset({".m4a", ".mp3", ".wav"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -54,6 +57,12 @@ class AudioBackend:
|
||||
voice_id: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoteRosterEntry:
|
||||
note_path: Path
|
||||
output_stem: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioJob:
|
||||
note_path: Path
|
||||
@@ -88,25 +97,128 @@ def spoken_text(markdown: str) -> str:
|
||||
|
||||
|
||||
def _prepare_audio_jobs(
|
||||
note_files: list[Path],
|
||||
note_roster: list[NoteRosterEntry],
|
||||
output_dir: Path,
|
||||
extension: str,
|
||||
) -> list[AudioJob]:
|
||||
"""Read non-empty per-slide notes into ordered audio jobs."""
|
||||
"""Read a complete per-slide notes roster into ordered audio jobs."""
|
||||
jobs: list[AudioJob] = []
|
||||
for note_path in note_files:
|
||||
text = spoken_text(note_path.read_text(encoding="utf-8"))
|
||||
invalid: list[str] = []
|
||||
for entry in note_roster:
|
||||
note_path = entry.note_path
|
||||
if not note_path.is_file():
|
||||
invalid.append(f"{note_path.name} is missing")
|
||||
continue
|
||||
try:
|
||||
text = spoken_text(note_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError) as exc:
|
||||
invalid.append(f"{note_path.name} is unreadable: {exc}")
|
||||
continue
|
||||
if not text:
|
||||
print(f"[skip] {note_path.name}: empty spoken text")
|
||||
invalid.append(f"{note_path.name} has no spoken text")
|
||||
continue
|
||||
jobs.append(AudioJob(
|
||||
note_path=note_path,
|
||||
text=text,
|
||||
output_path=output_dir / f"{note_path.stem}{extension}",
|
||||
output_path=output_dir / f"{entry.output_stem}{extension}",
|
||||
))
|
||||
if invalid:
|
||||
raise ValueError(
|
||||
"per-slide notes are incomplete: " + "; ".join(invalid)
|
||||
)
|
||||
return jobs
|
||||
|
||||
|
||||
def _expected_note_roster(project: Path) -> list[NoteRosterEntry]:
|
||||
"""Resolve the owning route's complete per-slide notes roster."""
|
||||
notes_dir = project / "notes"
|
||||
svg_files = sorted((project / "svg_output").glob("*.svg"))
|
||||
if svg_files:
|
||||
aliases: dict[int, list[Path]] = {}
|
||||
for path in sorted(notes_dir.glob("*.md")):
|
||||
match = re.search(r"slide[_]?(\d+)", path.stem)
|
||||
if match:
|
||||
aliases.setdefault(int(match.group(1)), []).append(path)
|
||||
note_roster: list[NoteRosterEntry] = []
|
||||
for index, svg_path in enumerate(svg_files, 1):
|
||||
exact = notes_dir / f"{svg_path.stem}.md"
|
||||
if exact.exists():
|
||||
note_roster.append(NoteRosterEntry(
|
||||
note_path=exact,
|
||||
output_stem=svg_path.stem,
|
||||
))
|
||||
continue
|
||||
matches = aliases.get(index, [])
|
||||
if len(matches) > 1:
|
||||
raise ValueError(
|
||||
f"multiple notes files match slide {index}: "
|
||||
+ ", ".join(path.name for path in matches)
|
||||
)
|
||||
note_roster.append(
|
||||
NoteRosterEntry(
|
||||
note_path=matches[0] if matches else exact,
|
||||
output_stem=svg_path.stem,
|
||||
)
|
||||
)
|
||||
return note_roster
|
||||
|
||||
slide_index_path = project / "analysis" / "slide_index.json"
|
||||
if slide_index_path.is_file():
|
||||
try:
|
||||
slide_index = json.loads(
|
||||
slide_index_path.read_text(encoding="utf-8")
|
||||
)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"invalid slide index: {exc}") from exc
|
||||
if not isinstance(slide_index, dict):
|
||||
raise ValueError("invalid slide index root")
|
||||
slides = slide_index.get("slides")
|
||||
slide_count = slide_index.get("slide_count")
|
||||
if (
|
||||
not isinstance(slides, list)
|
||||
or isinstance(slide_count, bool)
|
||||
or not isinstance(slide_count, int)
|
||||
or slide_count != len(slides)
|
||||
):
|
||||
raise ValueError("invalid slide index notes roster")
|
||||
note_roster: list[NoteRosterEntry] = []
|
||||
for index, slide in enumerate(slides, 1):
|
||||
note_file = slide.get("note_file") if isinstance(slide, dict) else None
|
||||
if not isinstance(note_file, str) or Path(note_file).suffix != ".md":
|
||||
raise ValueError(
|
||||
f"invalid slide index note_file for slide {index}"
|
||||
)
|
||||
note_name = Path(note_file).name
|
||||
note_roster.append(
|
||||
NoteRosterEntry(
|
||||
note_path=notes_dir / note_name,
|
||||
output_stem=Path(note_name).stem,
|
||||
)
|
||||
)
|
||||
return note_roster
|
||||
|
||||
return [
|
||||
NoteRosterEntry(
|
||||
note_path=path,
|
||||
output_stem=path.stem,
|
||||
)
|
||||
for path in sorted(notes_dir.glob("*.md"))
|
||||
if path.name != "total.md"
|
||||
]
|
||||
|
||||
|
||||
def _remove_stale_audio_variants(output_path: Path) -> None:
|
||||
"""Remove other supported formats only after the target audio is published."""
|
||||
for candidate in output_path.parent.iterdir():
|
||||
if (
|
||||
candidate.name != output_path.name
|
||||
and candidate.is_file()
|
||||
and candidate.stem == output_path.stem
|
||||
and candidate.suffix.lower() in SUPPORTED_AUDIO_EXTENSIONS
|
||||
):
|
||||
candidate.unlink()
|
||||
|
||||
|
||||
async def _generate_edge_jobs(
|
||||
jobs: list[AudioJob],
|
||||
subtitle_dir: Path,
|
||||
@@ -126,7 +238,7 @@ async def _generate_edge_jobs(
|
||||
job.output_path,
|
||||
voice=voice,
|
||||
rate=rate,
|
||||
subtitle_path=subtitle_dir / f"{job.note_path.stem}.srt",
|
||||
subtitle_path=subtitle_dir / f"{job.output_path.stem}.srt",
|
||||
subtitle_max_chars=subtitle_max_chars,
|
||||
)
|
||||
|
||||
@@ -373,20 +485,25 @@ def main() -> int:
|
||||
project = args.project_path
|
||||
notes_dir = project / "notes"
|
||||
output_dir = args.output or (project / "audio")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
subtitle_dir = notes_dir / "subtitles"
|
||||
|
||||
try:
|
||||
note_roster = _expected_note_roster(project)
|
||||
if not note_roster:
|
||||
raise ValueError(f"no per-slide notes found in {notes_dir}")
|
||||
jobs = _prepare_audio_jobs(
|
||||
note_roster,
|
||||
output_dir,
|
||||
backend.extension,
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
if backend.provider == "edge":
|
||||
subtitle_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
note_files = [
|
||||
path for path in sorted(notes_dir.glob("*.md"))
|
||||
if path.name != "total.md"
|
||||
]
|
||||
if not note_files:
|
||||
print(f"error: no per-slide notes found in {notes_dir}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
jobs = _prepare_audio_jobs(note_files, output_dir, backend.extension)
|
||||
generated = 0
|
||||
if backend.provider == "edge":
|
||||
print(
|
||||
@@ -408,7 +525,7 @@ def main() -> int:
|
||||
|
||||
failed = False
|
||||
for job, result in zip(jobs, results):
|
||||
subtitle_path = subtitle_dir / f"{job.note_path.stem}.srt"
|
||||
subtitle_path = subtitle_dir / f"{job.output_path.stem}.srt"
|
||||
if result is not None:
|
||||
print(
|
||||
f"error: failed to generate {job.output_path}: {result}",
|
||||
@@ -416,6 +533,16 @@ def main() -> int:
|
||||
)
|
||||
failed = True
|
||||
continue
|
||||
try:
|
||||
_remove_stale_audio_variants(job.output_path)
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"error: failed to remove stale audio for "
|
||||
f"{job.output_path.stem}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
failed = True
|
||||
continue
|
||||
generated += 1
|
||||
print(f"[OK] {job.output_path}")
|
||||
print(f" {subtitle_path}")
|
||||
@@ -484,6 +611,7 @@ def main() -> int:
|
||||
language_hint=args.cosyvoice_language_hint,
|
||||
base_url=args.cosyvoice_base_url,
|
||||
)
|
||||
_remove_stale_audio_variants(output_path)
|
||||
except Exception as exc:
|
||||
print(f"error: failed to generate {output_path}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -492,11 +620,11 @@ def main() -> int:
|
||||
|
||||
if backend.provider == "edge":
|
||||
print(
|
||||
f"[Done] Generated {generated}/{len(note_files)} audio/SRT pair(s): "
|
||||
f"[Done] Generated {generated}/{len(note_roster)} audio/SRT pair(s): "
|
||||
f"{output_dir} + {subtitle_dir}"
|
||||
)
|
||||
else:
|
||||
print(f"[Done] Generated {generated}/{len(note_files)} audio file(s): {output_dir}")
|
||||
print(f"[Done] Generated {generated}/{len(note_roster)} audio file(s): {output_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -171,6 +171,14 @@ ANIMATION_TIMING_OPTION_FIELDS = (
|
||||
)
|
||||
ANIMATION_RESTARTS = ('always', 'when-not-active', 'never')
|
||||
ANIMATION_AFTER_EFFECTS = ('none', 'dim', 'hide', 'hide-on-next-click')
|
||||
_INTERPOLATED_BEHAVIOR_TAGS = frozenset({
|
||||
'anim',
|
||||
'animClr',
|
||||
'animEffect',
|
||||
'animMotion',
|
||||
'animRot',
|
||||
'animScale',
|
||||
})
|
||||
_NON_CONCRETE_FONT_NAMES = frozenset({
|
||||
'-apple-system',
|
||||
'blinkmacsystemfont',
|
||||
@@ -1454,6 +1462,32 @@ def _animation_row_for_options(
|
||||
return row
|
||||
|
||||
|
||||
def _interpolated_behavior_nodes(row: ET.Element) -> tuple[ET.Element, ...]:
|
||||
"""Return behavior nodes that can carry PowerPoint bounce metadata."""
|
||||
return tuple(
|
||||
node
|
||||
for node in row.iter()
|
||||
if _local_name(node.tag) in _INTERPOLATED_BEHAVIOR_TAGS
|
||||
)
|
||||
|
||||
|
||||
def animation_effect_supports_bounce_end(
|
||||
effect: object,
|
||||
effect_options: object = None,
|
||||
) -> bool:
|
||||
"""Return whether one concrete effect has an interpolated behavior."""
|
||||
animation, options = normalize_animation_effect_request(
|
||||
effect,
|
||||
effect_options,
|
||||
allow_none=False,
|
||||
allow_modes=False,
|
||||
)
|
||||
if animation is None:
|
||||
raise AssertionError('concrete animation normalization returned none')
|
||||
row = _animation_row_for_options(animation, options)
|
||||
return bool(_interpolated_behavior_nodes(row))
|
||||
|
||||
|
||||
def _apply_timing_options(row: ET.Element, target: AnimationTarget) -> None:
|
||||
if target.repeat_count is not None:
|
||||
row.set('repeatCount', str(round(target.repeat_count * 1000)))
|
||||
@@ -1479,19 +1513,11 @@ def _apply_timing_options(row: ET.Element, target: AnimationTarget) -> None:
|
||||
else:
|
||||
row.attrib.pop('decel', None)
|
||||
if target.bounce_end is not None:
|
||||
bounce_nodes = [
|
||||
node
|
||||
for node in row.iter()
|
||||
if _local_name(node.tag) in {
|
||||
'anim',
|
||||
'animClr',
|
||||
'animEffect',
|
||||
'animMotion',
|
||||
'animRot',
|
||||
'animScale',
|
||||
}
|
||||
]
|
||||
if target.bounce_end and not bounce_nodes:
|
||||
bounce_nodes = _interpolated_behavior_nodes(row)
|
||||
if target.bounce_end and not animation_effect_supports_bounce_end(
|
||||
target.effect,
|
||||
target.effect_options,
|
||||
):
|
||||
raise ValueError(
|
||||
f'animation effect {target.effect!r} has no behavior that '
|
||||
'supports bounce_end'
|
||||
|
||||
@@ -20,11 +20,24 @@ Dependencies:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - Windows
|
||||
fcntl = None
|
||||
|
||||
try:
|
||||
import msvcrt
|
||||
except ImportError: # pragma: no cover - POSIX
|
||||
msvcrt = None
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
@@ -38,7 +51,22 @@ configure_utf8_stdio()
|
||||
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
fd, temp_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
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 _chart_summary(slide_library: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -200,6 +228,39 @@ def build_source_profile(
|
||||
SOURCE_INDEX_NAME = "source_profile.json"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _source_index_lock(output_dir: Path):
|
||||
"""Serialize source-index bundle publication with a persistent lock file."""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = output_dir / f"{SOURCE_INDEX_NAME}.lock"
|
||||
with lock_path.open("a+b") as lock_file:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
return
|
||||
|
||||
if msvcrt is not None:
|
||||
lock_file.seek(0, os.SEEK_END)
|
||||
if lock_file.tell() == 0:
|
||||
lock_file.write(b"\0")
|
||||
lock_file.flush()
|
||||
lock_file.seek(0)
|
||||
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
lock_file.seek(0)
|
||||
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
return
|
||||
|
||||
raise RuntimeError(
|
||||
"Cannot safely update source_profile.json: no supported file-lock API"
|
||||
)
|
||||
|
||||
|
||||
def _load_source_index(index_path: Path) -> dict[str, Any]:
|
||||
"""Load and validate an existing multi-deck source index."""
|
||||
if not index_path.exists():
|
||||
@@ -230,14 +291,10 @@ def _load_source_index(index_path: Path) -> dict[str, Any]:
|
||||
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`.
|
||||
|
||||
The index stays the single must-read entry for the Strategist: it inlines every
|
||||
deck's digest under `decks[]`, so a one-deck project is a one-entry index and a
|
||||
multi-deck project lists each source deck self-containedly. Re-importing a deck
|
||||
with the same stem replaces its entry in place.
|
||||
"""
|
||||
def _upsert_source_index_unlocked(
|
||||
output_dir: Path,
|
||||
digest: dict[str, Any],
|
||||
) -> Path:
|
||||
index_path = output_dir / SOURCE_INDEX_NAME
|
||||
index = _load_source_index(index_path)
|
||||
stem = digest.get("stem")
|
||||
@@ -253,6 +310,18 @@ def upsert_source_index(output_dir: Path, digest: dict[str, Any]) -> Path:
|
||||
return index_path
|
||||
|
||||
|
||||
def upsert_source_index(output_dir: Path, digest: dict[str, Any]) -> Path:
|
||||
"""Merge one deck digest into the serialized multi-deck source index.
|
||||
|
||||
The index stays the single must-read entry for the Strategist: it inlines every
|
||||
deck's digest under `decks[]`, so a one-deck project is a one-entry index and a
|
||||
multi-deck project lists each source deck self-containedly. Re-importing a deck
|
||||
with the same stem replaces its entry in place.
|
||||
"""
|
||||
with _source_index_lock(output_dir):
|
||||
return _upsert_source_index_unlocked(output_dir, digest)
|
||||
|
||||
|
||||
def run_intake(pptx_path: Path, output_dir: Path) -> dict[str, Path]:
|
||||
"""Write `<stem>.identity.json`, `<stem>.slide_library.json`, and merge the
|
||||
deck's digest into the single multi-deck index `source_profile.json`."""
|
||||
@@ -264,9 +333,10 @@ def run_intake(pptx_path: Path, output_dir: Path) -> dict[str, Path]:
|
||||
|
||||
identity_path = output_dir / f"{stem}.identity.json"
|
||||
slide_library_path = output_dir / f"{stem}.slide_library.json"
|
||||
_write_json(identity_path, identity)
|
||||
_write_json(slide_library_path, slide_library)
|
||||
profile_path = upsert_source_index(output_dir, digest)
|
||||
with _source_index_lock(output_dir):
|
||||
_write_json(identity_path, identity)
|
||||
_write_json(slide_library_path, slide_library)
|
||||
profile_path = _upsert_source_index_unlocked(output_dir, digest)
|
||||
return {
|
||||
"identity": identity_path,
|
||||
"slide_library": slide_library_path,
|
||||
|
||||
+153
-34
@@ -37,6 +37,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import math
|
||||
import mimetypes
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -55,6 +56,15 @@ from .emu_units import NS, Xfrm, emu_to_px, fmt_num, format_ooxml_alpha
|
||||
from .ooxml_loader import OoxmlPackage, PartRef, blip_embed_relationship_ids
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PictureDiagnostic:
|
||||
"""Recoverable loss while converting one DrawingML picture."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
fallback: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PictureResult:
|
||||
"""Resolved picture: SVG element string + extracted media bytes."""
|
||||
@@ -63,6 +73,7 @@ class PictureResult:
|
||||
# Map of {filename: bytes} that the assembler should emit alongside
|
||||
# the SVG. Filename is the basename inside the package's media dir.
|
||||
media: dict[str, bytes] = field(default_factory=dict)
|
||||
diagnostics: tuple[PictureDiagnostic, ...] = ()
|
||||
|
||||
|
||||
class MediaResolutionError(RuntimeError):
|
||||
@@ -78,6 +89,7 @@ def convert_blip_fill(
|
||||
media_subdir: str = "assets",
|
||||
embed_inline: bool = False,
|
||||
asset_name_map: dict[str, str] | None = None,
|
||||
strict: bool = False,
|
||||
) -> PictureResult:
|
||||
"""Convert an <a:blipFill> element to SVG <image>.
|
||||
|
||||
@@ -125,9 +137,19 @@ def convert_blip_fill(
|
||||
filename = (asset_name_map or {}).get(target, pkg.media_filename(target))
|
||||
filename, img_bytes = _normalize_office_media(filename, img_bytes)
|
||||
tile_source_bytes = img_bytes
|
||||
filename, img_bytes = _apply_blip_image_effects(filename, img_bytes, blip)
|
||||
diagnostics = list(_unsupported_blip_effect_diagnostics(blip))
|
||||
filename, img_bytes, effect_diagnostics = _apply_blip_image_effects(
|
||||
filename,
|
||||
img_bytes,
|
||||
blip,
|
||||
)
|
||||
diagnostics.extend(effect_diagnostics)
|
||||
opacity_attr, opacity_diagnostics = _blip_opacity_attr(blip)
|
||||
diagnostics.extend(opacity_diagnostics)
|
||||
if strict and diagnostics:
|
||||
details = "; ".join(item.message for item in diagnostics)
|
||||
raise ValueError(f"Cannot reproduce DrawingML picture effects: {details}")
|
||||
href = _build_href(filename, img_bytes, media_subdir, embed_inline)
|
||||
opacity_attr = _blip_opacity_attr(blip)
|
||||
|
||||
# srcRect: l/t/r/b in 1/100000ths (so 50000 = 50%).
|
||||
src_rect = blip_fill_elem.find("a:srcRect", NS)
|
||||
@@ -170,7 +192,11 @@ def convert_blip_fill(
|
||||
media: dict[str, bytes] = {}
|
||||
if not embed_inline:
|
||||
media[filename] = img_bytes
|
||||
return PictureResult(svg=svg, media=media)
|
||||
return PictureResult(
|
||||
svg=svg,
|
||||
media=media,
|
||||
diagnostics=tuple(diagnostics),
|
||||
)
|
||||
|
||||
|
||||
def convert_picture(
|
||||
@@ -182,6 +208,7 @@ def convert_picture(
|
||||
media_subdir: str = "assets",
|
||||
embed_inline: bool = False,
|
||||
asset_name_map: dict[str, str] | None = None,
|
||||
strict: bool = False,
|
||||
) -> PictureResult:
|
||||
"""Translate <p:pic> to SVG <image> (or nested <svg>+<image> for cropping)."""
|
||||
blip_fill = pic_elem.find("p:blipFill", NS)
|
||||
@@ -193,6 +220,7 @@ def convert_picture(
|
||||
media_subdir=media_subdir,
|
||||
embed_inline=embed_inline,
|
||||
asset_name_map=asset_name_map,
|
||||
strict=strict,
|
||||
)
|
||||
|
||||
|
||||
@@ -201,18 +229,78 @@ def convert_picture(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _blip_opacity_attr(blip: ET.Element) -> str:
|
||||
def _blip_opacity_attr(
|
||||
blip: ET.Element,
|
||||
) -> tuple[str, tuple[PictureDiagnostic, ...]]:
|
||||
"""Translate DrawingML fixed image alpha to an SVG opacity attribute."""
|
||||
alpha = blip.find("a:alphaModFix", NS)
|
||||
if alpha is None:
|
||||
return ""
|
||||
alpha_effects = blip.findall("a:alphaModFix", NS)
|
||||
if not alpha_effects:
|
||||
return "", ()
|
||||
if len(alpha_effects) > 1:
|
||||
return "", (
|
||||
_effect_diagnostic(
|
||||
"duplicate a:alphaModFix effects cannot be reproduced safely"
|
||||
),
|
||||
)
|
||||
alpha = alpha_effects[0]
|
||||
try:
|
||||
opacity = max(0.0, min(1.0, float(alpha.attrib.get("amt", "100000")) / 100000.0))
|
||||
opacity = float(alpha.attrib.get("amt", "100000")) / 100000.0
|
||||
except ValueError:
|
||||
return ""
|
||||
return "", (
|
||||
_effect_diagnostic(
|
||||
f"invalid a:alphaModFix amt={alpha.attrib.get('amt')!r}"
|
||||
),
|
||||
)
|
||||
if not math.isfinite(opacity) or not 0.0 <= opacity <= 1.0:
|
||||
return "", (
|
||||
_effect_diagnostic(
|
||||
f"out-of-range a:alphaModFix amt={alpha.attrib.get('amt')!r}"
|
||||
),
|
||||
)
|
||||
if opacity >= 1.0:
|
||||
return ""
|
||||
return f' opacity="{format_ooxml_alpha(opacity)}"'
|
||||
return "", ()
|
||||
return f' opacity="{format_ooxml_alpha(opacity)}"', ()
|
||||
|
||||
|
||||
def _effect_diagnostic(message: str) -> PictureDiagnostic:
|
||||
return PictureDiagnostic(
|
||||
code="image-effect-omitted",
|
||||
message=message,
|
||||
fallback="retain the source image and omit only this image effect",
|
||||
)
|
||||
|
||||
|
||||
def _unsupported_blip_effect_diagnostics(
|
||||
blip: ET.Element,
|
||||
) -> tuple[PictureDiagnostic, ...]:
|
||||
"""Report direct a:blip effects outside the implemented subset."""
|
||||
supported_tags = {
|
||||
f"{{{NS['a']}}}lum",
|
||||
f"{{{NS['a']}}}alphaModFix",
|
||||
f"{{{NS['a']}}}extLst",
|
||||
}
|
||||
unsupported = sorted({
|
||||
child.tag.rsplit("}", 1)[-1]
|
||||
for child in blip
|
||||
if child.tag not in supported_tags
|
||||
})
|
||||
diagnostics = (
|
||||
[
|
||||
_effect_diagnostic(
|
||||
"unsupported direct a:blip effect(s): "
|
||||
+ ", ".join(f"a:{name}" for name in unsupported)
|
||||
)
|
||||
]
|
||||
if unsupported
|
||||
else []
|
||||
)
|
||||
if len(blip.findall("a:lum", NS)) > 1:
|
||||
diagnostics.append(
|
||||
_effect_diagnostic(
|
||||
"duplicate a:lum effects cannot be reproduced safely"
|
||||
)
|
||||
)
|
||||
return tuple(diagnostics)
|
||||
|
||||
_OFFICE_VECTOR_EXTS = {".emf", ".wmf"}
|
||||
|
||||
@@ -419,26 +507,61 @@ def _apply_blip_image_effects(
|
||||
filename: str,
|
||||
img_bytes: bytes,
|
||||
blip: ET.Element,
|
||||
) -> tuple[str, bytes]:
|
||||
) -> tuple[str, bytes, tuple[PictureDiagnostic, ...]]:
|
||||
"""Bake supported DrawingML blip effects into extracted image bytes.
|
||||
|
||||
Keeping the SVG as a plain <image> avoids introducing CSS filters that the
|
||||
downstream native PPTX converter cannot reliably map back to DrawingML.
|
||||
"""
|
||||
lum = blip.find("a:lum", NS)
|
||||
if lum is None:
|
||||
return filename, img_bytes
|
||||
lum_effects = blip.findall("a:lum", NS)
|
||||
if not lum_effects:
|
||||
return filename, img_bytes, ()
|
||||
if len(lum_effects) > 1:
|
||||
return filename, img_bytes, ()
|
||||
lum = lum_effects[0]
|
||||
|
||||
bright = _signed_pct_attr(lum, "bright")
|
||||
contrast = _signed_pct_attr(lum, "contrast")
|
||||
values: dict[str, float | None] = {}
|
||||
for name in ("bright", "contrast"):
|
||||
raw_value = lum.attrib.get(name)
|
||||
if raw_value is None:
|
||||
values[name] = None
|
||||
continue
|
||||
try:
|
||||
value = float(raw_value) / 100000.0
|
||||
except ValueError:
|
||||
return filename, img_bytes, (
|
||||
_effect_diagnostic(f"invalid a:lum {name}={raw_value!r}"),
|
||||
)
|
||||
if not math.isfinite(value) or not -1.0 <= value <= 1.0:
|
||||
return filename, img_bytes, (
|
||||
_effect_diagnostic(
|
||||
f"out-of-range a:lum {name}={raw_value!r}"
|
||||
),
|
||||
)
|
||||
values[name] = value
|
||||
bright = values["bright"]
|
||||
contrast = values["contrast"]
|
||||
if bright is None and contrast is None:
|
||||
return filename, img_bytes
|
||||
return filename, img_bytes, ()
|
||||
if Image is None or ImageEnhance is None:
|
||||
return filename, img_bytes
|
||||
return filename, img_bytes, (
|
||||
_effect_diagnostic(
|
||||
"a:lum requires Pillow, but Pillow is unavailable"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
image = Image.open(io.BytesIO(img_bytes))
|
||||
output_format = image.format or _pil_format_from_filename(filename)
|
||||
with Image.open(io.BytesIO(img_bytes)) as source_image:
|
||||
if getattr(source_image, "is_animated", False):
|
||||
return filename, img_bytes, (
|
||||
_effect_diagnostic(
|
||||
"a:lum on an animated image would flatten its frames"
|
||||
),
|
||||
)
|
||||
output_format = (
|
||||
source_image.format or _pil_format_from_filename(filename)
|
||||
)
|
||||
image = source_image.copy()
|
||||
if image.mode not in ("RGB", "RGBA"):
|
||||
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
||||
if bright is not None:
|
||||
@@ -452,19 +575,15 @@ def _apply_blip_image_effects(
|
||||
image.save(out, format=save_format, **save_kwargs)
|
||||
effect_key = f"lum-{bright}-{contrast}".encode("ascii")
|
||||
digest = hashlib.sha1(effect_key).hexdigest()[:8]
|
||||
return _effect_filename(filename, digest, save_format), out.getvalue()
|
||||
except Exception:
|
||||
return filename, img_bytes
|
||||
|
||||
|
||||
def _signed_pct_attr(elem: ET.Element, name: str) -> float | None:
|
||||
val = elem.attrib.get(name)
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return float(val) / 100000.0
|
||||
except ValueError:
|
||||
return None
|
||||
return (
|
||||
_effect_filename(filename, digest, save_format),
|
||||
out.getvalue(),
|
||||
(),
|
||||
)
|
||||
except (KeyError, OSError, ValueError) as exc:
|
||||
return filename, img_bytes, (
|
||||
_effect_diagnostic(f"a:lum could not be rendered: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
def _pil_format_from_filename(filename: str) -> str | None:
|
||||
|
||||
+27
-1
@@ -64,7 +64,12 @@ from .ooxml_loader import (
|
||||
SlideRef,
|
||||
inherited_shape_visibility,
|
||||
)
|
||||
from .pic_to_svg import MediaResolutionError, convert_blip_fill, convert_picture
|
||||
from .pic_to_svg import (
|
||||
MediaResolutionError,
|
||||
PictureResult,
|
||||
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 (
|
||||
@@ -151,6 +156,19 @@ class AssemblyContext:
|
||||
self.diagnose(code, message, fallback)
|
||||
|
||||
|
||||
def _diagnose_picture_result(
|
||||
ctx: AssemblyContext,
|
||||
result: PictureResult,
|
||||
) -> None:
|
||||
"""Project recoverable picture losses into the import report."""
|
||||
for diagnostic in result.diagnostics:
|
||||
ctx.diagnose(
|
||||
diagnostic.code,
|
||||
diagnostic.message,
|
||||
diagnostic.fallback,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -457,6 +475,7 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
|
||||
media_subdir=ctx.media_subdir,
|
||||
embed_inline=ctx.embed_images,
|
||||
asset_name_map=ctx.asset_name_map,
|
||||
strict=ctx.strict,
|
||||
)
|
||||
except (ValueError, MediaResolutionError) as exc:
|
||||
if ctx.strict:
|
||||
@@ -467,6 +486,7 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
|
||||
"omit the image fill and retain shape geometry/text",
|
||||
)
|
||||
else:
|
||||
_diagnose_picture_result(ctx, blip_result)
|
||||
if blip_result.svg:
|
||||
blip_image = _clip_blip_image(blip_result.svg, geom, ctx)
|
||||
ctx.media.update(blip_result.media)
|
||||
@@ -929,6 +949,7 @@ def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool)
|
||||
media_subdir=ctx.media_subdir,
|
||||
embed_inline=ctx.embed_images,
|
||||
asset_name_map=ctx.asset_name_map,
|
||||
strict=ctx.strict,
|
||||
)
|
||||
except MediaResolutionError as exc:
|
||||
if ctx.strict:
|
||||
@@ -941,6 +962,7 @@ def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool)
|
||||
return _fallback_node_svg(node, ctx, top_level=top_level)
|
||||
if not result.svg:
|
||||
return ""
|
||||
_diagnose_picture_result(ctx, result)
|
||||
ctx.media.update(result.media)
|
||||
effect_metadata = unsupported_target_effect_metadata(
|
||||
sp_pr,
|
||||
@@ -1280,9 +1302,11 @@ def _render_graphic_preview(node: ShapeNode, ctx: AssemblyContext) -> str:
|
||||
media_subdir=ctx.media_subdir,
|
||||
embed_inline=ctx.embed_images,
|
||||
asset_name_map=ctx.asset_name_map,
|
||||
strict=ctx.strict,
|
||||
)
|
||||
if not result.svg:
|
||||
return ""
|
||||
_diagnose_picture_result(ctx, result)
|
||||
ctx.media.update(result.media)
|
||||
return result.svg
|
||||
|
||||
@@ -1392,7 +1416,9 @@ def _emit_background_image(
|
||||
media_subdir=ctx.media_subdir,
|
||||
embed_inline=ctx.embed_images,
|
||||
asset_name_map=ctx.asset_name_map,
|
||||
strict=ctx.strict,
|
||||
)
|
||||
_diagnose_picture_result(ctx, result)
|
||||
if result.media:
|
||||
ctx.media.update(result.media)
|
||||
return result.svg
|
||||
|
||||
@@ -198,6 +198,14 @@ def is_within_path(path: Path, parent: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _has_usable_import(summary: dict[str, list[str]]) -> bool:
|
||||
"""Return whether import-sources produced at least one usable source artifact."""
|
||||
return any(
|
||||
summary.get(key)
|
||||
for key in ("archived", "markdown", "assets", "images", "analysis")
|
||||
)
|
||||
|
||||
|
||||
class ProjectManager:
|
||||
"""Create, inspect, validate, and populate project folders."""
|
||||
|
||||
@@ -727,6 +735,7 @@ class ProjectManager:
|
||||
sources_dir = self._source_dir(project_dir)
|
||||
summary: dict[str, list[str]] = {
|
||||
"archived": [],
|
||||
"url_records": [],
|
||||
"markdown": [],
|
||||
"assets": [],
|
||||
"images": [],
|
||||
@@ -775,14 +784,14 @@ class ProjectManager:
|
||||
self._import_url(item, markdown_path)
|
||||
except Exception as exc: # pragma: no cover - summary path
|
||||
archived = self._archive_url_record(sources_dir, item)
|
||||
summary["archived"].append(str(archived))
|
||||
summary["url_records"].append(str(archived))
|
||||
summary["skipped"].append(f"{item}: {exc}")
|
||||
continue
|
||||
|
||||
if not self._is_valid_imported_url_markdown(markdown_path):
|
||||
markdown_path.unlink(missing_ok=True)
|
||||
archived = self._archive_url_record(sources_dir, item)
|
||||
summary["archived"].append(str(archived))
|
||||
summary["url_records"].append(str(archived))
|
||||
summary["skipped"].append(f"{item}: URL conversion produced no usable Markdown")
|
||||
continue
|
||||
|
||||
@@ -1138,11 +1147,22 @@ def main(argv: list[str] | None = None) -> int:
|
||||
move=args.move,
|
||||
copy=args.copy,
|
||||
)
|
||||
print(f"[OK] Imported sources into: {args.project_path}")
|
||||
has_usable_import = _has_usable_import(summary)
|
||||
if has_usable_import:
|
||||
print(f"[OK] Imported sources into: {args.project_path}")
|
||||
else:
|
||||
print(
|
||||
f"[ERROR] No usable sources imported into: {args.project_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if summary["archived"]:
|
||||
print("\nArchived originals / URL records:")
|
||||
print("\nArchived originals:")
|
||||
for item in summary["archived"]:
|
||||
print(f" - {item}")
|
||||
if summary["url_records"]:
|
||||
print("\nArchived URL records:")
|
||||
for item in summary["url_records"]:
|
||||
print(f" - {item}")
|
||||
if summary["markdown"]:
|
||||
print("\nNormalized markdown:")
|
||||
for item in summary["markdown"]:
|
||||
@@ -1167,7 +1187,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print("\nSkipped:")
|
||||
for item in summary["skipped"]:
|
||||
print(f" - {item}")
|
||||
return 0
|
||||
return 0 if has_usable_import else 1
|
||||
|
||||
if args.command == "scaffold-spec":
|
||||
artifact_path = manager.scaffold_artifact(args.project_path, "design_spec")
|
||||
|
||||
+38
-31
@@ -12,15 +12,15 @@
|
||||
"skills/ppt-master/templates/schemas/*.json"
|
||||
],
|
||||
"exclude": [],
|
||||
"max_tokens": 388200
|
||||
"max_tokens": 397600
|
||||
},
|
||||
"file_budgets": {
|
||||
"AGENTS.md": 2400,
|
||||
"skills/ppt-master/SKILL.md": 850,
|
||||
"skills/ppt-master/references/executor-base.md": 8125,
|
||||
"skills/ppt-master/references/executor-base.md": 8250,
|
||||
"skills/ppt-master/references/executor-structured.md": 5300,
|
||||
"skills/ppt-master/references/executor-chart.md": 3100,
|
||||
"skills/ppt-master/references/executor-image.md": 1275,
|
||||
"skills/ppt-master/references/executor-image.md": 1500,
|
||||
"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,
|
||||
@@ -28,12 +28,12 @@
|
||||
"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": 14025,
|
||||
"skills/ppt-master/references/strategist.md": 14225,
|
||||
"skills/ppt-master/references/strategist-image.md": 2500,
|
||||
"skills/ppt-master/references/strategist-template.md": 2100,
|
||||
"skills/ppt-master/templates/design_spec_reference.md": 3125,
|
||||
"skills/ppt-master/templates/design_spec_reference.md": 3275,
|
||||
"skills/ppt-master/templates/spec_lock_reference.md": 2375,
|
||||
"skills/ppt-master/workflows/generate-pptx.md": 13100,
|
||||
"skills/ppt-master/workflows/generate-pptx.md": 13775,
|
||||
"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": 5700
|
||||
"max_tokens": 5775
|
||||
},
|
||||
"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": 58225
|
||||
"max_tokens": 58300
|
||||
},
|
||||
"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": 58400
|
||||
"max_tokens": 58475
|
||||
},
|
||||
"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": 9600
|
||||
"max_tokens": 9750
|
||||
},
|
||||
"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": 11375
|
||||
"max_tokens": 11450
|
||||
},
|
||||
"route.generate.planning": {
|
||||
"description": "Generate-PPTX planning through Strategist, including reference-first whole-document authoring and representative multi-source custom mode/style synthesis; schemas and optional scaffolds are tool-consumed.",
|
||||
@@ -170,7 +170,7 @@
|
||||
"registry": "visual-styles"
|
||||
}
|
||||
],
|
||||
"max_tokens": 62000
|
||||
"max_tokens": 62075
|
||||
},
|
||||
"route.generate.quick-test": {
|
||||
"description": "Explicit disposable Generate quick-test short circuit with the shared SVG authoring core.",
|
||||
@@ -183,14 +183,14 @@
|
||||
"skills/ppt-master/workflows/profiles/quick-test.md",
|
||||
"skills/ppt-master/references/shared-standards-core.md"
|
||||
],
|
||||
"max_tokens": 30825
|
||||
"max_tokens": 31550
|
||||
},
|
||||
"route.generate.planning-image": {
|
||||
"description": "Generate-PPTX planning context after a non-none image source is proposed or confirmed.",
|
||||
"description": "Generate-PPTX planning context after a non-none image source is proposed or confirmed; layout-catalog recall remains optional.",
|
||||
"scope": "cumulative",
|
||||
"include": [
|
||||
"route.generate.planning",
|
||||
"stage.generate.strategist.image-layout"
|
||||
"stage.generate.strategist.image"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 74850
|
||||
@@ -203,7 +203,7 @@
|
||||
"stage.generate.strategist.image"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 64000
|
||||
"max_tokens": 64500
|
||||
},
|
||||
"route.generate.planning-ai": {
|
||||
"description": "Generate-PPTX planning when AI image direction is available, including representative multi-source custom rendering synthesis.",
|
||||
@@ -335,7 +335,7 @@
|
||||
"stage.generate.executor.notes"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 85625
|
||||
"max_tokens": 87725
|
||||
},
|
||||
"route.generate.flat-ai-two-types": {
|
||||
"description": "Generate-PPTX context with AI images, representative multi-source custom rendering, and two local types.",
|
||||
@@ -383,7 +383,7 @@
|
||||
"stage.shared.generate-audio"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 14500
|
||||
"max_tokens": 15100
|
||||
},
|
||||
"route.generate.beautify-flat-no-image": {
|
||||
"description": "Generate-PPTX 1:1 beautify profile on the flat no-image path.",
|
||||
@@ -393,7 +393,7 @@
|
||||
"profile.generate.beautify-pptx"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 92525
|
||||
"max_tokens": 94675
|
||||
},
|
||||
"route.generate.flat-no-image-chart": {
|
||||
"description": "Default flat Generate-PPTX path with chart authoring, native-data replacement, and verification.",
|
||||
@@ -406,7 +406,7 @@
|
||||
"stage.generate.verify-charts"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 109975
|
||||
"max_tokens": 112075
|
||||
},
|
||||
"route.generate.brand-flat-no-image": {
|
||||
"description": "Brand-preset flat Generate-PPTX path without image acquisition.",
|
||||
@@ -418,7 +418,7 @@
|
||||
"stage.generate.template.brand"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 92650
|
||||
"max_tokens": 94750
|
||||
},
|
||||
"route.generate.deck-structured-no-image": {
|
||||
"description": "Deck-preset structured Generate-PPTX path without image acquisition.",
|
||||
@@ -430,7 +430,7 @@
|
||||
"stage.generate.template.deck"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 102600
|
||||
"max_tokens": 104700
|
||||
},
|
||||
"route.generate.layout-structured-no-image": {
|
||||
"description": "Layout-preset structured Generate-PPTX path without image acquisition.",
|
||||
@@ -442,7 +442,7 @@
|
||||
"stage.generate.template.layout"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 103050
|
||||
"max_tokens": 105150
|
||||
},
|
||||
"route.generate.topic-only-flat-no-image": {
|
||||
"description": "Topic research followed by the default flat no-image Generate-PPTX path.",
|
||||
@@ -452,7 +452,7 @@
|
||||
"route.generate.flat-no-image"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 87025
|
||||
"max_tokens": 89125
|
||||
},
|
||||
"stage.generate.executor.flat": {
|
||||
"description": "Incremental flat Executor core with representative multi-source custom mode/style execution.",
|
||||
@@ -632,7 +632,7 @@
|
||||
"skills/ppt-master/scripts/docs/pptx-transitions.md",
|
||||
"skills/ppt-master/scripts/docs/svg-pipeline.md"
|
||||
],
|
||||
"max_tokens": 28750
|
||||
"max_tokens": 28850
|
||||
},
|
||||
"stage.generate.video-motion-plan": {
|
||||
"description": "Conditional resolved animation-to-video handoff contract.",
|
||||
@@ -656,7 +656,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/workflows/stages/generate-audio.md"
|
||||
],
|
||||
"max_tokens": 4925
|
||||
"max_tokens": 5350
|
||||
},
|
||||
"governance.failure-recovery": {
|
||||
"description": "Global stop, retry, and resume policy.",
|
||||
@@ -664,7 +664,7 @@
|
||||
"files": [
|
||||
"skills/ppt-master/workflows/governance/failure-recovery.md"
|
||||
],
|
||||
"max_tokens": 2500
|
||||
"max_tokens": 2725
|
||||
},
|
||||
"stage.shared.conversion-reference": {
|
||||
"description": "Conditional source-conversion and import compatibility reference.",
|
||||
@@ -698,7 +698,7 @@
|
||||
"stage.generate.strategist.template"
|
||||
],
|
||||
"files": [],
|
||||
"max_tokens": 62275
|
||||
"max_tokens": 64150
|
||||
},
|
||||
"route.generate.planning-template-ai": {
|
||||
"description": "Explicit template workspace plus confirmed AI-image planning.",
|
||||
@@ -727,7 +727,7 @@
|
||||
"max_tokens": 2500
|
||||
},
|
||||
"stage.generate.strategist.image-layout": {
|
||||
"description": "Non-formula image planning plus the image-layout pattern catalog required for Section VIII rows.",
|
||||
"description": "Optional image-layout catalog recall layered on image planning when the Strategist wants additional composition vocabulary.",
|
||||
"scope": "incremental",
|
||||
"include": [
|
||||
"stage.generate.strategist.image"
|
||||
@@ -754,11 +754,10 @@
|
||||
"max_tokens": 6200
|
||||
},
|
||||
"stage.generate.executor.image": {
|
||||
"description": "Conditional image embedding and layout execution rules.",
|
||||
"description": "Conditional image embedding and layout execution rules; the optional layout catalog is not part of the required bundle.",
|
||||
"scope": "incremental",
|
||||
"files": [
|
||||
"skills/ppt-master/references/executor-image.md",
|
||||
"skills/ppt-master/references/image-layout-patterns.md",
|
||||
"skills/ppt-master/references/image-layout-spec.md",
|
||||
"skills/ppt-master/references/svg-image-embedding.md"
|
||||
],
|
||||
@@ -1137,6 +1136,14 @@
|
||||
"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/advanced-image-motion-smoke.md",
|
||||
"reason": "Maintainer-only executable image-and-motion smoke; never loaded by generation roles."
|
||||
},
|
||||
{
|
||||
"glob": "skills/ppt-master/scripts/docs/multilingual-text-smoke.md",
|
||||
"reason": "Maintainer-only executable multilingual export smoke; never loaded by generation roles."
|
||||
},
|
||||
{
|
||||
"glob": "skills/ppt-master/scripts/docs/update_spec.md",
|
||||
"reason": "Maintainer command reference for an optional helper script."
|
||||
|
||||
@@ -27,6 +27,7 @@ from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
SVG_WORK_DIR_NAMES = frozenset({'svg_output', 'svg_final', 'svg-flat', 'svg_flat'})
|
||||
SVG_FINAL_CANDIDATE_PREFIX = '.svg_final.candidate-'
|
||||
TEMPLATE_SOURCE_DIR_NAME = 'templates'
|
||||
TEMPLATE_SPEC_FILENAME = 'design_spec.md'
|
||||
_SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
|
||||
@@ -63,7 +64,10 @@ def project_root_for_svg_path(svg_path: Path) -> Path:
|
||||
"""Infer the project root from an SVG file path or SVG directory path."""
|
||||
path = Path(svg_path)
|
||||
base = path if path.is_dir() else path.parent
|
||||
if base.name in SVG_WORK_DIR_NAMES:
|
||||
if (
|
||||
base.name in SVG_WORK_DIR_NAMES
|
||||
or base.name.startswith(SVG_FINAL_CANDIDATE_PREFIX)
|
||||
):
|
||||
return base.parent
|
||||
if (
|
||||
base.name == TEMPLATE_SOURCE_DIR_NAME
|
||||
|
||||
+19
-1
@@ -76,6 +76,8 @@ IMAGE_EXT_BY_CONTENT_TYPE = {
|
||||
"image/x-wmf": "wmf",
|
||||
}
|
||||
LEGACY_GENERATED_IMAGE_RE = re.compile(r"^slide_\d{2}_image_\d{2}\.[A-Za-z0-9]+$")
|
||||
_READBACK_SLIDE_HEADING_RE = re.compile(r"^## Slide\s+\d+\s*$")
|
||||
_READBACK_NOTES_HEADING_RE = re.compile(r"^### Speaker Notes\s*$")
|
||||
|
||||
# Hyperlink schemes dropped during extraction (a blacklist of known-dangerous
|
||||
# schemes). PowerPoint also rejects unrecognized schemes at open time, so the
|
||||
@@ -119,6 +121,20 @@ def normalize_text(value: str) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _escape_readback_control_lines(value: str) -> str:
|
||||
"""Escape ordinary text lines that collide with converter section markers."""
|
||||
lines = value.split("\n")
|
||||
return "\n".join(
|
||||
f"\\{line}"
|
||||
if (
|
||||
_READBACK_SLIDE_HEADING_RE.fullmatch(line)
|
||||
or _READBACK_NOTES_HEADING_RE.fullmatch(line)
|
||||
)
|
||||
else line
|
||||
for line in lines
|
||||
)
|
||||
|
||||
|
||||
def normalize_ext(ext: str | None, content_type: str | None = None) -> str:
|
||||
"""Return a lowercase extension without a leading dot."""
|
||||
if ext:
|
||||
@@ -345,7 +361,9 @@ def text_frame_to_markdown(text_frame: object, shape: object = None) -> str:
|
||||
|
||||
paragraphs = []
|
||||
for paragraph in visible_paragraphs:
|
||||
text = _paragraph_to_markdown(paragraph, shape)
|
||||
text = _escape_readback_control_lines(
|
||||
_paragraph_to_markdown(paragraph, shape)
|
||||
)
|
||||
if not text:
|
||||
continue
|
||||
if list_like:
|
||||
|
||||
+55
-13
@@ -31,6 +31,7 @@ The merged pipeline:
|
||||
if href starts with data: → skip (already inline)
|
||||
if href is unresolvable / external URL → skip
|
||||
if href points to EMF/WMF → skip (native PPTX passthrough only)
|
||||
if image belongs to a valid nested crop → preserve source pixels, embed
|
||||
if missing preserveAspectRatio → just embed (do not assume meet)
|
||||
if align == none → just embed (no spatial transform)
|
||||
if mode == slice → crop in memory, embed cropped bytes
|
||||
@@ -79,6 +80,9 @@ if __package__ in {None, ''}:
|
||||
from .crop_images import crop_image_to_size, get_crop_anchor, parse_preserve_aspect_ratio
|
||||
from .embed_images import _optimize_image_bytes, get_mime_type
|
||||
from .fix_image_aspect import calculate_fitted_dimensions
|
||||
from svg_to_pptx.drawingml.elements import ( # noqa: E402
|
||||
parse_project_nested_svg_crop,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from PIL import Image as PILImage # noqa: F401
|
||||
@@ -248,12 +252,23 @@ def _encode_pil_to_data_uri(
|
||||
) -> tuple[str, int] | None:
|
||||
"""Serialize *img* to a base64 data URI.
|
||||
|
||||
If the image hasn't been transformed (slice crop or meet fit), prefer
|
||||
re-encoding the original file bytes so we don't risk mutating an
|
||||
already-optimized asset. *fallback_bytes* carries the raw on-disk
|
||||
bytes for that path.
|
||||
If the image has not been transformed, ``--no-compress`` preserves a
|
||||
supported PNG/JPEG/GIF/WebP payload byte-for-byte. Compression mode may
|
||||
still retain a smaller original payload. *fallback_bytes* carries those
|
||||
raw on-disk bytes.
|
||||
"""
|
||||
original_mime_type = get_mime_type(src_path.name, fallback_bytes)
|
||||
if (
|
||||
not compress
|
||||
and fallback_bytes is not None
|
||||
and original_mime_type in _PIL_FORMAT_BY_MIME
|
||||
):
|
||||
encoded = base64.b64encode(fallback_bytes).decode('ascii')
|
||||
return (
|
||||
f'data:{original_mime_type};base64,{encoded}',
|
||||
len(fallback_bytes),
|
||||
)
|
||||
|
||||
# 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 = (
|
||||
@@ -316,6 +331,20 @@ def _set_href(image: ET.Element, value: str) -> None:
|
||||
image.set('href', value)
|
||||
|
||||
|
||||
def _nested_crop_image_ids(root: ET.Element) -> set[int]:
|
||||
"""Return child image identities from valid nested crop transports."""
|
||||
image_ids: set[int] = set()
|
||||
for elem in root.iter(f'{{{SVG_NS}}}svg'):
|
||||
if elem is root:
|
||||
continue
|
||||
try:
|
||||
crop = parse_project_nested_svg_crop(elem)
|
||||
except ValueError:
|
||||
continue
|
||||
image_ids.add(id(crop.image))
|
||||
return image_ids
|
||||
|
||||
|
||||
def _process_one_image(
|
||||
image: ET.Element,
|
||||
svg_dir: Path,
|
||||
@@ -323,6 +352,7 @@ def _process_one_image(
|
||||
compress: bool,
|
||||
max_dimension: int | None,
|
||||
image_scale: float,
|
||||
preserve_source_pixels: bool,
|
||||
verbose: bool,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Align (slice/meet) and embed a single <image>.
|
||||
@@ -447,12 +477,18 @@ def _process_one_image(
|
||||
new_h = new_h_calc
|
||||
target_box_w, target_box_h = new_w, new_h
|
||||
|
||||
target_w, target_h = _target_size(
|
||||
target_box_w,
|
||||
target_box_h,
|
||||
max_dimension=max_dimension,
|
||||
image_scale=image_scale,
|
||||
)
|
||||
if preserve_source_pixels:
|
||||
# The child's 1×1 box is source-unit crop geometry, not its rendered
|
||||
# frame. Keep the full source raster so the outer viewport remains
|
||||
# authoritative and independently shaped crops do not lose detail.
|
||||
target_w, target_h = final_img.size
|
||||
else:
|
||||
target_w, target_h = _target_size(
|
||||
target_box_w,
|
||||
target_box_h,
|
||||
max_dimension=max_dimension,
|
||||
image_scale=image_scale,
|
||||
)
|
||||
final_img, resized = _downscale_to_target(final_img, target_w, target_h)
|
||||
transformed = transformed or resized
|
||||
|
||||
@@ -463,7 +499,7 @@ def _process_one_image(
|
||||
final_img,
|
||||
img_path,
|
||||
compress=compress,
|
||||
max_dimension=max_dimension,
|
||||
max_dimension=None if preserve_source_pixels else max_dimension,
|
||||
fallback_bytes=raw_bytes if not transformed else None,
|
||||
)
|
||||
if encoded is None:
|
||||
@@ -483,7 +519,10 @@ def _process_one_image(
|
||||
del image.attrib['preserveAspectRatio']
|
||||
|
||||
if verbose:
|
||||
suffix = ' (cropped)' if transformed else ''
|
||||
if preserve_source_pixels:
|
||||
suffix = ' (nested crop, source pixels preserved)'
|
||||
else:
|
||||
suffix = ' (cropped)' if transformed else ''
|
||||
print(f' [OK] {img_path.name}{suffix}')
|
||||
return True, None
|
||||
|
||||
@@ -541,6 +580,7 @@ def align_and_embed_images_in_svg(
|
||||
)
|
||||
return (0, 1)
|
||||
root = tree.getroot()
|
||||
nested_crop_image_ids = _nested_crop_image_ids(root)
|
||||
|
||||
# Avoid double-iteration if an element matches both namespaced and
|
||||
# bare-tag iteration paths.
|
||||
@@ -561,7 +601,9 @@ def align_and_embed_images_in_svg(
|
||||
ok, err = _process_one_image(
|
||||
image, svg_dir,
|
||||
compress=compress, max_dimension=max_dimension,
|
||||
image_scale=image_scale, verbose=verbose,
|
||||
image_scale=image_scale,
|
||||
preserve_source_pixels=ident in nested_crop_image_ids,
|
||||
verbose=verbose,
|
||||
)
|
||||
if ok:
|
||||
processed += 1
|
||||
|
||||
+120
-67
@@ -125,27 +125,20 @@ def compute_line_positions(
|
||||
Returns (new_x, new_y).
|
||||
"""
|
||||
del text_el
|
||||
# Prefer explicit x/y on tspan
|
||||
t_x_attr = get_attr(tspan_el, "x")
|
||||
t_y_attr = get_attr(tspan_el, "y")
|
||||
t_dx_attr = get_attr(tspan_el, "dx")
|
||||
t_dy_attr = get_attr(tspan_el, "dy")
|
||||
|
||||
if t_x_attr is not None:
|
||||
nx = parse_first_number(t_x_attr)
|
||||
elif t_dx_attr is not None:
|
||||
nx = parse_first_number(t_x_attr) if t_x_attr is not None else cur_x
|
||||
if t_dx_attr is not None:
|
||||
dx = parse_first_number(t_dx_attr) or 0.0
|
||||
nx = (cur_x or 0.0) + dx
|
||||
else:
|
||||
nx = cur_x
|
||||
nx = (nx or 0.0) + dx
|
||||
|
||||
if t_y_attr is not None:
|
||||
ny = parse_first_number(t_y_attr)
|
||||
elif t_dy_attr is not None:
|
||||
ny = parse_first_number(t_y_attr) if t_y_attr is not None else cur_y
|
||||
if t_dy_attr is not None:
|
||||
dy = parse_first_number(t_dy_attr) or 0.0
|
||||
ny = (cur_y or 0.0) + dy
|
||||
else:
|
||||
ny = cur_y
|
||||
ny = (ny or 0.0) + dy
|
||||
|
||||
return nx, ny
|
||||
|
||||
@@ -193,6 +186,9 @@ PARAGRAPH_SPACE_BEFORE_ATTR = "data-paragraph-space-before"
|
||||
# (SVG used dy to simulate text wrapping; the downstream converter should
|
||||
# merge its runs into the previous <a:p> rather than start a new one).
|
||||
PARAGRAPH_SOFT_BREAK_ATTR = "data-paragraph-soft-break"
|
||||
# Marks an authored visual line boundary that remains a hard DrawingML break
|
||||
# in the default single-frame preserve mode.
|
||||
PARAGRAPH_LINE_BREAK_ATTR = "data-paragraph-line-break"
|
||||
|
||||
# Tolerance for detecting "base line-height" vs "paragraph gap": dy values
|
||||
# within ±DY_TOLERANCE_PX of each other are considered the same line-height.
|
||||
@@ -211,17 +207,41 @@ def _starts_with_list_marker(line_group: list[ET.Element]) -> bool:
|
||||
return bool(LIST_MARKER_RE.match(text))
|
||||
|
||||
|
||||
def _tspan_has_positional_descendant(tspan: ET.Element) -> bool:
|
||||
"""Return True if any nested tspan inside this one carries x/y/dy."""
|
||||
for child in list(tspan):
|
||||
if child.tag != f"{{{SVG_NS}}}tspan":
|
||||
continue
|
||||
for k in ("x", "y", "dy"):
|
||||
if child.get(k) is not None:
|
||||
return True
|
||||
if _tspan_has_positional_descendant(child):
|
||||
return True
|
||||
return False
|
||||
def _positional_tspan_attribute(tspan: ET.Element) -> str | None:
|
||||
"""Return the unsupported nested position attribute, if any."""
|
||||
for name in ("x", "y"):
|
||||
if tspan.get(name) is not None:
|
||||
return name
|
||||
raw_dy = tspan.get("dy")
|
||||
dy = parse_first_number(raw_dy) if raw_dy is not None else None
|
||||
if dy is not None and abs(dy) > 1e-6:
|
||||
return "dy"
|
||||
return None
|
||||
|
||||
|
||||
def nested_positional_tspan_errors(root: ET.Element) -> list[str]:
|
||||
"""Describe nested tspans whose baseline jumps cannot be exported."""
|
||||
errors: list[str] = []
|
||||
for text_el in root.iter(f"{{{SVG_NS}}}text"):
|
||||
text_label = (
|
||||
f"<text id={text_el.get('id')!r}>"
|
||||
if text_el.get("id")
|
||||
else "<text>"
|
||||
)
|
||||
for direct_child in list(text_el):
|
||||
if direct_child.tag != f"{{{SVG_NS}}}tspan":
|
||||
continue
|
||||
for descendant in direct_child.iter(f"{{{SVG_NS}}}tspan"):
|
||||
if descendant is direct_child:
|
||||
continue
|
||||
attribute = _positional_tspan_attribute(descendant)
|
||||
if attribute is None:
|
||||
continue
|
||||
errors.append(
|
||||
f"{text_label} contains a nested <tspan> with {attribute}; "
|
||||
"move x/y/non-zero dy to a direct child of <text>"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _build_paragraph_child_view(
|
||||
@@ -281,19 +301,20 @@ def _classify_paragraph_block(
|
||||
text_el: ET.Element,
|
||||
is_svg_tag,
|
||||
is_new_line_tspan,
|
||||
) -> tuple[float, list[float], list[bool], list[list[ET.Element]], ET.Element | None] | None:
|
||||
preserve_line_breaks: bool,
|
||||
) -> tuple[float, list[float], list[str], list[list[ET.Element]], ET.Element | None] | None:
|
||||
"""Detect a mergeable paragraph block.
|
||||
|
||||
Returns ``(base_line_height_px, extra_space_before_px_per_line,
|
||||
is_soft_break_per_line, line_groups, synthetic_first_line)`` if the children
|
||||
break_kind_per_line, line_groups, synthetic_first_line)`` if the children
|
||||
form a mergeable paragraph. Each list has one entry per direct-child tspan
|
||||
(line), including a synthetic first line when the source used leading text:
|
||||
|
||||
- extra_space_before_px_per_line[i]: extra px above base line-height,
|
||||
used as <a:spcBef> on the downstream <a:p>. First entry is 0.
|
||||
- is_soft_break_per_line[i]: True if this line should merge into the
|
||||
previous <a:p> (SVG dy was simulating word-wrap); False if it starts
|
||||
a fresh <a:p>. First entry is always False (paragraph head).
|
||||
- break_kind_per_line[i]: ``paragraph`` starts a fresh <a:p>, ``soft``
|
||||
joins the previous line for reflow, and ``line`` preserves the visual
|
||||
boundary as a hard DrawingML break. First entry is ``paragraph``.
|
||||
|
||||
Conditions (all must hold):
|
||||
- No direct text under <text>, except simple leading text that can be
|
||||
@@ -308,7 +329,8 @@ def _classify_paragraph_block(
|
||||
any larger dy must be ≤ MAX_DY_MULTIPLIER × base. Anything larger
|
||||
is treated as a section break and rejected.
|
||||
- Every line-break tspan that sets x repeats the parent <text>'s x.
|
||||
- No nested tspan inside any line carries x/y/dy.
|
||||
- A line-break tspan cannot add a non-zero dx offset.
|
||||
- No nested tspan inside any line carries x/y/non-zero dy.
|
||||
- Adjacent lines with different effective font sizes start new paragraphs.
|
||||
"""
|
||||
base_x = parse_first_number(get_attr(text_el, "x"))
|
||||
@@ -327,8 +349,6 @@ def _classify_paragraph_block(
|
||||
else:
|
||||
if not line_groups:
|
||||
return None
|
||||
if _tspan_has_positional_descendant(tspan):
|
||||
return None
|
||||
line_groups[-1].append(tspan)
|
||||
|
||||
if len(line_groups) < 2:
|
||||
@@ -348,6 +368,10 @@ def _classify_paragraph_block(
|
||||
t_x = parse_first_number(t_x_raw)
|
||||
if base_x is None or t_x is None or abs(t_x - base_x) > 1e-6:
|
||||
return None
|
||||
t_dx_raw = get_attr(tspan, "dx")
|
||||
t_dx = parse_first_number(t_dx_raw) if t_dx_raw is not None else None
|
||||
if t_dx is not None and abs(t_dx) > 1e-6:
|
||||
return None
|
||||
|
||||
t_dy_raw = get_attr(tspan, "dy")
|
||||
t_dy = parse_first_number(t_dy_raw) if t_dy_raw is not None else None
|
||||
@@ -361,9 +385,6 @@ def _classify_paragraph_block(
|
||||
return None
|
||||
dy_values.append(t_dy)
|
||||
|
||||
if _tspan_has_positional_descendant(tspan):
|
||||
return None
|
||||
|
||||
# Second pass: pick the base line-height as the minimum positive dy and
|
||||
# express each line's dy as base + extra space-before.
|
||||
positive_dys = [d for d in dy_values[1:] if d > 0]
|
||||
@@ -375,7 +396,7 @@ def _classify_paragraph_block(
|
||||
return None
|
||||
|
||||
extras: list[float] = [0.0] # first line never has space-before
|
||||
soft_breaks: list[bool] = [False] # first line starts a paragraph
|
||||
break_kinds = ["paragraph"]
|
||||
line_font_sizes = [
|
||||
_effective_line_font_size_px(text_el, group)
|
||||
for group in line_groups
|
||||
@@ -392,7 +413,7 @@ def _classify_paragraph_block(
|
||||
# dy strictly greater than base = hard paragraph break. List markers
|
||||
# and font-size changes also start a fresh paragraph so semantically
|
||||
# distinct visual lines do not merge into one PowerPoint line.
|
||||
is_soft = (
|
||||
reflow_candidate = (
|
||||
abs(extra) <= DY_TOLERANCE_PX
|
||||
and not _starts_with_list_marker(line_groups[idx])
|
||||
and abs(line_font_sizes[idx] - line_font_sizes[idx - 1]) <= 1e-6
|
||||
@@ -401,30 +422,36 @@ def _classify_paragraph_block(
|
||||
PARAGRAPH_SOFT_BREAK_ATTR
|
||||
)
|
||||
if explicit_soft_break == "0":
|
||||
is_soft = False
|
||||
break_kind = "paragraph"
|
||||
elif explicit_soft_break == "1":
|
||||
is_soft = True
|
||||
extras.append(0.0 if is_soft else extra)
|
||||
soft_breaks.append(is_soft)
|
||||
break_kind = "soft"
|
||||
elif reflow_candidate:
|
||||
break_kind = "line" if preserve_line_breaks else "soft"
|
||||
else:
|
||||
break_kind = "paragraph"
|
||||
extras.append(0.0 if break_kind != "paragraph" else extra)
|
||||
break_kinds.append(break_kind)
|
||||
|
||||
return base, extras, soft_breaks, line_groups, synthetic_first
|
||||
return base, extras, break_kinds, line_groups, synthetic_first
|
||||
|
||||
|
||||
def _emit_mergeable_paragraph(
|
||||
text_el: ET.Element,
|
||||
base_dy: float,
|
||||
extras: list[float],
|
||||
soft_breaks: list[bool],
|
||||
break_kinds: list[str],
|
||||
line_groups: list[list[ET.Element]],
|
||||
synthetic_first: ET.Element | None = None,
|
||||
) -> None:
|
||||
"""Rewrite text_el in place so it stays a single <text> with paragraph rows.
|
||||
|
||||
The base line-height goes on the parent <text> via PARAGRAPH_MARK_ATTR.
|
||||
Each direct-child tspan is normalized: x/y/dy stripped; inline-run
|
||||
Each direct-child tspan is normalized: x/y/dx/dy stripped; inline-run
|
||||
styling and nested tspans are preserved. Per-tspan attrs:
|
||||
- PARAGRAPH_SOFT_BREAK_ATTR="1" on tspans that should be appended to
|
||||
the previous <a:p> downstream (SVG used dy to simulate wrap)
|
||||
- PARAGRAPH_LINE_BREAK_ATTR="1" on tspans that retain an authored line
|
||||
boundary inside the previous <a:p>
|
||||
- PARAGRAPH_SPACE_BEFORE_ATTR on tspans that open a new paragraph
|
||||
with an extra gap (omitted when 0)
|
||||
"""
|
||||
@@ -448,7 +475,7 @@ def _emit_mergeable_paragraph(
|
||||
continue
|
||||
|
||||
container = ET.Element(f"{{{SVG_NS}}}tspan")
|
||||
for k in ("x", "y", "dy"):
|
||||
for k in ("x", "y", "dx", "dy"):
|
||||
line.attrib.pop(k, None)
|
||||
for run in group:
|
||||
container.append(run)
|
||||
@@ -460,19 +487,27 @@ def _emit_mergeable_paragraph(
|
||||
text_el.append(line)
|
||||
|
||||
extras_iter = iter(extras)
|
||||
soft_iter = iter(soft_breaks)
|
||||
break_iter = iter(break_kinds)
|
||||
for tspan in normalized_lines:
|
||||
for k in ("x", "y", "dy"):
|
||||
for k in ("x", "y", "dx", "dy"):
|
||||
if k in tspan.attrib:
|
||||
del tspan.attrib[k]
|
||||
for k in (
|
||||
PARAGRAPH_SOFT_BREAK_ATTR,
|
||||
PARAGRAPH_LINE_BREAK_ATTR,
|
||||
PARAGRAPH_SPACE_BEFORE_ATTR,
|
||||
):
|
||||
tspan.attrib.pop(k, None)
|
||||
try:
|
||||
extra = next(extras_iter)
|
||||
soft = next(soft_iter)
|
||||
break_kind = next(break_iter)
|
||||
except StopIteration:
|
||||
extra = 0.0
|
||||
soft = False
|
||||
if soft:
|
||||
break_kind = "paragraph"
|
||||
if break_kind == "soft":
|
||||
tspan.set(PARAGRAPH_SOFT_BREAK_ATTR, "1")
|
||||
elif break_kind == "line":
|
||||
tspan.set(PARAGRAPH_LINE_BREAK_ATTR, "1")
|
||||
elif extra > 1e-6:
|
||||
tspan.set(PARAGRAPH_SPACE_BEFORE_ATTR, format_number(extra))
|
||||
|
||||
@@ -480,17 +515,26 @@ def _emit_mergeable_paragraph(
|
||||
def flatten_text_with_tspans(
|
||||
tree: ET.ElementTree,
|
||||
merge_paragraphs: bool = False,
|
||||
preserve_line_breaks: bool = False,
|
||||
) -> bool:
|
||||
"""Flatten multi-line tspan text into independent text nodes when needed.
|
||||
|
||||
When ``merge_paragraphs`` is True, mergeable paragraph blocks (same x,
|
||||
dy clustered around one base line-height) are kept as a single <text>
|
||||
so downstream conversion emits one editable PowerPoint text frame
|
||||
with multiple <a:p>. Default False preserves the original behavior:
|
||||
every line-break tspan becomes its own <text>, matching the SVG's
|
||||
pixel-fidelity contract.
|
||||
dy clustered around one base line-height) are kept as a single <text>.
|
||||
``preserve_line_breaks`` marks ordinary visual rows as hard line breaks
|
||||
instead of reflowable continuations. Default split behavior still promotes
|
||||
every positioned row to its own <text>.
|
||||
"""
|
||||
root = tree.getroot()
|
||||
positional_errors = nested_positional_tspan_errors(root)
|
||||
if positional_errors:
|
||||
preview = "; ".join(positional_errors[:3])
|
||||
suffix = (
|
||||
""
|
||||
if len(positional_errors) <= 3
|
||||
else f"; +{len(positional_errors) - 3} more"
|
||||
)
|
||||
raise ValueError(f"Unsupported nested positional <tspan>: {preview}{suffix}")
|
||||
parent_map = {c: p for p in root.iter() for c in p}
|
||||
changed = False
|
||||
|
||||
@@ -539,22 +583,23 @@ def flatten_text_with_tspans(
|
||||
if not needs_flatten:
|
||||
continue
|
||||
|
||||
# Paragraph fast-path (opt-in via merge_paragraphs=True): if the
|
||||
# children form a mergeable paragraph (same x, dy clustered around
|
||||
# one base line-height with optional paragraph gaps, no nested
|
||||
# positional tspans), keep as one <text> and let the downstream
|
||||
# converter emit multiple <a:p> runs. When disabled, every tspan
|
||||
# gets its own independent <text> so the SVG's exact line layout
|
||||
# is preserved in PowerPoint.
|
||||
# Single-frame fast path: conservative same-x/dy blocks stay in one
|
||||
# <text>. The downstream converter either preserves visual breaks or
|
||||
# reflows them. Split mode promotes each positioned line to <text>.
|
||||
if merge_paragraphs:
|
||||
paragraph = _classify_paragraph_block(text_el, is_svg_tag, is_new_line_tspan)
|
||||
paragraph = _classify_paragraph_block(
|
||||
text_el,
|
||||
is_svg_tag,
|
||||
is_new_line_tspan,
|
||||
preserve_line_breaks,
|
||||
)
|
||||
if paragraph is not None:
|
||||
base_dy, extras, soft_breaks, line_groups, synthetic_first = paragraph
|
||||
base_dy, extras, break_kinds, line_groups, synthetic_first = paragraph
|
||||
_emit_mergeable_paragraph(
|
||||
text_el,
|
||||
base_dy,
|
||||
extras,
|
||||
soft_breaks,
|
||||
break_kinds,
|
||||
line_groups,
|
||||
synthetic_first=synthetic_first,
|
||||
)
|
||||
@@ -638,14 +683,22 @@ def _has_tspan_children(elem: ET.Element) -> bool:
|
||||
def _copy_inline_tspan(src: ET.Element, strip_line_attrs: bool) -> ET.Element:
|
||||
"""Deep-copy a tspan as an inline run, preserving nested tspan structure, head text, and tail text.
|
||||
|
||||
When strip_line_attrs is True, x/y/dy on the copied tspan are dropped because the
|
||||
enclosing <text> now positions the line. dx is preserved (safe inline kerning).
|
||||
When strip_line_attrs is True, x/y/dy are dropped because the enclosing
|
||||
<text> owns the resolved line position. Drop dx only from a positioned
|
||||
line starter, where compute_line_positions already consumed it; preserve
|
||||
dx on later inline runs.
|
||||
Nested tspans are copied recursively without stripping (they are already inline-only).
|
||||
"""
|
||||
new = ET.Element(f"{{{SVG_NS}}}tspan")
|
||||
consumed_dx = (
|
||||
strip_line_attrs
|
||||
and _positional_tspan_attribute(src) is not None
|
||||
)
|
||||
for k, v in src.attrib.items():
|
||||
if strip_line_attrs and k in ("x", "y", "dy"):
|
||||
continue
|
||||
if k == "dx" and consumed_dx:
|
||||
continue
|
||||
new.set(k, v)
|
||||
new.text = src.text
|
||||
for child in list(src):
|
||||
|
||||
@@ -348,9 +348,11 @@ except ImportError:
|
||||
try:
|
||||
from svg_to_pptx.tspan_flattener import (
|
||||
flatten_positional_tspans as _flatten_positional_tspans,
|
||||
nested_positional_tspan_errors as _nested_positional_tspan_errors,
|
||||
)
|
||||
except ImportError:
|
||||
_flatten_positional_tspans = None
|
||||
_nested_positional_tspan_errors = None
|
||||
|
||||
try:
|
||||
from svg_to_pptx.pptx_package.template_structure import (
|
||||
@@ -2526,6 +2528,17 @@ class SVGQualityChecker:
|
||||
self._check_root_module_text_bounds(root, result)
|
||||
self._check_fragmented_paragraph_text(root, result)
|
||||
self._check_unmergeable_leading_text(root, result)
|
||||
self._check_nested_positional_tspans(root, result)
|
||||
|
||||
def _check_nested_positional_tspans(
|
||||
self,
|
||||
root: ET.Element,
|
||||
result: Dict,
|
||||
) -> None:
|
||||
"""Reject nested baseline jumps that DrawingML runs cannot represent."""
|
||||
if _nested_positional_tspan_errors is None:
|
||||
return
|
||||
result['errors'].extend(_nested_positional_tspan_errors(root))
|
||||
|
||||
@classmethod
|
||||
def _single_line_text_runs(
|
||||
@@ -3507,7 +3520,7 @@ class SVGQualityChecker:
|
||||
)
|
||||
|
||||
def _check_unmergeable_leading_text(self, root: ET.Element, result: Dict) -> None:
|
||||
"""Warn when leading text cannot be normalized for paragraph merging."""
|
||||
"""Warn when leading text cannot be normalized into one PPT text frame."""
|
||||
risky = []
|
||||
for text_el in root.iter(f'{{{SVG_NS}}}text'):
|
||||
if not (text_el.text or "").strip():
|
||||
@@ -3525,7 +3538,7 @@ class SVGQualityChecker:
|
||||
suffix = '' if len(risky) <= 3 else f"; +{len(risky) - 3} more"
|
||||
result['warnings'].append(
|
||||
"Detected multi-line <text> with leading direct text that cannot "
|
||||
f"be normalized for PPT paragraph merging ({sample}{suffix})"
|
||||
f"be normalized into one PPT text frame ({sample}{suffix})"
|
||||
)
|
||||
|
||||
def _check_fragmented_paragraph_text(
|
||||
@@ -4798,10 +4811,16 @@ class SVGQualityChecker:
|
||||
return
|
||||
|
||||
flattened_root = copy.deepcopy(root)
|
||||
_flatten_positional_tspans(
|
||||
ET.ElementTree(flattened_root),
|
||||
merge_paragraphs=True,
|
||||
)
|
||||
try:
|
||||
_flatten_positional_tspans(
|
||||
ET.ElementTree(flattened_root),
|
||||
merge_paragraphs=True,
|
||||
preserve_line_breaks=True,
|
||||
)
|
||||
except ValueError:
|
||||
# The shared text check reports the unsupported nested-position
|
||||
# contract; avoid turning a quality result into a checker crash.
|
||||
return
|
||||
slots_by_id = {
|
||||
(slot.get('id') or '').strip(): slot
|
||||
for slot in flattened_root.iter(f'{{{SVG_NS}}}g')
|
||||
@@ -4821,7 +4840,7 @@ class SVGQualityChecker:
|
||||
f"{svg_path.name}: placeholder slot {slot_id} becomes "
|
||||
f"{len(native_children)} native children after positional "
|
||||
"<tspan> flattening; a carrier-bound slot must export as one "
|
||||
"text or picture carrier. Use one mergeable dy-stacked text "
|
||||
"text or picture carrier. Use one single-frame dy-stacked text "
|
||||
"frame, or move independently positioned lines outside the slot"
|
||||
)
|
||||
|
||||
|
||||
+236
-22
@@ -17,6 +17,7 @@ from pptx_animations import (
|
||||
ANIMATION_RESTARTS,
|
||||
ANIMATION_TIMING_OPTION_FIELDS,
|
||||
ANIMATION_TRIGGERS,
|
||||
animation_effect_supports_bounce_end,
|
||||
animation_seconds_to_milliseconds,
|
||||
normalize_animation_effect,
|
||||
normalize_animation_effect_options,
|
||||
@@ -30,10 +31,20 @@ from pptx_transitions import (
|
||||
)
|
||||
|
||||
from .drawingml.utils import SVG_NS
|
||||
from .pptx_package.narration import AUDIO_CONTENT_TYPES
|
||||
from .semantic_markers import is_static_page_frame
|
||||
|
||||
|
||||
_NON_VISUAL_TAGS = frozenset(('defs', 'title', 'desc', 'metadata', 'style'))
|
||||
_INHERITANCE_SENSITIVE_ANIMATION_FIELDS = frozenset({
|
||||
'effect',
|
||||
'effect_options',
|
||||
'repeat_count',
|
||||
'repeat_duration',
|
||||
'accelerate',
|
||||
'decelerate',
|
||||
'bounce_end',
|
||||
})
|
||||
_CHROME_ID_TOKENS = frozenset({
|
||||
'background', 'bg',
|
||||
'decoration', 'decorations', 'decor',
|
||||
@@ -163,15 +174,21 @@ def _require_unique_target_ids(
|
||||
raise ValueError(_duplicate_target_error(slide_name, duplicates))
|
||||
|
||||
|
||||
def scan_project_targets(project_path: Path) -> tuple[dict[str, list[GroupTarget]], list[str]]:
|
||||
"""Scan ``svg_output/*.svg`` for animation targets."""
|
||||
svg_dir = project_path / 'svg_output'
|
||||
def scan_project_targets(
|
||||
project_path: Path,
|
||||
*,
|
||||
svg_files: list[Path] | None = None,
|
||||
) -> tuple[dict[str, list[GroupTarget]], list[str]]:
|
||||
"""Scan selected SVG files, defaulting to ``svg_output/*.svg``."""
|
||||
targets_by_slide: dict[str, list[GroupTarget]] = {}
|
||||
anonymous_groups: list[str] = []
|
||||
if not svg_dir.is_dir():
|
||||
return targets_by_slide, [f'svg_output directory not found: {svg_dir}']
|
||||
if svg_files is None:
|
||||
svg_dir = project_path / 'svg_output'
|
||||
if not svg_dir.is_dir():
|
||||
return targets_by_slide, [f'svg_output directory not found: {svg_dir}']
|
||||
svg_files = sorted(svg_dir.glob('*.svg'))
|
||||
|
||||
for svg_path in sorted(svg_dir.glob('*.svg')):
|
||||
for svg_path in svg_files:
|
||||
targets, anonymous = scan_svg_targets(svg_path)
|
||||
targets_by_slide[svg_path.stem] = targets
|
||||
anonymous_groups.extend(anonymous)
|
||||
@@ -184,14 +201,18 @@ def default_config_path(project_path: Path) -> Path:
|
||||
|
||||
|
||||
def load_animation_config(project_path: Path, config_path: str | None = None) -> dict[str, Any] | None:
|
||||
"""Load optional animation config; return ``None`` when absent."""
|
||||
if config_path:
|
||||
"""Load animation config; only an absent default sidecar is optional."""
|
||||
if config_path is not None:
|
||||
if not config_path.strip():
|
||||
raise ValueError('Animation config path must be non-empty')
|
||||
path = Path(config_path)
|
||||
else:
|
||||
path = default_config_path(project_path)
|
||||
if config_path and not path.is_absolute():
|
||||
if config_path is not None and not path.is_absolute():
|
||||
path = project_path / path
|
||||
if not path.exists():
|
||||
if config_path is not None:
|
||||
raise FileNotFoundError(f'Animation config does not exist: {path}')
|
||||
return None
|
||||
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
@@ -225,6 +246,18 @@ def _animation_effect_error(effect: object, label: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_slide_animation_config(
|
||||
default_animation: dict[str, Any],
|
||||
slide_animation: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Merge one slide animation over defaults using writer inheritance rules."""
|
||||
resolved = dict(default_animation)
|
||||
if 'effect' in slide_animation and 'effect_options' not in slide_animation:
|
||||
resolved.pop('effect_options', None)
|
||||
resolved.update(slide_animation)
|
||||
return resolved
|
||||
|
||||
|
||||
def _animation_parameter_errors(
|
||||
value: dict[str, Any],
|
||||
label: str,
|
||||
@@ -370,11 +403,7 @@ def _animation_parameter_errors(
|
||||
errors.append(
|
||||
f'animations.json {label} sound must be a non-empty path string'
|
||||
)
|
||||
elif sound_is_path and Path(sound).suffix.lower() not in {
|
||||
'.m4a',
|
||||
'.mp3',
|
||||
'.wav',
|
||||
}:
|
||||
elif sound_is_path and Path(sound).suffix.lower() not in AUDIO_CONTENT_TYPES:
|
||||
errors.append(
|
||||
f'animations.json {label} sound must use .m4a, .mp3, or .wav'
|
||||
)
|
||||
@@ -761,6 +790,7 @@ def validate_animation_config_errors(config: dict[str, Any]) -> list[str]:
|
||||
_animation_scope_errors(slide_cfg, f'slide "{slide_name}"')
|
||||
)
|
||||
errors.extend(_animation_group_errors(slide_name, slide_cfg))
|
||||
errors.extend(_resolved_animation_parameter_errors(config))
|
||||
return list(dict.fromkeys(errors))
|
||||
|
||||
|
||||
@@ -904,24 +934,211 @@ def _animation_group_errors(
|
||||
return errors
|
||||
|
||||
|
||||
def _bounce_support_error(
|
||||
animation: dict[str, Any],
|
||||
label: str,
|
||||
) -> str | None:
|
||||
"""Return a writer-equivalent bounce support error for one resolved scope."""
|
||||
bounce_end = animation.get('bounce_end')
|
||||
if (
|
||||
isinstance(bounce_end, bool)
|
||||
or not isinstance(bounce_end, (int, float))
|
||||
or not math.isfinite(float(bounce_end))
|
||||
or float(bounce_end) <= 0
|
||||
):
|
||||
return None
|
||||
try:
|
||||
effect, options = normalize_animation_effect_request(
|
||||
animation.get('effect', 'auto'),
|
||||
animation.get('effect_options'),
|
||||
allow_none=True,
|
||||
allow_modes=True,
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
if effect is None or effect in ANIMATION_MODES:
|
||||
return None
|
||||
if animation_effect_supports_bounce_end(effect, options):
|
||||
return None
|
||||
return (
|
||||
f'animations.json {label} effect {effect!r} has no behavior that '
|
||||
'supports bounce_end'
|
||||
)
|
||||
|
||||
|
||||
def _resolved_animation_parameter_errors(config: dict[str, Any]) -> list[str]:
|
||||
"""Validate effective animation parameters after sidecar inheritance."""
|
||||
defaults = config.get('defaults', {})
|
||||
default_animation: dict[str, Any] = {'effect': 'auto'}
|
||||
if isinstance(defaults, dict):
|
||||
value = defaults.get('animation', {})
|
||||
if isinstance(value, dict):
|
||||
default_animation = resolve_slide_animation_config(
|
||||
default_animation,
|
||||
value,
|
||||
)
|
||||
|
||||
errors: list[str] = []
|
||||
default_error = _bounce_support_error(default_animation, 'defaults animation')
|
||||
if default_error:
|
||||
errors.append(default_error)
|
||||
|
||||
slides = config.get('slides', {})
|
||||
if not isinstance(slides, dict):
|
||||
return errors
|
||||
for slide_name, slide_cfg in slides.items():
|
||||
if not isinstance(slide_cfg, dict):
|
||||
continue
|
||||
slide_value = slide_cfg.get('animation', {})
|
||||
if not isinstance(slide_value, dict):
|
||||
continue
|
||||
slide_animation = resolve_slide_animation_config(
|
||||
default_animation,
|
||||
slide_value,
|
||||
)
|
||||
if _INHERITANCE_SENSITIVE_ANIMATION_FIELDS & set(slide_value):
|
||||
errors.extend(
|
||||
_animation_parameter_errors(
|
||||
slide_animation,
|
||||
f'slide "{slide_name}" animation',
|
||||
inherited_effect='auto',
|
||||
)
|
||||
)
|
||||
error = _bounce_support_error(
|
||||
slide_animation,
|
||||
f'slide "{slide_name}" animation',
|
||||
)
|
||||
if error:
|
||||
errors.append(error)
|
||||
|
||||
groups = slide_cfg.get('groups', {})
|
||||
if not isinstance(groups, dict):
|
||||
continue
|
||||
for group_id, group_cfg in groups.items():
|
||||
if (
|
||||
not isinstance(group_cfg, dict)
|
||||
or not _INHERITANCE_SENSITIVE_ANIMATION_FIELDS & set(group_cfg)
|
||||
):
|
||||
continue
|
||||
inherited_group_animation = {
|
||||
field: slide_animation[field]
|
||||
for field in (
|
||||
'effect',
|
||||
'effect_options',
|
||||
'duration',
|
||||
*ANIMATION_TIMING_OPTION_FIELDS,
|
||||
'after_effect',
|
||||
'sound',
|
||||
)
|
||||
if field in slide_animation
|
||||
}
|
||||
group_animation = resolve_slide_animation_config(
|
||||
inherited_group_animation,
|
||||
group_cfg,
|
||||
)
|
||||
errors.extend(
|
||||
_animation_parameter_errors(
|
||||
group_animation,
|
||||
f'group "{slide_name}/{group_id}"',
|
||||
inherited_effect='auto',
|
||||
)
|
||||
)
|
||||
error = _bounce_support_error(
|
||||
group_animation,
|
||||
f'group "{slide_name}/{group_id}"',
|
||||
)
|
||||
if error:
|
||||
errors.append(error)
|
||||
return errors
|
||||
|
||||
|
||||
def _declared_animation_sounds(
|
||||
config: dict[str, Any],
|
||||
) -> tuple[tuple[str, object], ...]:
|
||||
"""Return explicitly declared sidecar sound values with scope labels."""
|
||||
sounds: list[tuple[str, object]] = []
|
||||
defaults = config.get('defaults', {})
|
||||
if isinstance(defaults, dict):
|
||||
animation = defaults.get('animation', {})
|
||||
if isinstance(animation, dict) and 'sound' in animation:
|
||||
sounds.append(('defaults animation', animation['sound']))
|
||||
|
||||
slides = config.get('slides', {})
|
||||
if not isinstance(slides, dict):
|
||||
return tuple(sounds)
|
||||
for slide_name, slide_cfg in slides.items():
|
||||
if not isinstance(slide_cfg, dict):
|
||||
continue
|
||||
animation = slide_cfg.get('animation', {})
|
||||
if isinstance(animation, dict) and 'sound' in animation:
|
||||
sounds.append((f'slide "{slide_name}" animation', animation['sound']))
|
||||
groups = slide_cfg.get('groups', {})
|
||||
if not isinstance(groups, dict):
|
||||
continue
|
||||
for group_id, group_cfg in groups.items():
|
||||
if isinstance(group_cfg, dict) and 'sound' in group_cfg:
|
||||
sounds.append(
|
||||
(f'group "{slide_name}/{group_id}"', group_cfg['sound'])
|
||||
)
|
||||
return tuple(sounds)
|
||||
|
||||
|
||||
def _animation_sound_path_errors(
|
||||
project_path: Path,
|
||||
config: dict[str, Any],
|
||||
) -> list[str]:
|
||||
"""Validate declared animation sound files against the project root."""
|
||||
errors: list[str] = []
|
||||
project_root = project_path.resolve()
|
||||
for label, raw_sound in _declared_animation_sounds(config):
|
||||
if not isinstance(raw_sound, str) or not raw_sound.strip():
|
||||
continue
|
||||
sound_path = Path(raw_sound)
|
||||
if sound_path.suffix.lower() not in AUDIO_CONTENT_TYPES:
|
||||
errors.append(
|
||||
f'animations.json {label} sound must use .m4a, .mp3, or .wav'
|
||||
)
|
||||
continue
|
||||
if not sound_path.is_absolute():
|
||||
sound_path = project_root / sound_path
|
||||
sound_path = sound_path.resolve()
|
||||
if not sound_path.exists():
|
||||
errors.append(
|
||||
f'animations.json {label} sound file not found: {sound_path}'
|
||||
)
|
||||
elif not sound_path.is_file():
|
||||
errors.append(
|
||||
f'animations.json {label} sound path is not a regular file: '
|
||||
f'{sound_path}'
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def validate_animation_config(
|
||||
project_path: Path,
|
||||
config: dict[str, Any] | None = None,
|
||||
config_path: str | None = None,
|
||||
*,
|
||||
svg_files: list[Path] | None = None,
|
||||
) -> list[str]:
|
||||
"""Return sidecar-reference diagnostics for ``svg_output``.
|
||||
"""Return sidecar-reference diagnostics for the selected SVG slides.
|
||||
|
||||
Fatal field/type/value checks are owned by
|
||||
:func:`validate_animation_config_errors`. Anonymous groups are warnings;
|
||||
missing slides/groups and structural targets are fatal at export call sites.
|
||||
:func:`validate_animation_config_errors`. Anonymous groups are warnings;
|
||||
references to invalid sound files, missing slides/groups, and structural
|
||||
targets are fatal at export call sites. Slides omitted from a sparse
|
||||
sidecar inherit defaults.
|
||||
"""
|
||||
if config is None:
|
||||
config = load_animation_config(project_path, config_path)
|
||||
if not config:
|
||||
return []
|
||||
|
||||
warnings: list[str] = []
|
||||
targets_by_slide, anonymous_groups = scan_project_targets(project_path)
|
||||
warnings = _animation_sound_path_errors(project_path, config)
|
||||
targets_by_slide, anonymous_groups = scan_project_targets(
|
||||
project_path,
|
||||
svg_files=svg_files,
|
||||
)
|
||||
for item in anonymous_groups:
|
||||
warnings.append(f'{item} has no id and cannot be customized in animations.json')
|
||||
|
||||
@@ -945,9 +1162,6 @@ def validate_animation_config(
|
||||
slides = config.get('slides', {})
|
||||
if not isinstance(slides, dict):
|
||||
return list(dict.fromkeys(warnings))
|
||||
for slide_name in sorted(known_slides - set(slides)):
|
||||
warnings.append(f'animations.json omits slide: {slide_name}')
|
||||
|
||||
for slide_name, slide_cfg in slides.items():
|
||||
if slide_name not in known_slides:
|
||||
warnings.append(f'animations.json references missing slide: {slide_name}')
|
||||
|
||||
+37
-4
@@ -14,6 +14,35 @@ if TYPE_CHECKING:
|
||||
AffineMatrix = tuple[float, float, float, float, float, float]
|
||||
IDENTITY_MATRIX: AffineMatrix = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
||||
|
||||
TEXT_FLOW_PRESERVE = 'preserve'
|
||||
TEXT_FLOW_REFLOW = 'reflow'
|
||||
TEXT_FLOW_SPLIT = 'split'
|
||||
TEXT_FLOW_MODES = frozenset({
|
||||
TEXT_FLOW_PRESERVE,
|
||||
TEXT_FLOW_REFLOW,
|
||||
TEXT_FLOW_SPLIT,
|
||||
})
|
||||
|
||||
|
||||
def resolve_text_flow(
|
||||
text_flow: str | None = None,
|
||||
merge_paragraphs: bool | None = None,
|
||||
) -> str:
|
||||
"""Resolve the public text-layout options to one internal mode."""
|
||||
if text_flow is not None and merge_paragraphs is not None:
|
||||
raise ValueError(
|
||||
'text_flow and legacy merge_paragraphs cannot be used together'
|
||||
)
|
||||
if merge_paragraphs is not None:
|
||||
return TEXT_FLOW_REFLOW if merge_paragraphs else TEXT_FLOW_SPLIT
|
||||
resolved = TEXT_FLOW_PRESERVE if text_flow is None else text_flow
|
||||
if resolved not in TEXT_FLOW_MODES:
|
||||
choices = ', '.join(sorted(TEXT_FLOW_MODES))
|
||||
raise ValueError(
|
||||
f'unsupported text_flow {resolved!r}; expected one of: {choices}'
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShapeResult:
|
||||
@@ -73,9 +102,9 @@ class ConvertContext:
|
||||
# Explicit sidecar group ids may override the legacy chrome-name heuristic.
|
||||
# Explicit structural layer/role/placeholder markers remain non-animatable.
|
||||
animation_group_overrides: frozenset[str] = frozenset()
|
||||
# Default-on flag: merge mergeable paragraph blocks into one editable
|
||||
# text frame with multiple <a:p>. Disable it for strict line fidelity.
|
||||
merge_paragraphs: bool = True
|
||||
# Text-layout policy for positional tspans: preserve authored line breaks
|
||||
# in one frame, reflow them, or split them into independent frames.
|
||||
text_flow: str = TEXT_FLOW_PRESERVE
|
||||
# Explicit opt-in: replace marked chart/table fallback groups with editable
|
||||
# PowerPoint graphicFrames. Default stays off to preserve SVG output.
|
||||
native_objects_enabled: bool = False
|
||||
@@ -95,6 +124,9 @@ class ConvertContext:
|
||||
# Optional project theme-color contract. Exact locked colors are promoted
|
||||
# to context-safe DrawingML scheme slots while local colors stay concrete.
|
||||
theme_color_spec: ThemeColorSpec | None = None
|
||||
# Canonical BCP-47 content language from spec_lock.md. ``None`` preserves
|
||||
# the legacy per-run script heuristic for older projects and quick tests.
|
||||
primary_language: str | None = None
|
||||
|
||||
def next_id(self) -> int:
|
||||
"""Allocate the next shape ID."""
|
||||
@@ -234,7 +266,7 @@ class ConvertContext:
|
||||
# anim_targets is intentionally a fresh list on the child;
|
||||
# only the root-level context's list is read by the builder.
|
||||
animation_group_overrides=self.animation_group_overrides,
|
||||
merge_paragraphs=self.merge_paragraphs,
|
||||
text_flow=self.text_flow,
|
||||
native_objects_enabled=self.native_objects_enabled,
|
||||
image_optimize=self.image_optimize,
|
||||
image_max_dimension=self.image_max_dimension,
|
||||
@@ -244,6 +276,7 @@ class ConvertContext:
|
||||
trace_events=self.trace_events,
|
||||
theme_font_spec=self.theme_font_spec,
|
||||
theme_color_spec=self.theme_color_spec,
|
||||
primary_language=self.primary_language,
|
||||
)
|
||||
|
||||
def sync_from_child(self, child_ctx: ConvertContext) -> None:
|
||||
|
||||
+32
-12
@@ -26,7 +26,13 @@ from pptx_to_svg.preset_authoring import (
|
||||
)
|
||||
from resource_paths import icon_search_dirs_for_svg
|
||||
|
||||
from .context import ConvertContext, ShapeResult
|
||||
from .context import (
|
||||
TEXT_FLOW_PRESERVE,
|
||||
TEXT_FLOW_SPLIT,
|
||||
ConvertContext,
|
||||
ShapeResult,
|
||||
resolve_text_flow,
|
||||
)
|
||||
from .paths import (
|
||||
project_freeform_geometry_errors,
|
||||
project_gradient_geometry_errors,
|
||||
@@ -1358,7 +1364,7 @@ def convert_svg_to_slide_shapes(
|
||||
svg_path: str | Path,
|
||||
slide_num: int = 1,
|
||||
verbose: bool = False,
|
||||
merge_paragraphs: bool = True,
|
||||
merge_paragraphs: bool | None = None,
|
||||
image_optimize: bool = True,
|
||||
image_max_dimension: int | None = 2560,
|
||||
image_sizing: str = 'cap',
|
||||
@@ -1368,8 +1374,10 @@ def convert_svg_to_slide_shapes(
|
||||
animation_group_overrides: frozenset[str] | None = None,
|
||||
theme_font_spec: ThemeFontSpec | None = None,
|
||||
theme_color_spec: ThemeColorSpec | None = None,
|
||||
primary_language: str | None = None,
|
||||
trace_out: list[dict[str, Any]] | None = None,
|
||||
promote_background: bool = True,
|
||||
text_flow: str | None = None,
|
||||
) -> tuple[
|
||||
str,
|
||||
dict[str, bytes],
|
||||
@@ -1384,10 +1392,11 @@ def convert_svg_to_slide_shapes(
|
||||
svg_path: Path to the SVG file.
|
||||
slide_num: Slide number (for naming).
|
||||
verbose: Print progress info.
|
||||
merge_paragraphs: When True, mergeable paragraph blocks (same x,
|
||||
dy clustered around one base line-height) become a single
|
||||
editable text frame with multiple <a:p>. Disable it to preserve
|
||||
the SVG's exact line layout (one textbox per line).
|
||||
merge_paragraphs: Legacy compatibility option. True selects reflow;
|
||||
False selects split. Do not combine with ``text_flow``.
|
||||
text_flow: Positional-tspan policy. ``preserve`` keeps authored visual
|
||||
line breaks in one frame, ``reflow`` lets PowerPoint wrap the text,
|
||||
and ``split`` emits one text frame per visual line.
|
||||
image_optimize: Downsample oversized raster images for PPTX export.
|
||||
image_max_dimension: Maximum optimized image dimension in pixels.
|
||||
image_sizing: ``cap`` to only cap source dimensions, ``display`` to
|
||||
@@ -1404,6 +1413,8 @@ def convert_svg_to_slide_shapes(
|
||||
theme_color_spec: Optional context-aware theme-color contract. Exact
|
||||
locked colors emit DrawingML scheme tokens while local colors stay
|
||||
fixed.
|
||||
primary_language: Canonical BCP-47 project content language. ``None``
|
||||
keeps the legacy per-run script heuristic.
|
||||
trace_out: Optional list populated with one per-slide trace dictionary.
|
||||
promote_background: Promote the first eligible full-canvas rectangle
|
||||
into native ``p:bg``. Structured export disables this generic pass
|
||||
@@ -1423,6 +1434,7 @@ def convert_svg_to_slide_shapes(
|
||||
- content_type_overrides: Dict of {pptx internal path: content type}
|
||||
for package_files that require [Content_Types].xml overrides.
|
||||
"""
|
||||
text_flow = resolve_text_flow(text_flow, merge_paragraphs)
|
||||
svg_path = Path(svg_path)
|
||||
tree = ET.parse(str(svg_path))
|
||||
root = tree.getroot()
|
||||
@@ -1606,17 +1618,24 @@ def convert_svg_to_slide_shapes(
|
||||
# and an x-anchored tspan would render in the wrong column. finalize_svg
|
||||
# does the same flattening on disk; doing it here keeps native pptx output
|
||||
# correct when reading raw svg_output/.
|
||||
# merge_paragraphs additionally folds mergeable paragraph blocks into a
|
||||
# single annotated <text> for downstream multi-<a:p> conversion.
|
||||
# Preserve/reflow modes additionally fold conservative paragraph blocks
|
||||
# into one annotated <text>. Preserve keeps visual lines as DrawingML hard
|
||||
# breaks; reflow joins wrapping lines; split keeps one frame per line.
|
||||
from ..tspan_flattener import flatten_positional_tspans
|
||||
flattened = flatten_positional_tspans(tree, merge_paragraphs=merge_paragraphs)
|
||||
flattened = flatten_positional_tspans(
|
||||
tree,
|
||||
merge_paragraphs=text_flow != TEXT_FLOW_SPLIT,
|
||||
preserve_line_breaks=text_flow == TEXT_FLOW_PRESERVE,
|
||||
)
|
||||
if flattened:
|
||||
trace_steps.append({
|
||||
'action': 'flatten-positional-tspans',
|
||||
'merge_paragraphs': merge_paragraphs,
|
||||
'text_flow': text_flow,
|
||||
# Compatibility field for older trace readers.
|
||||
'merge_paragraphs': text_flow != TEXT_FLOW_SPLIT,
|
||||
})
|
||||
if verbose:
|
||||
print(' Flattened positional <tspan> into independent <text>')
|
||||
print(f' Lowered positional <tspan> using {text_flow} text flow')
|
||||
|
||||
_require_project_text_properties(root, svg_path)
|
||||
try:
|
||||
@@ -1648,7 +1667,7 @@ def convert_svg_to_slide_shapes(
|
||||
viewport_width=viewport_width,
|
||||
viewport_height=viewport_height,
|
||||
svg_dir=Path(svg_path).parent,
|
||||
merge_paragraphs=merge_paragraphs,
|
||||
text_flow=text_flow,
|
||||
image_optimize=image_optimize,
|
||||
image_max_dimension=image_max_dimension,
|
||||
image_sizing=image_sizing,
|
||||
@@ -1659,6 +1678,7 @@ def convert_svg_to_slide_shapes(
|
||||
trace_events=trace_events,
|
||||
theme_font_spec=theme_font_spec,
|
||||
theme_color_spec=theme_color_spec,
|
||||
primary_language=primary_language,
|
||||
inherited_styles=_extract_inheritable_styles(root),
|
||||
text_font_sizes=text_font_sizes,
|
||||
text_letter_spacings=text_letter_spacings,
|
||||
|
||||
+92
-36
@@ -30,7 +30,12 @@ from resource_paths import (
|
||||
svg_image_payload_error,
|
||||
)
|
||||
|
||||
from .context import ConvertContext, ShapeResult
|
||||
from .context import (
|
||||
TEXT_FLOW_PRESERVE,
|
||||
TEXT_FLOW_SPLIT,
|
||||
ConvertContext,
|
||||
ShapeResult,
|
||||
)
|
||||
from .theme_colors import color_node_xml
|
||||
from .theme_fonts import theme_font_tokens
|
||||
from .text_properties import (
|
||||
@@ -55,6 +60,7 @@ from .utils import (
|
||||
parse_inline_style, parse_font_family, is_cjk_char,
|
||||
detect_text_lang, estimate_text_cluster_widths, font_px_to_hpt,
|
||||
resolve_text_run_fonts, split_project_text_clusters,
|
||||
text_has_rtl_characters, text_uses_rtl,
|
||||
is_thick_circle_shorthand, parse_project_geometry_length,
|
||||
is_canonical_project_geometry_length,
|
||||
parse_project_image_aspect_ratio,
|
||||
@@ -2120,6 +2126,10 @@ def _strip_leading_chars_from_runs(
|
||||
stripped: list[dict[str, Any]] = []
|
||||
remaining = char_count
|
||||
for run in runs:
|
||||
if run.get('_line_break'):
|
||||
if remaining == 0:
|
||||
stripped.append(run)
|
||||
continue
|
||||
text = str(run.get('text', ''))
|
||||
if remaining >= len(text):
|
||||
remaining -= len(text)
|
||||
@@ -2233,8 +2243,11 @@ def _paragraph_pr_xml(
|
||||
body_xml: str = '',
|
||||
bullet: dict[str, Any] | None = None,
|
||||
ctx: ConvertContext | None = None,
|
||||
rtl: bool = False,
|
||||
) -> str:
|
||||
attrs = f'algn="{algn}"'
|
||||
if rtl:
|
||||
attrs += ' rtl="1"'
|
||||
if bullet:
|
||||
margin = px_to_emu(_bullet_margin_px(bullet, font_size))
|
||||
indent = px_to_emu(_bullet_indent_px(bullet, font_size))
|
||||
@@ -2537,7 +2550,15 @@ def _build_run_properties_xml(
|
||||
fonts,
|
||||
ctx.theme_font_spec if ctx is not None else None,
|
||||
) or resolve_text_run_fonts(text, fonts)
|
||||
lang = detect_text_lang(text)
|
||||
lang = detect_text_lang(
|
||||
text,
|
||||
ctx.primary_language if ctx is not None else None,
|
||||
)
|
||||
rtl_xml = (
|
||||
'\n<a:rtl val="1"/>'
|
||||
if text_has_rtl_characters(text)
|
||||
else ''
|
||||
)
|
||||
|
||||
fill_xml = _build_text_fill_xml(fill, fill_raw, opacity, ctx)
|
||||
outline_xml = _build_text_outline_xml(run, ctx)
|
||||
@@ -2548,7 +2569,7 @@ def _build_run_properties_xml(
|
||||
{effect_xml}
|
||||
<a:latin typeface="{_xml_escape(run_fonts['latin'])}"/>
|
||||
<a:ea typeface="{_xml_escape(run_fonts['ea'])}"/>
|
||||
<a:cs typeface="{_xml_escape(run_fonts['cs'])}"/>
|
||||
<a:cs typeface="{_xml_escape(run_fonts['cs'])}"/>{rtl_xml}
|
||||
</a:rPr>'''
|
||||
|
||||
|
||||
@@ -2561,6 +2582,10 @@ def _coalesce_text_runs(
|
||||
merged: list[dict[str, Any]] = []
|
||||
previous_properties: str | None = None
|
||||
for run in runs:
|
||||
if run.get('_line_break'):
|
||||
merged.append({'_line_break': True})
|
||||
previous_properties = None
|
||||
continue
|
||||
text = str(run.get('text', ''))
|
||||
if not text:
|
||||
continue
|
||||
@@ -2601,6 +2626,8 @@ def _build_run_xml(
|
||||
effect_xml: str = '',
|
||||
) -> str:
|
||||
"""Build a single <a:r> XML from a run dict. Supports gradient fills on text."""
|
||||
if run.get('_line_break'):
|
||||
return '<a:br/>'
|
||||
text = str(run['text'])
|
||||
properties_xml = _build_run_properties_xml(
|
||||
run,
|
||||
@@ -2698,16 +2725,14 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
'stroke_opacity': stroke_opacity,
|
||||
}
|
||||
|
||||
# Paragraph mode: flatten_tspan marks <text> with data-paragraph-line-height
|
||||
# when its direct-child tspans form a mergeable paragraph (same x, dy
|
||||
# clustered around one base line-height). Each direct tspan becomes one
|
||||
# <a:p> so the paragraph survives as a single editable text frame.
|
||||
# Per-line data-paragraph-space-before encodes paragraph gaps (extra dy
|
||||
# above the base line-height) for the corresponding <a:p>.
|
||||
# Paragraph mode is controlled by ctx.merge_paragraphs. When off, ignore
|
||||
# any data-paragraph-* markers and fall through to the original
|
||||
# one-text-per-tspan path so the SVG's pixel layout is preserved.
|
||||
line_height_attr = elem.get('data-paragraph-line-height') if ctx.merge_paragraphs else None
|
||||
# Single-frame modes annotate conservative dy-stacked text with one base
|
||||
# line height. Semantic paragraphs become <a:p>; authored visual rows
|
||||
# either become <a:br/> (preserve) or join for wrapping (reflow).
|
||||
line_height_attr = (
|
||||
elem.get('data-paragraph-line-height')
|
||||
if ctx.text_flow != TEXT_FLOW_SPLIT
|
||||
else None
|
||||
)
|
||||
line_height_px = _f(line_height_attr) if line_height_attr is not None else None
|
||||
paragraph_runs: list[list[dict[str, Any]]] | None = None
|
||||
paragraph_space_before: list[float] = []
|
||||
@@ -2735,7 +2760,11 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
_estimate_bullet_line_width(line_runs, fonts, ctx)
|
||||
)
|
||||
soft_break = child.get('data-paragraph-soft-break') == '1'
|
||||
if soft_break and paragraph_runs:
|
||||
line_break = child.get('data-paragraph-line-break') == '1'
|
||||
if line_break and paragraph_runs:
|
||||
paragraph_runs[-1].append({'_line_break': True})
|
||||
paragraph_runs[-1].extend(line_runs)
|
||||
elif soft_break and paragraph_runs:
|
||||
# Append to the previous paragraph. A Latin line-wrap needs a
|
||||
# space to keep two words apart (SVG used a dy break, not
|
||||
# punctuation); CJK wraps mid-sentence with no inter-character
|
||||
@@ -2781,12 +2810,11 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
runs, single_bullet = _extract_text_bullet(runs)
|
||||
runs = _coalesce_text_runs(runs, fonts, ctx)
|
||||
|
||||
full_text = ''.join(r['text'] for r in runs) if runs else ''
|
||||
is_placeholder_carrier = (
|
||||
(elem.get('data-pptx-carrier') or '').strip().lower() == 'true'
|
||||
)
|
||||
full_text = ''.join(str(r.get('text', '')) for r in runs) if runs else ''
|
||||
if not full_text.strip():
|
||||
is_placeholder_carrier = (
|
||||
(elem.get('data-pptx-carrier') or '').strip().lower()
|
||||
== 'true'
|
||||
)
|
||||
if not is_placeholder_carrier:
|
||||
return None
|
||||
# A declared carrier must compile to one native text shape even when its
|
||||
@@ -2800,14 +2828,9 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
|
||||
# Estimate text dimensions
|
||||
if paragraph_runs is not None:
|
||||
# Use the WIDEST visual line (per-tspan as the deck author drew it),
|
||||
# not the joined-up paragraph: soft-broken paragraphs concatenate
|
||||
# many lines into one <a:p>, and measuring the joined string would
|
||||
# blow the textbox past the canvas.
|
||||
# Use the widest authored visual line, not a reflow-joined paragraph.
|
||||
text_width = max(visual_line_widths) if visual_line_widths else 0.0
|
||||
# Total height assumes the visual line count from the SVG source;
|
||||
# if PowerPoint wraps to more or fewer lines after the user resizes,
|
||||
# the user resizes the height accordingly.
|
||||
# Keep the authored visual-line count as the source height contract.
|
||||
text_height = (
|
||||
line_height_px * (len(visual_line_widths) - 1)
|
||||
+ sum(paragraph_space_before)
|
||||
@@ -2986,12 +3009,24 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
spc_bef_val = round(extra_px * FONT_PX_TO_HUNDREDTHS_PT)
|
||||
spc_bef_xml = f'<a:spcBef><a:spcPts val="{spc_bef_val}"/></a:spcBef>'
|
||||
runs_inner = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in line)
|
||||
first_text_run = next(
|
||||
(run for run in line if not run.get('_line_break')),
|
||||
None,
|
||||
)
|
||||
p_pr_xml = _paragraph_pr_xml(
|
||||
algn=algn,
|
||||
font_size=float(line[0].get('font_size', font_size)) if line else font_size,
|
||||
font_size=(
|
||||
float(first_text_run.get('font_size', font_size))
|
||||
if first_text_run is not None
|
||||
else font_size
|
||||
),
|
||||
body_xml=f'{ln_spc_xml}{spc_bef_xml}',
|
||||
bullet=bullet,
|
||||
ctx=ctx,
|
||||
rtl=text_uses_rtl(
|
||||
''.join(str(run.get('text', '')) for run in line),
|
||||
ctx.primary_language,
|
||||
),
|
||||
)
|
||||
paragraph_xml_chunks.append(
|
||||
f'<a:p>\n{p_pr_xml}\n{runs_inner}\n</a:p>'
|
||||
@@ -3004,6 +3039,7 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
font_size=float(runs[0].get('font_size', font_size)) if runs else font_size,
|
||||
bullet=single_bullet,
|
||||
ctx=ctx,
|
||||
rtl=text_uses_rtl(full_text, ctx.primary_language),
|
||||
)
|
||||
paragraphs_xml = f'<a:p>\n{p_pr_xml}\n{runs_xml}\n</a:p>'
|
||||
|
||||
@@ -3031,23 +3067,37 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
if exact_text_insets is None:
|
||||
raise ValueError('data-pptx-frame text insets were not resolved')
|
||||
left_inset, top_inset, right_inset = exact_text_insets
|
||||
exact_frame_wrap = (
|
||||
'none' if ctx.text_flow == TEXT_FLOW_PRESERVE else 'square'
|
||||
)
|
||||
body_pr_xml = (
|
||||
'<a:bodyPr wrap="square" '
|
||||
f'<a:bodyPr wrap="{exact_frame_wrap}" '
|
||||
f'lIns="{px_to_emu(left_inset)}" '
|
||||
f'tIns="{px_to_emu(top_inset)}" '
|
||||
f'rIns="{px_to_emu(right_inset)}" bIns="0" '
|
||||
'anchor="t" anchorCtr="0">\n<a:noAutofit/>\n</a:bodyPr>'
|
||||
)
|
||||
# Paragraph mode: wrap="square" so text reflows when the user resizes,
|
||||
# but NO spAutoFit — otherwise PowerPoint expands the frame to fit a
|
||||
# long joined-up <a:p> on one line, blowing past the canvas. The cx we
|
||||
# write below is the longest source SVG line without single-line renderer
|
||||
# headroom; PowerPoint wraps long paragraphs inside this design width.
|
||||
# Single-line text keeps wrap="none" + spAutoFit for tight fidelity.
|
||||
# Preserve mode keeps authored <a:br/> boundaries and lets an ordinary
|
||||
# generated text box follow later manual edits, such as deleting a break.
|
||||
# Reflow mode keeps the source width fixed as its wrapping constraint.
|
||||
# Exact imported frames above and structured placeholder carriers remain
|
||||
# fixed regardless of text-flow mode.
|
||||
elif paragraph_runs is not None:
|
||||
paragraph_wrap = (
|
||||
'none' if ctx.text_flow == TEXT_FLOW_PRESERVE else 'square'
|
||||
)
|
||||
paragraph_autofit = (
|
||||
'<a:spAutoFit/>'
|
||||
if (
|
||||
ctx.text_flow == TEXT_FLOW_PRESERVE
|
||||
and not is_placeholder_carrier
|
||||
)
|
||||
else '<a:noAutofit/>'
|
||||
)
|
||||
body_pr_xml = (
|
||||
'<a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" '
|
||||
'anchor="t" anchorCtr="0"/>'
|
||||
f'<a:bodyPr wrap="{paragraph_wrap}" '
|
||||
'lIns="0" tIns="0" rIns="0" bIns="0" '
|
||||
f'anchor="t" anchorCtr="0">\n{paragraph_autofit}\n</a:bodyPr>'
|
||||
)
|
||||
else:
|
||||
body_pr_xml = (
|
||||
@@ -4037,6 +4087,12 @@ def _optimize_image_for_pptx(
|
||||
original_size = img.size
|
||||
img = _resize_for_target(img, target_w, target_h)
|
||||
resized = img.size != original_size
|
||||
if (
|
||||
ctx.image_sizing == 'cap'
|
||||
and not geometry_normalized
|
||||
and not resized
|
||||
):
|
||||
return img_data, img_format
|
||||
# Preserve source semantics: only an original JPEG stays lossy. PNG and
|
||||
# other static raster formats use lossless PNG after any resize.
|
||||
prefer_jpeg = img_format.lower() in {'jpg', 'jpeg'}
|
||||
|
||||
+120
-6
@@ -22,6 +22,7 @@ from pptx_shapes import (
|
||||
svg_preset_preview_fingerprint,
|
||||
validate_ooxml_xfrm,
|
||||
)
|
||||
from language_tags import language_base, language_uses_rtl
|
||||
|
||||
from .context import AffineMatrix, ConvertContext, IDENTITY_MATRIX
|
||||
|
||||
@@ -3017,8 +3018,50 @@ def is_cjk_char(ch: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def detect_text_lang(text: str) -> str:
|
||||
"""Return a DrawingML language tag for a text run."""
|
||||
def _contains_codepoint_range(
|
||||
text: str,
|
||||
ranges: tuple[tuple[int, int], ...],
|
||||
) -> bool:
|
||||
"""Return whether text contains a code point in one of the ranges."""
|
||||
return any(
|
||||
start <= ord(ch) <= end
|
||||
for ch in text
|
||||
for start, end in ranges
|
||||
)
|
||||
|
||||
|
||||
def _default_language_for_script(
|
||||
default_language: str | None,
|
||||
bases: frozenset[str],
|
||||
fallback: str,
|
||||
) -> str:
|
||||
"""Prefer the project language when it belongs to the detected script."""
|
||||
if default_language and language_base(default_language) in bases:
|
||||
return default_language
|
||||
return fallback
|
||||
|
||||
|
||||
def text_has_rtl_characters(text: str) -> bool:
|
||||
"""Return whether text contains a strong right-to-left character."""
|
||||
return any(unicodedata.bidirectional(ch) in {'R', 'AL'} for ch in text)
|
||||
|
||||
|
||||
def text_uses_rtl(text: str, default_language: str | None = None) -> bool:
|
||||
"""Resolve paragraph direction from its first strong character or project."""
|
||||
for char in text:
|
||||
direction = unicodedata.bidirectional(char)
|
||||
if direction in {'R', 'AL'}:
|
||||
return True
|
||||
if direction == 'L':
|
||||
return False
|
||||
return bool(default_language and language_uses_rtl(default_language))
|
||||
|
||||
|
||||
def detect_text_lang(
|
||||
text: str,
|
||||
default_language: str | None = None,
|
||||
) -> str:
|
||||
"""Return a DrawingML language tag, preferring the project contract."""
|
||||
has_hangul = False
|
||||
has_kana = False
|
||||
has_east_asian_text = False
|
||||
@@ -3031,10 +3074,81 @@ def detect_text_lang(text: str) -> str:
|
||||
)
|
||||
has_east_asian_text = has_east_asian_text or is_cjk_char(ch)
|
||||
if has_hangul:
|
||||
return 'ko-KR'
|
||||
return _default_language_for_script(
|
||||
default_language,
|
||||
frozenset({'ko'}),
|
||||
'ko-KR',
|
||||
)
|
||||
if has_kana:
|
||||
return 'ja-JP'
|
||||
return 'zh-CN' if has_east_asian_text else 'en-US'
|
||||
return _default_language_for_script(
|
||||
default_language,
|
||||
frozenset({'ja'}),
|
||||
'ja-JP',
|
||||
)
|
||||
if has_east_asian_text:
|
||||
return _default_language_for_script(
|
||||
default_language,
|
||||
frozenset({'zh', 'ja', 'ko'}),
|
||||
'zh-CN',
|
||||
)
|
||||
if _contains_codepoint_range(text, (
|
||||
(0x0600, 0x06FF),
|
||||
(0x0750, 0x077F),
|
||||
(0x08A0, 0x08FF),
|
||||
(0xFB50, 0xFDFF),
|
||||
(0xFE70, 0xFEFF),
|
||||
(0x1EE00, 0x1EEFF),
|
||||
)):
|
||||
return _default_language_for_script(
|
||||
default_language,
|
||||
frozenset({'ar', 'fa', 'ps', 'sd', 'ug', 'ur'}),
|
||||
'ar-SA',
|
||||
)
|
||||
if _contains_codepoint_range(text, (
|
||||
(0x0590, 0x05FF),
|
||||
(0xFB1D, 0xFB4F),
|
||||
)):
|
||||
return _default_language_for_script(
|
||||
default_language,
|
||||
frozenset({'he', 'yi'}),
|
||||
'he-IL',
|
||||
)
|
||||
if _contains_codepoint_range(text, (
|
||||
(0x0900, 0x097F),
|
||||
(0xA8E0, 0xA8FF),
|
||||
)):
|
||||
return _default_language_for_script(
|
||||
default_language,
|
||||
frozenset({'hi', 'mr', 'ne', 'sa'}),
|
||||
'hi-IN',
|
||||
)
|
||||
if _contains_codepoint_range(text, ((0x0E00, 0x0E7F),)):
|
||||
return _default_language_for_script(
|
||||
default_language,
|
||||
frozenset({'th'}),
|
||||
'th-TH',
|
||||
)
|
||||
if _contains_codepoint_range(text, (
|
||||
(0x0400, 0x052F),
|
||||
(0x1C80, 0x1C8F),
|
||||
(0x2DE0, 0x2DFF),
|
||||
(0xA640, 0xA69F),
|
||||
)):
|
||||
return _default_language_for_script(
|
||||
default_language,
|
||||
frozenset({'be', 'bg', 'kk', 'ky', 'mk', 'mn', 'ru', 'sr', 'uk'}),
|
||||
'ru-RU',
|
||||
)
|
||||
if _contains_codepoint_range(text, (
|
||||
(0x0370, 0x03FF),
|
||||
(0x1F00, 0x1FFF),
|
||||
)):
|
||||
return _default_language_for_script(
|
||||
default_language,
|
||||
frozenset({'el'}),
|
||||
'el-GR',
|
||||
)
|
||||
return default_language or 'en-US'
|
||||
|
||||
|
||||
def _is_grapheme_extend(ch: str) -> bool:
|
||||
@@ -3157,7 +3271,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) != 'en-US':
|
||||
if any(is_cjk_char(ch) for ch in text):
|
||||
ea = fonts['ea']
|
||||
else:
|
||||
ea = latin
|
||||
|
||||
+1
@@ -151,6 +151,7 @@ def _build_native_chart(elem: ET.Element, ctx: ConvertContext, payload: dict[str
|
||||
chart_rels_id="rId1",
|
||||
chart_data=chart_data,
|
||||
inherited_styles=ctx.inherited_styles,
|
||||
primary_language=ctx.primary_language,
|
||||
)
|
||||
ctx.package_files[chart_rels_part] = _chart_rels_xml(f"../embeddings/{workbook_name}")
|
||||
if chart_data["kind"] == "xy":
|
||||
|
||||
+39
-10
@@ -15,6 +15,8 @@ from ..drawingml.utils import (
|
||||
parse_font_family,
|
||||
px_to_emu,
|
||||
quantize_ooxml_alpha,
|
||||
text_has_rtl_characters,
|
||||
text_uses_rtl,
|
||||
)
|
||||
from .chart_data import _DEFAULT_CHART_COLORS
|
||||
from .marker_common import (
|
||||
@@ -297,7 +299,11 @@ def _font_face_xml(font_face: str | None) -> str:
|
||||
fonts = parse_font_family(font_face)
|
||||
latin_font = _xml_escape(fonts["latin"])
|
||||
ea_font = _xml_escape(fonts["ea"])
|
||||
return f'<a:latin typeface="{latin_font}"/><a:ea typeface="{ea_font}"/>'
|
||||
return (
|
||||
f'<a:latin typeface="{latin_font}"/>'
|
||||
f'<a:ea typeface="{ea_font}"/>'
|
||||
f'<a:cs typeface="{latin_font}"/>'
|
||||
)
|
||||
|
||||
|
||||
def _chart_tx_pr_xml(
|
||||
@@ -306,16 +312,20 @@ def _chart_tx_pr_xml(
|
||||
*,
|
||||
bold: bool = False,
|
||||
font_face: str | None = None,
|
||||
language: str | None = None,
|
||||
) -> str:
|
||||
fill_xml = (
|
||||
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
|
||||
if color else ""
|
||||
)
|
||||
bold_attr = ' b="1"' if bold else ""
|
||||
resolved_language = language or 'en-US'
|
||||
rtl_attr = ' rtl="1"' if text_uses_rtl('', language) else ''
|
||||
return (
|
||||
"<c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr>"
|
||||
f'<a:defRPr sz="{font_size}"{bold_attr}>{fill_xml}{_font_face_xml(font_face)}</a:defRPr>'
|
||||
'</a:pPr><a:endParaRPr lang="en-US"/></a:p></c:txPr>'
|
||||
f"<c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr{rtl_attr}>"
|
||||
f'<a:defRPr lang="{resolved_language}" sz="{font_size}"{bold_attr}>'
|
||||
f'{fill_xml}{_font_face_xml(font_face)}</a:defRPr>'
|
||||
f'</a:pPr><a:endParaRPr lang="{resolved_language}"/></a:p></c:txPr>'
|
||||
)
|
||||
|
||||
|
||||
@@ -380,6 +390,7 @@ def _axis_title_xml(
|
||||
font_size: int,
|
||||
color: str | None = None,
|
||||
font_face: str | None = None,
|
||||
primary_language: str | None = None,
|
||||
) -> str:
|
||||
entry = _chart_text_entry(title)
|
||||
if entry is None:
|
||||
@@ -390,11 +401,19 @@ def _axis_title_xml(
|
||||
f'<a:solidFill><a:srgbClr val="{text_color}"/></a:solidFill>'
|
||||
if text_color else ""
|
||||
)
|
||||
lang = detect_text_lang(text)
|
||||
lang = detect_text_lang(text, primary_language)
|
||||
rtl_attr = (
|
||||
' rtl="1"'
|
||||
if text_uses_rtl(text, primary_language)
|
||||
else ''
|
||||
)
|
||||
run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
|
||||
return (
|
||||
"<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/>"
|
||||
f'<a:p><a:r><a:rPr lang="{lang}" sz="{_chart_text_entry_font_size(item, font_size)}">'
|
||||
f"{fill_xml}{_font_face_xml(_chart_text_entry_font_face(item, font_face))}</a:rPr>"
|
||||
f'<a:p><a:pPr{rtl_attr}/><a:r><a:rPr lang="{lang}" '
|
||||
f'sz="{_chart_text_entry_font_size(item, font_size)}">'
|
||||
f"{fill_xml}{_font_face_xml(_chart_text_entry_font_face(item, font_face))}"
|
||||
f"{run_rtl}</a:rPr>"
|
||||
f"<a:t>{_xml_escape(text)}</a:t></a:r></a:p>"
|
||||
"</c:rich></c:tx><c:layout/><c:overlay val=\"0\"/></c:title>"
|
||||
)
|
||||
@@ -667,7 +686,17 @@ def _text_box_xml(
|
||||
if color else ""
|
||||
)
|
||||
bold_attr = ' b="1"' if bold else ""
|
||||
lang = detect_text_lang(text)
|
||||
lang = detect_text_lang(text, ctx.primary_language)
|
||||
run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
|
||||
run_properties_xml = (
|
||||
f'{fill_xml}{_font_face_xml(font_face)}'
|
||||
f'{run_rtl}'
|
||||
)
|
||||
rtl_attr = (
|
||||
' rtl="1"'
|
||||
if text_uses_rtl(text, ctx.primary_language)
|
||||
else ''
|
||||
)
|
||||
name = _xml_escape(f"Chart {role.title()} {shape_id}")
|
||||
return f'''<p:sp>
|
||||
<p:nvSpPr>
|
||||
@@ -683,8 +712,8 @@ def _text_box_xml(
|
||||
<p:txBody>
|
||||
<a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t" anchorCtr="0"/>
|
||||
<a:lstStyle/>
|
||||
<a:p><a:pPr algn="{algn}"/>
|
||||
<a:r><a:rPr lang="{lang}" sz="{font_size}"{bold_attr}>{fill_xml}{_font_face_xml(font_face)}</a:rPr><a:t>{_xml_escape(text)}</a:t></a:r>
|
||||
<a:p><a:pPr algn="{algn}"{rtl_attr}/>
|
||||
<a:r><a:rPr lang="{lang}" sz="{font_size}"{bold_attr}>{run_properties_xml}</a:rPr><a:t>{_xml_escape(text)}</a:t></a:r>
|
||||
</a:p>
|
||||
</p:txBody>
|
||||
</p:sp>'''
|
||||
|
||||
+66
-9
@@ -5,7 +5,12 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from ..drawingml.utils import detect_text_lang, _xml_escape
|
||||
from ..drawingml.utils import (
|
||||
_xml_escape,
|
||||
detect_text_lang,
|
||||
text_has_rtl_characters,
|
||||
text_uses_rtl,
|
||||
)
|
||||
from .chart_data import (
|
||||
_DEFAULT_CHART_COLORS,
|
||||
_category_axis_is_date,
|
||||
@@ -152,6 +157,7 @@ def _data_labels_xml(
|
||||
font_size: int,
|
||||
default_color: str | None,
|
||||
default_font_face: str | None,
|
||||
language: str | None = None,
|
||||
) -> str:
|
||||
if config is None:
|
||||
return ""
|
||||
@@ -169,7 +175,13 @@ def _data_labels_xml(
|
||||
color = _hex_or_none(config.get("color")) or default_color
|
||||
bold = _chart_bool(config.get("bold"), False)
|
||||
font_face = _chart_text_entry_font_face(config, default_font_face)
|
||||
tx_pr_xml = _chart_tx_pr_xml(label_font_size, color, bold=bold, font_face=font_face)
|
||||
tx_pr_xml = _chart_tx_pr_xml(
|
||||
label_font_size,
|
||||
color,
|
||||
bold=bold,
|
||||
font_face=font_face,
|
||||
language=language,
|
||||
)
|
||||
num_fmt = _first_present(
|
||||
config.get("number_format"),
|
||||
config.get("numberFormat"),
|
||||
@@ -218,10 +230,17 @@ def _data_labels_xml(
|
||||
)
|
||||
item_bold = _chart_bool(item.get("bold"), bold)
|
||||
item_position_xml = f'<c:dLblPos val="{item_position}"/>' if item_position else ""
|
||||
item_text_properties_xml = _chart_tx_pr_xml(
|
||||
item_font_size,
|
||||
item_color,
|
||||
bold=item_bold,
|
||||
font_face=item_font_face,
|
||||
language=language,
|
||||
)
|
||||
point_label_xml += (
|
||||
f'<c:dLbl><c:idx val="{idx}"/>'
|
||||
f"{item_num_fmt_xml}"
|
||||
f"{_chart_tx_pr_xml(item_font_size, item_color, bold=item_bold, font_face=item_font_face)}"
|
||||
f"{item_text_properties_xml}"
|
||||
f"{item_position_xml}"
|
||||
f"{_data_label_flags_xml({**config, **item})}"
|
||||
"</c:dLbl>"
|
||||
@@ -243,7 +262,7 @@ def _data_labels_xml(
|
||||
point_label_xml += (
|
||||
f'<c:dLbl><c:idx val="{idx}"/>'
|
||||
f"{num_fmt_xml}"
|
||||
f"{_chart_tx_pr_xml(label_font_size, label_color, bold=bold, font_face=font_face)}"
|
||||
f"{_chart_tx_pr_xml(label_font_size, label_color, bold=bold, font_face=font_face, language=language)}"
|
||||
f"{position_xml}"
|
||||
f"{flags_xml}"
|
||||
"</c:dLbl>"
|
||||
@@ -314,6 +333,7 @@ def _series_xml(
|
||||
data_label_font_size: int = 900,
|
||||
data_label_color: str | None = None,
|
||||
data_label_font_face: str | None = None,
|
||||
language: str | None = None,
|
||||
category_column: int = 1,
|
||||
color_start_index: int | None = None,
|
||||
series_indices: list[int] | None = None,
|
||||
@@ -386,6 +406,7 @@ def _series_xml(
|
||||
font_size=data_label_font_size,
|
||||
default_color=data_label_color,
|
||||
default_font_face=data_label_font_face,
|
||||
language=language,
|
||||
)
|
||||
if _series_scoped_data_labels(data_labels)
|
||||
and chart_type in {"area", "bar", "column", "line"}
|
||||
@@ -417,14 +438,23 @@ def _chart_title_paragraph_xml(
|
||||
font_size: int,
|
||||
color: str | None = None,
|
||||
font_face: str | None = None,
|
||||
primary_language: str | None = None,
|
||||
) -> str:
|
||||
fill_xml = (
|
||||
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill>'
|
||||
if color else ""
|
||||
)
|
||||
lang = detect_text_lang(text)
|
||||
lang = detect_text_lang(text, primary_language)
|
||||
rtl_attr = (
|
||||
' rtl="1"'
|
||||
if text_uses_rtl(text, primary_language)
|
||||
else ''
|
||||
)
|
||||
run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
|
||||
return (
|
||||
f'<a:p><a:r><a:rPr lang="{lang}" sz="{font_size}">{fill_xml}{_font_face_xml(font_face)}</a:rPr>'
|
||||
f'<a:p><a:pPr{rtl_attr}/><a:r><a:rPr lang="{lang}" '
|
||||
f'sz="{font_size}">{fill_xml}{_font_face_xml(font_face)}'
|
||||
f'{run_rtl}</a:rPr>'
|
||||
f"<a:t>{_xml_escape(text)}</a:t></a:r></a:p>"
|
||||
)
|
||||
|
||||
@@ -437,6 +467,7 @@ def _chart_title_xml(
|
||||
subtitle: Any = None,
|
||||
subtitle_font_size: int | None = None,
|
||||
font_face: str | None = None,
|
||||
primary_language: str | None = None,
|
||||
) -> str:
|
||||
title_entry = _chart_text_entry(title)
|
||||
subtitle_entry = _chart_text_entry(subtitle)
|
||||
@@ -450,6 +481,7 @@ def _chart_title_xml(
|
||||
font_size=_chart_text_entry_font_size(item, font_size),
|
||||
color=_chart_text_entry_color(item, color),
|
||||
font_face=_chart_text_entry_font_face(item, font_face),
|
||||
primary_language=primary_language,
|
||||
))
|
||||
if subtitle_entry is not None:
|
||||
text, item = subtitle_entry
|
||||
@@ -458,6 +490,7 @@ def _chart_title_xml(
|
||||
font_size=_chart_text_entry_font_size(item, subtitle_font_size or font_size),
|
||||
color=_chart_text_entry_color(item, color),
|
||||
font_face=_chart_text_entry_font_face(item, font_face),
|
||||
primary_language=primary_language,
|
||||
))
|
||||
return (
|
||||
"<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/>"
|
||||
@@ -473,6 +506,7 @@ def _chart_legend_xml(
|
||||
font_size: int,
|
||||
color: str | None = None,
|
||||
font_face: str | None = None,
|
||||
primary_language: str | None = None,
|
||||
) -> str:
|
||||
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
|
||||
show_legend = payload.get("show_legend", style.get("show_legend", False))
|
||||
@@ -493,7 +527,7 @@ def _chart_legend_xml(
|
||||
return (
|
||||
f'<c:legend><c:legendPos val="{position}"/><c:layout/>'
|
||||
'<c:overlay val="0"/>'
|
||||
f'{_chart_tx_pr_xml(font_size, color, font_face=font_face)}'
|
||||
f'{_chart_tx_pr_xml(font_size, color, font_face=font_face, language=primary_language)}'
|
||||
'</c:legend>'
|
||||
)
|
||||
|
||||
@@ -684,6 +718,7 @@ def _axis_pair_xml(
|
||||
axes: dict[str, dict[str, Any]],
|
||||
secondary: bool,
|
||||
) -> str:
|
||||
primary_language = chart_style.get("primary_language")
|
||||
category_role = "secondary_category" if secondary else "category"
|
||||
value_role = "secondary_value" if secondary else "value"
|
||||
category = axes.get(category_role, {})
|
||||
@@ -710,12 +745,14 @@ def _axis_pair_xml(
|
||||
axis_font_size,
|
||||
chart_style.get("text_color"),
|
||||
font_face=chart_style.get("font_face"),
|
||||
language=primary_language,
|
||||
)
|
||||
cat_title_xml = "" if secondary else _axis_title_xml(
|
||||
_first_present(axis_titles.get("category"), axis_titles.get("x")),
|
||||
font_size=axis_title_font_size,
|
||||
color=chart_style.get("text_color"),
|
||||
font_face=chart_style.get("font_face"),
|
||||
primary_language=primary_language,
|
||||
)
|
||||
value_title_key = "secondary_value" if secondary else "value"
|
||||
value_title = axis_titles.get(value_title_key)
|
||||
@@ -726,6 +763,7 @@ def _axis_pair_xml(
|
||||
font_size=axis_title_font_size,
|
||||
color=chart_style.get("text_color"),
|
||||
font_face=chart_style.get("font_face"),
|
||||
primary_language=primary_language,
|
||||
)
|
||||
cat_gridlines = _axis_major_gridlines_xml(
|
||||
category,
|
||||
@@ -858,6 +896,7 @@ def _combo_plot_xml(
|
||||
data_label_font_size=axis_font_size,
|
||||
data_label_color=chart_style.get("text_color"),
|
||||
data_label_font_face=chart_style.get("font_face"),
|
||||
language=chart_style.get("primary_language"),
|
||||
line_style=plot.get("line_style", "line"),
|
||||
category_column=int(plot.get("category_column", 1)),
|
||||
color_start_index=start_index,
|
||||
@@ -875,6 +914,7 @@ def _combo_plot_xml(
|
||||
font_size=axis_font_size,
|
||||
default_color=chart_style.get("text_color"),
|
||||
default_font_face=chart_style.get("font_face"),
|
||||
language=chart_style.get("primary_language"),
|
||||
)
|
||||
if not _series_scoped_data_labels(data_labels)
|
||||
else ""
|
||||
@@ -1057,6 +1097,7 @@ def _chart_plot_xml(
|
||||
data_label_font_size=axis_font_size,
|
||||
data_label_color=chart_style.get("text_color"),
|
||||
data_label_font_face=chart_style.get("font_face"),
|
||||
language=chart_style.get("primary_language"),
|
||||
)
|
||||
data_labels_xml = (
|
||||
_data_labels_xml(
|
||||
@@ -1067,6 +1108,7 @@ def _chart_plot_xml(
|
||||
font_size=axis_font_size,
|
||||
default_color=chart_style.get("text_color"),
|
||||
default_font_face=chart_style.get("font_face"),
|
||||
language=chart_style.get("primary_language"),
|
||||
)
|
||||
if chart_type in {"area", "bar", "column", "line"}
|
||||
and not _series_scoped_data_labels(chart_data.get("data_labels"))
|
||||
@@ -1213,6 +1255,7 @@ def _xy_axis_xml(
|
||||
chart_style: dict[str, str | None],
|
||||
axes: dict[str, dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
primary_language = chart_style.get("primary_language")
|
||||
normalized_axes = axes or {}
|
||||
x_axis = normalized_axes.get("x", {})
|
||||
y_axis = normalized_axes.get("y", {})
|
||||
@@ -1221,18 +1264,21 @@ def _xy_axis_xml(
|
||||
axis_font_size,
|
||||
chart_style.get("text_color"),
|
||||
font_face=chart_style.get("font_face"),
|
||||
language=primary_language,
|
||||
)
|
||||
x_title_xml = _axis_title_xml(
|
||||
_first_present(axis_titles.get("x"), axis_titles.get("category")),
|
||||
font_size=axis_title_font_size,
|
||||
color=chart_style.get("text_color"),
|
||||
font_face=chart_style.get("font_face"),
|
||||
primary_language=primary_language,
|
||||
)
|
||||
y_title_xml = _axis_title_xml(
|
||||
_first_present(axis_titles.get("y"), axis_titles.get("value")),
|
||||
font_size=axis_title_font_size,
|
||||
color=chart_style.get("text_color"),
|
||||
font_face=chart_style.get("font_face"),
|
||||
primary_language=primary_language,
|
||||
)
|
||||
|
||||
def value_axis_xml(
|
||||
@@ -1354,6 +1400,7 @@ def _chart_xml(
|
||||
chart_rels_id: str,
|
||||
chart_data: dict[str, Any],
|
||||
inherited_styles: dict[str, str] | None = None,
|
||||
primary_language: str | None = None,
|
||||
) -> bytes:
|
||||
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
|
||||
colors = (
|
||||
@@ -1364,6 +1411,7 @@ def _chart_xml(
|
||||
text_sizes = _chart_text_sizes(payload, elem, inherited_styles)
|
||||
axis_titles = _axis_titles(payload)
|
||||
chart_style = _classic_chart_style(payload, elem, inherited_styles)
|
||||
chart_style["primary_language"] = primary_language
|
||||
plot_xml = _chart_plot_xml(
|
||||
chart_data,
|
||||
colors,
|
||||
@@ -1379,19 +1427,28 @@ def _chart_xml(
|
||||
subtitle=payload.get("subtitle"),
|
||||
subtitle_font_size=text_sizes["subtitle"],
|
||||
font_face=chart_style.get("font_face"),
|
||||
primary_language=primary_language,
|
||||
)
|
||||
legend_xml = _chart_legend_xml(
|
||||
payload,
|
||||
font_size=text_sizes["legend"],
|
||||
color=chart_style.get("text_color"),
|
||||
font_face=chart_style.get("font_face"),
|
||||
primary_language=primary_language,
|
||||
)
|
||||
chart_language = primary_language or "en-US"
|
||||
base_text_properties_xml = _chart_tx_pr_xml(
|
||||
text_sizes["base"],
|
||||
chart_style.get("text_color"),
|
||||
font_face=chart_style.get("font_face"),
|
||||
language=primary_language,
|
||||
)
|
||||
xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"
|
||||
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<c:date1904 val="0"/>
|
||||
<c:lang val="en-US"/>
|
||||
<c:lang val="{_xml_escape(chart_language)}"/>
|
||||
<c:chart>
|
||||
{title_xml}
|
||||
<c:plotArea><c:layout/>{plot_xml}{_chart_area_sp_pr_xml(chart_style.get("plot_fill"))}</c:plotArea>
|
||||
@@ -1400,7 +1457,7 @@ def _chart_xml(
|
||||
<c:dispBlanksAs val="gap"/>
|
||||
</c:chart>
|
||||
{_chart_area_sp_pr_xml(chart_style.get("chart_fill"))}
|
||||
{_chart_tx_pr_xml(text_sizes["base"], chart_style.get("text_color"), font_face=chart_style.get("font_face"))}
|
||||
{base_text_properties_xml}
|
||||
<c:externalData r:id="{chart_rels_id}"><c:autoUpdate val="0"/></c:externalData>
|
||||
</c:chartSpace>'''
|
||||
return xml.encode("utf-8")
|
||||
|
||||
+51
-8
@@ -10,7 +10,13 @@ from .marker_attributes import native_import_source
|
||||
|
||||
from ..drawingml.context import ConvertContext, ShapeResult
|
||||
from ..drawingml.theme_colors import ThemeColorSpec, color_node_xml
|
||||
from ..drawingml.utils import _xml_escape, detect_text_lang, font_px_to_hpt
|
||||
from ..drawingml.utils import (
|
||||
_xml_escape,
|
||||
detect_text_lang,
|
||||
font_px_to_hpt,
|
||||
text_has_rtl_characters,
|
||||
text_uses_rtl,
|
||||
)
|
||||
from .chart_style import _font_face_xml
|
||||
from .marker_common import (
|
||||
TABLE_URI,
|
||||
@@ -37,6 +43,7 @@ def _table_text_run(
|
||||
font_size: int | None,
|
||||
font_face: str | None,
|
||||
language: str | None,
|
||||
default_language: str | None,
|
||||
theme_color_spec: ThemeColorSpec | None,
|
||||
italic: bool | None = None,
|
||||
underline: bool | None = None,
|
||||
@@ -55,7 +62,7 @@ def _table_text_run(
|
||||
f' strike="{"sngStrike" if strike else "noStrike"}"'
|
||||
if strike is not None else ""
|
||||
)
|
||||
resolved_language = language or detect_text_lang(text)
|
||||
resolved_language = language or detect_text_lang(text, default_language)
|
||||
language_attr = f' lang="{_xml_escape(resolved_language)}"'
|
||||
alt_language_attr = (
|
||||
f' altLang="{_xml_escape(alt_language)}"' if alt_language else ""
|
||||
@@ -69,20 +76,40 @@ def _table_text_run(
|
||||
font_xml = (
|
||||
f'<a:latin typeface="{escaped_face}"/>'
|
||||
f'<a:ea typeface="{escaped_face}"/>'
|
||||
f'<a:cs typeface="{escaped_face}"/>'
|
||||
)
|
||||
else:
|
||||
font_xml = _font_face_xml(font_face)
|
||||
rtl_xml = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
|
||||
space_attr = ' xml:space="preserve"' if text != text.strip() else ""
|
||||
return (
|
||||
f'<a:r><a:rPr{language_attr}{alt_language_attr}{size_attr}{bold_attr}'
|
||||
f'{italic_attr}{underline_attr}{strike_attr}>'
|
||||
f'{color_xml}'
|
||||
f'{font_xml}'
|
||||
f'{rtl_xml}'
|
||||
"</a:rPr>"
|
||||
f"<a:t{space_attr}>{_xml_escape(text)}</a:t></a:r>"
|
||||
)
|
||||
|
||||
|
||||
def _table_paragraph_properties(
|
||||
align: str,
|
||||
*,
|
||||
emit_align: bool,
|
||||
text: str,
|
||||
language: str | None,
|
||||
) -> str:
|
||||
"""Build table paragraph properties with project-aware direction."""
|
||||
attrs = []
|
||||
if emit_align:
|
||||
attrs.append(f'algn="{align}"')
|
||||
if text_uses_rtl(text, language):
|
||||
attrs.append('rtl="1"')
|
||||
suffix = f" {' '.join(attrs)}" if attrs else ''
|
||||
return f'<a:pPr{suffix}/>'
|
||||
|
||||
|
||||
def _cell_payload(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
@@ -951,8 +978,12 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
|
||||
"" if cell_data.get("text") is None
|
||||
else str(cell_data.get("text"))
|
||||
)
|
||||
paragraph_props = (
|
||||
f'<a:pPr algn="{align}"/>' if align != "l" else "<a:pPr/>"
|
||||
default_language = language or ctx.primary_language
|
||||
paragraph_props = _table_paragraph_properties(
|
||||
align,
|
||||
emit_align=align != "l",
|
||||
text=text,
|
||||
language=default_language,
|
||||
)
|
||||
text_run_xml = _table_text_run(
|
||||
text,
|
||||
@@ -961,6 +992,7 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
|
||||
font_size=cell_font_size,
|
||||
font_face=font_face,
|
||||
language=language,
|
||||
default_language=ctx.primary_language,
|
||||
theme_color_spec=ctx.theme_color_spec,
|
||||
)
|
||||
paragraphs_xml = (
|
||||
@@ -970,10 +1002,19 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
|
||||
paragraph_parts: list[str] = []
|
||||
for paragraph in paragraphs:
|
||||
paragraph_align = paragraph.align or align
|
||||
paragraph_props = (
|
||||
f'<a:pPr algn="{paragraph_align}"/>'
|
||||
if paragraph.align is not None or paragraph_align != "l"
|
||||
else "<a:pPr/>"
|
||||
paragraph_text = (
|
||||
paragraph.text
|
||||
if paragraph.runs is None
|
||||
else ''.join(run.text for run in paragraph.runs)
|
||||
)
|
||||
paragraph_props = _table_paragraph_properties(
|
||||
paragraph_align,
|
||||
emit_align=(
|
||||
paragraph.align is not None
|
||||
or paragraph_align != "l"
|
||||
),
|
||||
text=paragraph_text,
|
||||
language=language or ctx.primary_language,
|
||||
)
|
||||
if paragraph.runs is None:
|
||||
text_run_xml = _table_text_run(
|
||||
@@ -983,6 +1024,7 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
|
||||
font_size=cell_font_size,
|
||||
font_face=font_face,
|
||||
language=language,
|
||||
default_language=ctx.primary_language,
|
||||
theme_color_spec=ctx.theme_color_spec,
|
||||
)
|
||||
else:
|
||||
@@ -998,6 +1040,7 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
|
||||
),
|
||||
font_face=run.font_family or font_face,
|
||||
language=run.lang or language,
|
||||
default_language=ctx.primary_language,
|
||||
theme_color_spec=ctx.theme_color_spec,
|
||||
italic=run.italic,
|
||||
underline=run.underline,
|
||||
|
||||
+61
-20
@@ -54,8 +54,14 @@ from pptx_opc_validation import (
|
||||
resolve_internal_opc_target as _resolve_internal_opc_target,
|
||||
verify_internal_relationships,
|
||||
)
|
||||
from language_tags import normalize_language_tag
|
||||
|
||||
from ..animation_config import MorphPair, resolve_morph_pairs
|
||||
from ..animation_config import (
|
||||
MorphPair,
|
||||
resolve_morph_pairs,
|
||||
resolve_slide_animation_config,
|
||||
)
|
||||
from ..drawingml.context import resolve_text_flow
|
||||
from ..drawingml.converter import convert_svg_to_slide_shapes
|
||||
from ..drawingml.theme_colors import (
|
||||
ThemeColorSpec,
|
||||
@@ -1662,7 +1668,7 @@ def _template_shape_for_item(
|
||||
return None
|
||||
if not shape_ids:
|
||||
text_hint = (
|
||||
"; multiline text placeholders require the default paragraph merge "
|
||||
"; multiline text placeholders require a single-frame text mode "
|
||||
"and cannot use --no-merge"
|
||||
if item.placeholder and item.placeholder_carrier_tag == "text"
|
||||
else ""
|
||||
@@ -3912,7 +3918,10 @@ _NOTES_MASTER_REL_TYPE = (
|
||||
)
|
||||
|
||||
|
||||
def _ensure_notes_master(extract_dir: Path) -> None:
|
||||
def _ensure_notes_master(
|
||||
extract_dir: Path,
|
||||
primary_language: str | None = None,
|
||||
) -> None:
|
||||
"""Create notesMaster parts and wire them into the presentation package."""
|
||||
ppt_dir = extract_dir / 'ppt'
|
||||
notes_masters_dir = ppt_dir / 'notesMasters'
|
||||
@@ -3920,7 +3929,10 @@ def _ensure_notes_master(extract_dir: Path) -> None:
|
||||
|
||||
notes_master_path = notes_masters_dir / 'notesMaster1.xml'
|
||||
if not notes_master_path.exists():
|
||||
notes_master_path.write_text(create_notes_master_xml(), encoding='utf-8')
|
||||
notes_master_path.write_text(
|
||||
create_notes_master_xml(primary_language),
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
theme_dir = ppt_dir / 'theme'
|
||||
theme_dir.mkdir(exist_ok=True)
|
||||
@@ -4044,8 +4056,10 @@ def _slide_animation_settings(
|
||||
if not isinstance(anim_value, dict):
|
||||
raise ValueError('animations.json slide animation must be an object')
|
||||
anim_cfg = anim_value
|
||||
resolved_cfg = dict(default_animation_cfg)
|
||||
resolved_cfg.update(anim_cfg)
|
||||
resolved_cfg = resolve_slide_animation_config(
|
||||
default_animation_cfg,
|
||||
anim_cfg,
|
||||
)
|
||||
if cli_overrides.get('animation'):
|
||||
effect, effect_options = normalize_animation_effect_request(
|
||||
animation,
|
||||
@@ -4594,7 +4608,7 @@ def create_pptx_with_native_svg(
|
||||
narration_padding: float = 0.5,
|
||||
cache_dir: Path | None = None,
|
||||
workers: int | None = None,
|
||||
merge_paragraphs: bool = True,
|
||||
merge_paragraphs: bool | None = None,
|
||||
image_optimize: bool = True,
|
||||
image_max_dimension: int | None = 2560,
|
||||
image_sizing: str = 'cap',
|
||||
@@ -4616,6 +4630,8 @@ def create_pptx_with_native_svg(
|
||||
expected_viewbox: str | None = None,
|
||||
animation_resource_root: Path | None = None,
|
||||
transition_effect_options: dict[str, object] | None = None,
|
||||
text_flow: str | None = None,
|
||||
primary_language: str | None = None,
|
||||
) -> bool:
|
||||
"""Create a PPTX file with native DrawingML shapes.
|
||||
|
||||
@@ -4656,12 +4672,16 @@ def create_pptx_with_native_svg(
|
||||
narration_audio: Optional dict mapping SVG stem to narration audio file.
|
||||
use_narration_timings: Whether to set slide auto-advance from audio duration.
|
||||
narration_padding: Extra seconds added after each narration before advancing.
|
||||
image_optimize: Whether native export downscales oversized raster images.
|
||||
image_max_dimension: Maximum optimized image dimension in pixels.
|
||||
image_sizing: ``cap`` only limits source dimensions; ``display`` sizes
|
||||
from rendered SVG boxes.
|
||||
merge_paragraphs: Legacy compatibility option. True selects reflow;
|
||||
False selects split. Do not combine with ``text_flow``.
|
||||
text_flow: Positional-tspan policy: preserve authored line breaks in
|
||||
one frame, reflow text, or split visual lines into separate frames.
|
||||
image_optimize: Whether native export optimizes raster images when needed.
|
||||
image_max_dimension: Preferred optimized image dimension cap in pixels.
|
||||
image_sizing: ``cap`` preserves unchanged source bytes and limits
|
||||
oversized sources; ``display`` sizes from rendered SVG boxes.
|
||||
image_scale: Target image pixels per SVG display pixel.
|
||||
image_quality: JPEG quality used for opaque optimized rasters.
|
||||
image_quality: JPEG quality used when opaque rasters are re-encoded.
|
||||
native_objects: Replace explicit ``data-pptx-replace-with`` chart/table
|
||||
fallback groups with native PowerPoint Chart/Table objects. Default off.
|
||||
conversion_trace_path: Optional JSON path for native conversion diagnostics.
|
||||
@@ -4688,12 +4708,17 @@ def create_pptx_with_native_svg(
|
||||
callers may omit it; other routes ignore this value.
|
||||
theme_color_spec: Locked project color scheme for context-aware
|
||||
flat/structured theme inheritance. Preserve mode ignores this value.
|
||||
primary_language: Canonical BCP-47 deck content language. ``None``
|
||||
preserves legacy per-run language detection.
|
||||
structured_baseline: Obsolete compatibility argument; must remain false.
|
||||
baseline_layout_specs: Obsolete compatibility argument; must remain None.
|
||||
|
||||
Returns:
|
||||
Whether all slides were successfully created.
|
||||
"""
|
||||
text_flow = resolve_text_flow(text_flow, merge_paragraphs)
|
||||
if primary_language is not None:
|
||||
primary_language = normalize_language_tag(primary_language)
|
||||
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]
|
||||
@@ -4878,16 +4903,19 @@ def create_pptx_with_native_svg(
|
||||
if image_sizing == 'display':
|
||||
image_mode = (
|
||||
f"display scale {image_scale:g}, "
|
||||
f"max {image_max_dimension or 'unlimited'} px"
|
||||
f"preferred max {image_max_dimension or 'unlimited'} px"
|
||||
)
|
||||
else:
|
||||
image_mode = f"cap max {image_max_dimension or 'unlimited'} px"
|
||||
image_mode = (
|
||||
f"preferred cap {image_max_dimension or 'unlimited'} px, "
|
||||
"unchanged bytes preserved"
|
||||
)
|
||||
print(
|
||||
" Image optimization: Enabled "
|
||||
f"({image_mode}, JPEG q{image_quality})"
|
||||
f"({image_mode}, JPEG q{image_quality} when re-encoded)"
|
||||
)
|
||||
else:
|
||||
print(" Image optimization: Disabled")
|
||||
print(" Image optimization: Disabled (original bytes)")
|
||||
elif use_compat_mode:
|
||||
print(f" Compatibility mode: Enabled (PNG + SVG dual format)")
|
||||
print(f" PNG renderer: {renderer_name} {renderer_status}")
|
||||
@@ -5149,7 +5177,7 @@ def create_pptx_with_native_svg(
|
||||
) = (
|
||||
convert_svg_to_slide_shapes(
|
||||
svg_path, slide_num=slide_num, verbose=verbose,
|
||||
merge_paragraphs=merge_paragraphs,
|
||||
text_flow=text_flow,
|
||||
image_optimize=image_optimize,
|
||||
image_max_dimension=image_max_dimension,
|
||||
image_sizing=image_sizing,
|
||||
@@ -5159,6 +5187,7 @@ def create_pptx_with_native_svg(
|
||||
animation_group_overrides=converter_group_overrides,
|
||||
theme_font_spec=active_theme_font_spec,
|
||||
theme_color_spec=active_theme_color_spec,
|
||||
primary_language=primary_language,
|
||||
promote_background=pptx_structure != "structured",
|
||||
trace_out=conversion_trace
|
||||
if conversion_trace is not None
|
||||
@@ -5420,13 +5449,17 @@ def create_pptx_with_native_svg(
|
||||
notes_content = notes.get(svg_stem, '') if notes else ''
|
||||
notes_text = markdown_to_plain_text(notes_content) if notes_content else ''
|
||||
if notes_text:
|
||||
_ensure_notes_master(extract_dir)
|
||||
_ensure_notes_master(extract_dir, primary_language)
|
||||
|
||||
notes_slides_dir = extract_dir / 'ppt' / 'notesSlides'
|
||||
notes_slides_dir.mkdir(exist_ok=True)
|
||||
|
||||
notes_xml_path = notes_slides_dir / f'notesSlide{slide_num}.xml'
|
||||
notes_xml = create_notes_slide_xml(slide_num, notes_text)
|
||||
notes_xml = create_notes_slide_xml(
|
||||
slide_num,
|
||||
notes_text,
|
||||
primary_language,
|
||||
)
|
||||
with open(notes_xml_path, 'w', encoding='utf-8') as f:
|
||||
f.write(notes_xml)
|
||||
|
||||
@@ -5834,7 +5867,15 @@ def create_pptx_with_native_svg(
|
||||
# author, 2013 dates, "generated using python-pptx", Slides=0) with
|
||||
# accurate, tool-neutral document properties.
|
||||
pres_format = _presentation_format(width_emu, height_emu)
|
||||
_stamp_docprops(extract_dir, public_slide_count, pres_format, doc_metadata)
|
||||
effective_doc_metadata = dict(doc_metadata or {})
|
||||
if primary_language is not None:
|
||||
effective_doc_metadata['language'] = primary_language
|
||||
_stamp_docprops(
|
||||
extract_dir,
|
||||
public_slide_count,
|
||||
pres_format,
|
||||
effective_doc_metadata,
|
||||
)
|
||||
|
||||
# Repackage PPTX to a temporary file first. The public output path is
|
||||
# replaced only after every slide and relationship has succeeded.
|
||||
|
||||
+338
-49
@@ -20,6 +20,10 @@ if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||
from language_tags import ( # noqa: E402
|
||||
LanguageTagError,
|
||||
normalize_language_tag,
|
||||
)
|
||||
from native_payloads import PAYLOAD_STORE_RELATIVE_PATH # noqa: E402
|
||||
from pptx_animations import ( # noqa: E402
|
||||
ANIMATIONS,
|
||||
@@ -45,7 +49,7 @@ if __package__ in {None, ''}:
|
||||
__package__ = 'svg_to_pptx'
|
||||
|
||||
from .dimensions import CANVAS_FORMATS, get_project_info
|
||||
from .discovery import find_svg_files, find_notes_files
|
||||
from .discovery import NotesFileReadError, find_notes_files, find_svg_files
|
||||
from .builder import create_pptx_with_native_svg
|
||||
from ..native_objects import (
|
||||
native_fallback_kind,
|
||||
@@ -54,6 +58,11 @@ from ..native_objects import (
|
||||
)
|
||||
from ..native_objects.marker_status import native_marker_release_block_reason
|
||||
from ..drawingml.theme_colors import ThemeColorError, load_theme_color_spec
|
||||
from ..drawingml.context import (
|
||||
TEXT_FLOW_PRESERVE,
|
||||
TEXT_FLOW_REFLOW,
|
||||
TEXT_FLOW_SPLIT,
|
||||
)
|
||||
from ..drawingml.theme_fonts import (
|
||||
ThemeFontError,
|
||||
load_master_text_style_spec,
|
||||
@@ -337,6 +346,7 @@ def _write_postflight_report(
|
||||
pptx_structure: str,
|
||||
backup_path: Path | None,
|
||||
conversion_trace_path: Path | None,
|
||||
deck_motion: dict[str, object],
|
||||
) -> _PostflightReceipt:
|
||||
"""Write the unified package/resource audit for a successful PPTX."""
|
||||
try:
|
||||
@@ -443,6 +453,7 @@ def _write_postflight_report(
|
||||
},
|
||||
'quality': quality,
|
||||
'resources': source_audit,
|
||||
'deck_motion': deck_motion,
|
||||
'backup_path': str(backup_path.resolve()) if backup_path else None,
|
||||
'conversion_trace_path': (
|
||||
str(conversion_trace_path.resolve())
|
||||
@@ -473,6 +484,58 @@ def _write_postflight_report(
|
||||
)
|
||||
|
||||
|
||||
def _load_deck_motion_handoff(
|
||||
project_path: Path,
|
||||
report_arg: str,
|
||||
svg_files: list[Path],
|
||||
) -> dict[str, object]:
|
||||
"""Load source-bound deck motion from a successful base export report."""
|
||||
report_path = Path(report_arg).expanduser()
|
||||
if not report_path.is_absolute() and not report_path.is_file():
|
||||
report_path = project_path / report_path
|
||||
try:
|
||||
report = json.loads(report_path.read_text(encoding='utf-8'))
|
||||
except FileNotFoundError as exc:
|
||||
raise ValueError(
|
||||
f'deck-motion handoff report does not exist: {report_path}'
|
||||
) from exc
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(
|
||||
f'deck-motion handoff report is unreadable: {report_path}: {exc}'
|
||||
) from exc
|
||||
if not isinstance(report, dict):
|
||||
raise ValueError('deck-motion handoff report must be a JSON object')
|
||||
if report.get('schema') != 'ppt-master.pptx-postflight-report.v1':
|
||||
raise ValueError(
|
||||
'deck-motion handoff requires a ppt-master postflight report'
|
||||
)
|
||||
if report.get('status') not in {'passed', 'passed-with-warnings'}:
|
||||
raise ValueError('deck-motion handoff report is not a successful export')
|
||||
source = _as_dict(report.get('source'))
|
||||
if source.get('fingerprint') != _svg_source_fingerprint(svg_files):
|
||||
raise ValueError(
|
||||
'deck-motion handoff does not match the current svg_output; '
|
||||
'run the base export again'
|
||||
)
|
||||
motion = report.get('deck_motion')
|
||||
if not isinstance(motion, dict):
|
||||
raise ValueError(
|
||||
'deck-motion handoff is missing from the base export report; '
|
||||
'run the base export again'
|
||||
)
|
||||
if motion.get('narration_timings') is True:
|
||||
raise ValueError(
|
||||
'deck-motion handoff must reference a base non-narrated export report'
|
||||
)
|
||||
if not isinstance(motion.get('transition'), dict):
|
||||
raise ValueError('deck-motion handoff transition must be an object')
|
||||
if not isinstance(motion.get('animation'), dict):
|
||||
raise ValueError('deck-motion handoff animation must be an object')
|
||||
if not isinstance(motion.get('cli_overrides'), dict):
|
||||
raise ValueError('deck-motion handoff cli_overrides must be an object')
|
||||
return motion
|
||||
|
||||
|
||||
def _print_postflight_receipt(receipt: _PostflightReceipt) -> None:
|
||||
"""Print the compact completion evidence; keep the full JSON on disk."""
|
||||
print(
|
||||
@@ -540,6 +603,28 @@ def _declared_canvas_viewbox(project_path: Path) -> str | None:
|
||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def _declared_primary_language(project_path: Path) -> str | None:
|
||||
"""Return the canonical content language declared by the execution lock."""
|
||||
lock_path = project_path / 'spec_lock.md'
|
||||
try:
|
||||
from update_spec import parse_lock
|
||||
|
||||
lock = parse_lock(lock_path)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
communication = lock.get('communication', {})
|
||||
value = communication.get('primary_language')
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
return normalize_language_tag(value)
|
||||
except LanguageTagError as exc:
|
||||
raise LanguageTagError(
|
||||
'spec_lock.md communication.primary_language '
|
||||
f'is invalid: {exc}'
|
||||
) from exc
|
||||
|
||||
|
||||
def _print_structure_contract_error(
|
||||
mode: str | None,
|
||||
*,
|
||||
@@ -692,6 +777,24 @@ def _recorded_narration_on_click_slides(
|
||||
return blocked
|
||||
|
||||
|
||||
def _resolve_animation_config_source(
|
||||
project_path: Path,
|
||||
requested_config: str | None,
|
||||
*,
|
||||
recorded_narration: bool,
|
||||
no_animations: bool,
|
||||
) -> str | None:
|
||||
"""Resolve the animation sidecar selected for this export."""
|
||||
if requested_config is not None or not recorded_narration or no_animations:
|
||||
return requested_config
|
||||
|
||||
canonical_exists = (project_path / 'animations.json').is_file()
|
||||
narration_exists = (project_path / 'narration_animations.json').is_file()
|
||||
if canonical_exists or narration_exists:
|
||||
return 'narration_animations.json'
|
||||
return None
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI entry point for the SVG to PPTX conversion tool."""
|
||||
transition_choices = [
|
||||
@@ -758,11 +861,13 @@ Speaker notes (enabled by default):
|
||||
- Use --no-notes to disable
|
||||
|
||||
Recorded narration:
|
||||
%(prog)s examples/ppt169_demo --recorded-narration audio
|
||||
%(prog)s examples/ppt169_demo --recorded-narration audio \\
|
||||
--inherit-motion-from validation/<base>.report.json
|
||||
- Keeps speaker notes when enabled
|
||||
- Prepares PowerPoint recorded timings and narrations
|
||||
- Requires one m4a/mp3/wav file per slide
|
||||
- Uses narration_animations.json by default
|
||||
- Uses narration_animations.json when animation sidecars exist
|
||||
- Inherits source-bound deck motion from the base postflight report
|
||||
- Use --animation-config animations.json for the canonical animation
|
||||
- Use --no-animations for narration and timings without animation motion
|
||||
- Embeds per-slide audio matched by SVG filename / slide number
|
||||
@@ -796,14 +901,35 @@ Recorded narration:
|
||||
),
|
||||
)
|
||||
|
||||
merge_group = parser.add_mutually_exclusive_group()
|
||||
merge_group.add_argument('--merge-paragraphs', action='store_true', dest='merge_paragraphs',
|
||||
help='Compatibility no-op: mergeable paragraph blocks are merged '
|
||||
'by default.')
|
||||
merge_group.add_argument('--no-merge', action='store_false', dest='merge_paragraphs',
|
||||
help='Disable paragraph merging. Every dy-stacked line becomes '
|
||||
'its own text frame for strict SVG line-layout fidelity.')
|
||||
parser.set_defaults(merge_paragraphs=True)
|
||||
text_flow_group = parser.add_mutually_exclusive_group()
|
||||
text_flow_group.add_argument(
|
||||
'--reflow-text',
|
||||
action='store_const',
|
||||
const=TEXT_FLOW_REFLOW,
|
||||
dest='text_flow',
|
||||
help=(
|
||||
'Let PowerPoint automatically reflow conservative dy-stacked text '
|
||||
'inside one editable text frame.'
|
||||
),
|
||||
)
|
||||
text_flow_group.add_argument(
|
||||
'--merge-paragraphs',
|
||||
action='store_const',
|
||||
const=TEXT_FLOW_REFLOW,
|
||||
dest='text_flow',
|
||||
help='Compatibility alias for --reflow-text.',
|
||||
)
|
||||
text_flow_group.add_argument(
|
||||
'--no-merge',
|
||||
action='store_const',
|
||||
const=TEXT_FLOW_SPLIT,
|
||||
dest='text_flow',
|
||||
help=(
|
||||
'Emit every positioned visual line as its own text frame for '
|
||||
'strict per-line SVG positioning.'
|
||||
),
|
||||
)
|
||||
parser.set_defaults(text_flow=TEXT_FLOW_PRESERVE)
|
||||
parser.add_argument(
|
||||
'--conversion-trace',
|
||||
nargs='?',
|
||||
@@ -855,19 +981,23 @@ Recorded narration:
|
||||
),
|
||||
)
|
||||
parser.add_argument('--no-image-optimize', action='store_true',
|
||||
help='Disable native PPTX raster image optimization; embeds original image bytes.')
|
||||
help='Disable native PPTX raster image optimization and always embed '
|
||||
'the original image bytes.')
|
||||
parser.add_argument('--image-max-dimension', type=int, default=2560,
|
||||
help='Preferred optimized raster cap in pixels; cap mode may retain more '
|
||||
'for cropped/stretched effective resolution (default: 2560).')
|
||||
help='Preferred raster cap in pixels. Cap mode re-encodes only images '
|
||||
'that require resizing or EXIF geometry normalization, and may '
|
||||
'retain more pixels for cropped/stretched visible resolution '
|
||||
'(default: 2560).')
|
||||
parser.add_argument('--image-sizing', choices=['cap', 'display'], default='cap',
|
||||
help='Raster sizing mode: cap limits source dimensions without '
|
||||
'undersupplying cropped/stretched visible pixels; '
|
||||
'display sizes from the SVG rendered box (default: cap).')
|
||||
help='Raster sizing mode: cap preserves original bytes unless resizing '
|
||||
'or EXIF geometry normalization is required; display targets the '
|
||||
'SVG rendered box for explicit compaction (default: cap).')
|
||||
parser.add_argument('--image-scale', type=float, default=2.0,
|
||||
help='Target optimized image pixels per SVG display pixel '
|
||||
'when --image-sizing=display (default: 2.0).')
|
||||
parser.add_argument('--image-quality', type=int, default=85,
|
||||
help='JPEG quality for optimized opaque raster images, 1-100 (default: 85).')
|
||||
help='JPEG quality for raster images re-encoded during optimization, '
|
||||
'1-100 (default: 85).')
|
||||
|
||||
def non_negative_float(value: str) -> float:
|
||||
try:
|
||||
@@ -925,9 +1055,10 @@ Recorded narration:
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
'Per-slide/per-object animation config. Recorded narration defaults '
|
||||
'to <project>/narration_animations.json; other exports default to '
|
||||
'<project>/animations.json when present.'
|
||||
'Per-slide/per-object animation config. Recorded narration uses '
|
||||
'<project>/narration_animations.json when an animation sidecar exists, '
|
||||
'or may inherit base postflight motion with --inherit-motion-from. '
|
||||
'Other exports default to <project>/animations.json when present.'
|
||||
),
|
||||
)
|
||||
animation_source.add_argument(
|
||||
@@ -952,6 +1083,16 @@ Recorded narration:
|
||||
'(<project>_<ts>_narrated.pptx) to tell them apart from silent exports.')
|
||||
parser.add_argument('--narration-padding', type=non_negative_float, default=0.5,
|
||||
help='Seconds to add after each narration before auto-advance (default: 0.5)')
|
||||
parser.add_argument(
|
||||
'--inherit-motion-from',
|
||||
type=str,
|
||||
default=None,
|
||||
metavar='BASE_POSTFLIGHT_REPORT',
|
||||
help=(
|
||||
'For recorded narration, inherit source-bound deck-wide transition, '
|
||||
'animation, and advance settings from a successful base export report'
|
||||
),
|
||||
)
|
||||
|
||||
raw_argv = list(argv) if argv is not None else sys.argv[1:]
|
||||
legacy_native_objects = '--native-objects' in raw_argv
|
||||
@@ -962,6 +1103,24 @@ Recorded narration:
|
||||
'--native-charts-and-tables.',
|
||||
file=sys.stderr,
|
||||
)
|
||||
if args.animation_config is not None and not args.animation_config.strip():
|
||||
print(
|
||||
'Error: --animation-config must be a non-empty file path',
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if args.inherit_motion_from and not args.recorded_narration:
|
||||
print(
|
||||
'Error: --inherit-motion-from requires --recorded-narration',
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if args.inherit_motion_from and args.no_animations:
|
||||
print(
|
||||
'Error: --inherit-motion-from cannot be combined with --no-animations',
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.quick_test:
|
||||
conflicts: list[str] = []
|
||||
@@ -1026,6 +1185,20 @@ Recorded narration:
|
||||
if args.quick_test
|
||||
else _declared_pptx_structure_mode(project_path)
|
||||
)
|
||||
primary_language = None
|
||||
if not args.quick_test:
|
||||
try:
|
||||
primary_language = _declared_primary_language(project_path)
|
||||
except LanguageTagError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if primary_language is None:
|
||||
print(
|
||||
"Warning: spec_lock.md has no "
|
||||
"communication.primary_language; using legacy per-run "
|
||||
"language detection.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if pptx_structure in _LEGACY_PPTX_STRUCTURE_MODES:
|
||||
_print_structure_contract_error(pptx_structure)
|
||||
return 1
|
||||
@@ -1127,10 +1300,22 @@ Recorded narration:
|
||||
# Native DrawingML is the only PPTX product. ``-s`` remains an explicit
|
||||
# diagnostic source override; standard export always reads svg_output/.
|
||||
native_source = args.source or 'output'
|
||||
native_files, native_source_dir = find_svg_files(project_path, native_source)
|
||||
native_files, native_source_dir = find_svg_files(
|
||||
project_path,
|
||||
native_source,
|
||||
allow_fallback=args.source is None,
|
||||
)
|
||||
ref_files = native_files
|
||||
if not native_files:
|
||||
print("Error: No SVG files found")
|
||||
if args.source is not None:
|
||||
requested_dir = project_path / native_source_dir
|
||||
print(
|
||||
"Error: No SVG files found in explicitly requested source: "
|
||||
f"{requested_dir}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print("Error: No SVG files found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Compatibility kwargs remain until the builder's old baseline-specific
|
||||
@@ -1258,7 +1443,11 @@ Recorded narration:
|
||||
enable_notes = not args.no_notes
|
||||
notes: dict[str, str] = {}
|
||||
if enable_notes:
|
||||
notes = find_notes_files(project_path, ref_files)
|
||||
try:
|
||||
notes = find_notes_files(project_path, ref_files)
|
||||
except NotesFileReadError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
narration_audio: dict[str, Path] = {}
|
||||
narration_audio_dir_arg = args.recorded_narration or args.narration_audio_dir
|
||||
@@ -1273,7 +1462,14 @@ Recorded narration:
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
narration_audio = find_narration_files(narration_audio_dir, ref_files)
|
||||
try:
|
||||
narration_audio = find_narration_files(
|
||||
narration_audio_dir,
|
||||
ref_files,
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if verbose:
|
||||
print(f" Narration audio directory: {narration_audio_dir}")
|
||||
print(f" Narration audio matched: {len(narration_audio)}/{len(ref_files)} slide(s)")
|
||||
@@ -1333,13 +1529,12 @@ Recorded narration:
|
||||
)
|
||||
return 1
|
||||
|
||||
effective_animation_config = args.animation_config
|
||||
if (
|
||||
effective_animation_config is None
|
||||
and args.recorded_narration
|
||||
and not args.no_animations
|
||||
):
|
||||
effective_animation_config = 'narration_animations.json'
|
||||
effective_animation_config = _resolve_animation_config_source(
|
||||
project_path,
|
||||
args.animation_config,
|
||||
recorded_narration=bool(args.recorded_narration),
|
||||
no_animations=args.no_animations,
|
||||
)
|
||||
|
||||
if effective_animation_config:
|
||||
config_path = Path(effective_animation_config)
|
||||
@@ -1387,7 +1582,11 @@ Recorded narration:
|
||||
|
||||
config_warnings: list[str] = []
|
||||
if animation_config:
|
||||
reference_messages = validate_animation_config(project_path, animation_config)
|
||||
reference_messages = validate_animation_config(
|
||||
project_path,
|
||||
animation_config,
|
||||
svg_files=native_files,
|
||||
)
|
||||
config_warnings = [
|
||||
message for message in reference_messages
|
||||
if ' has no id and cannot be customized in animations.json' in message
|
||||
@@ -1412,9 +1611,26 @@ Recorded narration:
|
||||
elif args.no_animations and verbose:
|
||||
print(" Animations: disabled")
|
||||
|
||||
inherited_motion: dict[str, object] = {}
|
||||
if args.inherit_motion_from:
|
||||
try:
|
||||
inherited_motion = _load_deck_motion_handoff(
|
||||
project_path,
|
||||
args.inherit_motion_from,
|
||||
native_files,
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if verbose:
|
||||
print(f" Deck motion handoff: {args.inherit_motion_from}")
|
||||
|
||||
defaults = animation_config.get('defaults', {}) if animation_config else {}
|
||||
transition_defaults = _as_dict(defaults.get('transition')) if isinstance(defaults, dict) else {}
|
||||
animation_defaults = _as_dict(defaults.get('animation')) if isinstance(defaults, dict) else {}
|
||||
inherited_transition = _as_dict(inherited_motion.get('transition'))
|
||||
inherited_animation = _as_dict(inherited_motion.get('animation'))
|
||||
inherited_overrides = _as_dict(inherited_motion.get('cli_overrides'))
|
||||
|
||||
transition_arg = args.transition
|
||||
transition_effect = (
|
||||
@@ -1423,7 +1639,11 @@ Recorded narration:
|
||||
else (
|
||||
transition_arg
|
||||
if transition_arg is not None
|
||||
else transition_defaults.get('effect', 'fade')
|
||||
else (
|
||||
inherited_transition['effect']
|
||||
if 'effect' in inherited_transition
|
||||
else transition_defaults.get('effect', 'fade')
|
||||
)
|
||||
)
|
||||
)
|
||||
try:
|
||||
@@ -1433,7 +1653,11 @@ Recorded narration:
|
||||
(
|
||||
None
|
||||
if transition_arg is not None or args.no_animations
|
||||
else transition_defaults.get('effect_options')
|
||||
else (
|
||||
inherited_transition.get('effect_options')
|
||||
if 'effect' in inherited_transition
|
||||
else transition_defaults.get('effect_options')
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -1441,7 +1665,11 @@ Recorded narration:
|
||||
(
|
||||
args.transition_duration
|
||||
if args.transition_duration is not None
|
||||
else transition_defaults.get('duration', 0.4)
|
||||
else (
|
||||
inherited_transition['duration']
|
||||
if 'duration' in inherited_transition
|
||||
else transition_defaults.get('duration', 0.4)
|
||||
)
|
||||
),
|
||||
"transition duration",
|
||||
allow_zero=transition is None,
|
||||
@@ -1449,7 +1677,11 @@ Recorded narration:
|
||||
auto_advance = (
|
||||
args.auto_advance
|
||||
if args.auto_advance is not None
|
||||
else transition_defaults.get('auto_advance')
|
||||
else (
|
||||
inherited_transition['auto_advance']
|
||||
if 'auto_advance' in inherited_transition
|
||||
else transition_defaults.get('auto_advance')
|
||||
)
|
||||
)
|
||||
if auto_advance is not None:
|
||||
auto_advance = validate_seconds(
|
||||
@@ -1471,7 +1703,11 @@ Recorded narration:
|
||||
# Per-element object motion is opt-in by default: unsolicited
|
||||
# auto-firing builds read as the "AI deck" tell. Page transitions
|
||||
# stay on; enable objects with -a or animations.json.
|
||||
else animation_defaults.get('effect', 'none')
|
||||
else (
|
||||
inherited_animation['effect_request']
|
||||
if 'effect_request' in inherited_animation
|
||||
else animation_defaults.get('effect', 'none')
|
||||
)
|
||||
)
|
||||
)
|
||||
normalized_animation = normalize_animation_effect(animation_effect)
|
||||
@@ -1487,7 +1723,11 @@ Recorded narration:
|
||||
(
|
||||
args.animation_duration
|
||||
if args.animation_duration is not None
|
||||
else animation_defaults.get('duration', 0.4)
|
||||
else (
|
||||
inherited_animation['duration']
|
||||
if 'duration' in inherited_animation
|
||||
else animation_defaults.get('duration', 0.4)
|
||||
)
|
||||
),
|
||||
"animation duration",
|
||||
allow_zero=False,
|
||||
@@ -1501,7 +1741,11 @@ Recorded narration:
|
||||
(
|
||||
args.animation_stagger
|
||||
if args.animation_stagger is not None
|
||||
else animation_defaults.get('stagger', 0.5)
|
||||
else (
|
||||
inherited_animation['stagger']
|
||||
if 'stagger' in inherited_animation
|
||||
else animation_defaults.get('stagger', 0.5)
|
||||
)
|
||||
),
|
||||
"animation stagger",
|
||||
allow_zero=True,
|
||||
@@ -1514,20 +1758,63 @@ Recorded narration:
|
||||
animation_trigger = normalize_animation_trigger(
|
||||
args.animation_trigger
|
||||
if args.animation_trigger is not None
|
||||
else animation_defaults.get('trigger', 'after-previous')
|
||||
else (
|
||||
inherited_animation['trigger']
|
||||
if 'trigger' in inherited_animation
|
||||
else animation_defaults.get('trigger', 'after-previous')
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
animation_cli_overrides = {
|
||||
'transition': args.transition is not None,
|
||||
'transition_duration': args.transition_duration is not None,
|
||||
'auto_advance': args.auto_advance is not None,
|
||||
'animation': args.animation is not None,
|
||||
'animation_duration': args.animation_duration is not None,
|
||||
'animation_stagger': args.animation_stagger is not None,
|
||||
'animation_trigger': args.animation_trigger is not None,
|
||||
'transition': (
|
||||
args.transition is not None
|
||||
or inherited_overrides.get('transition') is True
|
||||
),
|
||||
'transition_duration': (
|
||||
args.transition_duration is not None
|
||||
or inherited_overrides.get('transition_duration') is True
|
||||
),
|
||||
'auto_advance': (
|
||||
args.auto_advance is not None
|
||||
or inherited_overrides.get('auto_advance') is True
|
||||
),
|
||||
'animation': (
|
||||
args.animation is not None
|
||||
or inherited_overrides.get('animation') is True
|
||||
),
|
||||
'animation_duration': (
|
||||
args.animation_duration is not None
|
||||
or inherited_overrides.get('animation_duration') is True
|
||||
),
|
||||
'animation_stagger': (
|
||||
args.animation_stagger is not None
|
||||
or inherited_overrides.get('animation_stagger') is True
|
||||
),
|
||||
'animation_trigger': (
|
||||
args.animation_trigger is not None
|
||||
or inherited_overrides.get('animation_trigger') is True
|
||||
),
|
||||
}
|
||||
|
||||
deck_motion: dict[str, object] = {
|
||||
'transition': {
|
||||
'effect': transition,
|
||||
'effect_options': transition_effect_options,
|
||||
'duration': transition_duration,
|
||||
'auto_advance': auto_advance,
|
||||
},
|
||||
'animation': {
|
||||
'effect': normalized_animation or 'none',
|
||||
'effect_request': animation,
|
||||
'duration': animation_duration,
|
||||
'stagger': animation_stagger,
|
||||
'trigger': animation_trigger,
|
||||
},
|
||||
'cli_overrides': animation_cli_overrides,
|
||||
'narration_timings': use_narration_timings,
|
||||
}
|
||||
|
||||
if args.recorded_narration:
|
||||
@@ -1595,7 +1882,7 @@ Recorded narration:
|
||||
narration_audio=narration_audio,
|
||||
use_narration_timings=use_narration_timings,
|
||||
narration_padding=args.narration_padding,
|
||||
merge_paragraphs=args.merge_paragraphs,
|
||||
text_flow=args.text_flow,
|
||||
image_optimize=not args.no_image_optimize,
|
||||
image_max_dimension=args.image_max_dimension,
|
||||
image_sizing=args.image_sizing,
|
||||
@@ -1610,6 +1897,7 @@ Recorded narration:
|
||||
theme_font_spec=theme_font_spec,
|
||||
master_text_style_spec=master_text_style_spec,
|
||||
theme_color_spec=theme_color_spec,
|
||||
primary_language=primary_language,
|
||||
)
|
||||
|
||||
if verbose:
|
||||
@@ -1712,6 +2000,7 @@ Recorded narration:
|
||||
pptx_structure=pptx_structure,
|
||||
backup_path=backup_path,
|
||||
conversion_trace_path=conversion_trace_path,
|
||||
deck_motion=deck_motion,
|
||||
)
|
||||
except PptxPostflightValidationError as exc:
|
||||
print(
|
||||
|
||||
+35
-18
@@ -6,9 +6,15 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class NotesFileReadError(RuntimeError):
|
||||
"""Report a matched notes file that cannot be decoded or read."""
|
||||
|
||||
|
||||
def find_svg_files(
|
||||
project_path: Path,
|
||||
source: str = 'output',
|
||||
*,
|
||||
allow_fallback: bool = True,
|
||||
) -> tuple[list[Path], str]:
|
||||
"""Find SVG files in the project.
|
||||
|
||||
@@ -18,6 +24,8 @@ def find_svg_files(
|
||||
- 'output': svg_output (hand-authored source; native default)
|
||||
- 'final': svg_final (post-processed preview; diagnostic input)
|
||||
- or any subdirectory name
|
||||
allow_fallback: Try svg_output and then the project root when the
|
||||
requested directory is missing.
|
||||
|
||||
Returns:
|
||||
(list_of_svg_files, actual_directory_name) tuple.
|
||||
@@ -31,6 +39,8 @@ def find_svg_files(
|
||||
svg_dir = project_path / dir_name
|
||||
|
||||
if not svg_dir.exists():
|
||||
if not allow_fallback:
|
||||
return [], dir_name
|
||||
print(f" Warning: {dir_name} directory does not exist, trying svg_output")
|
||||
dir_name = 'svg_output'
|
||||
svg_dir = project_path / dir_name
|
||||
@@ -76,26 +86,33 @@ def find_notes_files(
|
||||
svg_index_mapping[i] = svg_path.stem
|
||||
|
||||
for notes_file in notes_dir.glob('*.md'):
|
||||
stem = notes_file.stem
|
||||
|
||||
# Try index-based matching (backward compat with slide01.md format).
|
||||
match = re.search(r'slide[_]?(\d+)', stem)
|
||||
mapped_stem = (
|
||||
svg_index_mapping.get(int(match.group(1)))
|
||||
if match
|
||||
else None
|
||||
)
|
||||
filename_match = stem in svg_stems_mapping
|
||||
if mapped_stem is None and not filename_match:
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(notes_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read().strip()
|
||||
if not content:
|
||||
continue
|
||||
content = notes_file.read_text(encoding='utf-8').strip()
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise NotesFileReadError(
|
||||
f"Cannot read matched notes file {notes_file}: {exc}"
|
||||
) from exc
|
||||
if not content:
|
||||
continue
|
||||
|
||||
stem = notes_file.stem
|
||||
if mapped_stem:
|
||||
notes[mapped_stem] = content
|
||||
|
||||
# Try index-based matching (backward compat with slide01.md format)
|
||||
match = re.search(r'slide[_]?(\d+)', stem)
|
||||
if match:
|
||||
index = int(match.group(1))
|
||||
mapped_stem = svg_index_mapping.get(index)
|
||||
if mapped_stem:
|
||||
notes[mapped_stem] = content
|
||||
|
||||
# Filename-based matching (overrides index-based)
|
||||
if stem in svg_stems_mapping:
|
||||
notes[stem] = content
|
||||
except Exception:
|
||||
pass
|
||||
# Filename-based matching overrides index-based matching.
|
||||
if filename_match:
|
||||
notes[stem] = content
|
||||
|
||||
return notes
|
||||
|
||||
+29
-13
@@ -92,27 +92,43 @@ def find_narration_files(audio_dir: Path, svg_files: list[Path]) -> dict[str, Pa
|
||||
path for path in sorted(audio_dir.iterdir())
|
||||
if path.is_file() and path.suffix.lower() in NARRATION_EXTENSIONS
|
||||
]
|
||||
exact = {path.stem: path for path in audio_files}
|
||||
normalized: dict[str, Path] = {}
|
||||
numbered: dict[int, Path] = {}
|
||||
exact: dict[str, list[Path]] = {}
|
||||
normalized: dict[str, list[Path]] = {}
|
||||
numbered: dict[int, list[Path]] = {}
|
||||
for path in audio_files:
|
||||
normalized.setdefault(_normalize_title(path.stem), path)
|
||||
exact.setdefault(path.stem, []).append(path)
|
||||
normalized.setdefault(_normalize_title(path.stem), []).append(path)
|
||||
number = _leading_number(path.stem)
|
||||
if number is not None:
|
||||
numbered.setdefault(number, path)
|
||||
numbered.setdefault(number, []).append(path)
|
||||
|
||||
matched: dict[str, Path] = {}
|
||||
claimed_by: dict[Path, str] = {}
|
||||
for index, svg in enumerate(svg_files, 1):
|
||||
stem = svg.stem
|
||||
if stem in exact:
|
||||
matched[stem] = exact[stem]
|
||||
candidates = exact.get(stem)
|
||||
if not candidates:
|
||||
candidates = normalized.get(_normalize_title(stem))
|
||||
if not candidates:
|
||||
candidates = numbered.get(index)
|
||||
if not candidates:
|
||||
continue
|
||||
norm = _normalize_title(stem)
|
||||
if norm in normalized:
|
||||
matched[stem] = normalized[norm]
|
||||
continue
|
||||
if index in numbered:
|
||||
matched[stem] = numbered[index]
|
||||
if len(candidates) > 1:
|
||||
names = ", ".join(path.name for path in candidates)
|
||||
raise ValueError(
|
||||
f"multiple narration audio files match slide {stem!r}: "
|
||||
f"{names}; keep exactly one supported file for this slide"
|
||||
)
|
||||
candidate = candidates[0]
|
||||
previous_stem = claimed_by.get(candidate)
|
||||
if previous_stem is not None:
|
||||
raise ValueError(
|
||||
f"narration audio file {candidate.name!r} matches multiple slides: "
|
||||
f"{previous_stem!r}, {stem!r}; provide one distinct audio file "
|
||||
"per slide"
|
||||
)
|
||||
matched[stem] = candidate
|
||||
claimed_by[candidate] = stem
|
||||
return matched
|
||||
|
||||
|
||||
|
||||
+58
-15
@@ -4,7 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from ..drawingml.utils import detect_text_lang
|
||||
from language_tags import normalize_language_tag
|
||||
|
||||
from ..drawingml.utils import (
|
||||
detect_text_lang,
|
||||
text_has_rtl_characters,
|
||||
text_uses_rtl,
|
||||
)
|
||||
|
||||
|
||||
def markdown_to_plain_text(md_content: str) -> str:
|
||||
@@ -54,16 +60,27 @@ def markdown_to_plain_text(md_content: str) -> str:
|
||||
return '\n'.join(result).strip()
|
||||
|
||||
|
||||
def create_notes_slide_xml(slide_num: int, notes_text: str) -> str:
|
||||
def create_notes_slide_xml(
|
||||
slide_num: int,
|
||||
notes_text: str,
|
||||
primary_language: str | None = None,
|
||||
) -> str:
|
||||
"""Create notes slide XML.
|
||||
|
||||
Args:
|
||||
slide_num: Slide number.
|
||||
notes_text: Notes text in plain text format.
|
||||
primary_language: Canonical BCP-47 deck language, when available.
|
||||
|
||||
Returns:
|
||||
Notes slide XML string.
|
||||
"""
|
||||
primary_language = (
|
||||
normalize_language_tag(primary_language)
|
||||
if primary_language is not None
|
||||
else None
|
||||
)
|
||||
default_language = primary_language or 'en-US'
|
||||
notes_text = (notes_text
|
||||
.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
@@ -72,20 +89,40 @@ def create_notes_slide_xml(slide_num: int, notes_text: str) -> str:
|
||||
paragraphs: list[str] = []
|
||||
for para in notes_text.split('\n'):
|
||||
if para.strip():
|
||||
lang = detect_text_lang(para)
|
||||
lang = detect_text_lang(para, primary_language)
|
||||
paragraph_rtl = ' rtl="1"' if text_uses_rtl(
|
||||
para,
|
||||
primary_language,
|
||||
) else ''
|
||||
run_rtl = (
|
||||
'<a:rtl val="1"/>'
|
||||
if text_has_rtl_characters(para)
|
||||
else ''
|
||||
)
|
||||
paragraphs.append(f'''<a:p>
|
||||
<a:pPr{paragraph_rtl}/>
|
||||
<a:r>
|
||||
<a:rPr lang="{lang}" dirty="0"/>
|
||||
<a:rPr lang="{lang}" dirty="0">{run_rtl}</a:rPr>
|
||||
<a:t>{para}</a:t>
|
||||
</a:r>
|
||||
</a:p>''')
|
||||
else:
|
||||
paragraphs.append('<a:p><a:endParaRPr lang="en-US" dirty="0"/></a:p>')
|
||||
paragraph_rtl = (
|
||||
' rtl="1"'
|
||||
if primary_language and text_uses_rtl('', primary_language)
|
||||
else ''
|
||||
)
|
||||
paragraphs.append(
|
||||
f'<a:p><a:pPr{paragraph_rtl}/>'
|
||||
f'<a:endParaRPr lang="{default_language}" dirty="0"/></a:p>'
|
||||
)
|
||||
|
||||
paragraphs_xml = (
|
||||
'\n '.join(paragraphs)
|
||||
if paragraphs
|
||||
else '<a:p><a:endParaRPr lang="en-US" dirty="0"/></a:p>'
|
||||
else (
|
||||
f'<a:p><a:endParaRPr lang="{default_language}" dirty="0"/></a:p>'
|
||||
)
|
||||
)
|
||||
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
@@ -160,9 +197,15 @@ def create_notes_slide_rels_xml(slide_num: int) -> str:
|
||||
</Relationships>'''
|
||||
|
||||
|
||||
def create_notes_master_xml() -> str:
|
||||
def create_notes_master_xml(primary_language: str | None = None) -> str:
|
||||
"""Create a minimal PowerPoint-compatible notes master XML."""
|
||||
return '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
language = (
|
||||
normalize_language_tag(primary_language)
|
||||
if primary_language is not None
|
||||
else 'en-US'
|
||||
)
|
||||
paragraph_rtl = ' rtl="1"' if text_uses_rtl('', language) else ''
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:notesMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
@@ -194,7 +237,7 @@ def create_notes_master_xml() -> str:
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="en-US"/></a:p></p:txBody>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
@@ -209,7 +252,7 @@ def create_notes_master_xml() -> str:
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="en-US"/></a:p></p:txBody>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
@@ -238,7 +281,7 @@ def create_notes_master_xml() -> str:
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="en-US"/></a:p></p:txBody>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
@@ -253,7 +296,7 @@ def create_notes_master_xml() -> str:
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="en-US"/></a:p></p:txBody>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
@@ -268,7 +311,7 @@ def create_notes_master_xml() -> str:
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="en-US"/></a:p></p:txBody>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
@@ -278,8 +321,8 @@ def create_notes_master_xml() -> str:
|
||||
hlink="hlink" folHlink="folHlink"/>
|
||||
<p:hf/>
|
||||
<p:notesStyle>
|
||||
<a:lvl1pPr marL="0" algn="l">
|
||||
<a:defRPr sz="1200" lang="en-US"/>
|
||||
<a:lvl1pPr marL="0" algn="l"{paragraph_rtl}>
|
||||
<a:defRPr sz="1200" lang="{language}"/>
|
||||
</a:lvl1pPr>
|
||||
</p:notesStyle>
|
||||
</p:notesMaster>'''
|
||||
|
||||
+22
-6
@@ -28,21 +28,37 @@ from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
def _flatten_module():
|
||||
"""Load the shared on-disk flattener after exposing the scripts root."""
|
||||
scripts_dir = Path(__file__).resolve().parent.parent
|
||||
if str(scripts_dir) not in sys.path:
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
from svg_finalize import flatten_tspan # type: ignore
|
||||
return flatten_tspan
|
||||
|
||||
|
||||
def flatten_positional_tspans(
|
||||
tree: ET.ElementTree,
|
||||
merge_paragraphs: bool = False,
|
||||
preserve_line_breaks: bool = False,
|
||||
) -> bool:
|
||||
"""Flatten positional ``<tspan>`` elements into independent ``<text>``.
|
||||
|
||||
Delegates to ``svg_finalize.flatten_tspan.flatten_text_with_tspans`` so
|
||||
the in-memory transform exactly matches the on-disk one. When
|
||||
``merge_paragraphs`` is True, mergeable paragraph blocks are preserved
|
||||
as a single <text> for downstream multi-<a:p> conversion.
|
||||
as a single <text>. ``preserve_line_breaks`` marks visual rows for hard
|
||||
DrawingML line breaks instead of reflow.
|
||||
|
||||
Returns True if any tspan was rewritten.
|
||||
"""
|
||||
scripts_dir = Path(__file__).resolve().parent.parent
|
||||
if str(scripts_dir) not in sys.path:
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
from svg_finalize.flatten_tspan import flatten_text_with_tspans # type: ignore
|
||||
return flatten_text_with_tspans(tree, merge_paragraphs=merge_paragraphs)
|
||||
return _flatten_module().flatten_text_with_tspans(
|
||||
tree,
|
||||
merge_paragraphs=merge_paragraphs,
|
||||
preserve_line_breaks=preserve_line_breaks,
|
||||
)
|
||||
|
||||
|
||||
def nested_positional_tspan_errors(root: ET.Element) -> list[str]:
|
||||
"""Return shared diagnostics for unsupported nested baseline jumps."""
|
||||
return _flatten_module().nested_positional_tspan_errors(root)
|
||||
|
||||
+19
@@ -12,6 +12,7 @@ from .selectors import (
|
||||
_replacement_text,
|
||||
_table_selectors,
|
||||
)
|
||||
from .transitions import transition_unknown_fields
|
||||
|
||||
|
||||
def _slot_lookup(library: dict[str, Any]) -> dict[tuple[int, str], dict[str, Any]]:
|
||||
@@ -291,6 +292,24 @@ def check_plan(library: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
for slide_index, slide in enumerate(plan.get("slides", []), start=1):
|
||||
source_slide = int(slide.get("source_slide", 0))
|
||||
transition = slide.get("transition")
|
||||
if isinstance(transition, dict):
|
||||
unknown_transition_fields = transition_unknown_fields(transition)
|
||||
if unknown_transition_fields:
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "transition_unknown_fields",
|
||||
"plan_slide": slide_index,
|
||||
"source_slide": source_slide,
|
||||
"fields": unknown_transition_fields,
|
||||
"message": (
|
||||
"transition has unknown field(s): "
|
||||
+ ", ".join(unknown_transition_fields)
|
||||
),
|
||||
}
|
||||
)
|
||||
summary["error"] += 1
|
||||
replacements = slide.get("replacements", [])
|
||||
if not isinstance(replacements, list):
|
||||
results.append(
|
||||
|
||||
+3
-3
@@ -127,11 +127,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
],
|
||||
default=DEFAULT_TRANSITION,
|
||||
help=(
|
||||
"Page-to-page transition applied to every cloned slide "
|
||||
"Page-to-page transition policy for every cloned slide "
|
||||
"(per-slide 'transition' in the plan overrides this). "
|
||||
"Use a PowerPoint-native key; old names are compatibility inputs. "
|
||||
f"Default: {DEFAULT_TRANSITION}. Use 'none' for no motion, "
|
||||
"or 'keep' to preserve each source slide's existing transition."
|
||||
f"Default: {DEFAULT_TRANSITION} (preserve the source). "
|
||||
"Use 'none' to remove visual motion."
|
||||
),
|
||||
)
|
||||
apply.add_argument(
|
||||
|
||||
+25
-9
@@ -1,9 +1,8 @@
|
||||
"""apply: page-to-page transitions for cloned slides.
|
||||
|
||||
Native templates usually ship an empty ``<p:transition/>`` that renders as no
|
||||
motion, so ``apply`` injects a default transition unless told to ``keep`` the
|
||||
source or set ``none``. Effects and OOXML mutation come from the shared
|
||||
``pptx_transitions`` core so every PPTX path uses the same writer.
|
||||
Template Fill preserves each source transition unless the CLI or a per-slide
|
||||
plan entry requests a replacement. Effects and OOXML mutation come from the
|
||||
shared ``pptx_transitions`` core so every PPTX path uses the same writer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,16 +18,28 @@ from pptx_transitions import (
|
||||
validate_seconds,
|
||||
)
|
||||
|
||||
# Default page transition injected by `apply` when neither the CLI flag nor a
|
||||
# per-slide plan field asks for something else. Use `keep` to preserve the
|
||||
# source transitions instead.
|
||||
DEFAULT_TRANSITION = "fade"
|
||||
DEFAULT_TRANSITION_DURATION = 0.5
|
||||
KEEP_TRANSITION = "keep"
|
||||
# Preserve source transitions unless the CLI or a per-slide plan entry selects
|
||||
# a replacement. The duration is consumed only when a visual effect is written.
|
||||
DEFAULT_TRANSITION = KEEP_TRANSITION
|
||||
DEFAULT_TRANSITION_DURATION = 0.5
|
||||
TRANSITION_OBJECT_FIELDS = frozenset(
|
||||
{
|
||||
"effect",
|
||||
"effect_options",
|
||||
"duration",
|
||||
"advance_after",
|
||||
}
|
||||
)
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def transition_unknown_fields(raw: dict[str, Any]) -> list[str]:
|
||||
"""Return unsupported fields from a per-slide transition object."""
|
||||
return sorted(set(raw) - TRANSITION_OBJECT_FIELDS)
|
||||
|
||||
|
||||
def _set_slide_transition(
|
||||
slide_root: ET.Element,
|
||||
*,
|
||||
@@ -96,6 +107,11 @@ def _resolve_slide_transition(
|
||||
)
|
||||
return effect, effect_options, default_duration, None
|
||||
if isinstance(raw, dict):
|
||||
unknown = transition_unknown_fields(raw)
|
||||
if unknown:
|
||||
raise RuntimeError(
|
||||
"Transition has unknown field(s): " + ", ".join(unknown)
|
||||
)
|
||||
effect = raw.get("effect", default_effect)
|
||||
raw_options = raw.get("effect_options")
|
||||
if raw_options is not None and "effect" not in raw:
|
||||
|
||||
+318
-105
@@ -6,14 +6,29 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from .checker import _chart_lookup, _slot_lookup, _table_lookup
|
||||
from .ooxml import _load_json, _write_json
|
||||
from .selectors import (
|
||||
_chart_selectors,
|
||||
_replacement_selectors,
|
||||
_replacement_text,
|
||||
_table_cell_text,
|
||||
_table_selectors,
|
||||
)
|
||||
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
_SLIDE_HEADING_RE = re.compile(r"^## Slide\s+\d+\s*$", re.MULTILINE)
|
||||
_TOTAL_SLIDES_RE = re.compile(r"^- Total slides:\s*(\d+)\s*$", re.MULTILINE)
|
||||
_SLIDE_HEADING_RE = re.compile(r"^## Slide\s+(\d+)\s*$", re.MULTILINE)
|
||||
_SPEAKER_NOTES_RE = re.compile(r"^### Speaker Notes\s*$", re.MULTILINE)
|
||||
_MARKDOWN_LINK_RE = re.compile(r"(?<!!)\[([^\]]+)\]\([^)]+\)")
|
||||
_LIST_PREFIX_RE = re.compile(r"^\s*(?:[-+*]|\d+[.)])\s+")
|
||||
_TABLE_BREAK_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
||||
_TABLE_SEPARATOR_RE = re.compile(r"^:?-{3,}:?$")
|
||||
_ESCAPED_READBACK_CONTROL_RE = re.compile(
|
||||
r"^\\(## Slide\s+\d+|### Speaker Notes)$"
|
||||
)
|
||||
|
||||
|
||||
def _latest_export(project_path: Path) -> Path:
|
||||
@@ -25,21 +40,122 @@ def _latest_export(project_path: Path) -> Path:
|
||||
|
||||
|
||||
def _readback_slide_count(markdown: str) -> int:
|
||||
match = _TOTAL_SLIDES_RE.search(markdown)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return len(_SLIDE_HEADING_RE.findall(markdown))
|
||||
|
||||
|
||||
def _normalize_text(value: object) -> str:
|
||||
return re.sub(r"\s+", "", str(value or "")).strip()
|
||||
def _slide_sections(markdown: str) -> dict[int, str]:
|
||||
matches = list(_SLIDE_HEADING_RE.finditer(markdown))
|
||||
sections: dict[int, str] = {}
|
||||
for index, match in enumerate(matches):
|
||||
end = (
|
||||
matches[index + 1].start()
|
||||
if index + 1 < len(matches)
|
||||
else len(markdown)
|
||||
)
|
||||
sections[int(match.group(1))] = markdown[match.end():end].strip()
|
||||
return sections
|
||||
|
||||
|
||||
def _contains_text(markdown: str, value: object) -> bool:
|
||||
normalized = _normalize_text(value)
|
||||
if not normalized:
|
||||
def _split_notes(section: str) -> tuple[str, str]:
|
||||
match = _SPEAKER_NOTES_RE.search(section)
|
||||
if match is None:
|
||||
return section, ""
|
||||
return section[:match.start()].strip(), section[match.end():].strip()
|
||||
|
||||
|
||||
def _normalize_line(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def _unescape_readback_control_line(value: str) -> str:
|
||||
"""Restore the display text of an escaped read-back section marker."""
|
||||
match = _ESCAPED_READBACK_CONTROL_RE.fullmatch(value)
|
||||
return match.group(1) if match else value
|
||||
|
||||
|
||||
def _paragraph_lines(value: object) -> list[str]:
|
||||
text = str(value or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
return [
|
||||
normalized
|
||||
for line in text.split("\n")
|
||||
if (normalized := _normalize_line(line))
|
||||
]
|
||||
|
||||
|
||||
def _markdown_line_variants(markdown: str) -> list[set[str] | None]:
|
||||
variants: list[set[str] | None] = []
|
||||
for raw_line in markdown.splitlines():
|
||||
stripped = raw_line.strip()
|
||||
if (
|
||||
not stripped
|
||||
or stripped.startswith("|")
|
||||
or stripped.startswith("![")
|
||||
or stripped.startswith("> [Chart")
|
||||
or stripped == "_No extractable text content._"
|
||||
):
|
||||
variants.append(None)
|
||||
continue
|
||||
display = _MARKDOWN_LINK_RE.sub(r"\1", stripped)
|
||||
without_prefix = _LIST_PREFIX_RE.sub("", display, count=1)
|
||||
line_variants = {
|
||||
_unescape_readback_control_line(_normalize_line(candidate))
|
||||
for candidate in (stripped, display, without_prefix)
|
||||
}
|
||||
line_variants.discard("")
|
||||
variants.append(line_variants)
|
||||
return variants
|
||||
|
||||
|
||||
def _contains_paragraphs(markdown: str, value: str) -> bool:
|
||||
expected = _paragraph_lines(value)
|
||||
if not expected:
|
||||
return True
|
||||
return normalized in _normalize_text(markdown)
|
||||
actual = _markdown_line_variants(markdown)
|
||||
if len(expected) > len(actual):
|
||||
return False
|
||||
for start in range(len(actual) - len(expected) + 1):
|
||||
if all(
|
||||
actual[start + offset] is not None
|
||||
and expected[offset] in actual[start + offset]
|
||||
for offset in range(len(expected))
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _table_cells(markdown: str) -> list[list[str]]:
|
||||
cells: list[list[str]] = []
|
||||
for raw_line in markdown.splitlines():
|
||||
stripped = raw_line.strip()
|
||||
if not (stripped.startswith("|") and stripped.endswith("|")):
|
||||
continue
|
||||
values = re.split(r"(?<!\\)\|", stripped[1:-1])
|
||||
normalized_values = [
|
||||
value.strip().replace(r"\|", "|")
|
||||
for value in values
|
||||
]
|
||||
if normalized_values and all(
|
||||
_TABLE_SEPARATOR_RE.fullmatch(value) is not None
|
||||
for value in normalized_values
|
||||
):
|
||||
continue
|
||||
for value in normalized_values:
|
||||
cells.append(_paragraph_lines(_TABLE_BREAK_RE.sub("\n", value)))
|
||||
return cells
|
||||
|
||||
|
||||
def _contains_table_cell(markdown: str, value: str) -> bool:
|
||||
expected = _paragraph_lines(value)
|
||||
if not expected:
|
||||
return True
|
||||
return expected in _table_cells(markdown)
|
||||
|
||||
|
||||
def _contains_chart_text(markdown: str, value: str) -> bool:
|
||||
return (
|
||||
_contains_paragraphs(markdown, value)
|
||||
or _contains_table_cell(markdown, value)
|
||||
)
|
||||
|
||||
|
||||
def _first_library(project_path: Path) -> dict[str, Any] | None:
|
||||
@@ -49,61 +165,137 @@ def _first_library(project_path: Path) -> dict[str, Any] | None:
|
||||
return _load_json(libraries[0])
|
||||
|
||||
|
||||
def _slot_role_lookup(library: dict[str, Any] | None) -> dict[tuple[int, str], str]:
|
||||
if library is None:
|
||||
return {}
|
||||
lookup: dict[tuple[int, str], str] = {}
|
||||
for slide in library.get("slides", []):
|
||||
slide_index = int(slide.get("slide_index", 0))
|
||||
for slot in slide.get("slots", []):
|
||||
slot_id = slot.get("slot_id")
|
||||
if isinstance(slot_id, str):
|
||||
lookup[(slide_index, slot_id)] = str(slot.get("role") or "")
|
||||
return lookup
|
||||
def _matched_library_target(
|
||||
lookup: dict[tuple[int, str], dict[str, Any]] | None,
|
||||
source_slide: int,
|
||||
selectors: list[str],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the first library target matched by the runtime selector order."""
|
||||
if lookup is None:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
target
|
||||
for selector in selectors
|
||||
if (target := lookup.get((source_slide, selector))) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _title_texts(plan: dict[str, Any], role_lookup: dict[tuple[int, str], str]) -> list[tuple[int, str]]:
|
||||
titles: list[tuple[int, str]] = []
|
||||
def _replacement_tokens(
|
||||
plan: dict[str, Any],
|
||||
library: dict[str, Any] | None,
|
||||
) -> list[tuple[int, str, str, str]]:
|
||||
tokens: list[tuple[int, str, str, str]] = []
|
||||
slot_lookup = _slot_lookup(library) if library is not None else None
|
||||
for plan_slide, slide in enumerate(plan.get("slides", []), start=1):
|
||||
source_slide = int(slide.get("source_slide", 0))
|
||||
replacements = slide.get("replacements", [])
|
||||
if not isinstance(replacements, list):
|
||||
continue
|
||||
first_text = ""
|
||||
found_title = False
|
||||
slide_tokens: list[tuple[str, bool]] = []
|
||||
for replacement in replacements:
|
||||
if not isinstance(replacement, dict):
|
||||
continue
|
||||
text = str(replacement.get("text") or "").strip()
|
||||
if not text:
|
||||
text = _replacement_text(replacement)
|
||||
if not _paragraph_lines(text):
|
||||
continue
|
||||
if not first_text:
|
||||
first_text = text
|
||||
slot_id = replacement.get("slot_id")
|
||||
if isinstance(slot_id, str) and role_lookup.get((source_slide, slot_id)) == "title_candidate":
|
||||
titles.append((plan_slide, text))
|
||||
found_title = True
|
||||
break
|
||||
if not found_title and first_text:
|
||||
titles.append((plan_slide, first_text))
|
||||
return titles
|
||||
slot = _matched_library_target(
|
||||
slot_lookup,
|
||||
source_slide,
|
||||
_replacement_selectors(replacement),
|
||||
)
|
||||
if (
|
||||
library is not None
|
||||
and replacement.get("optional")
|
||||
and slot is None
|
||||
):
|
||||
continue
|
||||
is_title = (
|
||||
slot is not None
|
||||
and str(slot.get("role") or "") == "title_candidate"
|
||||
)
|
||||
slide_tokens.append((text, is_title))
|
||||
if (
|
||||
library is None
|
||||
and slide_tokens
|
||||
and not any(is_title for _, is_title in slide_tokens)
|
||||
):
|
||||
first_text, _ = slide_tokens[0]
|
||||
slide_tokens[0] = (first_text, True)
|
||||
for text, is_title in slide_tokens:
|
||||
tokens.append(
|
||||
(
|
||||
plan_slide,
|
||||
text,
|
||||
(
|
||||
"title_missing_in_readback"
|
||||
if is_title
|
||||
else "body_text_missing_in_readback"
|
||||
),
|
||||
"title" if is_title else "replacement text",
|
||||
)
|
||||
)
|
||||
return tokens
|
||||
|
||||
|
||||
def _table_tokens(plan: dict[str, Any]) -> list[tuple[int, str]]:
|
||||
def _table_tokens(
|
||||
plan: dict[str, Any],
|
||||
library: dict[str, Any] | None,
|
||||
) -> list[tuple[int, str]]:
|
||||
tokens: list[tuple[int, str]] = []
|
||||
table_lookup = _table_lookup(library) if library is not None else None
|
||||
for plan_slide, slide in enumerate(plan.get("slides", []), start=1):
|
||||
source_slide = int(slide.get("source_slide", 0))
|
||||
for table_edit in slide.get("table_edits", []) or []:
|
||||
table = _matched_library_target(
|
||||
table_lookup,
|
||||
source_slide,
|
||||
_table_selectors(table_edit),
|
||||
)
|
||||
if (
|
||||
library is not None
|
||||
and table_edit.get("optional")
|
||||
and table is None
|
||||
):
|
||||
continue
|
||||
for cell in table_edit.get("cells", []) or []:
|
||||
text = str(cell.get("text") or "").strip()
|
||||
if text:
|
||||
text = _table_cell_text(cell)
|
||||
if _paragraph_lines(text):
|
||||
tokens.append((plan_slide, text))
|
||||
return tokens
|
||||
|
||||
|
||||
def _chart_tokens(plan: dict[str, Any]) -> list[tuple[int, str]]:
|
||||
def _format_chart_series_value(value: object) -> str:
|
||||
"""Match ppt_to_md's display formatting for chart series values."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
return str(value)
|
||||
|
||||
|
||||
def _chart_tokens(
|
||||
plan: dict[str, Any],
|
||||
library: dict[str, Any] | None,
|
||||
) -> list[tuple[int, str]]:
|
||||
tokens: list[tuple[int, str]] = []
|
||||
chart_lookup = _chart_lookup(library) if library is not None else None
|
||||
for plan_slide, slide in enumerate(plan.get("slides", []), start=1):
|
||||
source_slide = int(slide.get("source_slide", 0))
|
||||
for chart_edit in slide.get("chart_edits", []) or []:
|
||||
chart = _matched_library_target(
|
||||
chart_lookup,
|
||||
source_slide,
|
||||
_chart_selectors(chart_edit),
|
||||
)
|
||||
if (
|
||||
library is not None
|
||||
and chart_edit.get("optional")
|
||||
and chart is None
|
||||
):
|
||||
continue
|
||||
for category in chart_edit.get("categories", []) or []:
|
||||
if str(category).strip():
|
||||
tokens.append((plan_slide, str(category)))
|
||||
@@ -112,35 +304,34 @@ def _chart_tokens(plan: dict[str, Any]) -> list[tuple[int, str]]:
|
||||
if name:
|
||||
tokens.append((plan_slide, name))
|
||||
for value in series.get("values", []) or []:
|
||||
tokens.append((plan_slide, str(value)))
|
||||
text = _format_chart_series_value(value)
|
||||
if text:
|
||||
tokens.append((plan_slide, text))
|
||||
return tokens
|
||||
|
||||
|
||||
def _append_token_checks(
|
||||
def _append_page_token_checks(
|
||||
*,
|
||||
results: list[dict[str, Any]],
|
||||
summary: dict[str, int],
|
||||
markdown: str,
|
||||
tokens: list[tuple[int, str]],
|
||||
code: str,
|
||||
label: str,
|
||||
sections: dict[int, str],
|
||||
tokens: list[tuple[int, str, str, str]],
|
||||
status: str,
|
||||
matcher: Callable[[str, str], bool],
|
||||
) -> None:
|
||||
seen: set[tuple[int, str]] = set()
|
||||
for plan_slide, text in tokens:
|
||||
key = (plan_slide, text)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if _contains_text(markdown, text):
|
||||
summary_key = status.lower()
|
||||
for plan_slide, text, code, label in tokens:
|
||||
body, _ = _split_notes(sections.get(plan_slide, ""))
|
||||
if matcher(body, text):
|
||||
summary["ok"] += 1
|
||||
continue
|
||||
summary["warn"] += 1
|
||||
summary[summary_key] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "WARN",
|
||||
"status": status,
|
||||
"code": code,
|
||||
"plan_slide": plan_slide,
|
||||
"message": f"{label} not found in read-back Markdown",
|
||||
"message": f"{label} not found on the corresponding read-back slide",
|
||||
"text": text,
|
||||
}
|
||||
)
|
||||
@@ -179,6 +370,7 @@ def validate_project(project_path: Path) -> dict[str, Any]:
|
||||
markdown = readback_path.read_text(encoding="utf-8", errors="replace")
|
||||
results: list[dict[str, Any]] = []
|
||||
summary = {"ok": 0, "warn": 0, "error": 0}
|
||||
sections = _slide_sections(markdown)
|
||||
|
||||
expected_slides = len(plan.get("slides", []) or [])
|
||||
actual_slides = _readback_slide_count(markdown)
|
||||
@@ -196,63 +388,84 @@ def validate_project(project_path: Path) -> dict[str, Any]:
|
||||
}
|
||||
)
|
||||
|
||||
for plan_slide in range(1, expected_slides + 1):
|
||||
if plan_slide in sections:
|
||||
continue
|
||||
summary["error"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "slide_missing_in_readback",
|
||||
"plan_slide": plan_slide,
|
||||
"message": "planned slide section is missing from read-back Markdown",
|
||||
}
|
||||
)
|
||||
|
||||
library = _first_library(project_path)
|
||||
role_lookup = _slot_role_lookup(library)
|
||||
_append_token_checks(
|
||||
_append_page_token_checks(
|
||||
results=results,
|
||||
summary=summary,
|
||||
markdown=markdown,
|
||||
tokens=_title_texts(plan, role_lookup),
|
||||
code="title_missing_in_readback",
|
||||
label="key title",
|
||||
sections=sections,
|
||||
tokens=_replacement_tokens(plan, library),
|
||||
status="ERROR",
|
||||
matcher=_contains_paragraphs,
|
||||
)
|
||||
_append_token_checks(
|
||||
_append_page_token_checks(
|
||||
results=results,
|
||||
summary=summary,
|
||||
markdown=markdown,
|
||||
tokens=_table_tokens(plan),
|
||||
code="table_text_missing_in_readback",
|
||||
label="table text",
|
||||
sections=sections,
|
||||
tokens=[
|
||||
(plan_slide, text, "table_text_missing_in_readback", "table cell text")
|
||||
for plan_slide, text in _table_tokens(plan, library)
|
||||
],
|
||||
status="ERROR",
|
||||
matcher=_contains_table_cell,
|
||||
)
|
||||
_append_token_checks(
|
||||
_append_page_token_checks(
|
||||
results=results,
|
||||
summary=summary,
|
||||
markdown=markdown,
|
||||
tokens=_chart_tokens(plan),
|
||||
code="chart_text_missing_in_readback",
|
||||
label="chart text",
|
||||
sections=sections,
|
||||
tokens=[
|
||||
(plan_slide, text, "chart_text_missing_in_readback", "chart text")
|
||||
for plan_slide, text in _chart_tokens(plan, library)
|
||||
],
|
||||
status="WARN",
|
||||
matcher=_contains_chart_text,
|
||||
)
|
||||
|
||||
planned_notes = [
|
||||
slide
|
||||
for slide in plan.get("slides", []) or []
|
||||
if str(slide.get("notes") or slide.get("speaker_notes") or "").strip()
|
||||
]
|
||||
note_sections = markdown.count("### Speaker Notes")
|
||||
if len(planned_notes) == note_sections:
|
||||
summary["ok"] += 1
|
||||
elif planned_notes:
|
||||
summary["warn"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "WARN",
|
||||
"code": "notes_count_mismatch",
|
||||
"expected": len(planned_notes),
|
||||
"actual": note_sections,
|
||||
"message": "read-back speaker notes count does not match planned notes",
|
||||
}
|
||||
)
|
||||
elif note_sections:
|
||||
summary["warn"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "WARN",
|
||||
"code": "unexpected_notes_in_readback",
|
||||
"expected": 0,
|
||||
"actual": note_sections,
|
||||
"message": "read-back contains speaker notes although the plan has no notes fields",
|
||||
}
|
||||
)
|
||||
for plan_slide, slide in enumerate(plan.get("slides", []) or [], start=1):
|
||||
_, notes = _split_notes(sections.get(plan_slide, ""))
|
||||
planned_notes = str(slide.get("notes") or slide.get("speaker_notes") or "")
|
||||
if _paragraph_lines(planned_notes):
|
||||
if _contains_paragraphs(notes, planned_notes):
|
||||
summary["ok"] += 1
|
||||
continue
|
||||
summary["error"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "ERROR",
|
||||
"code": "notes_missing_in_readback",
|
||||
"plan_slide": plan_slide,
|
||||
"message": (
|
||||
"planned speaker notes not found on the corresponding "
|
||||
"read-back slide"
|
||||
),
|
||||
"text": planned_notes,
|
||||
}
|
||||
)
|
||||
elif notes.strip():
|
||||
summary["warn"] += 1
|
||||
results.append(
|
||||
{
|
||||
"status": "WARN",
|
||||
"code": "unexpected_notes_in_readback",
|
||||
"plan_slide": plan_slide,
|
||||
"message": (
|
||||
"read-back slide contains speaker notes although the "
|
||||
"plan slide has no notes field"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
report = {
|
||||
"schema": "template_fill_pptx_validate.v1",
|
||||
|
||||
+29
-1
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib import error, request
|
||||
|
||||
@@ -57,7 +58,34 @@ def get_bytes(url: str, *, timeout: int = 180) -> bytes:
|
||||
|
||||
|
||||
def download_audio(url: str, output_path: Path) -> None:
|
||||
output_path.write_bytes(get_bytes(url))
|
||||
publish_audio_bytes(get_bytes(url), output_path)
|
||||
|
||||
|
||||
def publish_audio_bytes(audio: bytes, output_path: Path) -> None:
|
||||
"""Publish non-empty provider audio without exposing a partial target."""
|
||||
if not audio:
|
||||
raise RuntimeError("TTS provider returned empty audio data")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, raw_path = tempfile.mkstemp(
|
||||
prefix=f".{output_path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=output_path.parent,
|
||||
)
|
||||
staged_path = Path(raw_path)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
descriptor = -1
|
||||
stream.write(audio)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
if staged_path.stat().st_size <= 0:
|
||||
raise RuntimeError("TTS provider returned empty audio data")
|
||||
os.replace(staged_path, output_path)
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
staged_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def extension_from_format(audio_format: str) -> str:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user