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:
KeyInfo Bot
2026-08-29 00:01:56 +08:00
parent e5c3273172
commit 729ad409dd
187 changed files with 22335 additions and 9348 deletions
+4 -4
View File
@@ -96,8 +96,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "ebd74d1f1d61a686f0f80e10abde5029fc4beeca", "commit": "d6bcaf96b7946667f4a8871b0688b903181db527",
"syncedAt": "2026-08-25T16:00:00Z" "syncedAt": "2026-08-28T16:00:00Z"
}, },
{ {
"id": "grill-me", "id": "grill-me",
@@ -114,8 +114,8 @@
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"adapter": "skill-collection", "adapter": "skill-collection",
"commit": "0a2bd1d531595b1d6d115c2c912f897e9053d3b8", "commit": "1cb20a1d1431f9216f44230a1e5a698c23ba9f70",
"syncedAt": "2026-08-27T16:00:00Z" "syncedAt": "2026-08-28T16:00:00Z"
} }
] ]
} }
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作", "name": "playwright浏览器自动化操作",
"version": "20260605", "version": "20260605",
"keySource": "none", "keySource": "none",
"syncedAt": "2026-08-27T16:01:49Z" "syncedAt": "2026-08-28T16:01:55Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "next-skills", "sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"commit": "0a2bd1d531595b1d6d115c2c912f897e9053d3b8", "commit": "1cb20a1d1431f9216f44230a1e5a698c23ba9f70",
"adapter": "skill-collection", "adapter": "skill-collection",
"sourcePath": "skills", "sourcePath": "skills",
"syncedAt": "2026-08-27T16:00:00Z" "syncedAt": "2026-08-28T16:00:00Z"
} }
@@ -39,7 +39,10 @@ build/run obstacles, accumulated as you first hit them).
and never for real production? Spellings: an explicit and never for real production? Spellings: an explicit
`EXPOSE_TESTING_API=1` for local production builds; `process.env.DEPLOY_ENV `EXPOSE_TESTING_API=1` for local production builds; `process.env.DEPLOY_ENV
=== 'staging'` for a generic CI/staging env var; `process.env.VERCEL_ENV === === 'staging'` for a generic CI/staging env var; `process.env.VERCEL_ENV ===
'preview'` on Vercel. 'preview'` on Vercel. Set the condition during `next build`, not only
`next start`. Otherwise `instant()` may not acquire the testing cookie
before the test times out; rebuild the artifact before debugging the
assertion.
3. **RUN**: how is the Playwright suite invoked, and against which 3. **RUN**: how is the Playwright suite invoked, and against which
`BASE_URL`? `BASE_URL`?
4. **TEST USER**: which account does the suite run as, and how does login 4. **TEST USER**: which account does the suite run as, and how does login
@@ -64,7 +67,10 @@ build/run obstacles, accumulated as you first hit them).
chosen mechanism. For a local `build && start` rig the artifact is the one chosen mechanism. For a local `build && start` rig the artifact is the one
freshly built, so no SHA probe is needed. Record the port, stop the previous freshly built, so no SHA probe is needed. Record the port, stop the previous
server before starting, fail the loop on `EADDRINUSE`, and verify the newly server before starting, fail the loop on `EADDRINUSE`, and verify the newly
started process owns the port before running the test. started process owns the port before running the test. `next start` can fork
a `next-server` child, so the launcher process ID may not own the port. Start
the server in a process group that the rig can stop as a unit, or discover
and stop the process listening on the recorded port before the next build.
## The file: copy, fill, commit as `instant-nav.rig.md` ## The file: copy, fill, commit as `instant-nav.rig.md`
@@ -5,7 +5,8 @@ description: >
insights it surfaces. Use when the user wants to enable or adopt insights it surfaces. Use when the user wants to enable or adopt
Partial Prefetching, flip the `partialPrefetching` flag, opt routes Partial Prefetching, flip the `partialPrefetching` flag, opt routes
in with `export const prefetch = 'partial'`, audit in with `export const prefetch = 'partial'`, audit
`<Link prefetch={true}>` calls, or resolve the `Link prefetch={true}` behavior, preserve existing prefetched UI
with `instant()` tests, or resolve the
instant-link-prefetch-partial and instant-shell-url-data insights. instant-link-prefetch-partial and instant-shell-url-data insights.
--- ---
@@ -13,19 +14,23 @@ description: >
Enable Partial Prefetching and walk the app until every link reuses a shared App Shell. This skill sequences the work; per-insight recipes live in the dev overlay fix cards and their docs pages. The [Adopting Partial Prefetching guide](https://nextjs.org/docs/app/guides/adopting-partial-prefetching) is the canonical reference for the concepts this skill applies. Enable Partial Prefetching and walk the app until every link reuses a shared App Shell. This skill sequences the work; per-insight recipes live in the dev overlay fix cards and their docs pages. The [Adopting Partial Prefetching guide](https://nextjs.org/docs/app/guides/adopting-partial-prefetching) is the canonical reference for the concepts this skill applies.
The one thing that shapes everything below: **these insights surface only in `next dev`, in the dev overlay's Insights tab.** Nothing fails the build. There is no build-only fallback loop — confirming an insight is _cleared_ means driving the running app in a browser. But a missing browser gates that verification, not the whole skill: the adoption work is static and runs from the guide, so do the static pass anyway and hand off the live shell check. The development insights and the preservation tests are two different paths. Insights surface only in `next dev`, in the dev overlay's Insights tab. Test-backed preservation runs against a production-like build with `instant()` and does not need a development server. After the flag is enabled, the separate URL-data insight sweep still uses `next dev`.
Talk to the user in terms of what they'll see — PRs, features, and how the app behaves after — never the insight slugs or step labels. Before you start, tell them briefly what Partial Prefetching changes: a `<Link>` loads a shared App Shell, and `prefetch={true}` no longer prefetches everything the old full prefetch did. ## preservation gate
When using test-backed preservation, the first implementation milestone is a passing flag-off `instant()` suite. Set up the production test rig, write the selected assertions, run them with `partialPrefetching` disabled, and record the command and exit status. Test-only configuration required by the rig is allowed, but until that baseline passes, do not enable `partialPrefetching` or edit the destination, cache boundaries, or Link props. Installing missing test dependencies is part of reaching the baseline, not a reason to adopt first. Use the manual path only when `rig-template.md` identifies a concrete blocker the repository cannot resolve, and record the blocker and deferred test coverage.
Talk to the user in terms of what they'll see — PRs, features, and how the app behaves after — never the insight slugs or step labels. Before you start, tell them briefly what Partial Prefetching changes: links to a route prefetch one shared App Shell, and `prefetch={true}` can also resolve cached URL-specific content. The audit determines which UI from the legacy full prefetch to preserve.
## requires ## requires
- **Cache Components on (`cacheComponents: true`).** This is the only hard requirement; `partialPrefetching` depends on it. Full Cache Components adoption is the ideal starting point but not a gate. Nothing in this skill blocks the build, and neither do the prerender insights an unadopted route surfaces, like a leftover `unstable_noStore` or a `cookies()` read outside `<Suspense>`: they are non-blocking dev signals, expected on any fresh branch off `main`, not a reason to stop. They replace the URL-data insight only on their own route in the [step 3](#step-3-sweep-for-url-data-insights-after-enabling) sweep; the flag-off step 1 audit and its static adoption run regardless. The only thing that actually stops this skill is a build-blocking failure, and anything build-blocking would have been resolved before you reached here. Otherwise fix the prerender insights you hit as inline [`next-cache-components-adoption`](https://github.com/vercel/next.js/tree/canary/skills/next-cache-components-adoption) work, or hand them off, and keep going. - **Cache Components adopted (`cacheComponents: true`) with a passing build.** Both `partialPrefetching` and the route-level `prefetch` export require Cache Components. If it is off, use [`next-cache-components-adoption`](https://github.com/vercel/next.js/tree/canary/skills/next-cache-components-adoption) first and return after its build-blocking prerender errors are resolved. Those errors can fail `next build`; only the Partial Prefetching insights handled by this skill are non-blocking development signals.
- **Next.js 16.3 or later.** `partialPrefetching`, the `prefetch` route segment config, and the prefetch insights all land there. - **Next.js 16.3 or later.** `partialPrefetching`, the `prefetch` route segment config, and the prefetch insights all land there.
- **A browser you can drive.** Install [`next-dev-loop`](https://github.com/vercel/next.js/tree/canary/skills/next-dev-loop) before starting, unless it is already available — it ships alongside this skill (`npx skills add https://github.com/vercel/next.js/tree/canary/skills/next-dev-loop`). Install it without asking — it's a tool, not a product change — and don't assume it's blocked: verify a real blocker (no network, no npm, read-only filesystem) before falling back, and name it in your report. Link prefetches fire when a link renders and enters the viewport, and shell validation fires on navigation — neither is reachable from `curl` or the build. If the app is webpack-pinned, drive a browser directly (`agent-browser`, Playwright) — you lose the framework cross-checks, not the insights; they're still in the overlay and the dev log. - **A browser you can drive.** Test-backed preservation uses an existing or minimal production-mode Playwright suite; manual preservation and the final demonstration use the running production app. The development insight path and the post-flag URL-data sweep use [`next-dev-loop`](https://github.com/vercel/next.js/tree/canary/skills/next-dev-loop); install it before either development pass unless it is already available (`npx skills add https://github.com/vercel/next.js/tree/canary/skills/next-dev-loop`). If the app is webpack-pinned, drive a browser directly (`agent-browser`, Playwright) — you lose the framework cross-checks, not the insights; they're still in the overlay and the dev log.
- **A runnable app.** Verification runs against `next dev` for the insight sweep and a production `next build`/`next start` for prefetching (prefetching is prod-only), so the app has to boot in both. If it reads a database or required env at import (e.g. an `env.ts` that throws on a missing `DATABASE_URL`), confirm it starts — with the real environment, or local data you stand up — before step 1. An app that won't run can't be swept or verified. - **A runnable app.** Preservation and the final demonstration need a production-like build because automatic prefetching runs only in production. The development server is required only when using the insight path or running the post-flag URL-data sweep; do not start it merely to confirm a test-backed preservation case. If the app reads a database or required environment at import, confirm the environment used by the chosen path can start before step 1.
### notes ### notes
@@ -35,75 +40,104 @@ Talk to the user in terms of what they'll see — PRs, features, and how the app
## background ## background
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. Adopting Partial Prefetching means every route preserves the prefetched UI that matters, 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: 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). 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 ## working surfaces
- **The dev server terminal — your primary record.** Each validated route's insights are logged as `Error: Route "...": Next.js encountered ...` lines with the `https://nextjs.org/docs/messages/<slug>` link. Tail the dev log during the sweep; it's the greppable record of what fired where, and it works the same on Turbopack and webpack. - **The production-mode `instant()` suite — the primary record for test-backed preservation.** Reuse the app's production build, test context, and Playwright setup. Read an existing `instant-nav.rig.md` first; if the project has no rig, create it from **`rig-template.md`**. The same tests define the legacy target before adoption and become the work queue after each destination opts into Partial Prefetching. Development can help investigate a failure, but only this suite decides whether the prefetched UI was preserved.
- **The dev server terminal — the primary record for the insight path.** Each validated route's insights are logged as `Error: Route "...": Next.js encountered ...` lines with the `https://nextjs.org/docs/messages/<slug>` link. Tail the dev log during the sweep; it's the greppable record of what fired where, and it works the same on Turbopack and webpack.
- **The dev overlay Insights tab.** Insights are the amber, non-blocking tab. It appears only once an insight has fired, so a route that surfaces nothing shows no tab at all — that's the clean state, not a missing feature. Don't hunt for the tab on a quiet route; confirm clean from the dev log above, which is the reliable signal. The precondition is no blocking-prerender errors — those replace the insight on their route (see requires). An unrelated Issue (a hydration error, a console error) doesn't block the sweep; don't stall on it. When the tab is present, the overlay pill shows the count and each insight has fix cards linking its docs page. The overlay renders inside a shadow root (`nextjs-portal`), so accessibility-tree snapshots don't see it — evaluate into `shadowRoot` when you need to read or click it programmatically. - **The dev overlay Insights tab.** Insights are the amber, non-blocking tab. It appears only once an insight has fired, so a route that surfaces nothing shows no tab at all — that's the clean state, not a missing feature. Don't hunt for the tab on a quiet route; confirm clean from the dev log above, which is the reliable signal. The precondition is no blocking-prerender errors — those replace the insight on their route (see requires). An unrelated Issue (a hydration error, a console error) doesn't block the sweep; don't stall on it. When the tab is present, the overlay pill shows the count and each insight has fix cards linking its docs page. The overlay renders inside a shadow root (`nextjs-portal`), so accessibility-tree snapshots don't see it — evaluate into `shadowRoot` when you need to read or click it programmatically.
- **`next-dev-loop`** to drive navigations and read the overlay. Prefer it over hand-rolled browser automation for the same reasons as in the Cache Components skill (webpack apps: see requires). When browsing its `/_next/mcp` tools, the prefetch insights surface through `get_errors` and the overlay, not the similarly-named `get_request_insights`. That one is the span and performance recorder (gated behind `experimental.requestInsights`) and reports nothing about prefetching. - **`next-dev-loop`** to drive navigations and read the overlay. Prefer it over hand-rolled browser automation for the same reasons as in the Cache Components skill (webpack apps: see requires). When browsing its `/_next/mcp` tools, the prefetch insights surface through `get_errors` and the overlay, not the similarly-named `get_request_insights`. That one is the span and performance recorder (gated behind `experimental.requestInsights`) and reports nothing about prefetching.
Every insight has a docs page — open it. Fetch the linked page for every distinct insight you encounter; the inline message is a summary, the page is the recipe. Every insight has a docs page — open it. Fetch the linked page for every distinct insight you encounter; the inline message is a summary, the page is the recipe.
## step 1: audit `<Link prefetch={true}>` (before enabling) ## step 1: audit `<Link prefetch={true}>` navigations (before enabling)
If `partialPrefetching: true` is already set in `next.config.ts`, the app is adopted — skip to [step 3](#step-3-sweep-for-url-data-insights-after-enabling). Otherwise work the audit with the global flag **off**, adopting each destination with `export const prefetch = 'partial'` — enabling the flag first would mark every route adopted and silence the [`instant-link-prefetch-partial`](https://nextjs.org/docs/messages/instant-link-prefetch-partial) insight this audit runs on. Ask the user how to ship it, in the language of PRs: Keep the global flag **off** through this audit and the legacy baseline in step 2. Enabling it earlier would remove the legacy behavior the migration needs to measure. If the flag is already on in unshipped work, use the pre-flag commit for the audit and baseline. When the user is available, ask how to ship it in the language of PRs:
- **One branch** — the whole audit in one change, with the flag enabled and the codemod run at the end (step 2). - **One branch** — the whole audit in one change, with the flag enabled and the codemod run at the end (step 4).
- **Route by route** — each adopted destination ships as its own PR. The insight still fires for the destinations you haven't reached, a live worklist, and step 2 comes after the last one. - **Route by route** — each adopted destination ships as its own PR. The insight still fires for the destinations you haven't reached, a live worklist, and step 4 comes after the last one.
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. The work and its order are identical either way — only the commit boundaries differ. When no user is available, 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 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. Always inspect custom Link wrappers and trace their consumers: a wrapper can call `router.prefetch()` on hover or touch while a consumer omits `prefetch` or passes `prefetch={false}`. Record the declarative and imperative behavior separately, and use keyboard activation when verifying the declarative path so hover prefetching does not mask it. If nothing matches, say so in your report and move on to [step 2](#step-2-enable-the-flag). Enumerate explicit prefetch and manual prefetch sites across the whole source tree, not only `app/` — they often live in `src/components` or shared UI packages. Start from `next/link` imports and re-exports, then follow custom wrappers to their consumers. Use `rg -n '\bprefetch\b|router\.prefetch' -g '*.tsx' -g '*.jsx' .` as a candidate list, not as the complete audit; inspect conditional props and forwarded `LinkProps` to determine the effective production value. Include every audited navigation whose effective production Link value is `prefetch={true}`: explicit `true`, a bare `prefetch` prop, and expressions that resolve to `true`. Exclude the default value, `prefetch="auto"`, and `prefetch={false}` from the preservation suite because they do not request the legacy full prefetch. Audit existing [`router.prefetch()`](https://nextjs.org/docs/app/api-reference/functions/use-router#userouter) calls separately because they have no Link insight. For new manual prefetching, follow the [Prefetching guide](https://nextjs.org/docs/app/guides/prefetching#manual-prefetch). If no Link resolves to `prefetch={true}`, say so and move on to [step 4](#step-4-enable-the-flag).
Then, for each one: ### Choose what to preserve and how to verify it
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 [`instant-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. Before writing tests or editing destinations, follow the guide's [migration guidance](https://nextjs.org/docs/app/guides/adopting-partial-prefetching#migrate-existing-full-prefetches) to propose the UI worth preserving. Present the result in one concise table:
2. **Adopt the destination.** Add the temporary route config with a link to the migration guide. That clears the insight for every link pointing at it:
```tsx | Navigation | Proposed result |
// See: https://nextjs.org/docs/app/guides/adopting-partial-prefetching | ---------- | --------------- |
export const prefetch = 'partial'
```
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 per-link-prefetch candidate for step 5, not a finished adoption. Keep `prefetch={true}` on its links and mark the route: Group equivalent navigations. Summarize what will be ready immediately and what will stream. When a proposal is ambiguous, show the navigation in the running app and ask the user to confirm it. If they are unavailable, follow the guide and record the assumption.
```tsx After the target UI is settled, inspect the existing test setup. The `instant()` helper comes from the separate [`@next/playwright`](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests) package, not `next/experimental/testmode/playwright`.
// TODO(per-link-prefetch): assess with the user whether URL data should resolve before click.
// See: https://nextjs.org/docs/app/guides/optimizing-prefetching
export const prefetch = 'partial'
```
Use that exact prefix so step 5 can grep them back. Don't cache or decide anything for these routes now. - **Applicable production-mode suite:** use test-backed preservation by default. Reuse the project's `@next/playwright` tests, production scripts, authentication, and existing `instant-nav.rig.md`. Follow the guide's [prefetched UI test workflow](https://nextjs.org/docs/app/guides/adopting-partial-prefetching#verify-prefetched-ui-with-tests) and make the complete flag-off suite green before adoption. The unchanged assertions drive the migration and stay as regression coverage.
- **No applicable production-mode suite:** set up the production-mode rig in **`rig-template.md`** using the project's package manager and test conventions. This is part of test-backed adoption and does not require a user to be present.
- **Rig cannot run reliably:** work through **`rig-template.md`** setup and liveness checks. Fall back to manual preservation only for a concrete blocker the repository cannot resolve, such as unavailable credentials or an inaccessible production environment. Record the blocker and the deferred test coverage; do not claim test-backed verification.
3. **Preserve what that prefetch delivered.** The guide's [audit table](https://nextjs.org/docs/app/guides/adopting-partial-prefetching#auditing-link-prefetchtrue-calls) is the canonical decision — fetch it and apply the matching row. Caching uncached content is the judgment call in that table: trace where the data comes from and what freshness and revalidation it needs, per the [`use cache`](https://nextjs.org/docs/app/api-reference/directives/use-cache) docs, and ask the user when the answer isn't clear-cut. The URL-data routes you marked in the previous item wait for step 5. No user input is required to reuse an existing suite or create the rig. Ask only when the repository cannot answer an environment question or when the target UI itself is a product decision. If no user is available, use the guide's safe product default and reserve manual verification for a concrete rig blocker. Treat new prefetched UI as step 7 work; verify any deliberate removal separately after adoption.
If the repository already has `instant()` e2e coverage for a destination, run it before editing and preserve its assertion as the contract. A successful build or completed navigation does not prove that the same UI was prefetched. If caching the primary data loader still leaves only a fallback inside `instant()`, inspect rendered descendants and providers for dynamic work, then expand the cache only to the smallest coherent rendered subtree that restores the contract. This workflow is specific to a clicked `<Link>`. A direct call such as `router.prefetch('/dashboard')` is a manual prefetch, not a Link prefetch; keep it in the source audit and verify it separately in step 6.
## step 2: capture the legacy baseline
Do not enable `partialPrefetching` or edit route behavior, Link props, or cache boundaries during this step. Test-only configuration required to run `instant()` is allowed.
For test-backed preservation, complete the [preservation gate](#preservation-gate): write the complete `instant()` suite and **run it** against the production-like rig with Partial Prefetching disabled. A test file, build, completed navigation, or command printed for the user is not a baseline. Do not continue to step 3 until the suite has actually passed.
For manual preservation, finish the before/target inventory before editing any destination. Fall back to this path only for a concrete rig blocker identified through `rig-template.md`, and record the blocker and deferred tests.
## step 3: adopt destinations and restore the target
Adopt every audited destination with the temporary route config. The route export is enough for the unchanged tests to exercise Partial Prefetching on that destination while the global flag remains off:
```tsx
// See: https://nextjs.org/docs/app/guides/adopting-partial-prefetching
export const prefetch = 'partial'
```
If other URL-specific UI might be worth prefetching but was not part of the legacy contract, keep `prefetch={true}` on its links and mark the route for step 7:
```tsx
// TODO(per-link-prefetch): assess with the user whether URL data should resolve before click.
// See: https://nextjs.org/docs/app/guides/optimizing-prefetching
export const prefetch = 'partial'
```
Use that exact prefix so step 7 can grep them back. Do not select new target UI now; restore only the target chosen from the legacy behavior.
For test-backed preservation, rerun the affected **unchanged** tests after each destination changes and treat failures as the work queue. Run the complete suite and record its passing exit status before enabling the global flag. For manual preservation, compare the adopted production navigation with the selected target and document anything not yet restored. Apply the guide's matching preservation pattern for caching and Link-prop changes, and ask the user before making an unclear freshness or caching decision. New URL-data candidates marked above wait for step 7.
When restoring the target changes caching or invalidation, follow the project's existing verification approach. Reuse or extend an applicable suite for the affected lifecycle, such as freshness after mutations, cache scope, or generated values. If the project doesn't test this type of behavior, do not introduce new test infrastructure during adoption; verify it manually in production and record the expected and observed results. A green `instant()` test proves readiness, not cache correctness. Ask the user only when the intended behavior is unclear.
> **If you add `use cache`, verify under `next start`, not only the build.** A `cookies()`/`headers()`/session read anywhere in the cached call tree throws at request time while `next build` passes clean. See [`use cache`](https://nextjs.org/docs/app/api-reference/directives/use-cache). > **If you add `use cache`, verify under `next start`, not only the build.** A `cookies()`/`headers()`/session read anywhere in the cached call tree throws at request time while `next build` passes clean. See [`use cache`](https://nextjs.org/docs/app/api-reference/directives/use-cache).
## step 2: enable the flag ## step 4: enable the flag
Once every audited destination has `prefetch = 'partial'`, finish in two moves. 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. 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 its generated Partial Prefetching guide comment. It leaves other values such as `prefetch = 'force-disabled'` in place, along with your `TODO(per-link-prefetch)` markers and their Optimizing prefetching guide links, 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 every `export const prefetch = 'partial'`, including exports below a `TODO(per-link-prefetch)` marker, and removes its generated Partial Prefetching guide comment. The TODO marker and its Optimizing prefetching guide link stay for step 7. Other values such as `prefetch = 'force-disabled'` stay in place.
```bash ```bash
npx @next/codemod@latest remove-partial-prefetch ./app 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'` and its generated Partial Prefetching guide comment from every `app/**/{page,layout}.{js,jsx,ts,tsx}` — leave other `prefetch` values in place, and leave the `TODO(per-link-prefetch)` markers and Optimizing prefetching guide links where they are. Don't hand-edit when the codemod can run. Use `./src/app` in a `src/` project and check the reported file count. 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'` and its generated Partial Prefetching guide comment from every `app/**/{page,layout}.{js,jsx,ts,tsx}` — leave other `prefetch` values in place, and leave the `TODO(per-link-prefetch)` markers and Optimizing prefetching guide links where they are. Don't hand-edit when the codemod can run.
## step 3: sweep for URL-data insights (after enabling) After the flag and codemod land together, rerun the locked preservation suite when using the test-backed path. Otherwise repeat the documented production comparisons under the final global configuration.
This is a dev-only second pass. The shell check runs only with the flag on, fires at navigation time, and never blocks the build, so it can happen any time after step 2. Build the route queue from a concrete source (the last `next build` route table, or the `app/` tree) and keep it as a todo list. ## step 5: sweep for URL-data insights (after enabling)
This is a dev-only second pass. The shell check runs only with the flag on, fires at navigation time, and never blocks the build, so it can happen any time after step 4. Build the route queue from a concrete source (the last `next build` route table, or the `app/` tree) and keep it as a todo list.
Sweep feature by feature. A feature is a single product surface — `app/settings/**`, `app/posts/[slug]/**` — not a whole top-level area. Finish one end-to-end before starting the next: load its routes in `next dev` and resolve their insights. The insight never blocks the build and each route is independent, so a partial sweep leaves a working app, and each feature is a self-contained change the user can review or ship on its own. Sweep feature by feature. A feature is a single product surface — `app/settings/**`, `app/posts/[slug]/**` — not a whole top-level area. Finish one end-to-end before starting the next: load its routes in `next dev` and resolve their insights. The insight never blocks the build and each route is independent, so a partial sweep leaves a working app, and each feature is a self-contained change the user can review or ship on its own.
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. 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, 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 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. 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.
@@ -111,13 +145,15 @@ Loading a route with the flag on prerenders its App Shell, which validates more
These fixes rarely involve the user — each insight names the offending read and its docs page has the fix, so apply it and keep sweeping. Collect the rare exceptions for one batched question at the end: a page that is entirely one URL-dependent region (wrapping it all leaves an empty shell), or a route that should arguably stay opted out. Don't narrate the refactor with comments — the `<Suspense>` boundaries speak for themselves. These fixes rarely involve the user — each insight names the offending read and its docs page has the fix, so apply it and keep sweeping. Collect the rare exceptions for one batched question at the end: a page that is entirely one URL-dependent region (wrapping it all leaves an empty shell), or a route that should arguably stay opted out. Don't narrate the refactor with comments — the `<Suspense>` boundaries speak for themselves.
## step 4: verify ## step 6: verify
Checklist before checking in with the user: Checklist before checking in with the user:
- **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. - **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 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. - The insights validate shell _structure_, not that a prefetch actually happened. Confirm on the production run (automatic prefetching runs only in production) that navigating a changed link lands on the shared shell instantly.
- For test-backed preservation, every locked `instant()` test for an audited `<Link prefetch={true}>` passes against the production run. For manual preservation, the before/after inventory and any deferred test follow-ups are recorded.
- Any caching or invalidation changed to preserve the target is verified through an applicable existing test suite or a recorded manual check when the project has no such coverage.
- **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 per-link-prefetch behavior to a docs-supported `<Link prefetch={true}>`. - **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 per-link-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. - **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. - `next build` still passes.
@@ -126,19 +162,23 @@ Then check in with the user. Speak their language — no insight slugs or step l
- What you did: which links you audited, which destinations you adopted, and what each link now prefetches. - What you did: which links you audited, which destinations you adopted, and what each link now prefetches.
- What changed: dropped props, `use cache` boundaries added, and which routes carry a `TODO(per-link-prefetch)` marker for later. - What changed: dropped props, `use cache` boundaries added, and which routes carry a `TODO(per-link-prefetch)` marker for later.
- Demo against a production run. Prefetching is limited in development, so `next dev` won't show the result — run `next build` and `next start`, and hand the user that URL. That run needs the app's real environment (database, auth, secrets), and a partial or stale install or leftover generated artifacts can fail the build for reasons unrelated to the adoption. Set the expectation up front that verification is a complete, credentialed production run, not a quick check. - Demo against a production run. Automatic prefetching runs only in production, so `next dev` won't show the result — run `next build` and `next start`, and hand the user that URL. That run needs the app's real environment (database, auth, secrets), and a partial or stale install or leftover generated artifacts can fail the build for reasons unrelated to the adoption. Set the expectation up front that verification is a complete, credentialed production run, not a quick check.
- Show, don't tell: drive one link live in the headed browser against the production server, so they see the shared App Shell paint instantly and the URL-specific region stream in. Attach before/after screenshots only when a live browser isn't possible. - Show, don't tell: drive one link live in the headed browser against the production server, so they see the shared App Shell paint instantly and the URL-specific region stream in. Attach before/after screenshots only when a live browser isn't possible.
- Give them the click-through: a table of each changed route — the link to click, and what to expect after the click (what paints instantly, what streams in) — so they can verify each result themselves. - Give them the click-through: a table of each changed route — the link to click, and what to expect after the click (what paints instantly, what streams in) — so they can verify each result themselves.
- The question: "Want to commit this (or open the PR) before we look at which routes should also prefetch their URL-specific content?" Wait for the answer — adoption and per-link prefetching read best as their own changes. - The question: "Want to commit this (or open the PR) before we look at which routes should also prefetch their URL-specific content?" Wait for the answer — adoption and per-link prefetching read best as their own changes.
## step 5: per-link prefetching (optional) ## step 7: per-link prefetching (optional)
The audit marked the candidates instead of deciding them. Grep for `TODO(per-link-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 per-link prefetch costs a server invocation per prefetchable link — the guide's [trade-offs](https://nextjs.org/docs/app/guides/optimizing-prefetching#trade-offs) section is the checklist. Don't make these calls alone. The audit marked candidates beyond the already-preserved legacy contract instead of deciding them. Grep for `TODO(per-link-prefetch)` and walk the list with the user in one conversation. The question per route is whether they want the additional URL-dependent content prefetched ahead of the click, or streaming in after navigation is fine. A per-link prefetch costs a server invocation per prefetchable link — the guide's [trade-offs](https://nextjs.org/docs/app/guides/optimizing-prefetching#trade-offs) section is the checklist. Don't make these calls alone.
Where the answer is yes, follow the [Optimizing prefetching guide](https://nextjs.org/docs/app/guides/optimizing-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/optimizing-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(per-link-prefetch)` marker survives this step. Confirm the opted-in links against a production run (`next build` and `next start` — the per-link prefetch runs 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 no, delete the marker and leave the route on the App Shell default. Where the answer is yes, follow the [Optimizing prefetching guide](https://nextjs.org/docs/app/guides/optimizing-prefetching), confirm the opted-in link against a production run, and delete the marker when the selected result is verified.
No `TODO(per-link-prefetch)` marker survives the finished step. Per-link optimization remains a separate commit or PR from adoption.
Finally, show any effective `prefetch={false}` links in a concise `Navigation | Why it may no longer be needed` table. Explain that `false` disables all prefetching, while Partial Prefetching's default `auto` behavior prefetches only the shared App Shell, so opt-outs added to avoid legacy full-route prefetching may now be unnecessary. Invite the user to revisit them separately.
## further reading ## further reading
- [Instant navigation](https://nextjs.org/docs/app/guides/instant-navigation) — the broader validation model and loading-state tooling. - [Instant navigation](https://nextjs.org/docs/app/guides/instant-navigation) — the broader validation model and loading-state tooling.
- [Prevent regressions with e2e tests](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests) — the `@next/playwright` `instant()` helper locks in what a navigation shows immediately; recommend it once the sweep is clean, since nothing else guards these in CI. - [Prevent regressions with e2e tests](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests) — use the `@next/playwright` `instant()` helper to build the flag-off baseline suite, then keep it as the CI regression guard.
- [`next-cache-components-optimizer`](https://github.com/vercel/next.js/tree/canary/skills/next-cache-components-optimizer) — grows each route's static shell so the App Shell carries more. - [`next-cache-components-optimizer`](https://github.com/vercel/next.js/tree/canary/skills/next-cache-components-optimizer) — grows each route's static shell so the App Shell carries more.
@@ -0,0 +1,172 @@
# Production `instant()` rig
The preservation suite needs a production build that exposes the Next.js
testing API, a stable URL for that build, and a Playwright command that can
drive the audited Links. Discover this setup once, record it in
`instant-nav.rig.md`, and reuse it throughout adoption.
Read an existing `instant-nav.rig.md` before creating one. Inspect the
repository before asking the user:
- `package.json` scripts for build, start, and end-to-end tests
- `playwright.config.*` for `baseURL`, `webServer`, projects, and authentication
- `next.config.*` for existing `experimental` options
- CI, preview deployment, container, and hosting configuration
- test helpers for login, `storageState`, fixtures, flags, and seeded data
Ask only for details the repository cannot answer, such as unavailable
credentials or which remote environment may expose the testing API.
## What the rig must define
### Production build and server
Use `next build` followed by `next start`, or a remote artifact produced by the
same production build. Automatic prefetching does not run in `next dev`, so a
development server cannot verify preservation.
Record separate build and start commands. For a local rig, record the port,
stop any previous server before starting, fail on `EADDRINUSE`, and confirm the
new process owns the port before running Playwright. `next start` can fork a
`next-server` child, so the launcher process ID may not own the port. Start the
server in a process group that the rig can stop as a unit, or discover and stop
the process listening on the recorded port before the next build.
### Testing API
An `instant()` test against a production build requires
`experimental.exposeTestingApiInProductionBuild`. Gate it so real production
builds do not expose the API:
```ts filename="next.config.ts" highlight={3,8-10}
import type { NextConfig } from 'next'
const exposeTestingApi = process.env.EXPOSE_TESTING_API === '1'
const nextConfig: NextConfig = {
cacheComponents: true,
experimental: {
exposeTestingApiInProductionBuild: exposeTestingApi,
},
}
export default nextConfig
```
Merge the option into an existing `experimental` object instead of replacing
the project's other experimental options.
Set the condition while running `next build`. Setting it only for `next start`
is too late because the testing API is compiled into the production artifact.
When the artifact was built without it, Next.js does not activate the
navigation lock, so the test cannot distinguish prefetched UI from streamed
dynamic content. Rebuild with the condition enabled before interpreting the
results. Use the project's existing environment naming when it already
distinguishes test, staging, preview, and production builds.
### Test command and base URL
Record the exact Playwright command and how it receives the measured build's
URL. Reuse the project's package manager, Playwright configuration, projects,
and reporters. The suite must import `instant()` from `@next/playwright`. If
the dependencies are absent, install `@next/playwright` on the same release
line as the project's `next`, alongside `@playwright/test`.
For a local rig, a typical sequence is:
```bash filename="Terminal"
EXPOSE_TESTING_API=1 pnpm build
pnpm start --port 3000
BASE_URL=http://localhost:3000 pnpm playwright test tests/prefetch-preservation.spec.ts
```
Adapt the script names and port to the project. Keep the production server
running while the test command executes. Follow the public
[client-navigation test](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests): load the source route, confirm the real
Link is visible, then enter `instant()`, click, wait for the destination URL,
and assert the prefetched UI.
### Test context
Record the state required to reach the audited Links and destination UI:
- Use `public; no authentication` when the navigation is public.
- Otherwise record the test account and login mechanism, including a fixture,
`storageState`, API login, or seeded session.
- Record flags, plan, role, locale, seeded data, and other state that can change
which UI the test sees.
A test user is not required. The field exists to make authenticated and
state-dependent tests reproducible when the app needs one.
### Drift
List differences between the state used to choose the preservation target and
the state used by Playwright. Feature flags, permissions, empty test data, and
locale differences can make an assertion fail because the target is
unreachable, not because Partial Prefetching removed it. Write `none known`
only after checking the test context.
### Iteration loop
Record the complete loop the agent can repeat without rediscovering commands:
- Local: build with the testing API, start the new artifact, run the focused
suite, stop the server, edit, and repeat.
- Remote: push, wait for the measured artifact, verify it matches `HEAD`, run
the focused suite against its URL, edit, and repeat.
Note any step the agent cannot perform without the user, including deployment
approval, protected branches, secrets, or multi-factor authentication.
### Artifact liveness
For a remote rig, record how the test proves the deployment matches `HEAD`.
Prefer an endpoint or response header that exposes the deployed commit SHA. If
the app has neither, use the deployment provider's API to select the artifact
whose commit SHA matches `HEAD`.
A freshly completed local `build` followed by `start` does not need a SHA
probe. Record `n/a; local build and start`.
### Walls
Record build and run obstacles with their working resolution, such as required
environment variables, server-only imports that fail during prerendering,
unavailable credentials, or a process that keeps reclaiming the test port.
Reuse these notes on the next iteration.
## Write `instant-nav.rig.md`
Place this file at the repository root or next to the end-to-end configuration:
```md
# instant-nav rig: <project>
- BUILD: <commands or platform that builds and serves the measured production artifact>
- EXPOSE: <condition that enables exposeTestingApiInProductionBuild during build>
- RUN: <focused Playwright command and how it receives BASE_URL>
- TEST USER: <public/no auth, or account and login>; state: <flags, role, data, locale>
- DRIFT: <differences that could change the asserted UI>
- LOOP: <local build → start → test, or push → deploy → test>; agent limits: <...>
- LIVENESS: <deployed SHA check, or n/a for a local build and start>
- WALLS: <project-specific obstacles and their resolutions>
```
Every field needs a concrete value. `n/a` is valid only with a reason, such as
`TEST USER: public; no authentication` or `LIVENESS: n/a; local build and
start`.
## Check the rig before writing the baseline
Before recording the legacy prefetched UI:
1. Build with the testing API condition enabled.
2. Start or locate that exact artifact and confirm the base URL responds.
3. Run one focused `instant()` smoke test through a real `<Link>` navigation.
4. Confirm the test can reach its source Link and eventual destination UI in
the recorded test context.
Fix the rig before interpreting a preservation failure. A missing testing API,
stale deployment, unreachable target, or wrong test state is an environment
failure rather than evidence that the migration changed the prefetch.
+1 -1
View File
@@ -334,7 +334,7 @@ Whatever you state explicitly is followed; whatever you leave unspecified the ag
> **Output:** The SVG pipeline has one PPTX converter: it reads `svg_output/` and writes a directly editable native DrawingML deck to `exports/<name>_<timestamp>.pptx`. The default Generate flow runs `finalize_svg.py` and produces self-contained previews in `svg_final/`; PowerPoint's manual **Convert to Shape** command is outside the supported contract. Explicit [quick generation](./skills/ppt-master/workflows/profiles/quick-generate.md) skips Strategist, confirmation, `design_spec.md`, `spec_lock.md`, and `finalize_svg.py`: whatever you state explicitly is followed, and whatever you leave unspecified the agent decides directly in one active context. It still converts sources, researches factual gaps, applies shared mode/style/aesthetic guidance, prepares required images/icons, authors formulas as native inline or block markers, considers native shapes and data visualizations, hand-authors SVG, passes the lockless Quick final quality check, and exports the final PPTX. It writes no substitute plan and cannot resume after context loss. Formula markers compile their LaTeX payload to editable OMML for PowerPoint 2010+; block groups and inline `<tspan>` runs keep ordinary SVG previews that are replaced during export. Formula rendering and editability in Keynote, WPS, LibreOffice, and other non-PowerPoint clients are not part of this contract. Ordinary export capabilities remain available as needed, including native chart/table replacement, notes, motion, narration, and diagnostics; notes, custom object animation, and narration start off, and the agent may enable them when the request or deck needs them. A default-path Quick export writes the normal postflight report and snapshots `svg_output/` to `backup/<timestamp>/svg_output/`; an explicit output path keeps the ordinary no-backup behavior. By default charts and tables export as individually editable SVG-derived DrawingML shapes, which prioritize cross-app visual consistency. Pass `--native-charts-and-tables` to replace eligible groups with PowerPoint-native Chart/Table objects backed by data, which provide **Edit Data** and object-specific controls but may render differently across apps; this variant is saved as `exports/<name>_<timestamp>_native_charts_tables.pptx`. Both chart/table export variants are editable—the distinction is the PowerPoint object model, not editability itself. > **Output:** The SVG pipeline has one PPTX converter: it reads `svg_output/` and writes a directly editable native DrawingML deck to `exports/<name>_<timestamp>.pptx`. The default Generate flow runs `finalize_svg.py` and produces self-contained previews in `svg_final/`; PowerPoint's manual **Convert to Shape** command is outside the supported contract. Explicit [quick generation](./skills/ppt-master/workflows/profiles/quick-generate.md) skips Strategist, confirmation, `design_spec.md`, `spec_lock.md`, and `finalize_svg.py`: whatever you state explicitly is followed, and whatever you leave unspecified the agent decides directly in one active context. It still converts sources, researches factual gaps, applies shared mode/style/aesthetic guidance, prepares required images/icons, authors formulas as native inline or block markers, considers native shapes and data visualizations, hand-authors SVG, passes the lockless Quick final quality check, and exports the final PPTX. It writes no substitute plan and cannot resume after context loss. Formula markers compile their LaTeX payload to editable OMML for PowerPoint 2010+; block groups and inline `<tspan>` runs keep ordinary SVG previews that are replaced during export. Formula rendering and editability in Keynote, WPS, LibreOffice, and other non-PowerPoint clients are not part of this contract. Ordinary export capabilities remain available as needed, including native chart/table replacement, notes, motion, narration, and diagnostics; notes, custom object animation, and narration start off, and the agent may enable them when the request or deck needs them. A default-path Quick export writes the normal postflight report and snapshots `svg_output/` to `backup/<timestamp>/svg_output/`; an explicit output path keeps the ordinary no-backup behavior. By default charts and tables export as individually editable SVG-derived DrawingML shapes, which prioritize cross-app visual consistency. Pass `--native-charts-and-tables` to replace eligible groups with PowerPoint-native Chart/Table objects backed by data, which provide **Edit Data** and object-specific controls but may render differently across apps; this variant is saved as `exports/<name>_<timestamp>_native_charts_tables.pptx`. Both chart/table export variants are editable—the distinction is the PowerPoint object model, not editability itself.
> **Already have a `.pptx` you want to reuse?** Hand the AI that deck plus your material and ask it to "fill this deck with the new content" — it fills text, table, and chart data into your existing design and exports only the pages you pick, staying natively editable. See the [FAQ](./docs/faq.md) and [template-fill workflow](./skills/ppt-master/workflows/template-fill-pptx.md). > **Already have a `.pptx` you want to reuse?** Give the AI the deck and material and ask it to "fill this deck with the new content" — Edit Native PPTX keeps the design and unchanged pages byte-for-byte, edits chosen pages, supports selection/reordering, and can add notes or narration. See the [FAQ](./docs/faq.md) and [workflow](./skills/ppt-master/workflows/edit-native-pptx.md).
> **Something went wrong?** If the AI loses context, ask it to read `skills/ppt-master/SKILL.md`; for everything else, check the **[FAQ](./docs/faq.md)** — it covers model selection, layout issues, export problems, and more. Continuously updated from real user reports. > **Something went wrong?** If the AI loses context, ask it to read `skills/ppt-master/SKILL.md`; for everything else, check the **[FAQ](./docs/faq.md)** — it covers model selection, layout issues, export problems, and more. Continuously updated from real user reports.
@@ -2,8 +2,8 @@
"sourceId": "ppt-master", "sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"commit": "ebd74d1f1d61a686f0f80e10abde5029fc4beeca", "commit": "d6bcaf96b7946667f4a8871b0688b903181db527",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/ppt-master", "sourcePath": "skills/ppt-master",
"syncedAt": "2026-08-25T16:00:00Z" "syncedAt": "2026-08-28T16:00:00Z"
} }
@@ -2,7 +2,7 @@
name: ppt-master name: ppt-master
description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。" description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。"
metadata: metadata:
version: "5.0.0" version: "5.1.0"
copyright: "Copyright (c) 2025-2026 Hugo He" copyright: "Copyright (c) 2025-2026 Hugo He"
license: "MIT" license: "MIT"
official_repository: "https://github.com/hugohe3/ppt-master" official_repository: "https://github.com/hugohe3/ppt-master"
@@ -40,8 +40,7 @@ use CWD, or assume a repo checkout. If unavailable, ask; never search or guess.
| Generate PPTX — ordinary Default | [`workflows/generate-pptx.md`](workflows/generate-pptx.md) | | Generate PPTX — ordinary Default | [`workflows/generate-pptx.md`](workflows/generate-pptx.md) |
| Generate PPTX — ordinary explicit Quick | [`workflows/profiles/quick-generate.md`](workflows/profiles/quick-generate.md) | | Generate PPTX — ordinary explicit Quick | [`workflows/profiles/quick-generate.md`](workflows/profiles/quick-generate.md) |
| Create Template | [`workflows/create-template.md`](workflows/create-template.md) | | Create Template | [`workflows/create-template.md`](workflows/create-template.md) |
| Fill Native PPTX | [`workflows/template-fill-pptx.md`](workflows/template-fill-pptx.md) | | Edit Native PPTX | [`workflows/edit-native-pptx.md`](workflows/edit-native-pptx.md) |
| Enhance Native PPTX | [`workflows/native-enhance-pptx.md`](workflows/native-enhance-pptx.md) |
**Hard rule — selected authority only**: Do not load another top-level route's **Hard rule — selected authority only**: Do not load another top-level route's
procedure after routing. Image to PPTX and Beautify are mutually exclusive; procedure after routing. Image to PPTX and Beautify are mutually exclusive;
@@ -41,13 +41,13 @@ history, or resumable planning state. Context loss restarts the Quick run.
| `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. | | `analysis/page-context/P<NN>.usage.json` | Derived optional context telemetry | Measured on-demand page-context size plus hashes of owning inputs/references | `page-context --record-usage` deterministically replaces only the invoked page's snapshot; `page-context-report` summarizes existing snapshots. Telemetry may be partial. Use token data to evaluate context cost, never as content or an execution contract. |
| `images/` | Runtime image pool | User, extracted, AI, web, slice, and EMF/WMF assets | Default Step 5 or Quick Generate resource preparation writes here; `analysis/image_analysis.csv` derives from current contents | | `images/` | Runtime image pool | User, extracted, AI, web, slice, and EMF/WMF assets | Default Step 5 or Quick Generate resource preparation writes here; `analysis/image_analysis.csv` derives from current contents |
| `images/image_prompts.json`, `image_queries.json`, `image_sources.json` | Conditional resource contracts | AI/web execution status and provenance | Create only for a triggered path, including Quick. They guide preparation/attribution, never page design. | | `images/image_prompts.json`, `image_queries.json`, `image_sources.json` | Conditional resource contracts | AI/web execution status and provenance | Create only for a triggered path, including Quick. They guide preparation/attribution, never page design. |
| `icons/` | Prepared project icon pool | Bundled icons copied by `icon_sync.py` plus user-provided, template, imported, or custom icon SVGs | SVG authoring may choose any icon in this project-local pool per page; `spec_lock.icons.inventory` indexes the default plan's curated synced bundled pool rather than assigning page usage or defining an exhaustive whitelist. Exporter global fallback is legacy compatibility only. | | `icons/` | Prepared project icon pool | Bundled icons copied by `icon_sync.py` plus user-provided, template, imported, or custom icon SVGs | SVG authoring may choose any icon in this project-local pool per page; `spec_lock.icons.inventory` indexes the default plan's curated synced bundled pool rather than assigning page usage or defining an exhaustive whitelist. Preview, finalization, checking, and export resolve only complete `library/name` references under this root. |
| `${SKILL_DIR}/templates/{brands,styles,layouts,decks}/*_index.json` | Library discovery indexes | The complete registered option source for Default Stage-1 template selection and chat listing | The UI server or chat branch reads these indexes only to populate the Stage-1 choice, after the communication recommendation is authored. Never scan kind directories to add options or use index summaries as Stage-1 planning evidence. Derive a library root from kind + entry id. Exact unregistered roots remain explicit inputs. Quick does not read the catalog. | | `${SKILL_DIR}/templates/{brands,styles,layouts,decks}/*_index.json` | Library discovery indexes | The complete registered option source for Default Stage-1 template selection and chat listing | The UI server or chat branch reads these indexes only to populate the Stage-1 choice, after the communication recommendation is authored. Never scan kind directories to add options or use index summaries as Stage-1 planning evidence. Derive a library root from kind + entry id. Exact unregistered roots remain explicit inputs. Quick does not read the catalog. |
| `templates/` | Project template reference | Stage-1-confirmed non-free selection or Quick direct-input installed specs, one file per selected workspace, the effective structural SVG roster (Layout when present, otherwise Deck), and non-image assets | Default template-aware Strategist work from Stage 2 onward, Quick's current agent before direct authoring, and every later role read this project-local state only, never the library/external installation root. The active planner reads every installed template Design Spec and the effective structural SVG roster; Brand and Style are intentionally roster-free, and Deck structure is shadowed when Layout is present. Continuous Executor reuses that context; fresh Executor reads the Design Spec once and each selected complete SVG, when any, only before first use or after its SHA changes. | | `templates/` | Project template reference | Stage-1-confirmed non-free selection or Quick direct-input installed specs, one file per selected workspace, the effective structural SVG roster (Layout when present, otherwise Deck), and non-image assets | Default template-aware Strategist work from Stage 2 onward, Quick's current agent before direct authoring, and every later role read this project-local state only, never the library/external installation root. The active planner reads every installed template Design Spec and the effective structural SVG roster; Brand and Style are intentionally roster-free, and Deck structure is shadowed when Layout is present. Continuous Executor reuses that context; fresh Executor reads the Design Spec once and each selected complete SVG, when any, only before first use or after its SHA changes. |
| `templates/template_execution_manifest.json` (`v1`) + `templates/template_execution/*.text-slots.json` (`v2-min`) | Derived template index | Compact prototype/source-import summary plus per-prototype text-slot diagnostics; the sidecar integrity hash is tool-only | Materialization may publish these deterministic records, but page-context does not inject or require them and models do not read them during page authoring. The complete prototype SVG is the sole visual/template authority; never author from either JSON artifact. | | `templates/template_execution_manifest.json` (`v1`) + `templates/template_execution/*.text-slots.json` (`v2-min`) | Derived template index | Compact prototype/source-import summary plus per-prototype text-slot diagnostics; the sidecar integrity hash is tool-only | Materialization may publish these deterministic records, but page-context does not inject or require them and models do not read them during page authoring. The complete prototype SVG is the sole visual/template authority; never author from either JSON artifact. |
| `<import_workspace>/svg/` | Imported native-payload backing | Complete PPTX-derived metadata, hidden carriers, fallback evidence, and source structure | Keep immutable; create-template materialization may resolve a validated source ref against these files, but models do not edit or bulk-read them | | `<import_workspace>/svg/` | Imported native-payload backing | Complete PPTX-derived metadata, hidden carriers, fallback evidence, and source structure | Keep immutable; create-template materialization may resolve a validated source ref against these files, but models do not edit or bulk-read them |
| `<import_workspace>/svg-flat/` | Optional complete-page verification backing | Self-contained visual composition generated only by explicit `--inheritance-mode both` | Keep immutable when requested; never use as authoring or materialization input | | `<import_workspace>/svg-flat/` | Optional complete-page verification backing | Self-contained visual composition generated only by explicit `--inheritance-mode both` | Keep immutable when requested; never use as authoring or materialization input |
| `<import_workspace>/authoring-svg/` | Template-creation author source | Layered editable SVG IR for imported Master, Layout, and Slide objects | Template_Designer reads and edits this bundle; final template SVGs are materialized from it rather than copied from lossless backing | | `<import_workspace>/authoring-svg/` | Template-creation author source | Canonical compact layered SVG IR for imported Master, Layout, and Slide objects | The PPTX import transaction publishes it already normalized and decoration-factored; Template_Designer reads and edits it, and final template SVGs are materialized from it rather than copied from lossless backing |
| `<import_workspace>/authoring-svg/authoring_summary.json` | Model-readable authoring index | Current SVG roster plus compact per-file canvas, size, text, image, vector, placeholder, and source-ref counts | Models read this before authoring SVGs; regenerate after direct IR edits | | `<import_workspace>/authoring-svg/authoring_summary.json` | Model-readable authoring index | Current SVG roster plus compact per-file canvas, size, text, image, vector, placeholder, and source-ref counts | Models read this before authoring SVGs; regenerate after direct IR edits |
| `<import_workspace>/authoring-svg/authoring_manifest.json` | Tool-only authoring provenance contract | Per-document source/authoring hashes and document-local source-ref paths | Generated atomically with the IR; materialization validates it before reusing native payload; never load it into model context or duplicate raw payload here | | `<import_workspace>/authoring-svg/authoring_manifest.json` | Tool-only authoring provenance contract | Per-document source/authoring hashes and document-local source-ref paths | Generated atomically with the IR; materialization validates it before reusing native payload; never load it into model context or duplicate raw payload here |
| `<import_workspace>/authoring-svg-flat/` | Optional complete-page verification IR | Self-contained page composition view with its own summary and provenance manifest | Generate only from an explicitly requested `svg-flat/`; use to verify composition, while layered `authoring-svg/` remains the canonical editable source | | `<import_workspace>/authoring-svg-flat/` | Optional complete-page verification IR | Self-contained page composition view with its own summary and provenance manifest | Generate only from an explicitly requested `svg-flat/`; use to verify composition, while layered `authoring-svg/` remains the canonical editable source |
@@ -56,11 +56,11 @@ history, or resumable planning state. Context loss restarts the Quick run.
| `confirm_ui/recommendations.stage1.json`, `.stage2.json` | Confirmation proposals | Template-independent communication contract, then template-aware complete solution plus production mechanics | Author the Stage-1 communication recommendation without using candidate indexes or workspaces as evidence; candidate display state may be prepared independently. Its page confirms communication plus template mode/selection in one submission. Create Stage 2 only after the selection is installed or free design closes and the handoff/equivalent state is ready. `template_application` decides only how to use installed project-local state. The active unconfirmed stage may be overwritten; normal progression leaves confirmed Stage 1 intact. | | `confirm_ui/recommendations.stage1.json`, `.stage2.json` | Confirmation proposals | Template-independent communication contract, then template-aware complete solution plus production mechanics | Author the Stage-1 communication recommendation without using candidate indexes or workspaces as evidence; candidate display state may be prepared independently. Its page confirms communication plus template mode/selection in one submission. Create Stage 2 only after the selection is installed or free design closes and the handoff/equivalent state is ready. `template_application` decides only how to use installed project-local state. The active unconfirmed stage may be overwritten; normal progression leaves confirmed Stage 1 intact. |
| `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. | | `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, including each native formula's exact LaTeX marker and ordinary SVG preview | 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. Formula export replaces only its explicit marker subtree under [`native-formula.md`](./native-formula.md). | | `svg_output/` | Page-design author source | Main-agent handwritten SVG pages containing the complete visible design, including each native formula's exact LaTeX marker and ordinary SVG preview | 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. Formula export replaces only its explicit marker subtree under [`native-formula.md`](./native-formula.md). |
| `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/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 or Quick §4 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 | | `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/` | Default-only derived visual preview | Self-contained post-processed SVGs that may be opened directly or inserted as SVG pictures | Default rebuilds it from `svg_output/` with `finalize_svg.py`; Quick omits it. Never use it as a supported PPTX source. | | `svg_final/` | Default-only derived visual preview | Self-contained post-processed SVGs that may be opened directly or inserted as SVG pictures | Default rebuilds it from `svg_output/` with `finalize_svg.py`; Quick omits it. Never use it as a supported PPTX source. |
| `validation/workflow.log` | Cold workflow audit log | Append-only Python command envelopes, material tagged outcomes, bounded warning/OK/stderr samples, per-run omission counts, and selective manual entries for important details with no owning Python output | `project_manager.py init` creates the log and records its milestone. Later project-scoped Python tools find that existing log through the shared CLI bootstrap and record the bounded audit selection without a wrapper command; their full console output is not copied. A helper whose arguments/cwd do not identify the active project receives `PPT_MASTER_PROJECT_PATH=<project_path>` on the same Python command. A role may run `workflow_log.py` once for a material non-Python stage handoff or rework reason, user-approved exception, or manual recovery choice; never duplicate artifacts, routine progress, or private reasoning. Detached services retain detailed output in their component logs. Never read this log during normal generation/resume or use it as stage, artifact, or quality authority; inspect it only for an explicit user-requested run review. In Quick it remains an incomplete operational audit, not a design history or resume source. | | `validation/workflow.log` | Cold workflow audit log | Append-only Python command envelopes, material tagged outcomes, bounded warning/OK/stderr samples, per-run omission counts, and selective manual entries for important details with no owning Python output | `project_manager.py init` creates the log and records its milestone. Later project-scoped Python tools find that existing log through the shared CLI bootstrap and record the bounded audit selection without a wrapper command; their full console output is not copied. A helper whose arguments/cwd do not identify the active project receives `PPT_MASTER_PROJECT_PATH=<project_path>` on the same Python command. A role may run `workflow_log.py` once for a material non-Python stage handoff or rework reason, user-approved exception, or manual recovery choice; never duplicate artifacts, routine progress, or private reasoning. Detached services retain detailed output in their component logs. Never read this log during normal generation/resume or use it as stage, artifact, or quality authority; inspect it only for an explicit user-requested run review. In Quick it remains an incomplete operational audit, not a design history or resume source. |
| `validation/svg_quality_report.json` | Final SVG quality provenance | Final SVG gate split into blocking / introduced / inherited / source-import categories, bound to the checked SVG bytes by SHA-256 | Default runs `svg_quality_checker.py --stage final --json`; Quick adds `--quick-generate` so the checker ignores Design Spec/lock and validates the lockless flat roster. Export links the report only when fingerprints match; Quick requires that link to pass before PPTX creation. | | `validation/svg_quality_report.json` | Final SVG quality provenance | Final SVG gate split into blocking / introduced / inherited / source-import categories, bound to the checked SVG bytes by SHA-256 | Default runs `svg_quality_checker.py --canonical-authoring --stage final --json`; Quick adds `--quick-generate` so the checker ignores Design Spec/lock and infers flat versus structured validation from the complete SVG roster. Export links the report only when fingerprints match; Quick requires that link to pass before PPTX creation. |
| `validation/<output_stem>.report.json` | Published-package audit | PPTX package/resource postflight status, part counts, and quality-gate linkage | Both Generate profiles write it and emit `[POSTFLIGHT]` after package validation. | | `validation/<output_stem>.report.json` | Published-package audit | PPTX package/resource postflight status, part counts, and quality-gate linkage | Both Generate profiles write it and emit `[POSTFLIGHT]` after package validation. |
| `exports/` | Delivery artifacts | Native DrawingML PPTX and explicit native-object/narration variants | Default Step 7.3 or Quick direct export writes final deliverables from `svg_output/`. | | `exports/` | Delivery artifacts | Native DrawingML PPTX and explicit native-object/narration variants | Default Step 7.3 or Quick direct export writes final deliverables from `svg_output/`. |
| `backup/<timestamp>/svg_output/` | Default-path frozen author-source archive | Re-export source without re-running LLM | Both Generate profiles write a snapshot for default-path exports; explicit `-o/--output` skips it. | | `backup/<timestamp>/svg_output/` | Default-path frozen author-source archive | Re-export source without re-running LLM | Both Generate profiles write a snapshot for default-path exports; explicit `-o/--output` skips it. |
@@ -77,7 +77,7 @@ history, or resumable planning state. Context loss restarts the Quick run.
| PPTX structure | `analysis/<stem>.slide_library.json` owns native geometry, slot facts, and SmartArt layout/relationships for direct PPTX workflows. The Beautify inventory only deterministically inlines the subset required by its validation contract; neither the full ledger nor its stdout projections replace `slide_library.json` as the native-structure fact source. | | PPTX structure | `analysis/<stem>.slide_library.json` owns native geometry, slot facts, and SmartArt layout/relationships for direct PPTX workflows. The Beautify inventory only deterministically inlines the subset required by its validation contract; neither the full ledger nor its stdout projections replace `slide_library.json` as the native-structure fact source. |
| Design contract | Final confirmation once → audited `design_spec.md` → optional same-file refinement/approval → context-authored lock. Never maintain a parallel draft/lock. Executor may apply `Template Application` prose but never replace identity. Repair divergence from the approved Design Spec/context unless it fails active-decision fidelity. | | Design contract | Final confirmation once → audited `design_spec.md` → optional same-file refinement/approval → context-authored lock. Never maintain a parallel draft/lock. Executor may apply `Template Application` prose but never replace identity. Repair divergence from the approved Design Spec/context unless it fails active-decision fidelity. |
| Flat packaging authority | Free-design, brand-only, Style-only, and every plan with `template_reuse_scope: style` declare `pptx_structure.mode: flat` and omit `pptx_masters`, `pptx_layouts`, `page_pptx_layouts`, and `page_layouts`. A Style installed alongside Layout/Deck changes only Direction / method and does not force the non-Style structure plan to flat. `svg_output/` owns the complete Slide-local visual design without root Master/Layout identity, fixed-layer ownership, or placeholder metadata. Export materializes one clean project-owned Master plus one Blank Layout, applies the locked theme defaults, removes stock content placeholders/Layout inventory, and retains only the standard date/footer/slide-number capability hooks. | | Flat packaging authority | Free-design, brand-only, Style-only, and every plan with `template_reuse_scope: style` declare `pptx_structure.mode: flat` and omit `pptx_masters`, `pptx_layouts`, `page_pptx_layouts`, and `page_layouts`. A Style installed alongside Layout/Deck changes only Direction / method and does not force the non-Style structure plan to flat. `svg_output/` owns the complete Slide-local visual design without root Master/Layout identity, fixed-layer ownership, or placeholder metadata. Export materializes one clean project-owned Master plus one Blank Layout, applies the locked theme defaults, removes stock content placeholders/Layout inventory, and retains only the standard date/footer/slide-number capability hooks. |
| Template structure authority | `template_reuse_scope: mirror|layout` uses `page_layouts` for each page's authoring-input prototype. `pptx_masters` / `pptx_layouts` own the unique reusable output definitions, while `page_pptx_layouts` owns page assignment. Strict keeps the prototype contract; adaptive may use a current or new Layout already declared by Strategist. A construction-discovered structural change returns upstream for definition and assignment repair before authoring resumes. Mirror additionally preserves literal visuals/text topology; layout allows project-controlled reflow/re-skinning. Unused definitions may register without a published Slide. Templates validate provenance but never add missing visible page objects during export. | | Template structure authority | `template_reuse_scope: mirror|layout` uses `page_layouts` for each page's authoring-input prototype. `pptx_masters` / `pptx_layouts` own the unique reusable output definitions, while `page_pptx_layouts` owns page assignment. Strict keeps the prototype; adaptive Layout choice is authorized by Strategist or Quick's frozen Template Application. A construction-discovered structural change returns upstream for definition and assignment repair before authoring resumes. Mirror additionally preserves ordinary authored visuals/text topology; only a JSON-first Chart/Table's derived preview children may regenerate while its metadata/structure remain fixed. Layout allows project-controlled reflow/re-skinning. Unused definitions may register without a published Slide. Templates validate provenance but never add missing visible page objects during export. |
| Fact classes | External facts resolve through `sources/*.facts.json`; invented demo KPIs/targets/internal ratios are labeled `scenario` in `design_spec.md §IX` and visibly in the page. Never promote scenario data into the external fact registry. | | Fact classes | External facts resolve through `sources/*.facts.json`; invented demo KPIs/targets/internal ratios are labeled `scenario` in `design_spec.md §IX` and visibly in the page. Never promote scenario data into the external fact registry. |
| Imported-template authoring | Editable SVGs under `authoring-svg/` own create-template edits, `authoring_summary.json` owns model-facing orientation, and `authoring_manifest.json` owns tool-only source-object identity. Lossless `svg/` owns immutable native payload and fallback evidence; optional `svg-flat/` owns only complete-page verification. Materialized `templates/*.svg` own the validated deliverable contract and contain no IR-only source refs. | | Imported-template authoring | Editable SVGs under `authoring-svg/` own create-template edits, `authoring_summary.json` owns model-facing orientation, and `authoring_manifest.json` owns tool-only source-object identity. Lossless `svg/` owns immutable native payload and fallback evidence; optional `svg-flat/` owns only complete-page verification. Materialized `templates/*.svg` own the validated deliverable contract and contain no IR-only source refs. |
| Legacy template input | Old unmapped/distilled/preserve structured projects and incomplete template packages are not migrated in place. [`create-template`](../workflows/create-template.md) authors a new current workspace: original PPTX Type A may preserve existing native topology in mirror; legacy SVG-only Type B is visual reference for `standard` / `fidelity`. Intentional free-design, Brand-only, and Style-only `flat` projects are already current. The exporter does not migrate or visually cluster legacy structure. | | Legacy template input | Old unmapped/distilled/preserve structured projects and incomplete template packages are not migrated in place. [`create-template`](../workflows/create-template.md) authors a new current workspace: original PPTX Type A may preserve existing native topology in mirror; legacy SVG-only Type B is visual reference for `standard` / `fidelity`. Intentional free-design, Brand-only, and Style-only `flat` projects are already current. The exporter does not migrate or visually cluster legacy structure. |
@@ -86,7 +86,7 @@ history, or resumable planning state. Context loss restarts the Quick run.
| SVG source | `svg_output/` is the only author source for generated pages. | | SVG source | `svg_output/` is the only author source for generated pages. |
| Page-design closure | On SVG-authoring routes, every visible exported-slide object exists in the corresponding page SVG or an explicitly referenced visual asset. | | Page-design closure | On SVG-authoring routes, every visible exported-slide object exists in the corresponding page SVG or an explicitly referenced visual asset. |
| Package-behavior separation | Speaker notes, animations, transitions, narration, and direct native-PPTX workflows keep their owning artifacts; do not force them into SVG metadata. | | Package-behavior separation | Speaker notes, animations, transitions, narration, and direct native-PPTX workflows keep their owning artifacts; do not force them into SVG metadata. |
| Post-processed SVG | In Default Generate, `svg_final/` is disposable, must be rebuilt in Step 7.2, and serves only as a self-contained visual preview / manually insertable SVG picture. Quick omits it. | | Post-processed SVG | In Default Generate, `svg_final/` is an optional disposable preview rebuilt by Step 7.2; it serves only as a self-contained visual preview / manually insertable SVG picture. Quick omits it. |
| Workflow audit log | `validation/workflow.log` automatically records each project-scoped Python command envelope, all explicit error/failure and receipt/report lines, bounded warning/OK/stderr samples, summary context, and omission counts, plus explicitly selected manual audit entries. It does not retain the full console stream or automatically capture direct file-authoring actions, host-native tools, pre-project conversion, binary-buffer writes, hidden child output, or detached-service activity; absence of a detail line proves nothing about those stages. Automatic recording failure is advisory and never changes the owning tool's outcome; a failed explicit manual append reports failure because no entry was recorded. | | Workflow audit log | `validation/workflow.log` automatically records each project-scoped Python command envelope, all explicit error/failure and receipt/report lines, bounded warning/OK/stderr samples, summary context, and omission counts, plus explicitly selected manual audit entries. It does not retain the full console stream or automatically capture direct file-authoring actions, host-native tools, pre-project conversion, binary-buffer writes, hidden child output, or detached-service activity; absence of a detail line proves nothing about those stages. Automatic recording failure is advisory and never changes the owning tool's outcome; a failed explicit manual append reports failure because no entry was recorded. |
| 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. | | 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. | | Shape-conversion boundary | PowerPoint's manual Convert-to-Shape operation on `svg_final/` is outside the project compatibility contract. |
@@ -111,7 +111,7 @@ contract.
| `<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 | | `<import_workspace>/authoring-svg/authoring_summary.json` | Current authoring SVGs plus tool-only manifest roster | `python3 ${SKILL_DIR}/scripts/svg_authoring_view.py <import_workspace>/authoring-svg --refresh-summary`; in-place vector/picture extraction refreshes it automatically |
| `notes/slide_*.md` | `notes/total.md`, when speaker notes are enabled | `python3 ${SKILL_DIR}/scripts/total_md_split.py <project_path>` | | `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>` | | `svg_final/` | `svg_output/` plus project assets | `python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>` |
| `validation/svg_quality_report.json` | `svg_output/`, plus locks/template provenance in Default Generate | Default: `python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> --stage final --json`; Quick: append `--quick-generate` | | `validation/svg_quality_report.json` | `svg_output/`, plus locks/template provenance in Default Generate | Default: `python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> --canonical-authoring --stage final --json`; Quick: append `--quick-generate` |
| Native PPTX + `validation/<output_stem>.report.json` | `svg_output/` plus notes/assets and final quality report | `python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>` | | Native PPTX + `validation/<output_stem>.report.json` | `svg_output/` plus notes/assets and final quality report | `python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>` |
| Quick native PPTX | `svg_output/`, prepared resources, passing Quick final report | `python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path> --quick-generate` | | Quick native PPTX | `svg_output/`, prepared resources, passing Quick final report | `python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path> --quick-generate` |
@@ -25,7 +25,7 @@ Evaluate branches from each object's actual information model, not only from a C
**Hard rule — flat PowerPoint structure**: Free-design, brand-only, Style-only, and every `template_reuse_scope: style` project use `pptx_structure.mode: flat`: write no root Master/Layout identity, `data-pptx-layer`, or `data-pptx-placeholder`; every visible object remains Slide-local, and the root declares exactly one canonical `data-pptx-page-role` (`cover` / `toc` / `section` / `content` / `ending`). A Style workspace supplies reusable communication/design direction, composition rhythm, and information-expression defaults without page prototypes. Its identity-adjacent color, typography, icon, and image defaults yield to the final Brand/Deck identity and confirmed project lock. When a Style is installed alongside Layout/Deck, it changes only Direction / method and follows the resolved non-Style structure route. Export materializes one clean project-owned Master plus one Blank Layout from the current lock. Add `data-pptx-role` only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by specialized metadata; the marked element uses a stable unique `id`. See [`semantic-svg.md`](./semantic-svg.md). **Hard rule — flat PowerPoint structure**: Free-design, brand-only, Style-only, and every `template_reuse_scope: style` project use `pptx_structure.mode: flat`: write no root Master/Layout identity, `data-pptx-layer`, or `data-pptx-placeholder`; every visible object remains Slide-local, and the root declares exactly one canonical `data-pptx-page-role` (`cover` / `toc` / `section` / `content` / `ending`). A Style workspace supplies reusable communication/design direction, composition rhythm, and information-expression defaults without page prototypes. Its identity-adjacent color, typography, icon, and image defaults yield to the final Brand/Deck identity and confirmed project lock. When a Style is installed alongside Layout/Deck, it changes only Direction / method and follows the resolved non-Style structure route. Export materializes one clean project-owned Master plus one Blank Layout from the current lock. Add `data-pptx-role` only to structural page-frame objects whose package, page-number, or animation behavior is not already expressed by specialized metadata; the marked element uses a stable unique `id`. See [`semantic-svg.md`](./semantic-svg.md).
**Hard rule — supported PPTX route**: The only supported generated-PPTX path is `svg_output/` through the project SVG-to-DrawingML converter. Step 7.2 still generates `svg_final/` as a mandatory self-contained visual preview that may be inserted as an SVG picture. Do not treat PowerPoint's manual Convert-to-Shape operation as an authoring target or compatibility requirement. **Hard rule — supported PPTX route**: The only supported generated-PPTX path is `svg_output/` through the project SVG-to-DrawingML converter. Step 7.2 generates `svg_final/` as an optional self-contained visual preview that may be inserted as an SVG picture; its absence never blocks export. Do not treat PowerPoint's manual Convert-to-Shape operation as an authoring target or compatibility requirement.
> Note: this rule covers page design only. Speaker notes, animations, transitions, narration, and direct native-PPTX workflows retain their separate artifacts and package-level processing. > Note: this rule covers page design only. Speaker notes, animations, transitions, narration, and direct native-PPTX workflows retain their separate artifacts and package-level processing.
@@ -33,11 +33,10 @@ Evaluate branches from each object's actual information model, not only from a C
## 1. Effect Capability Discovery ## 1. Effect Capability Discovery
**Mandatory — select by visual job**: establish each page's semantic skeleton, **Reference — effects vocabulary**: [`svg-effects.md`](./svg-effects.md) §6.1
then run the already-loaded [`svg-effects.md`](./svg-effects.md) §6.1 procedure lists the visual jobs an effect can serve, with its Visual Job Router as recall,
before finalizing, with its Visual Job Router as recall; use §6.13 for a and §6.13 offers coordinated page recipes. The catalog expands construction
coordinated page recipe when useful. The catalog expands construction vocabulary. Active cross-page continuous action
vocabulary; it creates no effect quota. Active cross-page continuous action
additionally loads [`animations.md`](./animations.md) §3.1 before authoring additionally loads [`animations.md`](./animations.md) §3.1 before authoring
both endpoints. both endpoints.
@@ -52,7 +51,7 @@ baked/alternative-only.
## 2. Design Parameter Confirmation (Mandatory Step) ## 2. Design Parameter Confirmation (Mandatory Step)
Before the first SVG page, output a confirmation listing: the compact communication objective, canvas dimensions, body font size, color scheme (primary/secondary/accent HEX), font plan, and the live-preview URL reported by the launcher. If the preview launch failed, state that failure before generating SVGs instead of silently proceeding. Prevents purpose/spec/execution drift. Before the first SVG page, output a confirmation listing: the compact communication objective, canvas dimensions, body font size, color scheme (primary/secondary/accent HEX), font plan, the measured line capacity of the body and annotation roles (run `python3 ${SKILL_DIR}/scripts/text_measure.py measure` on one representative CJK/Latin line per role and state "≈ N chars per W px"; the checker's module check adds DrawingML wrapping headroom, so a hand count of characters × font size underestimates by roughly 15%), and the live-preview URL reported by the launcher. If the preview launch failed, state that failure before generating SVGs instead of silently proceeding. Prevents purpose/spec/execution drift.
### 2.1 Execution context validity (Mandatory) ### 2.1 Execution context validity (Mandatory)
@@ -70,17 +69,17 @@ Consume stdout directly; stop on non-zero exit. The projection is derived, not a
**Same-context repair**: in a valid uncompacted context, a bounded repair that preserves roster/order/identity/communication needs only affected Design Spec/lock fragment readback plus `project_manager.py validate`. Any broader or invalid-context repair requires the complete reads above. **Same-context repair**: in a valid uncompacted context, a bounded repair that preserves roster/order/identity/communication needs only affected Design Spec/lock fragment readback plus `project_manager.py validate`. Any broader or invalid-context repair requires the complete reads above.
**Hard rule — exact page roster**: `design_spec.md §IX` is the ordered queue: one final slide per entry, with the same id/order. The UI range no longer applies. Never add, drop, merge, split, or reorder; repair/reconfirm the Design Spec first. **Hard rule — exact page roster**: `design_spec.md §IX` is the ordered queue: one final slide per entry, with the same id/order. Never add, drop, merge, split, or reorder while drawing. In a continuous run the same context may first repair the affected §IX blocks and `page_rhythm` rows and rerun `project_manager.py validate` when the page count stays inside the Stage-1 confirmed range; leaving that range reconfirms Stage 1.
**Hard rule — binding selection vs realization**: use Strategist-selected semantic content, resources/paths, structured-template Master/Layout routing keys, core fonts, palette anchors, icon-library/stroke anchors, and crop boundaries. Adapt realization—including which prepared project-local icon, if any, best serves each page—without changing those binding selections, except sparse local font/color garnish allowed below. Missing or unresolved material stops execution and returns to Strategist-owned acquisition/failure recovery; never search, generate, download, sync, invent, or substitute it. Binding selection changes require upstream repair. **Hard rule — binding selection vs realization**: use Strategist-selected semantic content, resources/paths, structured-template Master/Layout routing keys, core fonts, palette anchors, icon-library/stroke anchors, and crop boundaries. Adapt realization—including which prepared project-local icon, if any, best serves each page—without changing those binding selections, except sparse local font/color garnish allowed below. Missing or unresolved material stops execution and returns to Strategist-owned acquisition/failure recovery; never search, generate, download, sync, invent, or substitute it. Binding selection changes require upstream repair.
**Reference — planning advice, not a layout lock**: treat §V/§IX `Layout`, cover/closing composition, capability recommendations, §III motif direction, Chart/Table `family/key` construction references, and §VIII image-layout patterns as non-binding inputs. Consider each, then adopt, adapt, or decline it without upstream repair when the same semantic job and every binding user/template/resource constraint remain satisfied. Executor owns final carrier choice, page-scale composition, information-preserving visualization realization, geometry, spacing, coordinates, native preset/Boolean/freeform construction, and effects. **Reference — planning advice, not a layout lock**: treat §V/§IX `Layout`, cover/closing composition, capability recommendations, §III motif direction, Chart/Table `family/key` construction references, and §VIII image-layout patterns as non-binding inputs. Consider each, then adopt, adapt, or decline it without upstream repair when the same semantic job and every binding user/template/resource constraint remain satisfied. Executor owns final carrier choice, page-scale composition, information-preserving visualization realization, geometry, spacing, coordinates, native preset/Boolean/freeform construction, and effects.
**Hard rule — content vs expression**: `design_spec.md §IX` owns each page's semantic content and supplies complete preferred wording and block texture; those expression choices are not verbatim requirements unless explicitly literal. Executor may paraphrase, condense repetition, regroup or reorder material within the same page, and switch among prose, bullets, keywords, labels, or visual annotation when fit or readability benefits. The result must remain information-equivalent: preserve the `Core message`, `Audience move`, and every substantive claim, fact, data value, proper name, qualifier or caveat, relationship, key argument or evidence, and literal requirement. Never add a claim, move content across pages, or drop information to make the layout fit; return an unfit or underspecified block for Design Spec repair. **Hard rule — content vs expression**: `design_spec.md §IX` owns each page's semantic content and supplies either complete preferred wording and block texture (`complete` depth) or a short block list (`brief` depth); written expression choices are not verbatim requirements unless explicitly literal. Executor may paraphrase, condense repetition, regroup or reorder material within the same page, and switch among prose, bullets, keywords, labels, or visual annotation when fit or readability benefits. The result must remain information-equivalent: preserve the `Core message`, `Audience move`, and every substantive claim, fact, data value, proper name, qualifier or caveat, relationship, key argument or evidence, and literal requirement. Never add a claim, move content across pages, or drop information to make the layout fit; return an unfit or underspecified block for Design Spec repair.
Use named lock roles literally when that role applies, and use optional `Template Application` from the retained Design Spec. Choose contextual page-local values from the Design Spec, style, content, and current composition rather than forcing every object into a lock row. A page-context delta overrides neither facts nor constraints. Deprecated `page-context --bundle` is a compatibility no-op. Use named lock roles literally when that role applies, and use optional `Template Application` from the retained Design Spec. Choose contextual page-local values from the Design Spec, style, content, and current composition rather than forcing every object into a lock row. A page-context delta overrides neither facts nor constraints. Deprecated `page-context --bundle` is a compatibility no-op.
**Source verification**: §IX owns the complete page brief; the page delta does not carry the source corpus. Read sources only to resolve listed `Fact IDs` or verify required claims, quotes, names, or data. Do not add facts, claims, or selected content. Return underspecified blocks for Design Spec repair. **Source verification**: §IX owns the page brief at its confirmed depth; the page delta does not carry the source corpus. Read sources only to resolve listed `Fact IDs` or verify required claims, quotes, names, or data. Do not add facts, claims, or selected content. Return underspecified blocks for Design Spec repair.
**Per-page communication trace**: Read `communication.objective`, `communication.core_message`, and the current §IX `Core message` + `Audience move` before choosing composition. The page must advance the compact objective and move the audience as authored in §IX; the global core message remains the deck-wide north star. A page that cannot state this movement is an upstream outline defect — surface `warning: P<NN> has no communication move` instead of compensating with decorative layout. Do not invent a new purpose, ask, or outcome at execution time. Structural pages may advance the contract by establishing relevance / tension / decision frame or by completing the final commitment; they are not exempt from having a reason to exist. **Per-page communication trace**: Read `communication.objective`, `communication.core_message`, and the current §IX `Core message` + `Audience move` before choosing composition. The page must advance the compact objective and move the audience as authored in §IX; the global core message remains the deck-wide north star. A page that cannot state this movement is an upstream outline defect — surface `warning: P<NN> has no communication move` instead of compensating with decorative layout. Do not invent a new purpose, ask, or outcome at execution time. Structural pages may advance the contract by establishing relevance / tension / decision frame or by completing the final commitment; they are not exempt from having a reason to exist.
@@ -92,11 +91,11 @@ Use named lock roles literally when that role applies, and use optional `Templat
|---|---| |---|---|
| `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. | | `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; when notes are enabled, let them add interpretation and transitions. Mix prose, structured evidence, and necessary lists according to their semantic relationship. | | `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. | | `presentation` | Make one claim and one dominant visual expression legible at projection distance. Keep visible copy concise; when notes are enabled, explanation and transitions can live there. 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. 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. **Default — authored texture (may override when information-equivalent)**: at `complete` depth start from each `design_spec.md §IX Content` block's written texture because it is the Strategist's recommended expression; at `brief` depth each block already carries its phrasing; expand it into page copy under the reading mode. 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 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. Start from the shared leading ranges in [`shared-standards-core.md`](./shared-standards-core.md) §4.2, then adjust for the typeface, reading distance, explicit user/template requirements, and locked 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. Start from the shared leading ranges in [`shared-standards-core.md`](./shared-standards-core.md) §4.2, then adjust for the typeface, reading distance, explicit user/template requirements, and locked 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. - **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.
@@ -110,12 +109,13 @@ Apply the content-vs-expression contract above within the selected reading mode.
**Execution anchors and contextual values**: **Execution anchors and contextual values**:
- Base icons may use any SVG already prepared under `<project_path>/icons/`. `icons.library` records the Strategist's primary bundled style choice and `icons.inventory` indexes its curated synced pool; neither assigns icons to pages or limits other project-local assets. `simple-icons` brand marks appear there only when the content actually needs that real brand; they are not a separately confirmed library. - Base icons may use any SVG already prepared under `<project_path>/icons/`. `icons.library` records the Strategist's primary bundled style choice and `icons.inventory` indexes its curated synced pool; neither assigns icons to pages or limits other project-local assets. `simple-icons` entries are real brand marks; they are not a separately confirmed library.
- Illustrated icons are prepared transparent slice files under `images/` and follow [`executor-image.md`](./executor-image.md), even when they perform the same compact semantic job as an SVG icon. Never move them into `icons/`, add them to `icons.inventory`, or render them through `<use data-icon>`. Use or combine them with prepared SVG icons when the page benefits, keeping the result visually coherent and applying no coverage quota. - Illustrated icons are prepared transparent slice files under `images/` and follow [`executor-image.md`](./executor-image.md), even when they perform the same compact semantic job as an SVG icon. Never move them into `icons/`, add them to `icons.inventory`, or render them through `<use data-icon>`.
- Core color roles retain their meaning. Derive tints, shades, alpha, gradients, and effects; preserve natural asset colors; and use sparse page-local accents for differentiation/ornament. They must not become a competing or recurring palette. - Core color roles retain their meaning. Derive tints, shades, alpha, gradients, and effects; preserve natural asset colors; and use sparse page-local accents for differentiation/ornament. They must not become a competing or recurring palette.
- §V `Spacing anchors` (page margin, block gap, column gutter, corner radius, body leading) are deck-wide identity anchors: reuse them on every page and depart only for a page job, never to make content fit.
- Resolve structural families by role: exact `<role>_family` first, then `title_family` for title roles or `body_family` for other unoverridden roles, then legacy `font_family`. Never flatten declared role overrides. A sparse export-safe accent family may style short non-structural display/ornament only—never title/body/data/annotation. Recurrence requires upstream selection. - Resolve structural families by role: exact `<role>_family` first, then `title_family` for title roles or `body_family` for other unoverridden roles, then legacy `font_family`. Never flatten declared role overrides. A sparse export-safe accent family may style short non-structural display/ornament only—never title/body/data/annotation. Recurrence requires upstream selection.
- Font sizes use the named `typography` role values as deck-wide anchors. Map every structural text item to a declared role before drawing; never inherit a template placeholder size. Start from the anchor, then use composition and content fit to adjust that occurrence by at most `±2`px. Keep same-page peers consistent and preserve the role hierarchy; bounded adjustment does not create a new role. - Font sizes use the named `typography` role values as deck-wide anchors. Map every structural text item to a declared role before drawing; never inherit a template placeholder size. Start from the anchor, then use composition and content fit to adjust that occurrence by at most `±2`px. Keep same-page peers consistent and preserve the role hierarchy; bounded adjustment does not create a new role.
- **Core message ≥ `body`**: map the page's primary claim to declared `lead` / `subtitle`, never below the current body treatment. Footnotes, page numbers, and credits use declared `footnote` / `annotation`; do not invent a smaller role. - **Text roles**: declared `lead` / `subtitle` carry the page's primary claim; `footnote` / `annotation` carry footnotes, page numbers, and credits. Sizes come from declared roles.
- **Write unitless px, with at most two decimals.** Structural and mapped-role text uses only its anchor or a value within its `±2`px band; the sparse display-size exception is defined separately below. Do not substitute familiar pt-style numbers or emit long precision tails. - **Write unitless px, with at most two decimals.** Structural and mapped-role text uses only its anchor or a value within its `±2`px band; the sparse display-size exception is defined separately below. Do not substitute familiar pt-style numbers or emit long precision tails.
- **Sparse display-size exception**: a short non-structural Hero/Display element may use one undeclared size outside all anchor bands at most twice across the deck without a lock row. The third occurrence makes that size recurring: stop and return to Strategist to name the role in the Design Spec and `spec_lock.md`, then read back and validate the affected fragments before reuse. This exception never applies to titles, body copy, subtitles, annotations, footnotes, captions, data labels, or card copy, and nearby sizes must not be introduced to imitate one recurring treatment. - **Sparse display-size exception**: a short non-structural Hero/Display element may use one undeclared size outside all anchor bands at most twice across the deck without a lock row. The third occurrence makes that size recurring: stop and return to Strategist to name the role in the Design Spec and `spec_lock.md`, then read back and validate the affected fragments before reuse. This exception never applies to titles, body copy, subtitles, annotations, footnotes, captions, data labels, or card copy, and nearby sizes must not be introduced to imitate one recurring treatment.
- **Prepared decorative lettering**: When the approved plan selects stable artistic lettering as part of the visual, place its prepared AI/slice file as an image asset and keep the ordinary editable title/subtitle in separate native text frames. Do not recreate the asset with layered glyph copies or native WordArt; when the plan keeps that wording as native text and prepares no lettering asset, use ordinary `<text>` without inventing a missing image. - **Prepared decorative lettering**: When the approved plan selects stable artistic lettering as part of the visual, place its prepared AI/slice file as an image asset and keep the ordinary editable title/subtitle in separate native text frames. Do not recreate the asset with layered glyph copies or native WordArt; when the plan keeps that wording as native text and prepares no lettering asset, use ordinary `<text>` without inventing a missing image.
@@ -142,9 +142,9 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P<NN>`
| Tag | Layout discipline | | Tag | Layout discipline |
|-----|-------------------| |-----|-------------------|
| `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 adopt, adapt, or decline the recommended composition. Avoid an information-empty generic cover/sign-off unless content, user direction, or template requires it. | | `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 adopt, adapt, or decline the recommended composition. |
| `dense` | Information-heavy. Card grids, multi-column layouts, KPI dashboards, tables, and charts are all permitted. This is the baseline behavior. | | `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. | | `breathing` | Low-density impact page. Naked text blocks, dividers, whitespace, or full-bleed imagery can carry the content structure. 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. |
> Mechanical repetition comes from reusing the same carrier and topology without a page job—not from semantic cards themselves. Cards remain appropriate when they express real peer grouping, comparison, hierarchy, or capacity; vary rhythm when the content relationship changes. Context recovery follows §2.1. > Mechanical repetition comes from reusing the same carrier and topology without a page job—not from semantic cards themselves. Cards remain appropriate when they express real peer grouping, comparison, hierarchy, or capacity; vary rhythm when the content relationship changes. Context recovery follows §2.1.
@@ -157,23 +157,40 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P<NN>`
## 3. Execution Guidelines ## 3. Execution Guidelines
- **Element grouping (Mandatory)**: wrap each logical Slide-local body unit in a descriptive, page-unique top-level `<g id>`. Every visible direct root `<g>` except a compact helper-authored preset atom declares root-coordinate `data-pptx-bounds="x y width height"`; that text-free atom stays top-level when standalone, uses `data-pptx-frame`, and never carries bounds. Frame/native coordinates do not replace bounds on any other group, and placeholder bounds also supply the slot frame. Nested groups need no bounds and any such values are ignored. Checker compares root bounds with the `viewBox`, recursively checks estimable text against its root module with DrawingML wrapping headroom, and independently checks every estimable visible text carrier against the page without that headroom: through `1px` is ignored; module overflow warns through `5%` and fails above it, while larger page overflow always fails. Unestimable visible text receives an advisory warning. Only a wholly off-canvas direct-root Morph endpoint may set `data-pptx-morph-staging="true"`; keep its text inside its own module bounds, use an explicit pair when Morph remains enabled, and never use the marker for partial overflow. Images, shapes, paths, `<use>`, effects, and object frames remain geometrically free. Flat pages use ordinary groups; structured slots already qualify, while titles, direct Master/Layout atoms, and canvas-level static framing may remain root primitives. On flat pages, give a root background image or full-canvas scrim/decoration rectangle a stable `id` plus `data-pptx-role="background"` / `"decoration"`; never wrap it only to silence the advisory. - **Element grouping (Mandatory)**: wrap each logical Slide-local body unit in a descriptive, page-unique top-level `<g id>`. Every visible direct root `<g>` except a compact helper-authored preset atom declares root-coordinate `data-pptx-bounds="x y width height"`; that text-free atom stays top-level when standalone, uses `data-pptx-frame`, and never carries bounds. Frame/native coordinates do not replace bounds on any other group, and placeholder bounds also supply the slot frame. Nested groups need no bounds and any such values are ignored. Checker fails ordinary root-group overlap exceeding `1px` on both axes; structured slots, structural-role groups, and off-canvas Morph staging groups are exempt, but structured Slide-local groups are not. Checker compares root bounds with the `viewBox`, recursively checks estimable text—including both shared multiline forms—against its root module with DrawingML wrapping headroom, and independently checks every estimable visible text carrier against the page without that headroom: through `1px` is ignored; module overflow warns through `5%` and fails above it, while larger page overflow always fails. Unestimable visible text receives an advisory warning. Only a wholly off-canvas direct-root Morph endpoint may set `data-pptx-morph-staging="true"`; keep its text inside its own module bounds, use an explicit pair when Morph remains enabled, and never use the marker for partial overflow. Images, shapes, paths, `<use>`, effects, and object frames remain geometrically free. Flat pages use ordinary groups; structured slots already qualify, while titles, direct Master/Layout atoms, and canvas-level static framing may remain root primitives. On flat pages, give a root background image or full-canvas scrim/decoration rectangle a stable `id` plus `data-pptx-role="background"` / `"decoration"`; never wrap it only to silence the advisory.
- **Reference — not a constraint**: top-level groups set semantic and automatic-animation granularity, but they may contain descriptive nested `<g>` edit groups when the page has meaningful internal subunits. Nested groups need no bounds and create no automatic animation step; use or omit them from the page's actual editing semantics, with no default pattern, depth, or quota. - **Reference — not a constraint**: top-level groups set semantic and automatic-animation granularity, but they may contain descriptive nested `<g>` edit groups when the page has meaningful internal subunits. Nested groups need no bounds and create no automatic animation step; use or omit them from the page's actual editing semantics, with no default pattern, depth, or quota.
- **Default — size `data-pptx-bounds` as the intended module zone, not a glyph box (may skip when no text is estimable)**: make the zone as generous as the canvas and sibling layout allow, without overlapping another module zone. An untransformed line spans `y - 0.85 × font_size` to `y + 0.35 × font_size`; width uses the shared SVG-to-PPTX per-run estimate and safety headroom. If text does not fit, first expand a zone that has unused non-overlapping space; otherwise reflow or adapt. Larger bounds do not repair off-canvas text. - **Default — size `data-pptx-bounds` as the intended module zone, not a glyph box (may skip when no text is estimable)**: make the zone as generous as the canvas and sibling layout allow, without overlapping another module zone. An untransformed line spans `y - 0.85 × font_size` to `y + 0.35 × font_size`; width uses the shared SVG-to-PPTX per-run estimate and safety headroom. The same estimator is callable before writing: `python3 ${SKILL_DIR}/scripts/text_measure.py measure|wrap|box ...` measures lines, wraps one paragraph to a max width as ready `<tspan>` rows, and computes a text block's module zone; one calibration per role serves the deck, and later calls are for lines that approach a limit, batched through `--stdin`. If text does not fit, first expand a zone that has unused non-overlapping space; otherwise reflow or adapt. Larger bounds do not repair off-canvas text.
- **Spec adherence**: follow binding color, canvas, typography, identity, resource, and template anchors; apply layout and other Reference directions under §2.1 without turning them into locks - **Spec adherence**: follow binding color, canvas, typography, identity, resource, and template anchors; apply layout and other Reference directions under §2.1 without turning them into locks
- **Template structure**: inherit the native visual framework only for `template_reuse_scope: mirror|layout`; `style` uses the flat route - **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 - **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. - **Generation rhythm**: P01 → first-page gate → remaining pages with one page gate per first-exercised `not-exercised` item → final gate, in one context without batches or other mid-run checker calls.
- **Fact provenance**: when a §IX page lists `Fact IDs`, resolve each ID from `sources/*.facts.json` and keep the claim/value unchanged. Render a compact source footnote using the source name and a short URL/domain when space permits; when speaker notes are enabled, state the attribution naturally there too. When §IX says `Data class: scenario`, place a visible localized `Scenario data` / `情景数据` label adjacent to the affected KPI/chart and, when notes are enabled, state naturally there that the number is illustrative. Never attach an external fact ID to scenario data or let an unlabeled invented KPI look factual. - **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.
- **Mandatory — resolve the page carrier mix before coordinates**: decide background paint/field, editable text and optional lettering, native geometry/lines, prepared photos/scenes/illustration/icon assets, and applicable visualizations in one page-level composition decision. Use any suitable subset; omitting a carrier remains valid. Do not finish a text/container layout and then treat the other families as optional decoration. Use only prepared external resources, preserve every binding resource job and constraint, and choose the actual combination, visual weight, z-order, and local native construction from the page message and hierarchy. The resolved style controls treatment and emphasis, never carrier eligibility, image source, or the complete native construction vocabulary. - **Mandatory — resolve the page carrier mix before coordinates**: decide background paint/field, editable text and optional lettering, native geometry/lines, prepared photos/scenes/illustration/icon assets, and applicable visualizations in one page-level composition decision. Use only prepared external resources, preserve every binding resource job and constraint, and choose the actual combination, visual weight, z-order, and local native construction from the page message and hierarchy. The resolved style controls treatment and emphasis, never carrier eligibility, image source, or the complete native construction vocabulary. At this decision, recall the construction vocabulary already loaded: the resolved style's §1 `Composition geometry`, [`svg-effects.md`](./svg-effects.md) §6.1 Visual Job Router, and [`native-shape-authoring.md`](./native-shape-authoring.md) §2.1 composition lenses.
- **Default — stage each page with the style's composition geometry (may override when another page-fit move is stronger)**: an SVG page is a canvas, not a DOM. Resolve the page-scale move from `spec_lock.md`: a preset uses that selected style's §1 `Composition geometry`; `custom` executes `visual_style_behavior` first, then uses §1 geometry only from exact `visual_style_references` that the behavior assigns a shape or composition job. Other bases contribute only their assigned job, and an unreferenced novel custom follows its behavior alone. Treat every listed move as generative vocabulary rather than a finite menu, then apply [`native-shape-authoring.md`](./native-shape-authoring.md) §2.1's shared exact-fit geometry gate. - **Default — stage each page with the style's composition geometry (may override when another page-fit move is stronger)**: an SVG page is a canvas, not a DOM. Resolve the page-scale move from `spec_lock.md`: a preset uses that selected style's §1 `Composition geometry`; `custom` executes `visual_style_behavior` first, then uses §1 geometry only from exact `visual_style_references` that the behavior assigns a shape or composition job. Other bases contribute only their assigned job, and an unreferenced novel custom follows its behavior alone. Treat every listed move as generative vocabulary rather than a finite menu, then apply [`native-shape-authoring.md`](./native-shape-authoring.md) §2.1's shared exact-fit geometry gate.
- **Default — consider the planned motif direction (may override when another coherent expression better serves the deck)**: when §III `Theme` recommends a cross-page motif or element family, decide whether it earns a continuity job. If adopted, keep its reuse coherent while varying scale, crop, density, position, and content interaction by page role; otherwise adapt or decline it and establish a more fitting style-consistent expression. An explicit user/template motif remains binding. - **Default — consider the planned motif direction (may override when another coherent expression better serves the deck)**: when §III `Theme` recommends a cross-page motif or element family, decide whether it earns a continuity job. If adopted, keep its reuse coherent while varying scale, crop, density, position, and content interaction by page role; otherwise adapt or decline it and establish a more fitting style-consistent expression. An explicit user/template motif remains binding.
- **Ordinary carriers stay ordinary**: cards, icon-and-label rows, color swatches, soft shadows, and gradient fields are everyday slide carriers. Use them whenever content groups, compares, enumerates, or names a color, material, or sample, and give peers one shared treatment. When the subject itself is a color or material, draw it: a swatch is content, its value comes from the source, and it needs no lock row.
- **Reference — everyday device menu (not a constraint, not a quota)**: the pieces most slides are built from; reach for them by page job and let the locked style set their treatment.
| Device | Typical job | Realization |
|---|---|---|
| Gradient block or band | Cover / chapter field, title backing, zone separation | `<linearGradient>` / `<radialGradient>` in 23 stops of the deck hue |
| Rounded card | One content module among peers, a feature or option block | `<rect rx>` in `secondary_bg`; a shadow only when it floats over a photo or colored panel |
| Icon with label | Feature markers, list prefixes, step or category cues | `<use data-icon>` at 3248 px in an accent or primary role |
| Numbered circle or badge | Ordered steps, ranked items, chapter marks | `<circle>` + centered number; oversized numeral for a chapter |
| Color swatch | A color, material, or sample that the content names | `<circle>` / `<rect>` filled with the subject's own value, labeled with its name and HEX |
| KPI card | Metric name + hero number + trend or comparison | Card + number at the hero size + small annotation; icon optional |
| Takeaway box | One-sentence conclusion under a title | Tinted band (`fill-opacity` 0.060.10) with the sentence in lead size |
| Divider or rule | Separate sections, columns, header from body | Hairline `<line>` in `divider`, or a 2 px accent bar |
| Full-bleed image + scrim | Cover, chapter divider, mood page | Image `slice` + directional gradient scrim + floating title |
| Framed or shaped picture | Portrait, product, place, evidence photo | Circle / rounded clip on `<image>`, hairline frame, caption |
| Quote block | Pull quote, testimonial, source sentence | Oversized quotation mark or accent rule + text at lead size + attribution |
| Timeline or step strip | Ordered events or stages | Baseline `<line>` with ticks/nodes, or chevron presets, labels above/below |
- **Inherited containers**: preserve meaningful template frames; restyle radius, fill, stroke, and depth from the active Design Spec and `spec_lock.md`. Selected Chart/Table reference adaptation is owned by [`executor-visualization.md`](./executor-visualization.md); preview effects never override project styling or structural roles. - **Inherited containers**: preserve meaningful template frames; restyle radius, fill, stroke, and depth from the active Design Spec and `spec_lock.md`. Selected Chart/Table reference adaptation is owned by [`executor-visualization.md`](./executor-visualization.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 compose faithful primitives and exact presets as one page geometry system; use a Boolean only when the contour itself must merge, open, or fragment. Only when neither construction works should one page-specific polygon/path replace a stack of generic arrows. - **Reference — prefer semantic geometry over preset stacks**: for relationships such as ascending, converging, breaking through, or stacking, first compose faithful primitives and exact presets as one page geometry system; use a Boolean only when the contour itself must merge, open, or fragment. Only when neither construction works should one page-specific polygon/path replace a stack of generic arrows.
- **Reference — create depth with restraint**: use rhythm, spacing, typography, accent bars, and subtle tints before shadows. Reserve lift for a few genuinely floating elements; keep peer grids, dividers, and ordinary body containers flat. When material layering itself is part of the resolved visual style, follow that style's hierarchy instead of flattening its body planes.
- **Phased generation** (recommended): - **Phased generation** (recommended):
1. **Visual Construction Phase**: generate all SVG pages sequentially for visual consistency. Apply every triggered information-model branch while drawing. **MUST embed one object-scoped plot-area marker** per §IX-named or Quick-promoted value-driven chart object under [`executor-chart.md`](./executor-chart.md) §2; coordinate calibration is a post-generation step (see [`verify-charts`](../workflows/stages/verify-charts.md)). Write every `<object-key>=yes` native marker plus JSON metadata atomically under [`native-data-interface.md`](./native-data-interface.md) §2. **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; when justified, one registered [`svg-effects.md`](./svg-effects.md) §6.4 shadow/glow stays on the helper-authored shape). **First-page gate (Mandatory)**: after completing the first page, run `python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> --stage first-page --json` directly 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. 1. **Visual Construction Phase**: generate all SVG pages sequentially for visual consistency. Apply every triggered information-model branch while drawing. **MUST embed one object-scoped plot-area marker** per §IX-named or Quick-promoted value-driven chart object under [`executor-chart.md`](./executor-chart.md) §2; coordinate calibration is a post-generation step (see [`verify-charts`](../workflows/stages/verify-charts.md)). Write every `<object-key>=yes` native marker plus JSON metadata atomically under [`native-data-interface.md`](./native-data-interface.md) §2, then record its fallback baseline before that page's gate with `python3 ${SKILL_DIR}/scripts/stamp_native_fallbacks.py <project_path>/svg_output/<page>.svg --write`; rerun it after any later visible edit inside the marker group, because a missing or stale baseline blocks the canonical gate and native Chart/Table export. **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; several presets selected for one page are generated in one `preset_shape_svg.py render-batch --input -` round (a gradient fill/stroke or a pattern fill is the one paint exception — keep those ordinary SVG; when justified, one registered [`svg-effects.md`](./svg-effects.md) §6.4 shadow/glow stays on the helper-authored shape). **First-page gate (Mandatory)**: after completing the first page, run `python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> --canonical-authoring --stage first-page --json` directly 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; the only further mid-run checker call is the first-exercise page gate (`--stage page --page <svg>`) on the first page that exercises a `not-exercised` item.
2. **Quality Check Gate**: only after every planned SVG exists, run `python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> --stage final --json` directly 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. 2. **Quality Check Gate**: only after every planned SVG exists, run `python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> --canonical-authoring --stage final --json` directly 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 (conditional)**: after SVGs pass the quality check, batch-generate speaker notes for narrative continuity only when the effective Speaker Notes outcome is enabled. 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.
**Mandatory — final carrier-receipt review**: The final checker prints one **Mandatory — final carrier-receipt review**: The final checker prints one
@@ -238,8 +255,9 @@ This decision applies only while drawing a new object. A suggestion never
triggers retrospective scanning, contour classification, or automatic triggers retrospective scanning, contour classification, or automatic
upgrading of ordinary SVG during export. upgrading of ordinary SVG during export.
**Hard rule**: do not hand-write `data-pptx-authoring`, `data-pptx-prst`, **Hard rule — helper-written metadata**: `data-pptx-authoring`, `data-pptx-prst`,
`data-pptx-frame`, adjustment metadata, or registry paths. The preset helper `data-pptx-frame`, adjustment metadata, and registry paths are written only by
the preset helper; hand-written values fail the checker. The preset helper
generates one compact atomic `<g>` from the shared 187-shape registry, with generates one compact atomic `<g>` from the shared 187-shape registry, with
semantic metadata and base paint written once. Rerun that helper when geometry semantic metadata and base paint written once. Rerun that helper when geometry
or paint changes; never edit one of its direct paths. or paint changes; never edit one of its direct paths.
@@ -260,7 +278,7 @@ not actions or hyperlinks.
**Hard rule — narrow helper scope**: Both helpers print only their documented **Hard rule — narrow helper scope**: Both helpers print only their documented
stdout fragment(s); neither writes a page or chooses layout. Read every returned stdout fragment(s); neither writes a page or chooses layout. Read every returned
fragment and insert it through the normal `apply_patch` page edit; never fragment and insert it through the normal page edit; never
redirect, loop, or batch helper output into `svg_output/`. redirect, loop, or batch helper output into `svg_output/`.
@@ -274,7 +292,7 @@ Format: `<index>_<page_name>.svg`. Use one roster-wide zero-padded index width s
Strategist chooses at most one primary bundled stylistic library and may select `simple-icons` alone or alongside it; Executor implements from the complete prepared project-local pool. Library details and selection rules: [`../templates/icons/README.md`](../templates/icons/README.md). This section defines placeholder syntax. Strategist chooses at most one primary bundled stylistic library and may select `simple-icons` alone or alongside it; Executor implements from the complete prepared project-local pool. Library details and selection rules: [`../templates/icons/README.md`](../templates/icons/README.md). This section defines placeholder syntax.
> **Prepared-project boundary.** Any SVG already under `<project_path>/icons/<lib>/` is valid execution material, whether selected from a bundled library or supplied by the user, a template, or an import workflow. New authoring must resolve there. The global fallback in `finalize_svg.py embed-icons` is legacy compatibility, not permission for Executor to discover or use an unprepared global icon. > **Prepared-project boundary.** Any SVG under `<project_path>/icons/<lib>/` is valid prepared material. Authoring, preview, finalization, and export resolve only complete case-sensitive `library/name` references there; no global or template-source fallback exists.
> **Icon identifiers are case-sensitive filenames.** Every `data-icon` value must use the exact project-local relative basename (`tabler-outline/award`, never `tabler-outline/Award`). Strategist records its curated bundled pool in `spec_lock.md`; Executor need not add other already-prepared project-local icons to that inventory. Custom identifiers preserve the custom file's exact case; the pipeline never silently lowercases names. > **Icon identifiers are case-sensitive filenames.** Every `data-icon` value must use the exact project-local relative basename (`tabler-outline/award`, never `tabler-outline/Award`). Strategist records its curated bundled pool in `spec_lock.md`; Executor need not add other already-prepared project-local icons to that inventory. Custom identifiers preserve the custom file's exact case; the pipeline never silently lowercases names.
@@ -307,15 +325,13 @@ Strategist chooses at most one primary bundled stylistic library and may select
> >
> **Missing `icons.stroke_width` in an existing stroke-library lock — fixed compatibility default**: use `2`, emit one warning, and continue. New authoring must still declare the field. > **Missing `icons.stroke_width` in an existing stroke-library lock — fixed compatibility default**: use `2`, emit one warning, and continue. New authoring must still declare the field.
> >
> Icons are auto-embedded by `finalize_svg.py` — no need to run `embed_icons.py` manually. > Icons are auto-embedded by `finalize_svg.py` — no need to run `svg_finalize/embed_icons.py` manually.
**Project-local verification**: verify the exact prepared file before use: **Project-local verification**: verify the exact prepared file before use:
```bash ```bash
test -f "<project_path>/icons/<lib>/<name>.svg" test -f "<project_path>/icons/<lib>/<name>.svg"
``` ```
**Default — purposeful icon use**: choose prepared icons per page when they compress a label, distinguish parallel categories, clarify a process / KPI / state, or improve navigation and visual rhythm. Omit them when imagery, charts, or typography already carry the meaning. There is no coverage quota, and the prepared pool need not be exhausted.
**Missing project-local icon** → return to Strategist's preparation / `icon_sync.py` gate. Do not search the global library, select an alternative, or copy a candidate in Executor. **Missing project-local icon** → return to Strategist's preparation / `icon_sync.py` gate. Do not search the global library, select an alternative, or copy a candidate in Executor.
**Hard rule — prepared assets**: Executor may freely combine project-local icons, regardless of namespace or style. It may not acquire a new icon or treat a globally resolvable file as prepared material. **Hard rule — prepared assets**: Executor may freely combine project-local icons, regardless of namespace or style. It may not acquire a new icon or treat a globally resolvable file as prepared material.
@@ -330,7 +346,7 @@ Read typography from `spec_lock.md`: `<role>_family` → `title_family` / `body_
**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`. **Missing required field — `typography.font_family`** → stop and return to Generate Step 4 / [`strategist.md`](strategist.md) §6.2 to repair `spec_lock.md`; do not infer a stack from `design_spec.md`.
**Hard rule**: every SVG `font-family` stack MUST resolve to target-installed/approved Latin and EA faces. PPTX writes one face per script; CSS tails affect preview only, and fonts are not embedded. Missing-face substitution is viewer-selected—not guaranteed Calibri or a later stack entry. **Hard rule**: every SVG `font-family` stack MUST resolve to target-installed/approved Latin and EA faces. PPTX writes one face per script from the stack: the first named Latin face fills `latin`, the first named CJK face fills `ea` and also `latin` when no named Latin face exists, and a generic family (`sans-serif`, `serif`) fills `latin` only when it precedes every named face. Fonts are not embedded. Missing-face substitution is viewer-selected—not guaranteed Calibri or a later stack entry.
--- ---
@@ -42,7 +42,7 @@ A dual-axis chart is valid only when both series share the exact time/category
domain and the units and visual identities stay unambiguous; otherwise separate domain and the units and visual identities stay unambiguous; otherwise separate
the views. the views.
**Per-object completeness**: preserve every authoritative series, category, point, label, unit, qualifier, source, and scale cue needed to read the chart. When the source cannot determine a required scale or derived value, return the ambiguity upstream in Default or resolve it from explicit source facts in Quick; never fabricate it at draw time. **Per-object completeness**: preserve every authoritative series, category, point, label, unit, qualifier, source, and scale cue needed to read the chart. For a `<object-key>=yes` chart, the JSON mirrors the drawn fallback item by item in the same edit: legend labels verbatim, point-level exception colors as `point_colors`, the axis scale, the classic `plot_area` taken from the plot-area marker, visible data labels or summary figures as `data_labels` or companion text, and the actual label / axis / grid colors — omit `text_color` and its siblings rather than guess them. When the source cannot determine a required scale or derived value, return the ambiguity upstream in Default or resolve it from explicit source facts in Quick; never fabricate it at draw time.
**Hard rule — schedule geometry**: A schedule is a Gantt chart when dates or **Hard rule — schedule geometry**: A schedule is a Gantt chart when dates or
durations determine each task bar's `x` and `width`, even if the source was a durations determine each task bar's `x` and `width`, even if the source was a
@@ -24,7 +24,7 @@ Handle images by status; enum and lifecycle: [`svg-image-embedding.md`](svg-imag
**Reference syntax**: see [`svg-image-embedding.md`](svg-image-embedding.md). **Reference syntax**: see [`svg-image-embedding.md`](svg-image-embedding.md).
**Template-bundled images**: [`apply-template-workspace.md`](../workflows/stages/apply-template-workspace.md) copies them into project `images/`. Outside `mirror`, reference `../images/<name>` and never copy a template SVG's bare sibling href: the rendered page lives in `svg_output/`. `mirror` ([`executor-structured.md`](./executor-structured.md) §1.1) keeps hrefs verbatim; export resolves them against `images/`. **Template-bundled images**: [`apply-template-workspace.md`](../workflows/stages/apply-template-workspace.md) copies them into project `images/`. Every page, including `mirror`, must rebase the same bytes to exact `../images/<name>`; this transport rewrite is not a visual edit. Never retain a bare or source-template href: preview, validation, and export resolve the written path exactly.
**Default — active image integration (may override when plain placement is **Default — active image integration (may override when plain placement is
stronger)**: Treat loaded [`image-layout-patterns.md`](./image-layout-patterns.md) stronger)**: Treat loaded [`image-layout-patterns.md`](./image-layout-patterns.md)
@@ -77,7 +77,7 @@ to variety.
**Placeholder**: Dashed border `<rect stroke-dasharray="8,4" .../>` + description text **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 adopted, adapted, or declined without rewriting the lock. **Crop policy**: read the §VIII row and its lock projection (`source`, `crop`). 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` / `crop` projection returns upstream instead of being inferred during execution; the §VIII `Layout pattern` remains a preferred expression that may be adopted, adapted, or declined.
**Hard rule — same-source addressable crops, only when adopted**: A layout **Hard rule — same-source addressable crops, only when adopted**: A layout
suggestion, including pattern `#M1-11`, never activates this transport. Pattern suggestion, including pattern `#M1-11`, never activates this transport. Pattern
@@ -56,4 +56,4 @@ Form one coherent argument in intended reading/reveal order: proposition → evi
Put transitions naturally in the opening sentence when useful; never label them. Keep one language. Spell out digits or symbols when literal TTS would sound wrong (for example, Chinese "百分之六十八" rather than "68%"). Put transitions naturally in the opening sentence when useful; never label them. Keep one language. Spell out digits or symbols when literal TTS would sound wrong (for example, Chinese "百分之六十八" rather than "68%").
After `notes/total.md` is complete, return to Generate Step 7.1; the route authority owns splitting and its success criterion. After `notes/total.md` is complete, return to Generate Step 7.1 (Default) or `quick-generate.md` §4 (Quick); each route owns splitting and its success criterion.
@@ -103,12 +103,11 @@ Choose the field and map required atoms, then follow:
| `labels` | Copy and caveats visibly attach directly or by leader/tether to what they explain; when a node has multiple text roles, cue → claim/value → support → note remains perceptibly descending and absent roles stay absent | | `labels` | Copy and caveats visibly attach directly or by leader/tether to what they explain; when a node has multiple text roles, cue → claim/value → support → note remains perceptibly descending and absent roles stay absent |
| `garnish` | Removing accents leaves all meaning intact | | `garnish` | Removing accents leaves all meaning intact |
**Hard rule — relationship before styling**: establish atoms, field, spine, nodes, and necessary edges before palette, type, effects, or containers. Prefer containment, alignment, baselines, and proximity; add lines/Connectors only for real edges, never to make a page look process-like. **Hard rule — relationship before styling**: establish atoms, field, spine, nodes, and necessary edges before palette, type, effects, or containers. Containment, alignment, baselines, and proximity express relationships without edges; lines/Connectors express real edges.
**Default — visible structural composition (may override for naked-text **Structural carriers**: a relationship-bearing field, spine, node carrier, or
rhythm/style)**: Make one relationship-bearing field, spine, node carrier, or directional shape can be the page-scale move; Structure `yes` by itself adds no
directional shape the page-scale move; never add geometry merely because geometry. When drawn roles interact, resolve relationship-bearing
Structure is `yes`. When drawn roles interact, resolve relationship-bearing
parent contour/direction → contact → joint or intentional void → parent contour/direction → contact → joint or intentional void →
z-order/occlusion → canvas-edge behavior before labels/garnish. Skip z-order/occlusion → canvas-edge behavior before labels/garnish. Skip
inapplicable operations; implicit/direct roles remain container-free. inapplicable operations; implicit/direct roles remain container-free.
@@ -18,7 +18,7 @@ Conditional Executor authority for `template_reuse_scope: mirror|layout` with `p
| Current page mapping | Read the retained `spec_lock.md page_layouts` row; a page change does not require another file load | | Current page mapping | Read the retained `spec_lock.md page_layouts` row; a page change does not require another file load |
| Selected prototype SVG | Read the complete `templates/<basename>.svg` once per valid context and reuse it until a known change or context invalidation | | Selected prototype SVG | Read the complete `templates/<basename>.svg` once per valid context and reuse it until a known change or context invalidation |
**Hard rule**: The complete prototype SVG is authoritative. An on-demand page-context result may fingerprint it but carries no prototype payload; never author from a roster, manifest, sidecar, filename, or summary alone. **Hard rule**: The complete Slide prototype SVG is authoritative and already resolves its Master + Layout context. Standalone Master/Layout definition SVGs are invalid. An on-demand page-context result may fingerprint the selected Slide prototype but carries no payload; never author from a roster, manifest, sidecar, filename, or summary alone.
Manifest/text-slot files are derived tool metadata, not model inputs. Missing metadata neither invalidates a legacy workspace nor permits text-topology changes. Manifest/text-slot files are derived tool metadata, not model inputs. Missing metadata neither invalidates a legacy workspace nor permits text-topology changes.
@@ -53,16 +53,16 @@ Resolve the per-page template SVG directly from the owning `spec_lock.md page_la
When `spec_lock.md` records the AI-derived `template_reuse_scope: mirror`, Executor switches to a literal replacement path. The workspace capability `replication_mode: mirror` is a prerequisite, not the trigger by itself: When `spec_lock.md` records the AI-derived `template_reuse_scope: mirror`, Executor switches to a literal replacement path. The workspace capability `replication_mode: mirror` is a prerequisite, not the trigger by itself:
1. **Per-page reference selection** — Strategist selects one mirror page per project page via `spec_lock.md page_layouts` (e.g., `P04: 015_content`). The basename is the mirror filename without extension; Strategist made this choice by reading `design_spec.md §V Page Roster` descriptions, not by guessing. 1. **Per-page reference selection** — Strategist selects one mirror page per project page via `spec_lock.md page_layouts` (e.g., `P04: 015_content`). The basename is the mirror filename without extension; Strategist made this choice by reading `design_spec.md §V Page Roster` descriptions, not by guessing.
2. **Copy, don't fill** — use the retained full mirror SVG as the starting point, then edit slide-specific text in place. Preserve every non-text element and every `data-pptx-*` structure attribute verbatim. Do not reopen the same path + SHA merely because another page selects it. 2. **Copy, don't fill** — use the retained full mirror SVG as the starting point, then edit slide-specific text in place. Preserve every ordinary non-text element and every `data-pptx-*` structure attribute verbatim. The sole exception is a direct JSON-first Chart/Table: keep its marker id/kind/authority and metadata unchanged, while derived preview children may be regenerated from that JSON. Do not reopen the same path + SHA merely because another page selects it.
3. **What you may edit** — decide the semantic slot mapping and replacement text only. Change only visible string values already carried by `<text>` and `<tspan>` nodes that express slide-specific content (title, body, captions, KPI labels, dates, page numbers). Keep the number, order, nesting relationship, and **all attributes** of every `<text>` / `<tspan>` node unchanged. Never merge or split nodes, move a string between nodes, add a new tspan, or delete an empty carrier. `svg_quality_checker.py` and export validate attributes, topology, and prototype hashes against the complete prototype internally. 3. **What you may edit** — decide the semantic slot mapping and replacement text only. Change only visible string values already carried by `<text>` and `<tspan>` nodes that express slide-specific content (title, body, captions, KPI labels, dates, page numbers). Keep the number, order, nesting relationship, and **all attributes** of every `<text>` / `<tspan>` node unchanged. Never merge or split nodes, move a string between nodes, add a new tspan, or delete an empty carrier. `svg_quality_checker.py` and export validate attributes, topology, and prototype hashes against the complete prototype internally.
4. **What you must not touch** — element positions, sizes, fonts, colors, fills, strokes, gradients, **which image each `<image>` points at**, `<g>` grouping, sprite-sheet `<svg viewBox>` wrappers, decorative `<rect>` / `<path>` / `<circle>` / `<polygon>` shapes, `<use data-icon="...">` markers, embedded chart data structures. Mirror's value is preserving the source deck's visual identity — any geometric / decorative drift defeats the purpose. **The `href` path is not the image**: normalizing a bare `href="cover_bg.png"` to `href="../images/<name>"` (when Step 3 relocated the asset to `images/`) points at the *same* image and changes nothing visual — that is an allowed path fix, not a fidelity edit. Leaving the bare href as-is is also fine; the exporter and live preview resolve bare hrefs against `images/` either way. 4. **What you must not touch** — element positions, sizes, fonts, colors, fills, strokes, gradients, **which image each ordinary `<image>` points at**, `<g>` grouping, sprite-sheet `<svg viewBox>` wrappers, decoration, `<use data-icon="...">` markers, or authoritative embedded Chart/Table JSON. JSON-first preview images/shapes are derived and excluded from this literal identity rule. **The `href` path is not the image**: normalize a bare `href="cover_bg.png"` to the exact `href="../images/<name>"` when Step 3 relocates those same bytes to `images/`; this required transport rewrite changes nothing visual. Do not leave the bare href or point back into the source template.
5. **Content fit** — if the replacement needs a different number of text segments/items, do not merge/split nodes, drop sourced content, or restructure the grid. Report `warning: P<NN> content does not fit mirror reference <basename>; choose another prototype or change template_reuse_scope to layout/style`, then return to Strategist to select the prototype or scope and update the planning mappings. 5. **Content fit** — if the replacement needs a different number of text segments/items, do not merge/split nodes, drop sourced content, or restructure the grid. Report `warning: P<NN> content does not fit mirror reference <basename>; choose another prototype or change template_reuse_scope to layout/style`, then return to Strategist to select the prototype or scope and update the planning mappings.
6. **Visible text editing** — mirror SVGs may keep literal source text rather than `{{...}}` authoring markers. Edit values in place while retaining imported semantic `data-pptx-placeholder` identity and exact text topology. 6. **Visible text editing** — mirror SVGs may keep literal source text rather than `{{...}}` authoring markers. Edit values in place while retaining imported semantic `data-pptx-placeholder` identity and exact text topology.
7. **Output filename** — follow the standard project SVG naming convention (`<index>_<page_name>.svg` where `<index>` matches the project page index, not the mirror source index). The mirror filename is the *reference*, not the *output*. 7. **Output filename** — follow the standard project SVG naming convention (`<index>_<page_name>.svg` where `<index>` matches the project page index, not the mirror source index). The mirror filename is the *reference*, not the *output*.
**Detecting mirror mode**: read `template_reuse_scope` from the retained lock. `replication_mode: mirror` in the installed template only determines whether that scope is legal; it must never force mirror behavior when the lock records `layout` or `style`. **Detecting mirror mode**: read `template_reuse_scope` from the retained lock. `replication_mode: mirror` in the installed template only determines whether that scope is legal; it must never force mirror behavior when the lock records `layout` or `style`.
**Mirror + visualization pages**: Chart, Table, and qualitative topology inside a mirror SVG are already drawn. Replace only permitted text while preserving prototype geometry; do not redraw from a catalog SVG or runtime grammar. A mirror template normally omits `page_visualizations`, and legacy `page_charts` never overrides fidelity. **Mirror + visualization pages**: Chart, Table, and qualitative topology inside a mirror SVG are already authored. Replace only permitted text and do not redraw from a catalog/runtime grammar. A JSON-first Chart/Table may refresh its approximate preview from unchanged authoritative JSON; this never permits metadata, bounds, marker, slot, or ordinary-visual drift. A mirror template normally omits `page_visualizations`, and legacy `page_charts` never overrides fidelity.
**Legacy template boundary**: A template with missing root Master identity, direct atomic placeholders, `data-pptx-layout-kind`, unmapped `baseline`, `preserve`, or `layout_strategy: distill` is not a fallback input. Stop and create a new current workspace through [`create-template`](../workflows/create-template.md) before generation. **Legacy template boundary**: A template with missing root Master identity, direct atomic placeholders, `data-pptx-layout-kind`, unmapped `baseline`, `preserve`, or `layout_strategy: distill` is not a fallback input. Stop and create a new current workspace through [`create-template`](../workflows/create-template.md) before generation.
@@ -72,7 +72,7 @@ Before generating each page, output which template is used:
``` ```
📝 **Template mapping**: `templates/03a_content_image_text.svg` (free-design routes may use "None") 📝 **Template mapping**: `templates/03a_content_image_text.svg` (free-design routes may use "None")
🎯 **Adherence rules / layout strategy**: [specific description] 🎯 **Adherence rules / application plan**: [specific description]
``` ```
- **Content pages**: template defines only header/footer; content area is free - **Content pages**: template defines only header/footer; content area is free
@@ -45,6 +45,7 @@ shared row/column information-model boundary.
3. Place every cell value, unit, qualifier, status, and source-bearing note in its correct intersection. 3. Place every cell value, unit, qualifier, status, and source-bearing note in its correct intersection.
4. Apply alignment consistently by content role, including comparable numeric alignment and stable header/body hierarchy. 4. Apply alignment consistently by content role, including comparable numeric alignment and stable header/body hierarchy.
5. Add rules, fills, banding, highlights, and in-cell indicators only after the grid reads correctly in plain form. 5. Add rules, fills, banding, highlights, and in-cell indicators only after the grid reads correctly in plain form.
6. For a `<object-key>=yes` table, project the finished grid into the JSON in the same edit: `row_heights`, header fill / text / bold / alignment, whole-row or whole-column fills, first-column emphasis, padding, and per-side `borders` mirroring the drawn rules; a font size plus a uniform border is not a projection. A graphical cell (inset badge, colored chip, mini bar) cannot be expressed by `a:tbl`: return that object to `Native-ready=no`.
**Per-cell completeness**: never drop a row, column, summary, footnote, unit, or qualifier to imitate a lighter catalog preview. Reflow text, widen the affected column, rebalance adjacent columns, or increase row height while preserving the active page's information contract and [`executor-base.md`](./executor-base.md) typography bounds. **Per-cell completeness**: never drop a row, column, summary, footnote, unit, or qualifier to imitate a lighter catalog preview. Reflow text, widen the affected column, rebalance adjacent columns, or increase row height while preserving the active page's information contract and [`executor-base.md`](./executor-base.md) typography bounds.
@@ -12,7 +12,7 @@ Active when at least one resource row has `Acquire Via: ai` / `web` / `slice`, o
| Mode | Trigger | | Mode | Trigger |
|---|---| |---|---|
| Default Generate | `generate-ppt` workflow, `design_spec.md §VIII` image rows present | | Default Generate | `generate-pptx` workflow, `design_spec.md §VIII` image rows present |
| Quick Generate | [`quick-generate`](../workflows/profiles/quick-generate.md) is active and the current main agent has resolved one or more required images in active context | | Quick Generate | [`quick-generate`](../workflows/profiles/quick-generate.md) is active and the current main agent has resolved one or more required images in active context |
| Standalone | Direct request against an existing project | | Standalone | Direct request against an existing project |
@@ -472,7 +472,7 @@ Defaulting an entire `ai` resource list to `none` because "SVG can always overla
**Forbidden — text that may be reworded**: any word that may later change belongs in Layer 2, not Layer 1. Layer 1 is for stable visual identifiers and designed lettering that is part of the image itself. **Forbidden — text that may be reworded**: any word that may later change belongs in Layer 2, not Layer 1. Layer 1 is for stable visual identifiers and designed lettering that is part of the image itself.
**Default — controlled, deck-aligned artistic authorship (may override when the user explicitly requests high expression or confirms a strongly expressive direction)**: For decorative lettering, give the model the exact intended string, communication role, placement/background relationship, deck identity, relative visual weight, and desired energy. The resolved rendering, semantic colors, mood, and page hierarchy define the envelope. Without the stated override, keep expression controlled and glyph-native: carry identity through the glyph silhouette, stroke construction, internal material/texture, contour-bound depth/light, and letterform composition; do not translate the topic into literal illustrations or detached decoration around the word. A lettering-plus-illustration lockup is a separate treatment and requires an explicit user request or confirmed design direction. Within the chosen treatment, let the model decide and combine—or omit—the calligraphic gesture, material, dimensionality, texture, lighting, internal hierarchy, and composition; such terms are possibility space, not an effect recipe. Do not flatten the art merely to simplify extraction: §4.3's separable-treatment gate, key field, clear padding, and cell isolation protect delivery without raising the chosen intensity. When fit is uncertain, use the lower effect density; never infer high expression or external motifs from the topic, place, or wording alone. Keep a multi-line lockup as one element when its hierarchy is part of the art. **Reference — controlled, deck-aligned artistic authorship** (the user may request high expression or confirm a strongly expressive direction): For decorative lettering, give the model the exact intended string, communication role, placement/background relationship, deck identity, relative visual weight, and desired energy. The resolved rendering, semantic colors, mood, and page hierarchy define the envelope. Controlled, glyph-native expression carries identity through the glyph silhouette, stroke construction, internal material/texture, contour-bound depth/light, and letterform composition; literal topic illustrations or detached decoration around the word compete with the glyph. A lettering-plus-illustration lockup is a separate treatment and requires an explicit user request or confirmed design direction. Within the chosen treatment, let the model decide and combine—or omit—the calligraphic gesture, material, dimensionality, texture, lighting, internal hierarchy, and composition; such terms are possibility space, not an effect recipe. Do not flatten the art merely to simplify extraction: §4.3's separable-treatment gate, key field, clear padding, and cell isolation protect delivery without raising the chosen intensity. When fit is uncertain, use the lower effect density; never infer high expression or external motifs from the topic, place, or wording alone. Keep a multi-line lockup as one element when its hierarchy is part of the art.
**Font choice for in-image text — free description, with the deck typography as one optional reference** **Font choice for in-image text — free description, with the deck typography as one optional reference**
@@ -62,6 +62,14 @@ Default chain (when `--provider` is unset):
Keyed providers without an API key are silently skipped — not an error. Keyed providers without an API key are silently skipped — not an error.
**Reference — provider fit by subject**: Pixabay's API serves at most a
1280 px long-edge file even when its metadata reports a much larger original,
so a landscape hero row with the default 1200×800 floors can fail promotion on
height; prefer Wikimedia or Pexels originals for full-bleed use. For murals,
manuscripts, artworks, and museum objects, pin `provider: wikimedia` on that
row: stock providers tag tourist snapshots with the place name, and those pass
`required_terms` while showing no artwork at all.
**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. **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.
--- ---
@@ -1,4 +1,4 @@
> See [`executor-base.md`](./executor-base.md) for the Shape-first page authority and [`shared-standards-core.md`](./shared-standards-core.md) for the mandatory SVG foundation. > See [`executor-base.md`](./executor-base.md) for page authoring and [`shared-standards-core.md`](./shared-standards-core.md) for the mandatory SVG foundation and object-local authority boundary.
# Native Data Interface # Native Data Interface
@@ -40,7 +40,29 @@ this enum.
## 2. PowerPoint-Native Chart / Table Replacement Markers (Opt-in) ## 2. PowerPoint-Native Chart / Table Replacement Markers (Opt-in)
[`executor-base.md`](./executor-base.md) remains the single Shape-first authoring authority: the complete visible SVG fallback is required regardless of native eligibility. This section only adds dormant replacement metadata to independently selected objects and defines how export may activate it. The complete visible SVG fallback remains required for browser preview and
default export. Chart/Table authority is nevertheless object-local rather than
globally Shape-first:
- **SVG-first (default)** — free-design, Brand-only, and Style-only authoring
omits `data-pptx-native-authority`. The visible marker subtree is the design
authority; JSON is its derived native projection. Canonical authoring records
`data-pptx-fallback-sha256` only after the fallback and JSON are synchronized.
A later visible edit requires regenerating the JSON and deliberately stamping
a new baseline together. Missing or stale baselines leave default fallback
export available but make `--native-charts-and-tables` fail closed.
- **JSON-first (template/native source)** — a validated PPTX import or a
template-owned Chart/Table writes
`data-pptx-native-authority="json"`. Its inline JSON is the semantic and native
authority; the visible SVG subtree is a derived, compact, and potentially
approximate preview. JSON edits regenerate that preview before template
publication, but preview differences never override the JSON. This marker is
legal only on active `chart` / `table` replacement groups, never formulas or
fallback-only status markers.
Both forms keep the JSON inside the SVG. `native_payloads.json.gz` is reserved
for supported opaque shape restoration payloads and never replaces semantic
Chart/Table JSON.
**Hard rule — selected-object authoring**: write the marker and JSON metadata in **Hard rule — selected-object authoring**: write the marker and JSON metadata in
the same edit for every supported chart and pure text-grid table; both are the same edit for every supported chart and pure text-grid table; both are
@@ -65,7 +87,23 @@ upstream repair.
`data-pptx-replace-with` marker, and single JSON `<metadata>` child as one `data-pptx-replace-with` marker, and single JSON `<metadata>` child as one
authoring unit. Write all three while that object's data is in context. Do not authoring unit. Write all three while that object's data is in context. Do not
defer the marker or metadata to `verify-charts`, the final quality gate, or defer the marker or metadata to `verify-charts`, the final quality gate, or
export. export. SVG-first authoring then stamps the completed visible subtree; JSON-first
template authoring instead writes the authority marker and derives its preview
from the inline payload.
After one SVG-first object's fallback and JSON have been reviewed as the same
authoring transaction, stamp the completed marker explicitly:
```bash
python3 skills/ppt-master/scripts/stamp_native_fallbacks.py \
"<svg-file-or-directory>" --write
```
Without `--write` the command is read-only. It validates every Chart/Table
payload before changing anything, skips JSON-first markers, and writes only the
current visible-subtree fingerprint. The hash is a synchronization receipt; it
detects later SVG edits but does not itself prove semantic equivalence, so never
stamp independently authored stale JSON merely to satisfy validation.
**Hard rule — activation is the opt-in, dormant unless exported with `--native-charts-and-tables`**: A marker only declares that a group is eligible for PowerPoint-native Chart/Table replacement. Normal `svg_to_pptx.py` runs keep the fallback SVG children and convert them into independently editable DrawingML shapes. Pass `--native-charts-and-tables` only when the data source and chart/table-specific object model matter more than cross-renderer layout fidelity: it emits the PowerPoint Chart/Table object and skips the fallback children to avoid duplicates. Native styling preserves the core palette, text, axis, grid, and background colors where possible, but it is still a PowerPoint Chart/Table object rather than a pixel-identical SVG drawing. **Hard rule — activation is the opt-in, dormant unless exported with `--native-charts-and-tables`**: A marker only declares that a group is eligible for PowerPoint-native Chart/Table replacement. Normal `svg_to_pptx.py` runs keep the fallback SVG children and convert them into independently editable DrawingML shapes. Pass `--native-charts-and-tables` only when the data source and chart/table-specific object model matter more than cross-renderer layout fidelity: it emits the PowerPoint Chart/Table object and skips the fallback children to avoid duplicates. Native styling preserves the core palette, text, axis, grid, and background colors where possible, but it is still a PowerPoint Chart/Table object rather than a pixel-identical SVG drawing.
@@ -92,7 +130,9 @@ marker/ancestor `translate` and `scale` transforms apply only when at least one
bound is inferred. `x`, `y`, `width`, and `height` must be finite and resolve bound is inferred. `x`, `y`, `width`, and `height` must be finite and resolve
inside PowerPoint's 32-bit DrawingML coordinate range; `width` and `height` inside PowerPoint's 32-bit DrawingML coordinate range; `width` and `height`
must resolve to at least one EMU. Native table frames must additionally resolve must resolve to at least one EMU. Native table frames must additionally resolve
to at least one EMU per resolved row and column. to at least one EMU per resolved row and column. JSON-first markers require all
four bounds directly in metadata; an approximate preview never supplies their
native frame.
**Classic plot-area layout**: supported classic charts accept root `plot_area`; **Classic plot-area layout**: supported classic charts accept root `plot_area`;
ChartEx rejects it. It contains only finite `x`, `y`, `width`, `height` in ChartEx rejects it. It contains only finite `x`, `y`, `width`, `height` in
@@ -101,11 +141,11 @@ writes `c:manualLayout`; omission keeps automatic layout.
**Validation**: `svg_quality_checker.py` validates replacement marker kind, JSON **Validation**: `svg_quality_checker.py` validates replacement marker kind, JSON
metadata, bounds/fallback availability, table rows/columns, supported chart metadata, bounds/fallback availability, table rows/columns, supported chart
type, chart data shape, and any imported fallback baseline before export. type, chart data shape, and the selected authority contract. Canonical
SVG-first authoring requires a fresh fallback baseline; JSON-first markers skip
Imported marker freshness, fallback classification, provenance, and legacy fallback freshness because their preview is not authoritative. Import
read compatibility are operational import concerns. Keep generated authoring provenance, fallback classification, and legacy spellings remain operational
free of those attributes; use the exact behavior and field index in compatibility fields; use the exact field index in
[`conversion.md`](../scripts/docs/conversion.md#native-table-and-chart-import-claims). [`conversion.md`](../scripts/docs/conversion.md#native-table-and-chart-import-claims).
```xml ```xml
@@ -127,9 +167,12 @@ free of those attributes; use the exact behavior and field index in
</g> </g>
``` ```
**Hard rule — transcribe the authored object**: metadata is the native object's **Hard rule — project by the selected authority**: for SVG-first authoring,
source of truth and must describe the same data and visible chart/table chrome metadata is derived from and must describe the same data and visible
as the fallback drawn in that marker group. chart/table chrome as the fallback drawn in that marker group. For JSON-first
template/native-source objects, the inline metadata is authoritative and the
fallback is only a readable derived preview; approximate preview chrome is not
a contract mismatch.
| Object | Required projection from the visible fallback | | Object | Required projection from the visible fallback |
|---|---| |---|---|
@@ -152,18 +195,30 @@ objects have no marker. Finding one marker somewhere on a page is insufficient.
rg -n 'data-pptx-replace-with="(chart|table)"|<metadata type="application/json">' <project_path>/svg_output/<current_page>.svg rg -n 'data-pptx-replace-with="(chart|table)"|<metadata type="application/json">' <project_path>/svg_output/<current_page>.svg
``` ```
**Table schema**: Native tables are rectangular DrawingML grids. Use `columns` **Table schema`ppt-master.semantic-table.v2` only**: Every payload requires
for the optional header row and `rows` for body rows; shorter rows are padded that exact `schema`; unversioned payloads and alternate field spellings fail.
with blank cells unless `strict_grid: true` is set. Tables may contain at most Native tables are rectangular DrawingML grids. Use `columns` for the optional
1000 resolved rows and 1000 resolved columns. Use `column_widths` and header row and `rows` for body rows; shorter rows are padded unless
`row_heights` as relative weights. Weight lists must match the resolved grid, `strict_grid: true`. Tables support at most 1000 resolved rows and columns.
contain finite non-negative numbers, and include at least one positive value. `column_widths` / `row_heights` are finite non-negative relative weights that
If present, `header_rows` must be an integer from `0` through the resolved row match the grid and include one positive value. `header_rows` is an integer in
count. Write `strict_grid`, `style.band_row`, and cell `bold` as JSON booleans. the resolved row range. `strict_grid`, `style.band_row`, and `bold` are JSON
Cell objects accept `text`, `fill`, `color`, booleans.
`align`, `valign`, `bold`, `font_size`, `padding`, `border_color`, and
`border_width`, plus optional `lang`; the same `padding`, `border_color`, PPTX import factors exact repetition into `defaults.cell`,
`border_width`, and `lang` keys may also live under `style` as table defaults. `defaults.paragraph`, `defaults.run`, and lower-case kebab-case `cell_styles`;
cells select a style with `cell_style`. Precedence is cell defaults → named
style → cell fields. Only object-valued `padding` merges by member; `borders`
and other values replace. Content/topology stays cell-local. This is JSON
inheritance, not SVG CSS: `<style>` and `class` are forbidden. Export expands
the payload in memory before validation and DrawingML construction.
Cells accept `text`, `fill`, `fill_opacity`, `color`, `align`, `valign`, `bold`,
`font_size`, `padding`, canonical side-specific `padding_*`, `border_color`,
`border_width`, `borders`, `lang`, `anchor_center`, and
`horizontal_overflow`. `align` is `l`, `ctr`, or `r`; `valign` is `top`,
`middle`, or `bottom`. Table-wide font/palette/banding/uniform-border policy
lives under `style`; exact imported defaults live under `defaults.cell`.
For multi-paragraph text, replace cell `text` with a non-empty `paragraphs` For multi-paragraph text, replace cell `text` with a non-empty `paragraphs`
list. Each entry is either a string or an object containing optional list. Each entry is either a string or an object containing optional
`align: "l|ctr|r"` and exactly one of `text` or non-empty `runs`; empty `align: "l|ctr|r"` and exactly one of `text` or non-empty `runs`; empty
@@ -180,11 +235,12 @@ instead of entering either the native payload or an effect-free fallback.
Relationship-bearing text, extensions, structural line breaks, fields, tabs, Relationship-bearing text, extensions, structural line breaks, fields, tabs,
bullets, malformed run topology, and unsupported text-body structure remain bullets, malformed run topology, and unsupported text-body structure remain
fallback-only. fallback-only.
Per-side cell borders use `borders.left|right|top|bottom`, where each value is Per-side cell borders use
`borders.left|right|top|bottom|diagonal_down|diagonal_up`, where each value is
either `{ "style": "none" }` or either `{ "style": "none" }` or
`{ "style": "solid", "color": "#RRGGBB", "width": <positive-px> }`. `{ "style": "solid", "color": "#RRGGBB", "width": <positive-px> }`.
Per-side borders are cell-only; legacy uniform `border_color` / `border_width` Per-side borders are cell-only. Uniform `border_color` / `border_width` may live
remain supported as defaults that an individual side may override. on the table style or cell; an explicit side overrides the uniform value.
When `lang` is absent, export derives `zh-CN` for CJK text and `en-US` When `lang` is absent, export derives `zh-CN` for CJK text and `en-US`
otherwise. `style.band_row: false` disables both `<a:tblPr bandRow>` and otherwise. `style.band_row: false` disables both `<a:tblPr bandRow>` and
materialized alternating row fills. Native table typography mirrors the materialized alternating row fills. Native table typography mirrors the
@@ -194,17 +250,20 @@ per-cell `font_size` only when the fallback visibly differs. If the fallback
has no explicit table font, Default uses the deck body family and declared body has no explicit table font, Default uses the deck body family and declared body
anchor from `spec_lock.md`; Quick uses its active-context body family and size. anchor from `spec_lock.md`; Quick uses its active-context body family and size.
**Hard rule — table metadata is the native source of truth**: Every row, **Hard rule — table native payload is complete**: A payload holding only `font_size` and a uniform border is not complete when the fallback draws a header band, row or column fills, first-column emphasis, non-uniform row heights, or sparse rules. Every row, summary line,
summary line, value, and cell-level style that must survive value, and cell-level style that must survive `--native-charts-and-tables` must
`--native-charts-and-tables` must be present in `columns` / `rows`. SVG fallback text is be present in `columns` / `rows`; SVG fallback text is discarded on that route.
discarded during native export. `svg_quality_checker.py` warns when visible For SVG-first objects this payload is a synchronized projection of the visible
fallback `<text>` inside a native table marker does not appear in metadata. table. For JSON-first template/native-source objects it is the table authority
For numeric or currency columns, use cell objects with `align: "r"`; SVG and the preview may be approximate. For numeric or currency columns, use cell
`text-anchor="end"` does not carry into the native table. objects with `align: "r"`; SVG `text-anchor="end"` does not carry into the
native table.
**Merged table cells — canonical rectangular contract only**: Put positive JSON **Merged table cells — canonical rectangular contract only**: Put positive JSON
integer `row_span` / `col_span` values on the merge anchor and keep every integer `row_span` / `col_span` values on the merge anchor and keep every
covered grid cell blank. Spans must stay within the resolved rectangular grid covered grid cell blank: write it as `{"merge_continuation": true}` (a bare
`{"text": ""}` is also blank, but only while no `defaults.cell` expansion adds
other fields to it). Spans must stay within the resolved rectangular grid
and may not overlap. The exporter emits the canonical DrawingML topology and may not overlap. The exporter emits the canonical DrawingML topology
(`rowSpan` on the top edge, `gridSpan` on the left edge, `hMerge` / `vMerge` on (`rowSpan` on the top edge, `gridSpan` on the left edge, `hMerge` / `vMerge` on
covered cells). CamelCase aliases, raw OOXML merge fields, top-level merge lists, covered cells). CamelCase aliases, raw OOXML merge fields, top-level merge lists,
@@ -294,26 +353,32 @@ imply full visual-axis parity.
`title_font_size`, `subtitle_font_size`, `axis_font_size`, `title_font_size`, `subtitle_font_size`, `axis_font_size`,
`axis_title_font_size`, `legend_font_size`, and `note_font_size` fields are `axis_title_font_size`, `legend_font_size`, and `note_font_size` fields are
required only when the native object must preserve typography that cannot be required only when the native object must preserve typography that cannot be
inferred unambiguously from the visible fallback. inferred unambiguously from an SVG-first fallback. JSON-first objects do not
infer typography from their approximate preview; put required values in JSON.
**Chart chrome metadata**: Metadata MUST match fallback chrome. For classic **Chart chrome metadata**: SVG-first metadata MUST match fallback chrome.
JSON-first metadata owns native chrome and may use a simpler preview. For classic
charts, a string or unbounded-object `title` becomes native `c:title`; charts, a string or unbounded-object `title` becomes native `c:title`;
`subtitle` is line two. A title object with complete `x`, `y`, `subtitle` is line two. A title object with complete `x`, `y`,
`width`, and `height` becomes a companion editable text box at those absolute `width`, and `height` becomes a companion editable text box at those absolute
slide-px bounds; partial bounds or `subtitle` fail. Use `name`, not slide-px bounds; partial bounds or `subtitle` fail. Use `name`, not
`title`, for object naming. `title`, `subtitle`, and axis-title objects may set `title`, for object naming. `title`, `subtitle`, and axis-title objects may set
`text`, `font_size`, `font_family`, and `color`. The checker rejects title/axis `text`, `font_size`, `font_family`, and `color`. On SVG-first markers the
text absent from the fallback; export omits it with a warning. ChartEx keeps an empty `<cx:title>` and checker rejects title/axis text absent from the fallback and export omits it
emits title/subtitle as companion editable text boxes. Axis with a warning; JSON-first keeps it. ChartEx writes no `<cx:title>` unless the payload gives a title — an empty ChartEx title makes PowerPoint show the series name as an automatic title — and emits title/subtitle as companion editable text boxes. Axis
titles are optional and explicit: use `axis_titles` with titles are optional and explicit: use `axis_titles` with
`category`, `value`, `x`, `y`, or `secondary_value` keys, or the root aliases `category`, `value`, `x`, `y`, or `secondary_value` keys, or the root aliases
`category_axis_title`, `value_axis_title`, `x_axis_title`, `y_axis_title`, and `category_axis_title`, `value_axis_title`, `x_axis_title`, `y_axis_title`, and
`secondary_value_axis_title`; do not add semantic axis titles that are not `secondary_value_axis_title`; SVG-first must not add semantic axis titles that
visible in the fallback. Set `show_value_axis_labels: false` when the fallback are absent from the fallback. Set `show_value_axis_labels: false` when the fallback
keeps category labels but omits numeric value-axis tick labels, such as a radar keeps category labels but omits numeric value-axis tick labels, such as a radar
chart without radial coordinates. Native legends are metadata-controlled: use chart without radial coordinates. Native legends are metadata-controlled: use
`show_legend: true` and `legend_position` only when the fallback's legend is `show_legend: true` and `legend_position` only when the fallback's legend is
meant to be replaced by PowerPoint's native legend. meant to be replaced by PowerPoint's native legend. SVG-first parity reads the
fallback literally: `style.axis_color` must equal the dominant stroke among
axis and grid lines, numbers match in written form (`286.20``286.2`), and
any marker text that is not a category, data label, axis label, or legend
entry needs its own companion entry (`note`, `caption`, …).
Companion text such as `caption`, `source`, `note`, `notes`, `footnote`, and Companion text such as `caption`, `source`, `note`, `notes`, `footnote`, and
`footnotes` is exported as editable PPT text boxes next to the native chart. A `footnotes` is exported as editable PPT text boxes next to the native chart. A
companion entry may be a string or an object with `text`, `x`, `y`, `width`, companion entry may be a string or an object with `text`, `x`, `y`, `width`,
@@ -327,10 +392,11 @@ belong to chart points.
**Chart color styling**: For classic native charts, `style.colors` sets series **Chart color styling**: For classic native charts, `style.colors` sets series
colors. The exporter also writes explicit chart-area fill, plot-area fill, colors. The exporter also writes explicit chart-area fill, plot-area fill,
axis line, gridline, and label text colors so PowerPoint does not substitute a axis line, gridline, and label text colors so PowerPoint does not substitute a
white/default-theme chart. If omitted, the exporter infers these colors from white/default-theme chart. For SVG-first, omitted values are inferred from
the visible SVG fallback: the largest panel-like `<rect>` becomes the chart the visible fallback: the largest panel-like `<rect>` becomes the chart
background, fallback text supplies label color, and fallback strokes supply background, fallback text supplies label color, and fallback strokes supply
axis/grid colors. Override any of them explicitly under `style` with axis/grid colors. JSON-first uses JSON values or stable exporter defaults, not
its preview. Override any of them explicitly under `style` with
`chart_area_fill`, `plot_area_fill`, `text_color`, `axis_color`, and `chart_area_fill`, `plot_area_fill`, `text_color`, `axis_color`, and
`grid_color`; use `"none"` for transparent chart or plot area fill. Generated `grid_color`; use `"none"` for transparent chart or plot area fill. Generated
payloads default to uppercase `#RRGGBB`. The exporter retains compatibility for payloads default to uppercase `#RRGGBB`. The exporter retains compatibility for
@@ -339,7 +405,7 @@ payloads default to uppercase `#RRGGBB`. The exporter retains compatibility for
inversion so negative bars keep the same series fill instead of turning into inversion so negative bars keep the same series fill instead of turning into
white/theme fill. white/theme fill.
For ChartEx native charts, valid payload `style.colors` (or root `colors`) For treemap and sunburst, `style.colors` projects the visible tile palette in order; aggregate figures drawn inside the marker belong in companion text. For ChartEx native charts, valid payload `style.colors` (or root `colors`)
populate the ChartEx color-style part instead of being replaced by a fixed populate the ChartEx color-style part instead of being replaced by a fixed
accent1accent6 list. Other ChartEx style semantics remain normalized. accent1accent6 list. Other ChartEx style semantics remain normalized.
@@ -75,14 +75,12 @@ levels. Every ordinary whole-object link uses the standard outer `<a href>`.
| Inline or whole-object slide jump | The same click carrier plus an internal slide relationship and `ppaction://hlinksldjump` | | Inline or whole-object slide jump | The same click carrier plus an internal slide relationship and `ppaction://hlinksldjump` |
| Supported PPTX import | Reconstruct the same canonical SVG `<a href>` form | | Supported PPTX import | Reconstruct the same canonical SVG `<a href>` form |
**Hard rule — Fill Native preservation**: Preserve external links. Retarget a **Hard rule — Edit Native PPTX preservation**: Unchanged round-trip pages keep
same-deck jump only when its source target maps unambiguously to one output their hyperlink XML and relationships byte-for-byte. External links are
slide; omitted or duplicated targets fail closed instead of linking to an preserved. With a `page_plan.json`, a same-deck jump is retargeted only when
orphan or wrong slide. its source target maps unambiguously to one output page; omitted or repeated
targets make `svg_to_pptx.py --roundtrip` fail instead of linking to an orphan
**Hard rule — Enhance Native preservation**: Preserve existing hyperlink XML or wrong slide. New links on an edited page use this SVG authoring contract.
and relationships unchanged. This route does not use the SVG authoring contract
to add new links.
--- ---
@@ -235,7 +235,12 @@ siblings whenever one-object contour semantics are unnecessary.
## 3. Fragment Generation ## 3. Fragment Generation
`render` emits one selected object. `render-batch` atomically emits multiple `render` emits one selected object. `render-batch` atomically emits multiple
already-selected objects for one current page or template construction. already-selected objects for one current page or template construction. Its
`--input` is a JSON array of objects with the same fields as the `render`
flags: required `preset`, `id`, and `frame` (`[x, y, width, height]`);
optional `object_kind`, `name`, `fill`, `fill_opacity`, `stroke`,
`stroke_width`, `stroke_opacity`, `stroke_linecap`, `stroke_linejoin`,
`filter_id`, and `adjustments` (an object such as `{"adj": "val 42000"}`).
Generated project pages choose each object's solid paint from the current page Generated project pages choose each object's solid paint from the current page
context, using `spec_lock.md` roles as reusable anchors rather than an exhaustive context, using `spec_lock.md` roles as reusable anchors rather than an exhaustive
palette; `create-template` takes colors from the confirmed brief and template palette; `create-template` takes colors from the confirmed brief and template
@@ -273,7 +278,7 @@ preset can never be authored as an ordinary `shape`.
**Hard rule — stdout-only exception**: the helper prints one or more **Hard rule — stdout-only exception**: the helper prints one or more
deterministic `<g>` fragments. Read that output and insert it with the normal deterministic `<g>` fragments. Read that output and insert it with the normal
page/template `apply_patch` edit. A batch JSON array is transient input for page edit. A batch JSON array is transient input for
already-selected objects in the current construction, never a project resource already-selected objects in the current construction, never a project resource
or multi-page plan. Do not redirect output into `svg_output/`, loop over or multi-page plan. Do not redirect output into `svg_output/`, loop over
pages/templates, or let the helper choose layout. The main Agent still authors pages/templates, or let the helper choose layout. The main Agent still authors
@@ -394,7 +399,7 @@ from the primary, and `fragment` returns each atomic filled region. The PPTX
stores the materialized freeform geometry, not replayable operation history. stores the materialized freeform geometry, not replayable operation history.
**Hard rule — stdout-only replacement**: The helper never writes the source **Hard rule — stdout-only replacement**: The helper never writes the source
page. In one normal `apply_patch` edit, remove every selected operand and insert page. In one normal page edit, remove every selected operand and insert
every returned path in root coordinate space at the primary operand's z-order, every returned path in root coordinate space at the primary operand's z-order,
using the placement contract above. Fragment paths remain separate shapes; an using the placement contract above. Fragment paths remain separate shapes; an
ordinary semantic group does not turn them into one structural atom. ordinary semantic group does not turn them into one structural atom.
@@ -2,13 +2,15 @@
# PPTX Structure Interface # PPTX Structure Interface
Conditional interface for PowerPoint Master, Layout, fixed-layer, and placeholder authoring. Load only when `spec_lock.md pptx_structure.mode` is `structured`. Conditional interface for PowerPoint Master, Layout, fixed-layer, and placeholder authoring. In Generate, load only for Default `spec_lock.md pptx_structure.mode: structured` or Quick structured Slide authoring from an installed Layout/Deck owner.
**Cross-reference map**: unqualified §1.5 and §4.2 references point to [`shared-standards-core.md`](./shared-standards-core.md); this file's own sections are §1–§3. **Cross-reference map**: unqualified §1.5 and §4.2 references point to [`shared-standards-core.md`](./shared-standards-core.md); this file's own sections are §1–§3.
## 1. PPTX Structure Routing ## 1. PPTX Structure Routing
Every new SVG project declares one deterministic route. Free-design, brand-only, and `template_reuse_scope: style` projects use `pptx_structure.mode: flat`, omit `pptx_masters` / `pptx_layouts` / `page_pptx_layouts` / `page_layouts`, and author no Master/Layout/layer/placeholder metadata. Export keeps all represented content Slide-local while materializing one clean project-owned Master plus one Blank Layout from the current color/typography lock; stock content placeholders and unused built-in Layouts are removed, while the standard date/footer/slide-number capability hooks remain. Deck/layout template projects whose AI-derived lock records `template_reuse_scope: mirror|layout` use `mode: structured`; `standard` / `fidelity` templates use their authored contract, while mirror templates use the validated source identities and parentage declared by the newly materialized workspace. Every new SVG project declares one deterministic route. Free-design, brand-only, and `template_reuse_scope: style` projects use `pptx_structure.mode: flat`, omit `pptx_masters` / `pptx_layouts` / `page_pptx_layouts` / `page_layouts`, and author no Master/Layout/layer/placeholder metadata. Export keeps all represented content Slide-local while materializing one clean project-owned Master plus one Blank Layout from the current color/typography lock; stock content placeholders and unused built-in Layouts are removed, while the standard date/footer/slide-number capability hooks remain. Deck/layout template projects whose AI-derived lock records `template_reuse_scope: mirror|layout` use `mode: structured`; `standard` / `fidelity` templates use their authored contract, while mirror templates use the validated source identities and parentage declared by the newly authored compact workspace.
**Quick exception**: Lock-row/Strategist statements below are Default-only. Quick keeps free/Brand/Style-only flat; an installed Layout/Deck owner is structured unless visual-only, with identities on SVG roots and title/body anchors inferred from slot carriers.
**Hard rule — no structure inference**: Flat export performs no promotion or deduplication; every object stays Slide-local. Structured template export compiles only declared root identities, atomic fixed layers, and slot groups—it does not assign Layout families, cluster pages, infer placeholders, repair missing metadata, or migrate legacy contracts. Create a new current workspace through [`create-template`](../workflows/create-template.md) before generating structured pages. **Hard rule — no structure inference**: Flat export performs no promotion or deduplication; every object stays Slide-local. Structured template export compiles only declared root identities, atomic fixed layers, and slot groups—it does not assign Layout families, cluster pages, infer placeholders, repair missing metadata, or migrate legacy contracts. Create a new current workspace through [`create-template`](../workflows/create-template.md) before generating structured pages.
@@ -16,7 +18,7 @@ Every new SVG project declares one deterministic route. Free-design, brand-only,
**Zero-slot Layout**: A named Layout may contain no slots and no fixed Layout atoms. This is valid for a cover, poster, full-visual page, or other fixed composition. Do not manufacture an empty `utility` kind or full-page fake `object` slot. **Zero-slot Layout**: A named Layout may contain no slots and no fixed Layout atoms. This is valid for a cover, poster, full-visual page, or other fixed composition. Do not manufacture an empty `utility` kind or full-page fake `object` slot.
**Adaptive change**: Template `strict` preserves the selected prototype contract. `adaptive` retains the prototype Master and may use a current or new Layout identity only when Strategist already declared it in the complete plan and lock. If construction proves that fixed Layout atoms or slot topology/bounds must change, stop and return upstream for Strategist to add or revise the definition and page mapping before authoring resumes; Executor never mutates a reused key or the lock. **Adaptive change**: `strict` preserves the prototype. `adaptive` retains its Master and uses only a Layout declared in Default's plan/lock or permitted by Quick's frozen Template Application. Required atom/slot-contract changes return Default for repair/readback/validation; Quick creates a new Layout only under that permission. Never mutate a reused key.
## 2. Explicit PPTX Master / Layout / Placeholder Metadata ## 2. Explicit PPTX Master / Layout / Placeholder Metadata
@@ -24,7 +26,7 @@ Every new SVG project declares one deterministic route. Free-design, brand-only,
**Project lock**: A Master row is `<master_key>: <PowerPoint picker name>`. A unique Layout row is `<layout_key>: <master_key> | <PowerPoint picker name> | <prototype source>`, where the source is a generated `P<NN>` or installed `template:<basename>`. A page assignment is `P<NN>: <layout_key>` under `page_pptx_layouts`. The SVG root values MUST match the assigned definition. A Layout key belongs to exactly one Master and must be globally unique. Reuse one key only when prototypes share identical ordered Layout atoms and slot ids/types/effective indices/default bounds/binding modes. An unused Layout uses a template SVG source and remains registered without a published carrier slide. Every structured route requires numeric `spec_lock.md` typography `title` / `body` rows. **Project lock**: A Master row is `<master_key>: <PowerPoint picker name>`. A unique Layout row is `<layout_key>: <master_key> | <PowerPoint picker name> | <prototype source>`, where the source is a generated `P<NN>` or installed `template:<basename>`. A page assignment is `P<NN>: <layout_key>` under `page_pptx_layouts`. The SVG root values MUST match the assigned definition. A Layout key belongs to exactly one Master and must be globally unique. Reuse one key only when prototypes share identical ordered Layout atoms and slot ids/types/effective indices/default bounds/binding modes. An unused Layout uses a template SVG source and remains registered without a published carrier slide. Every structured route requires numeric `spec_lock.md` typography `title` / `body` rows.
**Template behavior**: Strict preserves the selected prototype's declared Master/Layout/slot contract. Adaptive retains its Master and realizes the current or new Layout key/name declared by Strategist. A construction-discovered change to fixed Layout atoms or slot topology/bounds returns upstream for plan/lock repair, readback, and validation before authoring resumes. Mirror-created prototypes preserve validated source identity, literal paint, typography, effects, atomic geometry, and referenced assets in a new workspace. `standard` / `fidelity` never make source topology authoritative; mirror does not synthesize a replacement topology or fill missing facts. **Template behavior**: Strict preserves the selected prototype's Master/Layout/slot contract. Adaptive realizes only a Layout allowed by §1 and never mutates a reused key. Mirror-created prototypes preserve validated source identity, parentage, slots, meaning, and similar presentation in compact new SVG; paint/geometry nodes need not be isomorphic. `standard` / `fidelity` never make source topology authoritative; mirror does not synthesize replacement topology or fill missing facts.
Imported inherited-shape visibility remains an immutable analysis fact until a Imported inherited-shape visibility remains an immutable analysis fact until a
structured mirror is materialized. The final mirror root carries that fact with structured mirror is materialized. The final mirror root carries that fact with
@@ -34,24 +36,27 @@ present. Authored `standard` / `fidelity` templates normally omit both and use
the default `true`. See the default `true`. See
[`conversion.md`](../scripts/docs/conversion.md#import-compatibility-and-recovery-boundary). [`conversion.md`](../scripts/docs/conversion.md#import-compatibility-and-recovery-boundary).
**Master text-style contract**: Flat and structured export map the declared **Master text-style contract**: Default reads title/body anchors from its lock;
`title` anchor to every `a:defRPr` in Master `p:titleStyle`. Level 1 in both structured Quick infers them from semantic slot carriers with deterministic
fallbacks, while flat Quick retains stock defaults. An effective `title` anchor
maps to every `a:defRPr` in Master `p:titleStyle`. Level 1 in both
`p:bodyStyle` and `p:otherStyle` uses the declared `body` anchor; levels 29 `p:bodyStyle` and `p:otherStyle` uses the declared `body` anchor; levels 29
use a deterministic descending hierarchy from `15/16` through `8/16` of that use a deterministic descending hierarchy from `15/16` through `8/16` of that
size, rounded to 0.5 pt and floored at the smaller of 8 pt or the body size. size, rounded to 0.5 pt and floored at the smaller of 8 pt or the body size.
Existing per-level indentation and bullet properties remain unchanged. Existing per-level indentation and bullet properties remain unchanged.
| Master style | Locked source | XML field changed | | Master style | Effective source | XML field changed |
|---|---|---| |---|---|---|
| `p:titleStyle` | `typography.title` | Every `a:defRPr@sz` | | `p:titleStyle` | title anchor | Every `a:defRPr@sz` |
| `p:bodyStyle` | `typography.body` | Level 1 plus derived level 29 `a:defRPr@sz` | | `p:bodyStyle` | body anchor | Level 1 plus derived level 29 `a:defRPr@sz` |
| `p:otherStyle` | `typography.body` | Level 1 plus derived level 29 `a:defRPr@sz` | | `p:otherStyle` | body anchor | Level 1 plus derived level 29 `a:defRPr@sz` |
**Hard rule — narrow scope**: This Master update changes only Master **Hard rule — narrow scope**: This Master update changes only Master
`p:txStyles//a:defRPr@sz`; it preserves level indentation, bullet, margin, and `p:txStyles//a:defRPr@sz`; it preserves level indentation, bullet, margin, and
paragraph settings. It does not rewrite direct run sizes on generated slides, paragraph settings. It does not rewrite direct run sizes on generated slides,
so the initial slide rendering remains controlled by the authored SVG. Missing so the initial slide rendering remains controlled by the authored SVG. Missing
`title` or `body` rows fail flat or structured export. Default `title` or `body` rows fail flat or structured export; Quick structured
uses inferred/fallback anchors.
**Layout level-one text-default contract**: For every text-bearing placeholder **Layout level-one text-default contract**: For every text-bearing placeholder
whose first prototype run has a direct `a:rPr@sz`, explicit Layout export copies that whose first prototype run has a direct `a:rPr@sz`, explicit Layout export copies that
@@ -81,6 +86,15 @@ prototype size remain unchanged.
**Hard rule — explicit only**: On a structured `template_reuse_scope: mirror|layout` route, every SVG requires the four root Master/Layout identity attributes. Optional inherited-shape visibility uses only exact lowercase `true` / `false`; other spellings fail, and omission means `true`. Every Master/Layout atom and slot requires a unique stable `id` and is a direct root child. Layouts with zero slots are valid. `data-pptx-layout-kind`, `distilled`, and `utility` are legacy metadata and fail the structured contract. Flat `template_reuse_scope: style`, free-design, and brand-only pages omit the structural markers and visibility attributes; ordinary groups still use the shared `data-pptx-bounds` module contract. **Hard rule — explicit only**: On a structured `template_reuse_scope: mirror|layout` route, every SVG requires the four root Master/Layout identity attributes. Optional inherited-shape visibility uses only exact lowercase `true` / `false`; other spellings fail, and omission means `true`. Every Master/Layout atom and slot requires a unique stable `id` and is a direct root child. Layouts with zero slots are valid. `data-pptx-layout-kind`, `distilled`, and `utility` are legacy metadata and fail the structured contract. Flat `template_reuse_scope: style`, free-design, and brand-only pages omit the structural markers and visibility attributes; ordinary groups still use the shared `data-pptx-bounds` module contract.
**Identity is not layer membership**: An SVG `id` identifies one element and
must be unique inside that SVG document. Any number of direct atoms may repeat
the same `data-pptx-layer="master"` or `data-pptx-layer="layout"` value; the
layer attribute, never the `id`, determines ownership. Separate standalone SVG
pages may repeat the same stable fixed-atom `id` when they declare the same
Master/Layout contract. Unmarked visual content is Slide-local, except that the
optional direct solid Slide-background marker below makes one-page background
ownership explicit.
**Layer order**: Author the SVG in PowerPoint paint order: Master background, **Layer order**: Author the SVG in PowerPoint paint order: Master background,
Layout background, optional Slide background, remaining Master atoms, remaining Layout atoms, Layout background, optional Slide background, remaining Master atoms, remaining Layout atoms,
then slot groups and Slide-local content groups. Backgrounds are a special inheritance then slot groups and Slide-local content groups. Backgrounds are a special inheritance
@@ -110,6 +124,11 @@ 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` | | `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` | | `media` | one `<image>` or supported imported crop `<svg>`, marked as carrier | `media` |
A template-owned chart/table carrier may declare
`data-pptx-native-authority="json"`. Its inline metadata, marker identity,
bounds, and slot binding remain structural facts; its non-metadata SVG preview
children are derived and may be regenerated without changing that contract.
**Text slot carrier**: A multiline text placeholder must remain one native text **Text slot carrier**: A multiline text placeholder must remain one native text
frame. Default export and `--reflow-text` do; `--no-merge` cannot supply several frame. Default export and `--reflow-text` do; `--no-merge` cannot supply several
line shapes as one PowerPoint placeholder prototype/binding. Leave strict-line line shapes as one PowerPoint placeholder prototype/binding. Leave strict-line
@@ -181,14 +200,14 @@ video or audio media from a decorative SVG group.
## 3. Legacy Template Input Boundary ## 3. Legacy Template Input Boundary
Existing structured/template projects or packages that carry `native_structure.json` / `source_template.pptx`, `pptx_structure.mode: baseline|template|preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled` / `utility`, direct atomic placeholders, or an incomplete root Master identity are not generation/export inputs and are never upgraded in place. Create a separate current workspace through [`create-template`](../workflows/create-template.md). A project explicitly declaring `pptx_structure.mode: flat` is the current free-design/brand-only route and needs no conversion merely because it has no Master/Layout metadata. Existing structured/template projects or source-analysis packages that carry `analysis/native_structure.json` / `sources/source.pptx`, `pptx_structure.mode: baseline|template|preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled` / `utility`, direct atomic placeholders, or an incomplete root Master identity are not generation/export inputs and are never upgraded in place. Create a separate current workspace through [`create-template`](../workflows/create-template.md). A project explicitly declaring `pptx_structure.mode: flat` is the current free-design/brand-only route and needs no conversion merely because it has no Master/Layout metadata.
| Available source | Allowed create-template behavior | | Available source | Allowed create-template behavior |
|---|---| |---|---|
| Original PPTX Type A | `standard` / `fidelity` author new topology; `mirror` preserves supported Master/Layout/placeholder facts that still exist in the package | | Original PPTX Type A | `standard` / `fidelity` author new topology; `mirror` authors compact SVG from parsed evidence while preserving supported Master/Layout/placeholder facts that still exist in the package |
| Legacy or unstructured SVG Type B | `standard` / `fidelity` use pages as visual/contextual reference and author a complete new contract; old metadata is not output topology | | Legacy or unstructured SVG Type B | `standard` / `fidelity` use pages as visual/contextual reference and author a complete new contract; old metadata is not output topology |
| Complete current SVG Type B | `mirror` may preserve the explicit current contract in a new workspace; authored modes may replace it | | Complete current SVG Type B | `mirror` may author a compact equivalent while preserving the explicit current contract in a new workspace; authored modes may replace it |
Without an original PPTX or complete current Type B contract, do not claim mirror or source-topology recovery. After template creation, Generate PPTX Step 3 authors new structured `svg_output/` pages; the exporter only compiles those declarations and never derives, repairs, or migrates structure. Without an original PPTX or complete current Type B contract, do not claim mirror or source-topology recovery. After template creation, Generate PPTX Step 6 (or Quick §3) authors new structured `svg_output/` pages; the exporter only compiles those declarations and never derives, repairs, or migrates structure.
--- ---
@@ -16,7 +16,7 @@ PPT Master uses rendering-neutral compiler hints only where ordinary SVG cannot
The completed SVG remains the full visible page. Removing the metadata must not change browser rendering. Do not copy visible text, geometry, style, or asset values into metadata. The completed SVG remains the full visible page. Removing the metadata must not change browser rendering. Do not copy visible text, geometry, style, or asset values into metadata.
**Hard rule — route boundary**: Free-design, brand-only, and `template_reuse_scope: style` pages use `pptx_structure.mode: flat`, declare one canonical root `data-pptx-page-role`, and omit every Master/Layout/layer/placeholder marker in this document. Only deck/layout template pages whose AI-derived lock records `template_reuse_scope: mirror|layout` declare their final Master and Layout before drawing begins and omit `data-pptx-page-role`; the structured exporter compiles that contract and never selects, clusters, distills, or visually infers it. **Hard rule — route boundary**: Free-design, brand-only, and `template_reuse_scope: style` pages use `pptx_structure.mode: flat`, declare one canonical root `data-pptx-page-role`, and omit every Master/Layout/layer/placeholder marker in this document. Only Default `template_reuse_scope: mirror|layout` pages or Quick pages authoring an installed Layout/Deck owner's structure declare their final Master and Layout before drawing begins and omit `data-pptx-page-role`; the structured exporter compiles that contract and never selects, clusters, distills, or visually infers it.
**Hard rule — specialized metadata wins**: Use Master/Layout/placeholder metadata for native structure, `data-pptx-replace-with` for optional PowerPoint-native Chart/Table replacement, and the imported/authored shape metadata defined in [`shared-standards-core.md`](./shared-standards-core.md) §§1.41.5. Do not duplicate those facts with `data-pptx-role`. **Hard rule — specialized metadata wins**: Use Master/Layout/placeholder metadata for native structure, `data-pptx-replace-with` for optional PowerPoint-native Chart/Table replacement, and the imported/authored shape metadata defined in [`shared-standards-core.md`](./shared-standards-core.md) §§1.41.5. Do not duplicate those facts with `data-pptx-role`.
@@ -43,7 +43,7 @@ On structured `template_reuse_scope: mirror|layout` routes, Master and fixed Lay
| Requirement | Rule | | Requirement | Rule |
|---|---| |---|---|
| Placement | Every Master/Layout atom is a direct child of the root SVG and has a stable unique `id`. | | Placement | Every Master/Layout atom is a direct child of the root SVG and has a stable unique `id`. |
| Grouping | A `<g>` may not carry `data-pptx-layer="master|layout"`. Imported PowerPoint groups are recursively flattened and their transform/style/opacity/z-order semantics are pushed into atomic children. | | Grouping | A `<g>` may not carry `data-pptx-layer="master|layout"`. The sole exception is one validated compact authored-preset `<g>` ([`shared-standards-core.md`](./shared-standards-core.md) §1.5), which compiles to one native object. Imported PowerPoint groups flatten recursively into atomic children with their transform/style/opacity/z-order semantics. |
| Atomicity | One marked child must compile to one DrawingML object. A nested crop `<svg>` is allowed only when it is the supported single-picture carrier, not an arbitrary container. | | Atomicity | One marked child must compile to one DrawingML object. A nested crop `<svg>` is allowed only when it is the supported single-picture carrier, not an arbitrary container. |
| Consistency | Pages sharing one Master key repeat the identical ordered Master atom contract. Pages sharing one `(master, layout)` pair repeat the identical ordered Layout atom contract. | | Consistency | Pages sharing one Master key repeat the identical ordered Master atom contract. Pages sharing one `(master, layout)` pair repeat the identical ordered Layout atom contract. |
| Ownership | Concrete titles, body text, metrics, charts, tables, images, and page-specific decoration stay Slide-local or inside a declared slot. | | Ownership | Concrete titles, body text, metrics, charts, tables, images, and page-specific decoration stay Slide-local or inside a declared slot. |
@@ -125,7 +125,7 @@ Do not add structural roles to ordinary titles, body copy, cards, KPIs, diagrams
For structured `template_reuse_scope: mirror|layout` projects, validation rejects: For structured `template_reuse_scope: mirror|layout` projects, validation rejects:
- a missing root Master/Layout identity or a page-to-lock mismatch; - a missing root Master/Layout identity or a page-to-lock mismatch;
- a Master/Layout `<g>`, nested structure marker, missing/stale id, or inconsistent shared atom contract; - an ordinary Master/Layout `<g>` (the compact authored-preset atom is the sole exception), nested structure marker, missing/stale id, or inconsistent shared atom contract;
- a slot without positive bounds, a carrier-bound slot without exactly one compatible carrier, or a proxy binding on a non-`object` slot; - a slot without positive bounds, a carrier-bound slot without exactly one compatible carrier, or a proxy binding on a non-`object` slot;
- incomplete page mappings, cross-Master Layout-key reuse, or conflicting same-key Layout contracts. - incomplete page mappings, cross-Master Layout-key reuse, or conflicting same-key Layout contracts.
@@ -8,9 +8,9 @@ Mandatory reference for every route that authors or regenerates slide visuals th
|---|---| |---|---|
| Default or Quick Generate; otherwise noncanonical/alpha paint, advanced line or text treatment, gradient/filter/effect, transform, freeform/radial geometry, or constructed style | [`svg-effects.md`](./svg-effects.md) | | Default or Quick Generate; otherwise noncanonical/alpha paint, advanced line or text treatment, gradient/filter/effect, transform, freeform/radial geometry, or constructed style | [`svg-effects.md`](./svg-effects.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 | | 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 |
| `pptx_structure.mode: structured` | [`pptx-structure-interface.md`](./pptx-structure-interface.md) | | Default structured lock, or Quick installed Layout/Deck structured authoring | [`pptx-structure-interface.md`](./pptx-structure-interface.md) |
**Default — shared aesthetic baseline (may be overridden by explicit user, installed template / brand, or locked / Quick-resolved visual-style requirements)**: Required / Forbidden technical contracts remain absolute. When a higher authority is silent, build clear hierarchy through typography and leading, alignment, negative space, purposeful imagery / icons, and restrained repetition before decoration. Deliberate tightness, imbalance, off-axis placement, or container-heavy structure remains valid when that authority calls for it. **Default — shared aesthetic baseline (may be overridden by explicit user, installed template / brand, or locked / Quick-resolved visual-style requirements)**: Required / Forbidden technical contracts remain absolute. When a higher authority is silent, build clear hierarchy through typography and leading, alignment, negative space, purposeful imagery / icons, shapes, and repetition. Deliberate tightness, imbalance, off-axis placement, or container-heavy structure remains valid when that authority calls for it.
| Concern | Shared default | | Concern | Shared default |
|---|---| |---|---|
@@ -22,11 +22,11 @@ Mandatory reference for every route that authors or regenerates slide visuals th
| Content field | Establish the usable body frame before placing modules. Divide it into one or a small set of macro-regions from information weight and reading order: use unequal weight when the information differs, while true peers may share equal weight. Give each region its own local axes / micro-grid while retaining only the cross-region anchors the composition needs. On a dense page, let the planned content system organize that frame; create breathing room through gutters, module spacing, and intentional voids between semantic clusters. Unorganized residual space that leaves content stranded in one part of the frame is leftover blank, not negative space. | | Content field | Establish the usable body frame before placing modules. Divide it into one or a small set of macro-regions from information weight and reading order: use unequal weight when the information differs, while true peers may share equal weight. Give each region its own local axes / micro-grid while retaining only the cross-region anchors the composition needs. On a dense page, let the planned content system organize that frame; create breathing room through gutters, module spacing, and intentional voids between semantic clusters. Unorganized residual space that leaves content stranded in one part of the frame is leftover blank, not negative space. |
| Alignment and proximity | Establish shared axes from the current composition. Align related titles, copy, labels, images, and diagram nodes to those edges, centers, or baselines; group related elements more tightly than unrelated groups so spacing carries hierarchy. Break an axis only when the offset performs hierarchy, direction, or tension. | | Alignment and proximity | Establish shared axes from the current composition. Align related titles, copy, labels, images, and diagram nodes to those edges, centers, or baselines; group related elements more tightly than unrelated groups so spacing carries hierarchy. Break an axis only when the offset performs hierarchy, direction, or tension. |
| Visual weight | Judge weight from area, darkness, saturation, density, stroke, image detail, and elevation together. Distribute it to support the focal path; symmetry is optional, and deliberate imbalance may create direction. | | Visual weight | Judge weight from area, darkness, saturation, density, stroke, image detail, and elevation together. Distribute it to support the focal path; symmetry is optional, and deliberate imbalance may create direction. |
| Boundary strength | Match the relationship with the lightest sufficient boundary from this expressive ladder: spacing / alignment rule / bracket tint field outline filled panel true floating layer. Peer relationships use comparable strength while focus, hierarchy, or material difference may move to a stronger treatment. The ladder is not a required sequence or per-page quota. | | Boundary strength | Boundaries range from spacing / alignment, rule / bracket, and tint field through outline, filled panel, and true floating layer; choose the strength from the relationship. Peer relationships use comparable strength while focus, hierarchy, or material difference may use a different one. |
| Containers | Use a card or panel when it expresses grouping, hierarchy, boundary, capacity, or a distinct material plane. Otherwise prefer spacing, rules, or direct text / geometry; peer containers share treatment unless a semantic difference justifies contrast. An unplanned repeated web-card grid is a carrier / topology problem, not a reason to suppress meaningful borders, shapes, or containers. | | Containers | A card or panel expresses grouping, hierarchy, boundary, capacity, or a distinct material plane; peer containers share treatment unless a semantic difference justifies contrast. An unplanned repeated web-card grid is a carrier / topology problem, not a reason to suppress meaningful borders, shapes, or containers. |
| Titles and page chrome | Treat the semantic page title as part of the current composition rather than an automatic fixed header band; its position, scale, and relationship may change with page role while preserving the active route's content invariants. Add or retain running headers, footers, and page numbers only when they carry navigation, identity, attribution, or another explicit page job. Fidelity profiles preserve required source chrome. | | Titles and page chrome | Treat the semantic page title as part of the current composition rather than an automatic fixed header band; its position, scale, and relationship may change with page role while preserving the active route's content invariants. Add or retain running headers, footers, and page numbers only when they carry navigation, identity, attribution, or another explicit page job. Fidelity profiles preserve required source chrome. |
**Default — active effects vocabulary (may resolve to no added technique when no visual job is diagnosed)**: Default and Quick Generate complete the already-loaded [`svg-effects.md`](./svg-effects.md) §6.1 job diagnostic, which that file makes mandatory, before completing each page; whether any compatible technique is then added is this default's call, with the Visual Job Router as recall. **Reference — effects vocabulary**: Default and Quick Generate load [`svg-effects.md`](./svg-effects.md); its §6.1 Visual Job Router lists the visual jobs an effect can serve. Whether any compatible technique is added is the author's call.
**Fidelity labels**: **Fidelity labels**:
@@ -140,6 +140,20 @@ text uses a non-empty `font-family`, a finite positive unitless-px `font-size`,
underline/strike, text outline/alpha, gradient text, or text filter effects. underline/strike, text outline/alpha, gradient text, or text filter effects.
Unknown or unmapped declarations fail Checker preflight and native export. Unknown or unmapped declarations fail Checker preflight and native export.
**Hard rule — compact inherited authoring**: Author canonical compact SVG on
first publish. Put common typography presentation attributes on `<svg>`, with a
direct `font-family` whenever text is visible; root paint/effects are forbidden.
Put shared typography or paint on the nearest meaningful `<g>` and keep true
child overrides explicit. Inheritance is part of native export, not a lossy
post-process.
The source stays valid, browser-visible, semantic, locally editable SVG;
meaning and deterministic export outrank bytes. Never use classes/stylesheets,
aliases/private keys, encoded payloads, precision loss, or unrelated
indirection. `--canonical-authoring` reports drift from the compact form as an advisory
warning; `compact_svg_styles.py --inplace` applies the same normalization on
request to authored project pages, never to structured template rosters.
> **`marker-start` / `marker-end` is conditional** — see §1.1. > **`marker-start` / `marker-end` is conditional** — see §1.1.
> >
> **`clipPath` on `<image>` is conditional** — see §1.2. > **`clipPath` on `<image>` is conditional** — see §1.2.
@@ -299,7 +313,8 @@ preview/text hashes, connector endpoints, payload references, and adjustment
formulas—into short `data-pptx-native-ref` records in the same store. Checker, formulas—into short `data-pptx-native-ref` records in the same store. Checker,
template-structure validation, and export validate and hydrate both layers in template-structure validation, and export validate and hydrate both layers in
memory. Keep Master/Layout, placeholder, layer, editable-object, diagnostic, memory. Keep Master/Layout, placeholder, layer, editable-object, diagnostic,
and editable chart/table metadata inline. Legacy inline Base64 and v1 and editable chart/table metadata inline; authoritative Chart/Table JSON stays
inside its SVG marker, never in the payload store. Legacy inline Base64 and v1
payload-only stores remain readable. payload-only stores remain readable.
One effect reason remains its existing plain token. If one imported object has One effect reason remains its existing plain token. If one imported object has
@@ -312,28 +327,38 @@ one. This array is still diagnostic metadata, not an authoring surface.
| Representation | Contract | | Representation | Contract |
|---|---| |---|---|
| Lossless import SVG | Keep complete native payload, hidden carriers, and preview evidence in the temporary analysis workspace. It is immutable native-payload backing, not the editable template source. | | Lossless import SVG | Immutable native payload and preview evidence in the temporary analysis workspace; never editable template source. |
| Authoring IR bundle | Keep editable SVGs plus model-readable `authoring_summary.json` and tool-only `authoring_manifest.json`. Exclude opaque payload and duplicate hidden carriers from model context while retaining visible shape intent and a stable document-local `data-pptx-source-ref` on each imported logical object. Compact model-facing imported frames and safe transform page coordinates to at most two decimals before hashing the IR. The summary owns the compact current-file index; the manifest owns source paths and initial hashes and never enters model context. | | Authoring IR bundle | Editable SVG plus model-readable `authoring_summary.json` and tool-only `authoring_manifest.json`. Keep visible intent and document-local `data-pptx-source-ref`, but omit opaque/duplicate carriers. Before hashing, compact safe imported frame/transform coordinates to two decimals. Summary indexes current files; manifest owns source paths/hashes and stays outside model context. |
| `standard` / `fidelity` output | Use the compact authored-preset contract (§1.5) for newly authored stock shapes; do not transplant opaque import payload or source topology. | | `standard` / `fidelity` output | Use §1.5 compact presets; never transplant opaque payload or source topology. |
| `mirror` output | Materialize from the edited authoring IR. Rehydrate supported imported metadata only when a Slide-local/slot object's source ref and initial authoring hash still match; otherwise keep the current SVG fallback. Expand fixed Master/Layout group wrappers into direct semantic atoms while preserving source ownership, paint order, and visible appearance. | | `mirror` output | Template_Designer reviews/authors the compact parsed IR; materialization validates refs/graph and publishes that tree without restoring visible lossless subtrees. Recover only supported non-visible semantics; expand fixed Master/Layout wrappers without changing ownership or intended presentation. |
**Hard rule — model-facing page-coordinate precision**: **Default — model-facing page-coordinate precision (the canonical checker reports over-precision as an advisory warning)**:
| Surface | Precision contract | | Surface | Precision contract |
|---|---| |---|---|
| Imported `data-pptx-frame` in authoring IR | At most two decimals. An unchanged mirror source ref recovers the exact lossless frame before tool-side native-record externalization. | | Imported `data-pptx-frame` in authoring IR | At most two decimals; the compact frame owns visible geometry. |
| `data-pptx-bounds` in generated and final template SVG | At most two decimals. | | `data-pptx-bounds` in generated and final template SVG | At most two decimals. |
| `translate(...)`, `rotate(... cx cy)`, and `matrix(... e f)` | Translation values and rotation centers use at most two decimals. Keep the rotation angle and matrix `a b c d` coefficients unchanged. | | `translate(...)`, `rotate(... cx cy)`, and `matrix(... e f)` | Translation/center values use at most two decimals; keep angle and matrix `a b c d` unchanged. |
| Protected values | Do not apply this compaction to path/points geometry, normalized crop or nested `viewBox` ratios, gradient offsets, opacity, scale arguments, canonical authored-preset frames, or lossless/tool-side native frames. | | Protected values | Never compact path/points geometry, crop/nested-`viewBox` ratios, gradient offsets, opacity, scale, canonical preset frames, or lossless/tool-side frames. |
**Hard rule — authoring source refs**: `data-pptx-source-ref` is reserved for **Hard rule — authoring source refs**: `data-pptx-source-ref` is create-template
the create-template authoring IR. Its value is unique within one authoring SVG, IR-only and unique per document. Tools resolve it through that document's
not across the workspace, and must be resolved through that document's `authoring_manifest.json`; models never read the manifest. Extract/re-inline
`authoring_manifest.json` record by the owning tool. Models MUST NOT read that preserves the ref and vector inventory mapping. Final templates and
machine manifest. Moving a referenced subtree into `svg_output/` contain no source refs.
`icons/imported/` for readability must preserve the attribute and record it in
the vector inventory; re-inlining re-establishes the same mapping. Final materialized **Hard rule — decoration extraction**: move text-free imported vectors to
template SVGs and normal project `svg_output/` must not contain this attribute. `icons/imported/` and leave an inventoried `<use data-icon="imported/...">`.
The editor expands it; unchanged assets restore source objects and edited ones
become page-local vector units.
**Hard rule — imported source proxy fallback**: only unsupported, text-free,
schema-free, unmarked ornament may use an atomic
`<image data-pptx-source-proxy="native-restore">` preview under
`images/source-object-previews/`. Meaning-bearing content stays readable inline
or reports a conversion gap. Unchanged proxies restore; removing a Slide-local
proxy deletes it, while inherited proxies remain. Proxy edits fail export.
Extraction/proxies are import-time only, never free-authored `svg_output/`.
**Hard rule — structural-layer boundary**: An unchanged imported logical object **Hard rule — structural-layer boundary**: An unchanged imported logical object
may keep currently supported metadata while it remains Slide-local or inside a may keep currently supported metadata while it remains Slide-local or inside a
@@ -345,12 +370,12 @@ otherwise retaining the visible SVG fallback. A newly authored compact preset
to exactly one native shape/connector. Do not use this normalization to change to exactly one native shape/connector. Do not use this normalization to change
ownership or appearance. ownership or appearance.
**Hard rule — selective payload**: Do not copy every imported metadata block into **Hard rule — selective payload**: Keep the lossless import SVG as immutable
an authored template. Keep the full lossless import SVG separately as immutable evidence; do not copy every metadata block into a template. Mirror publishes the
audit/fallback backing. Mirror may reuse only metadata already supported by the compact authored subtree and recovers only converter-supported non-visible
converter on source-ref/hash-matching Slide-local/slot objects; unsupported or metadata, never ordinary visible source XML. Unsupported/edited objects use the
edited objects use the current SVG fallback. `data-pptx-replace-with` remains reserved for the SVG fallback. `data-pptx-replace-with` remains reserved for optional native
optional PowerPoint-native Chart/Table replacement contract. Chart/Table replacement.
**Registry and rendering rules**: **Registry and rendering rules**:
@@ -580,7 +605,11 @@ Use the already locked canvas id and exact viewBox. [`canvas-formats.md`](canvas
| PPTX translation | The exporter may map represented SVG content to DrawingML/native objects and deduplicate represented elements into Master/Layout/Slide parts. It MUST NOT invent visible slide content absent from the SVG. | | PPTX translation | The exporter may map represented SVG content to DrawingML/native objects and deduplicate represented elements into Master/Layout/Slide parts. It MUST NOT invent visible slide content absent from the SVG. |
| Excluded package behavior | Speaker notes, animations, transitions, narration audio, PPTX relationships, and direct native-PPTX workflows remain separately owned. They are not part of the SVG page-design contract. | | Excluded package behavior | Speaker notes, animations, transitions, narration audio, PPTX relationships, and direct native-PPTX workflows remain separately owned. They are not part of the SVG page-design contract. |
**Hard rule — page-design closure**: A final page SVG is the sole visual/design authority for that page on every SVG-authoring route. SVG is not the authority for the entire PPTX package. **Hard rule — page-design closure**: A final page SVG is complete but does not
own the whole PPTX package. Its ordinary content and SVG-first Chart/Table
markers are authoritative. For `data-pptx-native-authority="json"`, inline JSON
is authoritative and the visible subtree is a derived, possibly approximate
preview; authority never moves to a sidecar.
### 4.1 Semantic SVG Marker Contract ### 4.1 Semantic SVG Marker Contract
@@ -616,11 +645,11 @@ These forms are needed only when the stated PPT behavior matters:
| Desired behavior | Required form | | Desired behavior | Required form |
|---|---| |---|---|
| 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. | | 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 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. | | 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). | | 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-generate uses the same flat object ownership but converter-default theme scaffolding because no lock exists. | | Free-design / brand-only PowerPoint structure | Use `pptx_structure.mode: flat`. Keep represented objects Slide-local; export emits one clean Master plus Blank Layout, removes stock content placeholders/Layout inventory, and retains the standard date/footer/slide-number hooks. Do not author Master/Layout identities, layers, or slots. Without a Layout/Deck owner, Quick uses the same ownership with converter-default theme scaffolding. |
| Reusable template-based PowerPoint Layout | Select one complete authoring SVG per page in `page_layouts`, declare each unique Master/Layout definition once, and assign pages through `page_pptx_layouts`. Strict preserves the prototype contract; adaptive retains its Master and uses a current or new Layout key already declared and assigned by Strategist. Construction cannot extend or mutate that mapping downstream. Non-mirror skin follows `spec_lock`. | | Reusable template-based PowerPoint Layout | Default maps complete page prototypes through `page_layouts` and declared Master/Layout definitions through `page_pptx_layouts`; strict preserves the contract and adaptive uses a declared current/new Layout under its Master. Quick has no lock: read the complete roster and author the selected Master/Layout/slot contract in every output SVG; its all-or-none gate infers structured packaging. Never infer ownership from repeated Slide-local geometry. |
**Default — leading by role and density (may be overridden for user, template, typeface, legibility, or locked visual-style fit)**: For direct positioned `<tspan>` rows, start multiline titles around `1.21.3 × font-size`, dense / small body around `1.41.5 ×`, ordinary body around `1.51.6 ×`, and large / sparse / breathing body around `1.62.0 ×`. These are starting ranges, not checker quotas; display headlines may be tighter when the selected style calls for it. Author the spacing as positive relative `dy`, not CSS/SVG `line-height`, which has no registered DrawingML mapping. **Default — leading by role and density (may be overridden for user, template, typeface, legibility, or locked visual-style fit)**: For direct positioned `<tspan>` rows, start multiline titles around `1.21.3 × font-size`, dense / small body around `1.41.5 ×`, ordinary body around `1.51.6 ×`, and large / sparse / breathing body around `1.62.0 ×`. These are starting ranges, not checker quotas; display headlines may be tighter when the selected style calls for it. Author the spacing as positive relative `dy`, not CSS/SVG `line-height`, which has no registered DrawingML mapping.
@@ -628,7 +657,7 @@ These forms are needed only when the stated PPT behavior matters:
### 4.3 Element Grouping (Mandatory) ### 4.3 Element Grouping (Mandatory)
**Hard rule — root groups protect body-text layout**: Every visible direct root `<g>` except a compact helper-authored preset atom declares positive root-coordinate `data-pptx-bounds="x y width height"`. That text-free atom stays top-level when standalone, uses `data-pptx-frame`, and never carries bounds. Frame/native coordinates do not replace bounds on any other group; placeholder bounds also supply the slot frame. On flat pages, make each module zone as generous as the canvas and sibling layout allow without overlapping another module zone. Checker validates this subcanvas against the root `viewBox`, then recursively validates only estimable `<text>` descendants against it using the shared SVG-to-PPTX per-run width estimate, inline-formula native-height envelope, and DrawingML wrapping headroom. It validates every estimable visible text carrier directly against the root `viewBox` with the same per-run estimate before that headroom. Nested groups and all shapes, images, paths, `<use>` instances, effects, and object frames are not module-boundary inputs. Per side, Checker ignores overflow through `1px`; module-boundary overflow warns through `5%` and fails above `5%`, while any larger root-`viewBox` text overflow fails. Bounds do not clip or reflow; unestimable visible text receives an advisory warning. The only page-boundary exception is a wholly off-canvas direct-root Morph endpoint marked `data-pptx-morph-staging="true"`; its own module bounds still apply, retained Morph uses an explicit pair, and the marker never excuses partial page overflow. **Hard rule — root groups protect body-text layout**: Every visible direct root `<g>` except a compact helper-authored preset atom declares positive root-coordinate `data-pptx-bounds="x y width height"`. That text-free atom stays top-level when standalone, uses `data-pptx-frame`, and never carries bounds. Frame/native coordinates do not replace bounds on any other group; placeholder bounds also supply the slot frame. On flat pages, maximize ordinary zones within canvas/sibling space without overlap; Checker fails overlap exceeding `1px` on both axes. Structured slots, structural-role groups, and off-canvas Morph staging groups are exempt; structured Slide-local groups are not. Checker compares each subcanvas with the root `viewBox`, and estimable descendant text—including both §4.2 multiline forms—with the subcanvas using the shared per-run width estimate, inline-formula height envelope, and DrawingML wrapping headroom. It separately compares estimable visible text with the root `viewBox` before that headroom. Nested groups and all shapes, images, paths, `<use>` instances, effects, and object frames are not module-boundary inputs. Per side, Checker ignores overflow through `1px`; module-boundary overflow warns through `5%` and fails above `5%`, while any larger root-`viewBox` text overflow fails. Bounds do not clip or reflow; unestimable visible text receives an advisory warning. The only page-boundary exception is a wholly off-canvas direct-root Morph endpoint marked `data-pptx-morph-staging="true"`; its own module bounds still apply, retained Morph uses an explicit pair, and the marker never excuses partial page overflow.
Wrap each logical Slide-local body unit in one descriptive top-level `<g id>`; group count follows the page's semantic units, and each group becomes one stable animation target when animation is enabled. Generic deck-wide animation gives that target one step; an explicit animation sidecar may assign it several ordered effects. Nested implementation groups may remain anonymous and need no bounds; any nested bounds are ignored. Flat pages use ordinary groups; structured slots already qualify, while titles, direct atomic Master/Layout elements, and canvas-level static framing—including background images and full-canvas scrim/decoration rectangles—may remain root primitives. On flat pages, give such static framing a stable `id` plus `data-pptx-role="background"` / `"decoration"`; never add a `<g>` solely to silence an ungrouped-element advisory. Wrap each logical Slide-local body unit in one descriptive top-level `<g id>`; group count follows the page's semantic units, and each group becomes one stable animation target when animation is enabled. Generic deck-wide animation gives that target one step; an explicit animation sidecar may assign it several ordered effects. Nested implementation groups may remain anonymous and need no bounds; any nested bounds are ignored. Flat pages use ordinary groups; structured slots already qualify, while titles, direct atomic Master/Layout elements, and canvas-level static framing—including background images and full-canvas scrim/decoration rectangles—may remain root primitives. On flat pages, give such static framing a stable `id` plus `data-pptx-role="background"` / `"decoration"`; never add a `<g>` solely to silence an ungrouped-element advisory.
@@ -7,6 +7,6 @@ Compatibility router for the split SVG specifications. Runtime routes load the c
| XML/SVG foundation, shared visual-quality defaults, page closure, grouping | [`shared-standards-core.md`](./shared-standards-core.md) | Always for SVG authoring | | XML/SVG foundation, shared visual-quality defaults, page closure, grouping | [`shared-standards-core.md`](./shared-standards-core.md) | Always for SVG authoring |
| Advanced effects and geometry | [`svg-effects.md`](./svg-effects.md) | Always for Default / Quick Generate; otherwise when the corresponding effect or geometry is used | | Advanced effects and geometry | [`svg-effects.md`](./svg-effects.md) | Always for Default / Quick Generate; otherwise when the corresponding effect or geometry is used |
| Preset patterns and native chart/table metadata | [`native-data-interface.md`](./native-data-interface.md) | Corresponding native-data interface is used | | Preset patterns and native chart/table metadata | [`native-data-interface.md`](./native-data-interface.md) | Corresponding native-data interface is used |
| Master/Layout/placeholder structure | [`pptx-structure-interface.md`](./pptx-structure-interface.md) | `pptx_structure.mode: structured` | | Master/Layout/placeholder structure | [`pptx-structure-interface.md`](./pptx-structure-interface.md) | Default structured lock, or Quick installed Layout/Deck structured authoring |
**Hard rule**: This file is a routing pointer, not a combined runtime authority. Follow the selected route's required modules; do not load every remaining conditional module by default. **Hard rule**: This file is a routing pointer, not a combined runtime authority. Follow the selected route's required modules; do not load every remaining conditional module by default.
@@ -10,7 +10,7 @@ Always-on Stage-2 rendering-candidate extension plus confirmed image elaboration
## 1. Proposed and Confirmed Image Plan ## 1. Proposed and Confirmed Image Plan
Before Stage 2, construct rendering candidates independently of the proposed source set. After confirmation, use this module within [`strategist.md`](./strategist.md)'s one-pass page carrier planning: run its eligibility and fit decisions inside that pass, then plan and route only the resulting image, lettering, and illustrated-icon jobs; map the confirmed source set through §h and honor explicit `image_notes` roles. This module never reopens the complete carrier mix as a separate pass, materializes a file, or adds a source. The confirmed non-`none` set is an allowed acquisition boundary, not coverage: use a suitable subset and leave irrelevant sources unused. Explicit must-use sources, assets, or page roles remain required. Asset inventory and judgment determine unconfirmed count, subject, placement, and composition without substituting an unconfirmed source. Before Stage 2, construct rendering candidates independently of the proposed source set. After confirmation, use this module within [`strategist.md`](./strategist.md)'s one-pass page carrier planning: run its eligibility and fit decisions inside that pass, then plan and route only the resulting image, lettering, and illustrated-icon jobs; map the confirmed source set through §h and honor explicit `image_notes` roles. This module never reopens the complete carrier mix as a separate pass, materializes a file, or adds a source. The confirmed non-`none` set is the acquisition boundary. Explicit must-use sources, assets, or page roles remain required. Asset inventory and judgment determine unconfirmed count, subject, placement, and composition without substituting an unconfirmed source.
For illustration, confirmed `none` stops and explicit user intent wins. Otherwise the locked visual style's `Illus.` propensity (`core` / `supportive` / `sparse`) tunes centrality and recurrence after the per-page composition scan; it never restricts eligible page types, element scale, or carrier combinations. When illustration is active, prefer one coherent family that can serve the actual page jobs, including recurring title/corner chrome, dominant anchors, supporting figures, and accents. A compact icon cue does not discharge a scene, subject, or visual-weight job that a photo or illustration family would serve. For illustration, confirmed `none` stops and explicit user intent wins. Otherwise the locked visual style's `Illus.` propensity (`core` / `supportive` / `sparse`) tunes centrality and recurrence after the per-page composition scan; it never restricts eligible page types, element scale, or carrier combinations. When illustration is active, prefer one coherent family that can serve the actual page jobs, including recurring title/corner chrome, dominant anchors, supporting figures, and accents. A compact icon cue does not discharge a scene, subject, or visual-weight job that a photo or illustration family would serve.
@@ -20,31 +20,23 @@ For illustration, confirmed `none` stops and explicit user intent wins. Otherwis
For each sheet, plan one unplaced `ai` Illustration Sheet row plus one placed `slice` row per used element; only slice rows enter `spec_lock.md images`, and one element row may serve several §IX pages. State each element's communication job, placement/reuse relationship, relative visual weight, energy, family, and shape without prescribing an effect stack. Use glyph-native expression by default; record a lettering-plus-illustration lockup only when the user explicitly requests it or the confirmed direction requires it. Lettering sheets use `text_policy: embedded`; the asset may carry the complete display title, while any required searchable, selectable, or outline-visible title remains an ordinary separate native text frame. [`image-generator.md`](./image-generator.md) §§4.3 and 5.3 own the controlled-default/high-expression boundary, artistic authorship, grid, key field, slicing, and execution details. Final Stage 2 chooses the AI execution path under `image-generator.md` §7; do not pre-empt or re-pick it here. For each sheet, plan one unplaced `ai` Illustration Sheet row plus one placed `slice` row per used element; only slice rows enter `spec_lock.md images`, and one element row may serve several §IX pages. State each element's communication job, placement/reuse relationship, relative visual weight, energy, family, and shape without prescribing an effect stack. Use glyph-native expression by default; record a lettering-plus-illustration lockup only when the user explicitly requests it or the confirmed direction requires it. Lettering sheets use `text_policy: embedded`; the asset may carry the complete display title, while any required searchable, selectable, or outline-visible title remains an ordinary separate native text frame. [`image-generator.md`](./image-generator.md) §§4.3 and 5.3 own the controlled-default/high-expression boundary, artistic authorship, grid, key field, slicing, and execution details. Final Stage 2 chooses the AI execution path under `image-generator.md` §7; do not pre-empt or re-pick it here.
**Default — consider illustrated icons under confirmed AI permission**: when a **Illustrated icons (confirmed AI permission)**: a compact semantic cue may be
compact semantic job benefits from a project-specific illustrated cue, plan the produced as a project-specific illustrated cue through the same sheet-to-slice
useful cues through the same sheet-to-slice contract. Each placed cue uses contract. Each placed cue uses
`Type: Illustrated icon`, `Crop Policy: no-crop`, and an appropriate layout `Type: Illustrated icon`, `Crop Policy: no-crop`, and an appropriate layout
recommendation; the parent remains an unplaced `Type: Illustration Sheet`. recommendation; the parent remains an unplaced `Type: Illustration Sheet`.
There is no confirmation field or coverage quota. Illustrated cues may coexist There is no confirmation field. Illustrated cues may coexist
with base SVG/emoji icons when the overall visual system remains coherent, and with base SVG/emoji icons when the overall visual system remains coherent, and
their slices stay out of `icons/`. their slices stay out of `icons/`.
**Mandatory — scan decorative-lettering candidates before selection**: When **Reference — decorative-lettering candidates**: under confirmed `ai`, any
confirmed image usage retains `ai`, scan the complete page roster once before stable wording is a lettering candidate when an artistic treatment could
writing §VIII. Confirmed `ai` is a Permission, not coverage: never create communicate better than native type; confirmed `ai` is a Permission, not
lettering merely to justify the AI source or because no other AI-image job was coverage, and wording that fails either test stays native editable text. Page role, character count, word count, line count, kind of noun,
found. Candidate discovery asks two questions—is the wording stable, and could
an artistic treatment plausibly communicate better than native type. When
either answer is no, create no lettering row and keep the wording as native
editable text. Page role, character count, word count, line count, kind of noun,
and locked style never pre-filter candidates; a complete long title, multi-word and locked style never pre-filter candidates; a complete long title, multi-word
phrase, and multi-line lockup are as eligible as a short mark. Preserve each phrase, and multi-line lockup are as eligible as a short mark. Preserve each
full exact character sequence as one intended mark when its hierarchy belongs full exact character sequence as one intended mark when its hierarchy belongs
to the art; never trim, rewrite, or split it merely to ease generation. Passing to the art; never trim, rewrite, or split it merely to ease generation. Zero selected marks remains valid. Materialize each
both questions exposes a possible job rather than selecting it. Compare every
candidate inside the complete page and deck carrier mix, then choose any
coherent set whose artistic treatment wins that fit; zero selected marks
remains valid and needs no skip explanation or coverage quota. Materialize each
selected mark as an ordinary `ai` row or group compatible marks through the selected mark as an ordinary `ai` row or group compatible marks through the
§4.3 sheet/element rows rather than leaving it as a planning suggestion. Let §4.3 sheet/element rows rather than leaving it as a planning suggestion. Let
letterform character, treatment, and practical generation needs guide grouping. letterform character, treatment, and practical generation needs guide grouping.
@@ -57,13 +49,13 @@ editable-only hook, or Offline Manual path does not activate this proactive
rule; an explicit user-required lettering asset still follows the ordinary rule; an explicit user-required lettering asset still follows the ordinary
resource contract. resource contract.
**Mandatory — image-treatment path scan, not a quota**: Per selected image choose `none` (unchanged), `native` (SVG crop/clip, transform, opacity, frame/depth, overlap), or `prepared derivative` (separate pixel blur/tone or cutout/registered layers); `none` is valid. **Image treatment path** (per selected image): `none` (unchanged), `native` (SVG crop/clip, transform, opacity, frame/depth, overlap), or `prepared derivative` (separate pixel blur/tone or cutout/registered layers); `none` is valid.
When a subject crosses a native title, panel, frame, or shape, the prepared path is mandatory: plan a clean full-canvas base plus minimum registered RGBA layers; set full-canvas members `no-crop`; name their shared source/registration in `Reference`; suggest `#A2-03`. A shared plate requires padded-bbox-disjoint objects and independent final crops. Use `user` only when every final asset is supplied, otherwise `ai`; [`image-generator.md`](./image-generator.md) §4.4 owns preparation. An independent floating cutout may use `#A2-01`. When a subject crosses a native title, panel, frame, or shape, the prepared path is mandatory: plan a clean full-canvas base plus minimum registered RGBA layers; set full-canvas members `no-crop`; name their shared source/registration in `Reference`; suggest `#A2-03`. A shared plate requires padded-bbox-disjoint objects and independent final crops. Use `user` only when every final asset is supplied, otherwise `ai`; [`image-generator.md`](./image-generator.md) §4.4 owns preparation. An independent floating cutout may use `#A2-01`.
## 2. AI Image Strategy — always propose three; lock only for confirmed `ai` ## 2. AI Image Strategy — always propose three; lock only for confirmed `ai`
Before any rendering detail, use the already-loaded [`image-renderings/_index.md`](./image-renderings/_index.md) as the sole rendering-basis catalog authority. First author exactly three complete, project-fit solution intents; use the index to freeze each intent's exact rendering bases, then read once only the deduplicated referenced sibling files. Project one complete `image_strategy` into each direction regardless of `recommend.image_usage`. Every candidate carries localized `name`, `rendering: custom`, `visual`, `mood`, and non-empty localized `behavior`. Mood includes a recognizable real-world analogy. All three must credibly serve their owning whole solution; rendering treatments and bases may coincide when other components express the direction difference. Do not force artificial safe / shifted / bold extremes. Image colors always inherit that direction's deck HEX roles; never add an image palette or alter deck colors to rescue a rendering. Before any rendering detail, use the already-loaded [`image-renderings/_index.md`](./image-renderings/_index.md) as the sole rendering-basis catalog authority. First author exactly three complete, project-fit solution intents; use the index to freeze each intent's exact rendering bases, then read once only the deduplicated referenced sibling files. Project one complete `image_strategy` into each direction regardless of `recommend.image_usage`. Every candidate carries `name`, `rendering: custom`, `visual`, `mood`, and non-empty `behavior`, each written once in the confirmed UI language. Mood includes a recognizable real-world analogy. All three must credibly serve their owning whole solution; rendering treatments and bases may coincide when other components express the direction difference. Do not force artificial safe / shifted / bold extremes. Image colors always inherit that direction's deck HEX roles; never add an image palette or alter deck colors to rescue a rendering.
Every direction is a `custom` rendering, unconstrained by how it relates to the catalog. It may use catalog material in any way or none, including carrying one complete preset treatment unchanged. Name every actual id in the visible behavior and read only those files after selection; when several are named, each owns a distinct line, texture, depth, material, or mood contribution. Reference count has no fixed cap, and a second basis is never required. With no catalog basis, name none and read none. Under a template it obeys inherited identity and application. Only a confirmed custom locks its edited behavior as `image_rendering_behavior`; when catalog material is actually used, also project the exact ids as `image_rendering_references`, otherwise omit that field. Unselected candidates remain recommendation-only. Do not write a separate fourth `custom_candidates.image_strategy`; ignore legacy `image_palette`. Every direction is a `custom` rendering, unconstrained by how it relates to the catalog. It may use catalog material in any way or none, including carrying one complete preset treatment unchanged. Name every actual id in the visible behavior and read only those files after selection; when several are named, each owns a distinct line, texture, depth, material, or mood contribution. Reference count has no fixed cap, and a second basis is never required. With no catalog basis, name none and read none. Under a template it obeys inherited identity and application. Only a confirmed custom locks its edited behavior as `image_rendering_behavior`; when catalog material is actually used, also project the exact ids as `image_rendering_references`, otherwise omit that field. Unselected candidates remain recommendation-only. Do not write a separate fourth `custom_candidates.image_strategy`; ignore legacy `image_palette`.
@@ -73,7 +65,7 @@ For specialized or regulated paper-figure subjects, preserve the prompt depth re
## 3. Image Resource List ## 3. Image Resource List
Add §VIII rows only for planned images; permitted unused sources create no row. Fill filename, dimensions/ratio, layout suggestion, crop, purpose/type, acquisition, status, reference, and conditional AI fields. `Acquire Via` is `ai`, `web`, `user`, `placeholder`, or `slice`; status follows [`svg-image-embedding.md`](./svg-image-embedding.md). Keep any unavailable planned/required asset `Pending` or `Needs-Manual`; never delete or reclassify it to appear complete. After final confirmation, project each placed row into `spec_lock.md images` as `<path> | source=<Acquire Via> | pattern=<Layout pattern> | crop=<adaptive|no-crop>` and omit unplaced source/sheet rows. Preserve exact confirmed `source`/`crop`; keep non-empty `pattern`, including optional catalog ids, as preferred expression rather than locked geometry. Add §VIII rows only for planned images. Fill filename, dimensions/ratio, layout suggestion, crop, purpose/type, acquisition, status, reference, and conditional AI fields. `Acquire Via` is `ai`, `web`, `user`, `placeholder`, or `slice`; status follows [`svg-image-embedding.md`](./svg-image-embedding.md). Keep any unavailable planned/required asset `Pending` or `Needs-Manual`; never delete or reclassify it to appear complete. After final confirmation, project each placed row into `spec_lock.md images` as `<path> | source=<Acquire Via> | crop=<adaptive|no-crop>` and omit unplaced source/sheet rows. Preserve exact confirmed `source`/`crop`; `Layout pattern` stays in §VIII as preferred expression rather than locked geometry.
**Prepared derivatives**: Keep canonical; `Reference`: `Derived from <bare filename>; treatment=<operation>;`. Deterministic child: distinct `.png`, inherits acquisition; §4.4 follows `user`/`ai` above. Lock placed children; [`image-base.md`](./image-base.md) §23 owns preparation. **Prepared derivatives**: Keep canonical; `Reference`: `Derived from <bare filename>; treatment=<operation>;`. Deterministic child: distinct `.png`, inherits acquisition; §4.4 follows `user`/`ai` above. Lock placed children; [`image-base.md`](./image-base.md) §23 owns preparation.
@@ -81,9 +73,9 @@ References describe visual intent: AI uses subject + intent + composition withou
**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`, or manual fulfillment retain that provenance and advance through their own status lifecycle after entering `images/`; location never reclassifies them as `user / Existing`. **Prepared-user fast path**: For initial imported or user-supplied assets confirmed as `provided`, copy the exact `Filename` basename and derive `Dimensions` / `Ratio` from that row's EXIF-corrected `Width` / `Height` / native `AspectRatio` in the latest `analysis/image_analysis.csv`; `SourceDisplayRatio` is source-context metadata, not the bitmap crop ratio. Drop source-side directories, set `Acquire Via: user` and `Status: Existing`, and decide the remaining §VIII fields normally. Existing §VIII / lock / provenance-manifest records override this inference. Assets declared as `ai`, `web`, `slice`, or manual fulfillment retain that provenance and advance through their own status lifecycle after entering `images/`; location never reclassifies them as `user / Existing`.
**Mandatory**: each placed row gets one executable `Layout pattern`. It is preferred expression, not locked geometry; optional hierarchical ids from the already-read [`image-layout-patterns.md`](./image-layout-patterns.md) must be exact. They are prompt lookup handles for Executor, not exporter effect codes. Executor may adopt, adapt, or decline the suggestion while preserving resource identity/source, must-use status, crop/content, and explicit user/template constraints; layout-only changes need no upstream rewrite. **Layout pattern** (the checker requires one non-empty value per placed row): it is preferred expression, not locked geometry; optional hierarchical ids from the already-read [`image-layout-patterns.md`](./image-layout-patterns.md) must be exact. They are prompt lookup handles for Executor, not exporter effect codes. Executor may adopt, adapt, or decline the suggestion while preserving resource identity/source, must-use status, crop/content, and explicit user/template constraints; layout-only changes need no upstream rewrite.
**Mandatory — job-bearing entry for image-led `adaptive` rows**: name the page job the image resolves alongside the composition serving it. A bare skeleton id, or position, size, crop, or legibility scrim alone, names no job: the entry stays incomplete until it says what the image does to the content or to the page's shapes. The already-read [`image-layout-patterns.md`](./image-layout-patterns.md) is recall for that composition, never a menu — an unlisted combination or a technique it never names answers a job just as well, and the job is never restated to reach a listed entry. Plain split and full bleed stay valid when the named job is best served plainly. A `no-crop` or supporting row keeps one concise suggestion instead. **Reference — job-bearing pattern text**: a useful entry names the job the image does for the content or the page's shapes, not only a skeleton id, position, size, crop, or scrim. The already-read [`image-layout-patterns.md`](./image-layout-patterns.md) is recall for that composition, never a menu — an unlisted combination or a technique it never names answers a job just as well, and the job is never restated to reach a listed entry. Plain split and full bleed stay valid when the named job is best served plainly. A `no-crop` or supporting row keeps one concise suggestion instead.
Choose narrative intent before dimensions, then apply the already-read [`image-layout-spec.md`](./image-layout-spec.md) to the actual page region. Techniques needing a cutout, blurred crop, or desaturated copy require that prepared asset. Write `Crop Policy: no-crop` whenever cropping could remove required pixels, labels, evidence, identity, or edge content; screenshots, charts, certificates/contracts, dense diagrams, logos, and product markings 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. Choose narrative intent before dimensions, then apply the already-read [`image-layout-spec.md`](./image-layout-spec.md) to the actual page region. Techniques needing a cutout, blurred crop, or desaturated copy require that prepared asset. Write `Crop Policy: no-crop` whenever cropping could remove required pixels, labels, evidence, identity, or edge content; screenshots, charts, certificates/contracts, dense diagrams, logos, and product markings 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.
@@ -12,16 +12,16 @@ Conditional extension for applying an installed Brand/Style/Layout/Deck workspac
**Template vs preset**: A style mention and a Style workspace are different inputs. Bare names and style words remain interpretive input and never resolve to a local path; only a selected and installed workspace activates the rules below. Every installed `<project_path>/templates/design_spec.<kind>.<id>.md` is a template-design source; read all of them. The presence of a `design_spec.style.*.md` file is what marks an active Direction / method segment. Whether a source root was labelled `library` or `explicit` is installation provenance only and never affects Stage-2 precedence. **Template vs preset**: A style mention and a Style workspace are different inputs. Bare names and style words remain interpretive input and never resolve to a local path; only a selected and installed workspace activates the rules below. Every installed `<project_path>/templates/design_spec.<kind>.<id>.md` is a template-design source; read all of them. The presence of a `design_spec.style.*.md` file is what marks an active Direction / method segment. Whether a source root was labelled `library` or `explicit` is installation provenance only and never affects Stage-2 precedence.
**Legacy template boundary**: A Layout/Deck template containing `native_structure.json`, `source_template.pptx`, missing root Master identity, direct atomic placeholders, or old `baseline` / `preserve` / distillation metadata is not a Generate Step 3 input. Create a current workspace through [`create-template`](../workflows/create-template.md), preferably from the original PPTX when native topology matters. Brand and Style are intentionally roster-free; never reject either for omitting SVG or Master identity. Do not mutate the input in place. **Source-analysis template boundary**: A legacy or incomplete Layout/Deck is not a Generate Step 3 input. Rebuild it through [`create-template`](../workflows/create-template.md), preferably from the original PPTX when native topology matters. Brand and Style are intentionally roster-free. Never mutate the input.
**No template-mode confirmation**: Never ask the user to select `template_reuse_scope`, `template_adherence`, `mirror`, `layout`, `style`, `strict`, or `adaptive`. These are internal execution values for the current exporter. The user communicates intent in natural language; explicit instructions such as “全部原样保留”, “从中选合适的页面”, “可以重组”, or “只参考视觉” are authoritative. Without an explicit instruction, Strategist decides. **No template-mode confirmation**: Never ask the user to choose internal exporter reuse/adherence values. Natural-language instructions win. Otherwise decide from current content and installed state. Common readings are: reference-led may redesign after full-roster study; augment-only freezes non-slot objects, permits slot edits, and only adds; replacement-only changes information carriers while preserving the rest. They are examples, not fixed modes; use reference-led when no stronger fit exists.
**Hard rule — no Stage-1 influence**: Do not load this module, the installed template spec, prototypes, assets, or template canvas while authoring Stage 1. Stage 1 is already confirmed when this module begins; never revise it to match the workspace. **Hard rule — no Stage-1 influence**: Do not load this module, the installed template spec, prototypes, assets, or template canvas while authoring Stage 1. Stage 1 is already confirmed when this module begins; never revise it to match the workspace.
Immediately before authoring the Stage-2 solution, load each relevant template Immediately before authoring the Stage-2 solution, load each relevant template
resource once per path + SHA and inspect: resource once per path + SHA and inspect:
- every installed `design_spec.<kind>.<id>.md`; inspect the actual Page Roster and relevant SVG prototypes from Layout when present, otherwise from Deck; - every installed `design_spec.<kind>.<id>.md`; inspect the actual Page Roster and every complete Slide prototype from Layout when present, otherwise from Deck; a mirror scope note about omitted source identities is evidence, not a page-candidate list;
- the Identity, Structure, Reusable Application Context, and Direction / method segment owners, resolved here from the installed set under [`apply-template-workspace`](../workflows/stages/apply-template-workspace.md) §5; - the Identity, Structure, Reusable Application Context, and Direction / method segment owners, resolved here from the installed set under [`apply-template-workspace`](../workflows/stages/apply-template-workspace.md) §5;
- the confirmed current communication contract, source obligations, planned page count, and content shape of every planned page; - the confirmed current communication contract, source obligations, planned page count, and content shape of every planned page;
- the user's natural-language instructions, including any page names/numbers or elements they explicitly require. - the user's natural-language instructions, including any page names/numbers or elements they explicitly require.
@@ -34,8 +34,17 @@ Then author one plan that decides all of the following without presenting an opt
- for Style, which communication method, visual language, composition rhythm, and information-expression defaults are adopted or adapted without inventing page prototypes; - for Style, which communication method, visual language, composition rhythm, and information-expression defaults are adopted or adapted without inventing page prototypes;
- which visible elements must remain literal because the user said so, and which may change to serve the current content. - which visible elements must remain literal because the user said so, and which may change to serve the current content.
If a prototype detail becomes uncertain, re-read its installed SVG; do not
substitute memory, a semantic label, or the source PPTX.
For Layout/Deck, template size is evidence, not policy. A short template may use every prototype when the content genuinely fits; a 2030 page source may contribute only a few suitable pages, or several pages may be reorganized into a new sequence. Never infer that all pages must be kept or that visible sample content is protected merely because it exists in the template. Style and Brand have no prototype set. For Layout/Deck, template size is evidence, not policy. A short template may use every prototype when the content genuinely fits; a 2030 page source may contribute only a few suitable pages, or several pages may be reorganized into a new sequence. Never infer that all pages must be kept or that visible sample content is protected merely because it exists in the template. Style and Brand have no prototype set.
**Hard rule — Slide prototypes drive authoring**: Every template SVG is a
complete Slide prototype with resolved Master + Layout + Slide context. Use
these files for `page_layouts`; standalone Master/Layout definition SVGs are
invalid. An unselected authored Slide prototype may still supply a
`pptx_layouts` definition, while mirror exposes only actual source Slides.
Record the resulting exporter plan internally: Record the resulting exporter plan internally:
| Internal value | When the authored plan requires it | | Internal value | When the authored plan requires it |
@@ -48,7 +57,7 @@ Record the resulting exporter plan internally:
Write only the derived values to `spec_lock.md pptx_structure`; omit `template_adherence` for `style`. Do not put these internal values in `design_spec.md`, recommendation stage files, the Confirm UI, or `result.json`. Write only the derived values to `spec_lock.md pptx_structure`; omit `template_adherence` for `style`. Do not put these internal values in `design_spec.md`, recommendation stage files, the Confirm UI, or `result.json`.
**Mandatory — natural-language Stage-2 plan**: For Layout/Deck, summarize which prototypes are used/skipped/repeated/reordered, what stays literal, and what may be replaced or reorganized. For Brand/Style, summarize the installed identity or Direction / method constraints and state that pages remain freely composed unless another workspace supplies structure. Write the result to top-level `template_application.value` in `recommendations.stage2.json`; omit it without an active template. After Stage 2, re-read the confirmed `result.json` value (or exact chat answer), never the initial recommendation. Blank returns the decision to Strategist. Persist the effective plan on one line as `- **Template Application**: <prose>` in `design_spec.md §I`, then derive internal reuse/adherence values and mappings; never copy the prose to `spec_lock.md`. Do not add a questionnaire, internal controls, or fixed template-use options. **Mandatory — natural-language Stage-2 plan**: Write one concise paragraph. For Layout/Deck, state prototype use/order, what stays literal, and what may change; name exact SVG basenames for prototype-specific exceptions, never roles such as “cover”. For Brand/Style, state identity or Direction / method constraints and free composition unless structure comes from another workspace. Write top-level `template_application.value` in `recommendations.stage2.json`; omit it without a template. After Stage 2, re-read the confirmed `result.json` value (or exact chat answer); blank returns the decision to Strategist. Persist `- **Template Application**: <prose>` in `design_spec.md §I`, derive internal mappings, and never copy it to `spec_lock.md` or add fixed options.
**Two-stage boundary**: An installed template changes the content of final Stage 2, never the confirmation sequence. Run Stage 1 → final Stage 2 in order in both Confirm UI and chat fallback; do not skip a stage or treat template inspection as user confirmation. On browser timeout, return to the same stage in chat. **Two-stage boundary**: An installed template changes the content of final Stage 2, never the confirmation sequence. Run Stage 1 → final Stage 2 in order in both Confirm UI and chat fallback; do not skip a stage or treat template inspection as user confirmation. On browser timeout, return to the same stage in chat.
@@ -56,7 +65,7 @@ Write only the derived values to `spec_lock.md pptx_structure`; omit `template_a
## 2. Scenario Fit and Inherited Design ## 2. Scenario Fit and Inherited Design
**Mandatory — decide from the §1 inspection**: For an installed `kind: deck`, compare the retained Template Overview with the confirmed audience, intent, outcome, delivery context, artifact afterlife, and source obligations. Deck application is reusable context for this comparison, never the current project's application contract and never an override. Compare the retained Page Roster/relevant SVG prototypes with required narrative roles, content shapes, slots, and capacity. Reopen a resource only when its path + SHA changed. The template describes what exists; it never overrides the current project or own required/optional/repeatable or fixed/replaceable/example-only policy. For `kind: layout`, compare only structural roles, slots, and capacity. For an active Style segment, compare its communication method with the current contract and its composition requirements with any selected Layout/Deck structure. Surface a material incompatibility; never silently weaken one segment to make it fit. **Mandatory — decide from the §1 inspection**: For an installed `kind: deck`, compare the retained Template Overview with the confirmed audience, intent, outcome, delivery context, artifact afterlife, and source obligations. Deck application is reusable context for this comparison, never the current project's application contract and never an override. Compare the retained Page Roster and complete SVG roster with required narrative roles, content shapes, slots, and capacity. Reopen a resource only when its path + SHA changed. The template describes what exists; it never overrides the current project or own required/optional/repeatable or fixed/replaceable/example-only policy. For `kind: layout`, compare only structural roles, slots, and capacity. For an active Style segment, compare its communication method with the current contract and its composition requirements with any selected Layout/Deck structure. Surface a material incompatibility; never silently weaken one segment to make it fit.
| Internal scope | Appropriate when | | Internal scope | Appropriate when |
|---|---| |---|---|
@@ -68,7 +77,9 @@ 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. > 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**: Explicit current user instructions and final confirmation win. The installed set contains at most one contribution per kind, and all four kinds may coexist. Brand identity overrides Deck identity when both are present. Layout overrides only Deck structure; Deck still owns its reusable application context and any identity not overridden by Brand. Without Layout, Deck owns structure. Style owns Direction / method only: its color, typography, icon, and image values are candidate defaults and never override resolved Brand/Deck identity or become official facts. Preferred Mode / Visual Style values seed Stage 2; the Style overlay must resolve into the final single `mode` and `visual_style` lock rather than create a parallel narrative or aesthetic authority. Style takes this segment ahead of ordinary Stage-2 defaults; the active structural prototypes and Deck Signature facts remain compatibility constraints, not a second method owner. Library/explicit provenance never changes this order. Each of the three directions still carries six palette roles and complete fonts: repeat fixed Brand/Deck values with `typography.fixed: true`; adapt Style candidates to that identity and vary only open roles. Keep resolved icon and image constraints. Style Review Focus never activates [`visual-review`](../workflows/stages/visual-review.md); only an explicit user request does. **Template design precedence**: Explicit current user instructions and final confirmation win. The installed set contributes at most one of each kind; all four may coexist. Brand overrides Deck identity. Layout overrides Deck structure; Deck retains application context and identity not overridden by Brand. Without Layout, Deck owns structure. Style owns Direction / method only: its visual values remain candidate defaults and yield to resolved Brand/Deck identity, while its preferred Mode / Visual Style seed the single final locks instead of creating parallel authority. Style takes this segment ahead of ordinary Stage-2 defaults; active prototypes and Deck Signature facts remain compatibility constraints. Library/explicit provenance never changes this order.
**Default — template-led recommendation (may override when explicit user or confirmed-contract requirements demand another result)**: Make all three directions obey the same resolved template context. Repeat fixed Brand/Deck palette roles and complete fonts with `typography.fixed: true`; keep resolved icon/image constraints and vary only open roles or dimensions. Mark the viable direction that most fully expresses the template-owned or template-informed structure, visual language, and application rules as `design_directions.selected`. Never weaken template use or split its segments across cards to manufacture alternatives. Style Review Focus never activates [`visual-review`](../workflows/stages/visual-review.md); only an explicit user request does.
--- ---
@@ -79,11 +90,11 @@ For Style-only or Style + Brand, write `pptx_structure.mode: flat` plus `templat
For `mirror` / `layout`, write `pptx_structure.mode: structured` plus `template_adherence: strict|adaptive`; mirror always writes `strict`. Do not write legacy `baseline`, `template`, `preserve`, `layout_strategy`, or Layout-kind rows. For `mirror` / `layout`, write `pptx_structure.mode: structured` plus `template_adherence: strict|adaptive`; mirror always writes `strict`. Do not write legacy `baseline`, `template`, `preserve`, `layout_strategy`, or Layout-kind rows.
- **Master roster**: Write one `pptx_masters` row per Master as `<master_key>: <picker name>` and copy the workspace's prototype roster. Keys use 164 ASCII letters, digits, dots, underscores, or hyphens, start with a letter/digit, and contain no spaces; human-readable spaces belong only in the picker name. Master visuals are root-level atomic elements and may never be `<g>`. - **Master roster**: Write one `pptx_masters` row per Master as `<master_key>: <picker name>` and copy the workspace's prototype roster. Keys use 164 ASCII letters, digits, dots, underscores, or hyphens, start with a letter/digit, and contain no spaces; human-readable spaces belong only in the picker name. Master visuals are root-level atomic elements and may never be `<g>`.
- **Reusable Layout roster**: Write every unique Layout once as `<layout_key>: <master_key> | <PowerPoint layout name> | <prototype source>`. Copy installed `template:<basename>` sources, including currently unused Layouts. A new adaptive Layout uses its first generated `P<NN>` as source. Reuse a key only when fixed atoms and slot ids/types/indices/bounds/binding modes are identical. Name authored keys after composition, never page topic. A Layout may intentionally have zero slots; do not manufacture an empty `utility` kind or full-page fake slot. - **Reusable Layout roster**: Write every unique Layout once as `<layout_key>: <master_key> | <PowerPoint layout name> | <prototype source>`. Each installed `template:<basename>` source is a complete Slide prototype, including one not selected for the current generated deck. A new adaptive Layout uses its first generated `P<NN>` as source. Reuse a key only when fixed atoms and slot ids/types/indices/bounds/binding modes are identical. Name authored keys after composition, never page topic. A Layout may intentionally have zero slots; do not manufacture an empty `utility` kind or full-page fake slot.
- **Page assignment**: Write exactly one `page_pptx_layouts` row per page. Each key must exist in `pptx_layouts`. Check that distinct compositions do not collapse into role-only keys and that one skeleton does not split into topic-specific keys. - **Page assignment**: Write exactly one `page_pptx_layouts` row per page. Each key must exist in `pptx_layouts`. Check that distinct compositions do not collapse into role-only keys and that one skeleton does not split into topic-specific keys.
- **Slot planning**: Each reusable slot is a direct root `<g id>` with `data-pptx-placeholder`, positive design-zone bounds, and exactly one compatible direct carrier. Bounds come from the intended safe area, column, panel inset, or media frame—not sample text ink. A genuinely composite region may use only the explicit `object` + `proxy` downgrade. - **Slot planning**: Each reusable slot is a direct root `<g id>` with `data-pptx-placeholder`, positive design-zone bounds, and exactly one compatible direct carrier. Bounds come from the intended safe area, column, panel inset, or media frame—not sample text ink. A genuinely composite region may use only the explicit `object` + `proxy` downgrade.
- **Adaptive refinement**: Initial definitions are complete. If construction shows that reusable framing or slot topology/bounds must change, return to Strategist to add a definition sourced from that page and update its assignment before execution resumes. Executor never mutates or extends the contract; export only compiles declared structure and never discovers or clusters Layouts. - **Adaptive refinement**: Initial definitions are complete. If construction shows that reusable framing or slot topology/bounds must change, return to Strategist to add a definition sourced from that page and update its assignment before execution resumes. Executor never mutates or extends the contract; export only compiles declared structure and never discovers or clusters Layouts.
- **Input prototypes**: Add one `page_layouts` row per page. Strict preserves that SVG's contract; adaptive keeps its Master and may declare a new output Layout; mirror also preserves literal visuals and text-node topology. - **Input prototypes**: Add one `page_layouts` row per page using a complete Slide prototype. Strict preserves that SVG's contract; adaptive keeps its Master and may declare a new output Layout. Mirror preserves ordinary authored visuals and text-node topology; a direct JSON-first Chart/Table may regenerate only its derived preview children while retaining marker, metadata, bounds, and structure.
**Visualization compatibility**: Use `page_layouts` with optional Chart/Table **Visualization compatibility**: Use `page_layouts` with optional Chart/Table
`page_visualizations` only when the prototype shell can carry the actual §IX `page_visualizations` only when the prototype shell can carry the actual §IX
@@ -34,9 +34,9 @@ solution + production gate:
| Stage | Items | Role | | Stage | Items | Role |
|---|---|---| |---|---|---|
| **1 — communication contract + template choice** | `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 · explicit `free_design` or `templates` choice and selected roots | confirmed together; candidate workspaces do not influence the communication recommendation | | **1 — communication contract + template choice** | `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 · explicit `free_design` or `templates` choice and selected roots | confirmed together; candidate workspaces do not influence the communication recommendation |
| **2 — final solution + production** (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 · conditional AI-image acquisition path · generation mode · refine-spec toggle · proactive speaker notes / custom animations / narration audio | derived as one coherent plan from the confirmed contract; internal template exporter modes remain hidden | | **2 — final solution + production** (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 · conditional AI-image acquisition path · generation mode · refine-spec toggle · `design_spec_depth` · proactive speaker notes / custom animations / narration audio | derived as one coherent plan from the confirmed contract; internal template exporter modes remain hidden |
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, image direction, production mechanics, and how any installed template should be used. It never chooses or installs a template. Inspect only project-local template spec/prototypes, present one editable application plan, and keep exporter reuse/adherence internal. First author exactly three complete, project-fit solution directions from the confirmed contract and source; only then project each direction into mode, visual style, color, type, icons, and generated-image rendering for lower-level adjustment. Every direction projects a project-specific `custom` mode, `custom` visual style, and `custom` generated-image rendering; the fixed catalogs remain conservative lower-level single-select alternatives. Each direction is one complete design authored top-down within the confirmed contract, never assembled bottom-up from catalog picks; three exist so a single recommendation cannot lock the user in, and the fixed catalogs stay the manual lower layer. Its custom projections are unrestricted by catalog relationship and may carry one preset unchanged. The three directions are plainly different designs at the whole-deck level before any field is written; that difference lives in the solutions, not in one designated field. Whichever components a direction's own design requires carry it — mode, visual style, rendering, color, type, or icons — any of them may, and none is required to differ. A different name, note, or reference count alone is not a difference, and projections identical on every component are not three solutions. Do not force safe / shifted / bold archetypes or artificial extremes. After all three bundles are complete, compare them against the confirmed contract and source, choose the strongest overall fit, and write its actual zero-based index as `design_directions.selected` (`0`, `1`, or `2`); array order never determines preference. Every direction carries a complete generated-image rendering candidate even when AI imagery is not recommended; `recommend.image_usage` independently decides whether AI is proposed. Generated images inherit deck colors—there is no second image palette. 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`. 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, image direction, production mechanics, and how any installed template should be used. It never chooses or installs a template. Inspect only project-local template spec/prototypes, present one editable application plan, and keep exporter reuse/adherence internal. First author exactly three complete, project-fit solution directions from the confirmed contract and source; only then project each direction into mode, visual style, color, type, icons, and generated-image rendering for lower-level adjustment. Every direction projects a project-specific `custom` mode, `custom` visual style, and `custom` generated-image rendering; the fixed catalogs remain conservative lower-level single-select alternatives. Each direction is one complete design authored top-down within the confirmed contract, never assembled bottom-up from catalog picks; three exist so a single recommendation cannot lock the user in, and the fixed catalogs stay the manual lower layer. Its custom projections are unrestricted by catalog relationship and may carry one preset unchanged. The three directions are plainly different designs at the whole-deck level before any field is written; that difference lives in the solutions, not in one designated field. Whichever components a direction's own design requires carry it — mode, visual style, rendering, color, type, or icons — any of them may, and none is required to differ. A different name, note, or reference count alone is not a difference, and projections identical on every component are not three solutions. Do not force safe / shifted / bold archetypes or artificial extremes. After all three bundles are complete, choose the strongest overall fit when no template is installed; with installed template state, choose the viable direction that most strongly expresses its resolved context under [`strategist-template.md`](./strategist-template.md). Write its actual zero-based index as `design_directions.selected` (`0`, `1`, or `2`); array order never determines preference. Every direction carries a complete generated-image rendering candidate even when AI imagery is not recommended; `recommend.image_usage` independently decides whether AI is proposed. Generated images inherit deck colors—there is no second image palette. 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. Recommend `design_spec_depth: brief` — the same author draws the pages, so full page copy in the spec is duplicated work; recommend `complete` only when `split` mode, `refine_spec: true`, or a preservation profile forces it, or the user asked for a hand-off document others will read. 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`.
**Default — continuity-aware whole solution (may override when a scene reset communicates better)**: Within active-profile invariants and before recommending page count or production mechanics, judge whether adjacent explanation beats can remain within one recognizable mental map while a visible state changes. Where that choice lowers cognitive switching and motion has a named communication job, let it shape the solution's narrative spine, page rhythm, visual approach, and enabled notes/narration segmentation, and recommend the existing `proactive_custom_animations: true`. This is one positive signal, not the only reason to enable animation; absent it, retain the existing default or other valid evidence. Topic or wording repetition alone is insufficient. A `Motion suggestion` remains optional advice and never changes the effective outcome. **Default — continuity-aware whole solution (may override when a scene reset communicates better)**: Within active-profile invariants and before recommending page count or production mechanics, judge whether adjacent explanation beats can remain within one recognizable mental map while a visible state changes. Where that choice lowers cognitive switching and motion has a named communication job, let it shape the solution's narrative spine, page rhythm, visual approach, and enabled notes/narration segmentation, and recommend the existing `proactive_custom_animations: true`. This is one positive signal, not the only reason to enable animation; absent it, retain the existing default or other valid evidence. Topic or wording repetition alone is insufficient. A `Motion suggestion` remains optional advice and never changes the effective outcome.
@@ -119,7 +119,7 @@ When authoring §IX, translate every purpose named in `communication_intent` int
| Mobilize | Urgency + agency + concrete action + immediate next step | | Mobilize | Urgency + agency + concrete action + immediate next step |
| Record and hand off | Context + decisions + status + owners + unresolved items + durable provenance | | Record and hand off | Context + decisions + status + owners + unresolved items + durable provenance |
**Material-divergence consumption — outline-authoring only.** Apply the user's stated divergence intent when authoring the `§IX` outline. Record the prose (or "balanced default") in `design_spec.md §I` (Content Strategy). Do **NOT** write it to `spec_lock.md`—it is baked into `§IX` at authoring time and the Executor never reads it. It carries no page-count coupling. Beautify seeds verbatim preservation and surfaces the field as locked/read-only; the server restores the locked value on every staged submit. Fill Native PPTX does not surface the field because that route is outside this confirmation flow. **Material-divergence consumption — outline-authoring only.** Apply the user's stated divergence intent when authoring the `§IX` outline. Record the prose (or "balanced default") in `design_spec.md §I` (Content Strategy). Do **NOT** write it to `spec_lock.md`—it is baked into `§IX` at authoring time and the Executor never reads it. It carries no page-count coupling. Beautify seeds verbatim preservation and surfaces the field as locked/read-only; the server restores the locked value on every staged submit. Edit Native PPTX does not surface the field; it is outside this confirmation flow.
### d. Style Objective Confirmation ### d. Style Objective Confirmation
@@ -151,7 +151,7 @@ The deck's **visual aesthetic** — shape language, decoration density, whitespa
**Source**: **Source**:
- User named a style (chat / template / beautify) → it is truth: retain it as the required basis or inherited anchor in every custom behavior. Derive each direction's application through the style dimensions left open. When the user or template forbids all visual variation, the three style behaviors may be identical and the remaining open components carry the direction difference; state that boundary in the direction note instead of fabricating difference. - User named a style (chat / template / beautify) → it is truth: retain it as the required basis or inherited anchor in every custom behavior. Derive each direction's application through the style dimensions left open. When the user or template forbids all visual variation, the three style behaviors may be identical and the remaining open components carry the direction difference; state that boundary in the direction note instead of fabricating difference.
- No user description → author three project-fit whole solutions first, then project one complete custom aesthetic for each. A custom aesthetic is not constrained by its relationship to the catalog; it may use catalog material in any way or none, including carrying one fitting style unchanged. Directions may share catalog bases, and their `visual_style_behavior` values differ whenever the three designs genuinely differ in aesthetic rather than to satisfy a variation quota. Do not force different bases, a safe-to-bold ladder, or one deliberately extreme option merely to manufacture variety. Give each direction a localized name and use its localized note as a compact, user-facing style summary. The note may reuse localized display labels from Confirm UI's `visual_styles` catalog (for example, `瑞士极简`, `柔和圆角`, or `编辑出版`) when they concisely describe the result, but these labels are optional vocabulary, not a selection constraint or required mapping. Use concise natural language wherever the catalog wording does not fit, and never force the nearest label. Keep the summary to one or two short sentences without exposing catalog ids or reference mechanics. The Confirm UI exposes these three project-specific styles above all 18 fixed manual alternatives. - No user description → author three project-fit whole solutions first, then project one complete custom aesthetic for each. Write each behavior as the carriers and techniques the direction uses — containers, icons, swatches, shadows, gradients, image treatments, native shapes — never as a list of what it avoids; a prohibition appears only when the user or the material requires it, because a locked prohibition removes that tool from every page. A custom aesthetic is not constrained by its relationship to the catalog; it may use catalog material in any way or none, including carrying one fitting style unchanged. Directions may share catalog bases, and their `visual_style_behavior` values differ whenever the three designs genuinely differ in aesthetic rather than to satisfy a variation quota. Do not force different bases, a safe-to-bold ladder, or one deliberately extreme option merely to manufacture variety. Give each direction a name and note, written once in the confirmed UI language (plain keys, no locale suffixes), as a compact user-facing style summary. The note may reuse localized display labels from Confirm UI's `visual_styles` catalog (for example, `瑞士极简`, `柔和圆角`, or `编辑出版`) when they concisely describe the result, but these labels are optional vocabulary, not a selection constraint or required mapping. Use concise natural language wherever the catalog wording does not fit, and never force the nearest label. Keep the summary to one or two short sentences without exposing catalog ids or reference mechanics. The Confirm UI exposes these three project-specific styles above all 18 fixed manual alternatives.
**Forbidden — a non-catalog name as `visual_style`**: every direction recommendation uses literal `custom` for `visual_style`; bespoke prose belongs only in `visual_style_behavior`, while optional `visual_style_references` contain only first-column catalog ids. A name from the `_index` "Paired rendering" column (`flat`, `vector-illustration`, `digital-dashboard`, `3d-isometric`, `corporate-photo`, …) is an image-rendering id, not a style reference. Generic words such as flat / modern / clean / simple / minimal are also insufficient behavior: use the index to choose an exact basis when applicable, then state the executable shape language, composition, density, whitespace, typography, and texture carried into this direction. Those rules may match one preset exactly; do not invent a difference merely to justify `custom`. **Forbidden — a non-catalog name as `visual_style`**: every direction recommendation uses literal `custom` for `visual_style`; bespoke prose belongs only in `visual_style_behavior`, while optional `visual_style_references` contain only first-column catalog ids. A name from the `_index` "Paired rendering" column (`flat`, `vector-illustration`, `digital-dashboard`, `3d-isometric`, `corporate-photo`, …) is an image-rendering id, not a style reference. Generic words such as flat / modern / clean / simple / minimal are also insufficient behavior: use the index to choose an exact basis when applicable, then state the executable shape language, composition, density, whitespace, typography, and texture carried into this direction. Those rules may match one preset exactly; do not invent a difference merely to justify `custom`.
@@ -165,7 +165,9 @@ Record the confirmed visual style and rationale in `design_spec.md` first, inclu
### e. Color Scheme Recommendation ### e. Color Scheme Recommendation
**Hard rule**: User-specified colors are truth. Lock supplied HEX, brand colors, or natural-language directives; templates follow inherited-design precedence. Even direct locks fill all six roles (`background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, `body_text`) in each of the three directions: repeat fixed roles and vary only open ones. Never emit an empty palette. Keep body-text contrast at least 4.5:1 and preserve confirmed/brand semantic roles. **Hard rule**: User-specified colors are truth. Lock supplied HEX, brand colors, or natural-language directives; templates follow inherited-design precedence. Even direct locks fill all six roles (`background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, `body_text`) in each of the three directions: repeat fixed roles and vary only open ones. Never emit an empty palette. Preserve confirmed/brand semantic roles. When writing §III, derive the standard `secondary_text` and `divider` neutrals and project them to `spec_lock.md colors`; §V fixes the five deck-wide spacing anchors.
**Reference — not a constraint**: WCAG AA body-text contrast is 4.5:1.
**Reference — not a constraint**: Without user/template colors, propose project-specific directions from content and style. `scripts/config.py` industry colors and dominant/support/accent hierarchy are recall aids, never default locks, ratios, or color-count quotas. **Reference — not a constraint**: Without user/template colors, propose project-specific directions from content and style. `scripts/config.py` industry colors and dominant/support/accent hierarchy are recall aids, never default locks, ratios, or color-count quotas.
@@ -186,7 +188,7 @@ The base icon style is one single-select identity, not a material whitelist:
| **A** | Emoji | Casual, playful, social media | | **A** | Emoji | Casual, playful, social media |
| **B** | Built-in generic icon library | Recurring compact semantic cues in one coherent SVG style | | **B** | Built-in generic icon library | Recurring compact semantic cues in one coherent SVG style |
| **C** | Custom project icons | Supplied, template-carried, or imported assets | | **C** | Custom project icons | Supplied, template-carried, or imported assets |
| **D** | No base icons | Illustration, typography, shapes, or data already carry the compact cues | | **D** | No base icons | No shared generic base-icon identity is selected |
AI-generated illustrated icons are not a base-style option, add-on, Confirm UI AI-generated illustrated icons are not a base-style option, add-on, Confirm UI
field, or result key. Like decorative lettering, they are a downstream image field, or result key. Like decorative lettering, they are a downstream image
@@ -195,40 +197,38 @@ proactively when AI imagery is appropriate. Their transparent slices stay
under `images/`; never put them under `icons/`, add them to `icons.inventory`, under `images/`; never put them under `icons/`, add them to `icons.inventory`,
or reference them through `<use data-icon>`. or reference them through `<use data-icon>`.
Base SVG/emoji icons and illustrated-icon slices may be combined when the page Base SVG/emoji icons and illustrated-icon slices may be combined. Real brand
benefits, as long as the overall visual treatment remains coherent. Real brand
marks remain identity assets rather than another stylistic library. marks remain identity assets rather than another stylistic library.
The built-in icon library contains multiple stylistic libraries plus a brand-logo library: The built-in icon library contains multiple stylistic libraries plus a brand-logo library:
See [`../templates/icons/README.md`](../templates/icons/README.md) for the current library inventory, counts, prefixes, and SVG placeholder details. See [`../templates/icons/README.md`](../templates/icons/README.md) for the current library inventory, counts, prefixes, and SVG placeholder details.
Content-driven brand preparation applies under every base choice: if a real Brand preparation applies under every base choice: a real company, product,
company, product, service, or social identity appears and its mark improves service, or social identity that appears in the content may use its exact
recognition, prepare the exact supplied or `simple-icons` asset; otherwise do supplied or `simple-icons` mark. This requires no extra user-facing option.
not add one. This requires no extra user-facing option.
> **Mandatory rules for bundled SVG resources**: > **Mandatory rules for bundled SVG resources**:
> >
> **At the Strategist confirmation stage — decide the generic base library and stroke only; resolve generic and content-driven brand filenames after approval.** > **At the Strategist confirmation stage — decide the generic base library and stroke only; resolve generic and content-driven brand filenames after approval.**
> >
> 1. **Pick at most one primary stylistic library from the four bundled choices** — when generic icons are needed, read the source material and choose the one whose visual character best serves the deck: > 1. **One primary stylistic library per pool** (`icon_sync.py` rejects a batch that mixes them) — the four bundled choices:
> - **`chunk-filled`** — fill, straight-line geometry (M/L/H/V/Z only); sharp right angles; heavy, solid, architectural > - **`chunk-filled`** — fill, straight-line geometry (M/L/H/V/Z only); sharp right angles; heavy, solid, architectural
> - **`tabler-filled`** — fill, bezier curves and arcs (C/A); smooth, rounded, organic; medium weight, approachable > - **`tabler-filled`** — fill, bezier curves and arcs (C/A); smooth, rounded, organic; medium weight, approachable
> - **`tabler-outline`** — stroke (line art); airy, refined, lightweight; best for screen-only (thin strokes may be hard to read in print) > - **`tabler-outline`** — stroke (line art); airy, refined, lightweight; best for screen-only (thin strokes may be hard to read in print)
> - **`phosphor-duotone`** — duotone; main shape + 20% opacity backplate; medium weight, layered, contemporary > - **`phosphor-duotone`** — duotone; main shape + 20% opacity backplate; medium weight, layered, contemporary
> - During bundled-library selection, do not select generic icons from more than one of `chunk-filled` / `tabler-filled` / `tabler-outline` / `phosphor-duotone`. If the chosen library lacks an exact icon, find the closest alternative **within that same library**. > - A generic icon missing from the chosen library is replaced **within that same library**.
> - **`simple-icons` is never a Confirm UI choice**: it is a brand-logo resource that Strategist prepares only when the actual content needs a real company / product / service mark (customer logos, tech-stack icons, social handles). It may be used with any base selection, including `none`, and never substitutes for a missing generic icon. > - **`simple-icons` is never a Confirm UI choice**: it is a brand-logo resource for real company / product / service marks (customer logos, tech-stack icons, social handles). It may accompany any base selection, including `none`, and holds no generic icons.
> - This restriction governs Strategist selection from the bundled catalog, not the prepared project asset pool. User-provided, template-carried, imported, custom, and previously prepared files under `<project_path>/icons/` remain valid material regardless of namespace or visual style. > - This restriction governs Strategist selection from the bundled catalog, not the prepared project asset pool. User-provided, template-carried, imported, custom, and previously prepared files under `<project_path>/icons/` remain valid material regardless of namespace or visual style.
> 2. **Stroke weight lock (stroke-style libraries only)** — for stroke-based libraries (currently `tabler-outline`), pick one deck-wide value from `{1.5, 2, 3}` (default `2`). For heavier presence, switch library instead of going above `3`. > 2. **Stroke weight lock (stroke-style libraries only)** — for stroke-based libraries (currently `tabler-outline`), pick one deck-wide value from `{1.5, 2, 3}` (default `2`).
> >
> **After the Strategist confirmation stage is approved — when writing `design_spec.md` §VI / `spec_lock.md`**, materialize a curated project icon pool: > **After the Strategist confirmation stage is approved — when writing `design_spec.md` §VI / `spec_lock.md`**, materialize a curated project icon pool:
> >
> 3. Choose a reusable set that covers recurring semantics and likely slide needs in the confirmed outline. Do not preassign individual icons to pages or add filler to meet a quota. > 3. A confirmed bundled library is materialized as a synced project pool before Executor starts; Executor cannot sync. Which prepared icons a page uses is realization, never a preassignment.
> 4. Put known basenames in the final batch. For an uncertain one, search the chosen style library — or `simple-icons` for a real brand mark — with `rg --files "skills/ppt-master/templates/icons/<library>" -g '*<drawable-object>*.svg'`. Abstract concept words return nothing; translate the semantic into a drawable object first, per [`../templates/icons/README.md`](../templates/icons/README.md). Do not enumerate broad keyword families. > 4. Put known basenames in the final batch. For an uncertain one, search the chosen style library — or `simple-icons` for a real brand mark — with `rg --files "skills/ppt-master/templates/icons/<library>" -g '*<drawable-object>*.svg'`. Abstract concept words return nothing; translate the semantic into a drawable object first, per [`../templates/icons/README.md`](../templates/icons/README.md). Do not enumerate broad keyword families.
> 5. **Copy and validate in one batch** — run `python3 skills/ppt-master/scripts/icon_sync.py <project_path> <lib/name> [<lib/name> …]`. This both validates and materializes `<project>/icons/<lib>/`; skip per-file prechecks. > 5. **Copy and validate in one batch** — run `python3 skills/ppt-master/scripts/icon_sync.py <project_path> <lib/name> [<lib/name> …]`. This both validates and materializes `<project>/icons/<lib>/`; skip per-file prechecks.
> 6. Keep each successful, case-sensitive `lib/name`: bundled basenames are lowercase (`tabler-outline/award`, never `tabler-outline/Award`); custom icons retain exact case. > 6. Keep each successful, case-sensitive `lib/name`: bundled basenames are lowercase (`tabler-outline/award`, never `tabler-outline/Award`); custom icons retain exact case.
> 7. Record each synced bundled path with broad suitable scenarios in `design_spec.md` §VI; record the same curated pool, its primary stylistic library, and any stroke-library `stroke_width` in `spec_lock.md icons`. Keep actually needed `simple-icons/*` ids in the same inventory without treating them as a second stylistic library or user-facing selection. The pool is prepared optional material, not a page-use plan, coverage quota, or whitelist over other prepared project-local icons. > 7. Record each synced bundled path with broad suitable scenarios in `design_spec.md` §VI; record the same curated pool, its primary stylistic library, and any stroke-library `stroke_width` in `spec_lock.md icons`. Keep actually needed `simple-icons/*` ids in the same inventory without treating them as a second stylistic library or user-facing selection. `inventory` indexes the synced pool; other prepared project-local icons remain usable.
> >
> 🚧 **GATE — missing icon = re-pick now**: on non-zero exit, search a missing generic concept only in the chosen stylistic library, or a missing real brand mark in `simple-icons`; re-pick and rerun the final batch until clean. Never carry a missing icon forward or switch among the four stylistic libraries to fill the gap. > 🚧 **GATE — missing icon = re-pick now**: on non-zero exit, search a missing generic concept only in the chosen stylistic library, or a missing real brand mark in `simple-icons`; re-pick and rerun the final batch until clean. Never carry a missing icon forward or switch among the four stylistic libraries to fill the gap.
> >
@@ -293,86 +293,17 @@ page block, record the linked text/object and its exact absolute URI or final
inline/whole-object carrier, or create a link manifest or lock entry. Executor inline/whole-object carrier, or create a link manifest or lock entry. Executor
owns SVG authoring under [`native-hyperlinks.md`](./native-hyperlinks.md). owns SVG authoring under [`native-hyperlinks.md`](./native-hyperlinks.md).
### h. Image Source Recommendation
| Source id | Approach | Use when |
|---|---|---|
| `none` | No images | Data reports or process documentation whose visual burden is fully served by charts / native SVG |
| `provided` | User-provided assets | Existing images carry factual, brand, product, or narrative authority |
| `ai` | AI-generated | Invented or deliberately stylized scenes, illustrations, backgrounds, metaphors, decorative lettering, or another generated visual treatment is needed |
| `web` | Web-sourced | A named or evidence-bearing real-world subject must appear as itself |
| `placeholder` | Deferred | The image is required but will be supplied later |
**Current inventory**: If `images/` is non-empty, run `python3 scripts/analyze_images.py <project_path>/images` and read `analysis/image_analysis.csv` before recommending a source. Re-run after that folder changes.
**Hard rule**: Credentials do not decide image need. Missing `IMAGE_BACKEND`, host generation, or keyed stock-provider credentials never justifies `none` or deletion of a planned web-compatible role. Web search retains zero-config providers; an explicit generation-only requirement follows the normal Offline Manual boundary.
**Mandatory — no AI capability preflight**: When `recommend.image_usage` includes `ai`, preserve an explicit user path instruction; otherwise recommend `auto`. Do not inspect backend configuration, check host-tool availability, or probe a provider during planning. Generate Step 5 execution is the first capability check.
**Default — visual grounding before `none` (may override when the full-roster carrier review finds no useful image job)**: Honor an explicit no-image requirement. First decide whether the audience must recognize, experience, compare, or choose an externally verifiable subject, place, product, or setting. When yes, propose `provided` / `web`; propose `ai` when invented or deliberately stylized expression materially improves a planned visual job. Treat `none` as a positive whole-deck conclusion when no image source owns a meaningful communication job. Mixed sources may serve different page roles. The three Stage-2 style directions never settle source: a rendering candidate resolves how imagery looks, never whether a real subject must appear as itself.
**Default — consider proactive illustrated icons without creating another
confirmation field**: Before each Stage-2 `recommend.image_usage`, consider
whether compact semantic jobs would communicate better through a coherent
illustrated cue family. This may support an `ai` recommendation, but it is not
an automatic source trigger or coverage quota. Resource grouping remains a
fit-driven decision under [`strategist-image.md`](./strategist-image.md).
**Mandatory — scan proactive decorative-lettering capability without making
candidates an automatic source trigger**: Before each Stage-2
`recommend.image_usage`, scan the complete planned roster for exact stable
display strings whose artistic treatment could plausibly communicate better
than native type. Page role, character count, word count, line count, kind of
noun, and proposed style never pre-filter candidates: a complete long or
multi-line title is as eligible as a short mark. Never invent, rewrite,
shorten, or split copy to make generation easier. Passing both discovery
questions exposes one possible AI visual job, not a selected resource or a
mechanical reason to add `ai`. During page-carrier planning, compare those
candidates with native type and the complete deck mix; select only the marks
whose treatment wins that fit. Zero selected marks remains valid and needs no
skip explanation or coverage quota. A selected mark may be the sole intended
AI job and may support an AI recommendation stated in `image_notes.value`;
when either discovery answer is no, native editable text remains valid. The
absence of another AI-image job never forces lettering. Explicit no-AI or
editable-only requirements win. Execution follows
[`image-generator.md`](./image-generator.md) §7.
**Recommendation output**: Write `recommend.image_usage` as one source id or an array for mixed sources. Put the intended communication jobs of each proposed source, authoritative assets, preferred/avoided imagery, and placeholder tolerance in `image_notes.value`. When `ai` is proposed, explain in editable natural language how generated visuals are expected to contribute and mention any materially anticipated illustration, illustrated-icon, or lettering role. Keep the note an open strategy—not an enum, carrier allowlist, page-by-page assignment, count, or resource manifest; name exact pages/assets only when already authoritative or required. `none` is exclusive. Decks built around real-world recognition or choice, including travel itineraries, lean `provided` / `web`; generic human-scale topics such as family life, education, wellness, or children lean `ai` when no supplied asset carries the story and invented or stylized expression serves it. Regulated investor decks, B2B finance reports, and data-only dashboards remain eligible for `none` by judgment.
**Confirmed value wins**: Accept the confirmed legacy string or multi-select array. Map `ai→ai`, `web→web`, `provided→user`, and `placeholder→placeholder` into §VIII `Acquire Via`. Every direction already carries a rendering candidate whether or not AI is proposed; generated images inherit the deck colors and never introduce a second image-palette choice.
**Always-on decision module; conditional resource extension**:
1. Before authoring Stage-2 directions, load the workflow's complete fixed
planning-capability batch. It includes this module, the image-layout
authorities, all compact decision maps, the icon-library contract, the
complete Chart and Table expression vocabularies. After the
three whole-direction intents exist and their mode/style/rendering reference
ids are frozen, read only
those exact detail siblings once and author one complete custom rendering
inside each direction before deciding whether `recommend.image_usage`
includes AI.
2. Independently derive `recommend.image_usage` from source needs. Confirmed
non-`none` sources activate the module's resource-planning sections.
Confirmed `none` writes no image rows, but does not erase the three
recommendation-only rendering candidates or the already-loaded composition
vocabulary.
The module owns AI rendering alternatives, acquisition paths, resource rows, prompt depth, page roles, and placement intent.
### Page Carrier and Capability Planning (Non-blocking — Strategist recommends, no user confirmation needed) ### Page Carrier and Capability Planning (Non-blocking — Strategist recommends, no user confirmation needed)
**Mandatory — one-pass page carrier planning**: During the same §IX roster **Default — carrier planning in §IX (may stay implicit when a page's mix is obvious)**: During the same §IX roster
composition, resolve each page's complete semantic carrier mix—background composition, decide each page's semantic carrier mix—background
field, editable text and optional lettering, native-geometry/relationship jobs, field, editable text and optional lettering, native-geometry/relationship jobs,
photos/scenes/illustrations/icons, and applicable visualizations—before photos/scenes/illustrations/icons, and applicable visualizations—before
deriving §VIII resource rows. Decide the primary, structural, and supporting deriving §VIII resource rows. Decide the primary, structural, and supporting
jobs together; do not finish a text/container layout and then treat the other jobs together. Use existing §VI/§VIII/§IX fields: keep the
families as optional decoration. Use existing §VI/§VIII/§IX fields: keep the
ordinary icon basis and prepared-pool plan in §VI, and add an image, lettering, ordinary icon basis and prepared-pool plan in §VI, and add an image, lettering,
or illustrated-icon resource to §VIII only when the page mix assigns it a or illustrated-icon resource to §VIII only when the page mix assigns it a
plausible job. This creates no new field or candidate inventory; omitting any plausible job. This creates no new field or candidate inventory. Macro composition recommendations
carrier remains valid after the same review. Macro composition recommendations
remain Reference; planned resource identities/jobs and explicit user/template remain Reference; planned resource identities/jobs and explicit user/template
requirements retain their existing authority. requirements retain their existing authority.
@@ -421,13 +352,11 @@ Classify by information model, never source PowerPoint object type:
**Mandatory — relationship handoff**: keep every qualitative relationship in §IX free prose; never serialize grammar atoms, coordinates, or named models. Executor makes the per-page Structure decision at runtime. **Mandatory — relationship handoff**: keep every qualitative relationship in §IX free prose; never serialize grammar atoms, coordinates, or named models. Executor makes the per-page Structure decision at runtime.
**Mandatory — complete Chart/Table capability review**: During the same §IX **Reference — Chart/Table vocabularies**: the already-loaded Chart and Table
roster composition, compare every page's information model against every entry expression vocabularies list what can be selected for a page's information
in the already-loaded Chart and Table expression vocabularies. These model; their descriptions do not rank candidates or replace judgment from the
complete capability maps expose what can be selected; their descriptions do actual information. Custom objects and qualitative composition stay outside
not rank candidates or replace judgment from the actual information. They are them. Retain `no-template-match` when no registered reference fits.
neither usage quotas nor whitelists. Skip custom objects and qualitative
composition. Retain `no-template-match` when no registered reference fits.
**Selection**: **Selection**:
@@ -453,6 +382,73 @@ Correct failed selections by re-reading the complete vocabulary/registry;
| P03 | chart | line_chart | Compare the source metrics over time | | P03 | chart | line_chart | Compare the source metrics over time |
``` ```
### h. Image Source Recommendation
| Source id | Approach | Use when |
|---|---|---|
| `none` | No images | No image source owns a meaningful communication job for the planned deck |
| `provided` | User-provided assets | Existing images carry factual, brand, product, or narrative authority |
| `ai` | AI-generated | Invented or deliberately stylized scenes, illustrations, backgrounds, metaphors, decorative lettering, or another generated visual treatment is needed |
| `web` | Web-sourced | Named or evidence-bearing real-world subjects that must appear as themselves, plus generic photographic mood, background, or scene jobs that benefit from sourced visual grounding |
| `placeholder` | Deferred | The image is required but will be supplied later |
**Current inventory**: If `images/` is non-empty, run `python3 scripts/analyze_images.py <project_path>/images` and read `analysis/image_analysis.csv` before recommending a source. Re-run after that folder changes.
**Hard rule**: Credentials do not decide image need. Missing `IMAGE_BACKEND`, host generation, or keyed stock-provider credentials never justifies `none` or deletion of a planned web-compatible role. Web search retains zero-config providers; an explicit generation-only requirement follows the normal Offline Manual boundary.
**Mandatory — no AI capability preflight**: When `recommend.image_usage` includes `ai`, preserve an explicit user path instruction; otherwise recommend `auto`. Do not inspect backend configuration, check host-tool availability, or probe a provider during planning. Generate Step 5 execution is the first capability check.
**Default — visual grounding before `none` (may override when the full-roster carrier review finds no useful image job)**: Honor an explicit no-image requirement. First decide whether the audience must recognize, experience, compare, or choose an externally verifiable subject, place, product, or setting. When yes, propose `provided` / `web`; propose `ai` when invented or deliberately stylized expression materially improves a planned visual job. Mixed sources may serve different page roles. The three Stage-2 style directions never settle source: a rendering candidate resolves how imagery looks, never whether a real subject must appear as itself.
**Default — consider proactive illustrated icons without creating another
confirmation field**: Before each Stage-2 `recommend.image_usage`, consider
whether compact semantic jobs would communicate better through a coherent
illustrated cue family. This may support an `ai` recommendation, but it is not
an automatic source trigger or coverage quota. Resource grouping remains a
fit-driven decision under [`strategist-image.md`](./strategist-image.md).
**Mandatory — scan proactive decorative-lettering capability without making
candidates an automatic source trigger**: Before each Stage-2
`recommend.image_usage`, scan the complete planned roster for exact stable
display strings whose artistic treatment could plausibly communicate better
than native type. Page role, character count, word count, line count, kind of
noun, and proposed style never pre-filter candidates: a complete long or
multi-line title is as eligible as a short mark. Never invent, rewrite,
shorten, or split copy to make generation easier. Passing both discovery
questions exposes one possible AI visual job, not a selected resource or a
mechanical reason to add `ai`. During page-carrier planning, compare those
candidates with native type and the complete deck mix; select only the marks
whose treatment wins that fit. Zero selected marks remains valid and needs no
skip explanation or coverage quota. A selected mark may be the sole intended
AI job and may support an AI recommendation stated in `image_notes.value`;
when either discovery answer is no, native editable text remains valid. The
absence of another AI-image job never forces lettering. Explicit no-AI or
editable-only requirements win. Execution follows
[`image-generator.md`](./image-generator.md) §7.
**Recommendation output**: Write `recommend.image_usage` as one source id or an array for mixed sources. Put the intended communication jobs of each proposed source, authoritative assets, preferred/avoided imagery, and placeholder tolerance in `image_notes.value`. When `ai` is proposed, explain in editable natural language how generated visuals are expected to contribute and mention any materially anticipated illustration, illustrated-icon, or lettering role. Keep the note an open strategy—not an enum, carrier allowlist, page-by-page assignment, count, or resource manifest; name exact pages/assets only when already authoritative or required. `none` is exclusive.
**Confirmed value wins**: Accept the confirmed legacy string or multi-select array. Map `ai→ai`, `web→web`, `provided→user`, and `placeholder→placeholder` into §VIII `Acquire Via`. Every direction already carries a rendering candidate whether or not AI is proposed; generated images inherit the deck colors and never introduce a second image-palette choice.
**Always-on decision module; conditional resource extension**:
1. Before authoring Stage-2 directions, load the workflow's complete fixed
planning-capability batch. It includes this module, the image-layout
authorities, all compact decision maps, the icon-library contract, the
complete Chart and Table expression vocabularies. After the
three whole-direction intents exist and their mode/style/rendering reference
ids are frozen, read only
those exact detail siblings once and author one complete custom rendering
inside each direction before deciding whether `recommend.image_usage`
includes AI.
2. Independently derive `recommend.image_usage` from source needs. Confirmed
non-`none` sources activate the module's resource-planning sections.
Confirmed `none` writes no image rows, but does not erase the three
recommendation-only rendering candidates or the already-loaded composition
vocabulary.
The module owns AI rendering alternatives, acquisition paths, resource rows, prompt depth, page roles, and placement intent.
### Speaker Notes Requirements ### Speaker Notes Requirements
Resolve the effective Speaker Notes outcome from the latest explicit user Resolve the effective Speaker Notes outcome from the latest explicit user
@@ -498,7 +494,7 @@ custom; neither role globs a detail catalog (see
## 3. Color Selection Reference ## 3. Color Selection Reference
Do not start from a universal palette. Precedence is user / brand → active template → project-specific proposal; `scripts/config.py` industry anchors are optional recall. Keep body-text contrast at least 4.5:1; color count and distribution follow encoding, style, and natural assets, not a quota. Do not start from a universal palette. Precedence is user / brand → active template → project-specific proposal; `scripts/config.py` industry anchors are optional recall. Color count and distribution follow encoding, style, and natural assets.
Lock the stable role set the deck needs, including recurring neutrals such as `surface`, `grid`, `scrim`, `overlay`, or `block-shade`. These are identity anchors, not an exhaustive paint list. Executor may derive tints, shades, alpha, gradients, and effects, preserve necessary natural asset colors, and add sparse page-local accents for differentiation or ornament. Such accents must not form a competing/recurring palette; Strategist owns reusable positive / warning / negative roles. Lock the stable role set the deck needs, including recurring neutrals such as `surface`, `grid`, `scrim`, `overlay`, or `block-shade`. These are identity anchors, not an exhaustive paint list. Executor may derive tints, shades, alpha, gradients, and effects, preserve necessary natural asset colors, and add sparse page-local accents for differentiation or ornament. Such accents must not form a competing/recurring palette; Strategist owns reusable positive / warning / negative roles.
@@ -553,11 +549,11 @@ required meaning in the visible page and confirmed presenter channel.
**Default — visible-state sequence (may override when a new composition is clearer)**: Before freezing the §IX roster and enabled notes/narration boundaries, compare adjacent semantic beats within the active profile's roster/content invariants. When recurring roles, relationships, and spatial orientation form one mental map and the next beat has a meaningful state or focus change, plan neighboring pages as visible states of that scene: preserve recognizable anchors, make the semantic delta legible, and align each enabled notes/narration segment with its supporting visible state. This is a content-and-rhythm strategy, not a page quota. Reset the composition when the mental map changes or continuity adds no clarity. Within the confirmed page count, every state page must carry content and an `Audience move`; the effective motion outcome changes realization, not roster authority. **Default — visible-state sequence (may override when a new composition is clearer)**: Before freezing the §IX roster and enabled notes/narration boundaries, compare adjacent semantic beats within the active profile's roster/content invariants. When recurring roles, relationships, and spatial orientation form one mental map and the next beat has a meaningful state or focus change, plan neighboring pages as visible states of that scene: preserve recognizable anchors, make the semantic delta legible, and align each enabled notes/narration segment with its supporting visible state. This is a content-and-rhythm strategy, not a page quota. Reset the composition when the mental map changes or continuity adds no clarity. Within the confirmed page count, every state page must carry content and an `Audience move`; the effective motion outcome changes realization, not roster authority.
**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. **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. At `complete` depth write complete, usable phrasing into §IX; at `brief` depth write each page as a short block list — one bullet per block in the phrasing that fits it — and leave the full page copy to page authoring. Neither is a skeleton: every claim, fact, relationship, and qualifier the page must carry is present. Written wording 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. 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. > Note: §IX is the page brief at the confirmed depth; Executor retains it with the lock until context invalidation, then reloads both once.
### 6.2 Planning Artifact Content ### 6.2 Planning Artifact Content
@@ -571,7 +567,7 @@ final Stage 2 `false`, explicit objects-off, or explicit all-motion-off; only th
includes transitions. includes transitions.
1. With Generate Step 4's retained complete final-confirmation state, read `${SKILL_DIR}/templates/design_spec_reference.md`. 1. With Generate Step 4's retained complete final-confirmation state, read `${SKILL_DIR}/templates/design_spec_reference.md`.
2. Compose the whole Design Spec in active context before touching the target path. Create `design_spec.md` once from the schema marker through §X; do not copy a scaffold into the project or patch placeholder fields. Record production mechanics in §I, including one effective outcome plus provenance for Speaker Notes, Custom Animations, and Narration Audio. Resolve them from latest explicit user instruction → matching final Stage 2 proactive value → workflow 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, exact mathematical content when 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/visualization patterns remain References unless promoted, so Executor may adopt, adapt, or decline them without upstream repair while preserving their semantic jobs and all binding constraints. 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 final Stage 2 proactive value → workflow 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 title, core message, **Audience move**, content at the confirmed depth (complete preferred wording or a short block list), optional layout, exact mathematical content when 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 (continuous runs may repair the roster within the confirmed range per [`executor-base.md`](./executor-base.md) §2.1); non-literal wording, block texture, layout, cover/closing composition, capability recommendations, and image/visualization patterns remain References unless promoted, so Executor may adopt, adapt, or decline them without upstream repair while preserving their semantic jobs and all binding constraints.
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`. 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. 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 `${SKILL_DIR}/templates/spec_lock_reference.md`; create the lock once or resynchronize stale derived state from the approved Design Spec and context. Retain identity/refinements and stable roles/routing; omit unnamed page-local values, do not reopen evidence, and make no new recommendation. 5. Read `${SKILL_DIR}/templates/spec_lock_reference.md`; create the lock once or resynchronize stale derived state from the approved Design Spec and context. Retain identity/refinements and stable roles/routing; omit unnamed page-local values, do not reopen evidence, and make no new recommendation.
@@ -604,7 +600,7 @@ includes transitions.
- **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. - **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. - **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.
- **Mandatory — whole-roster rhythm check**: During the same §IX composition, compare neighbors and section arcs to judge whether chapter entries visibly reset, extended same-density runs are intentional, extended same-carrier or same-topology runs form an intentional semantic sub-arc, repeated dominant geometry carries a continuity job, any qualifying §6.1 visible-state sequence preserves a recognizable mental map while making its next semantic change legible, each section follows a mode-fitting progression—including framework → explanation/evidence → judgment/action when it serves the objective—and the final arc resolves the communication objective before a genuine ending lowers information load. Same section, equal weight or density, one style, and prior-page precedent do not establish a semantic sub-arc. Repair the existing roster, `Layout` recommendations, and `page_rhythm` choices in place. This is judgment, not quota; preserve intentional continuity, legitimately all-`dense` material, and 1:1/literal order. Do not invent filler pages to manufacture rhythm; a `breathing` page marks a meaningful pause—chapter transition, standalone emphasis, or SCQA bridge—and must stand alone. Create no field, lock row, artifact, or second review/execution pass. - **Mandatory — whole-roster rhythm check**: During the same §IX composition, compare neighbors and section arcs to judge whether chapter entries visibly reset, extended same-density runs are intentional, extended same-carrier or same-topology runs form an intentional semantic sub-arc, repeated dominant geometry carries a continuity job, any qualifying §6.1 visible-state sequence preserves a recognizable mental map while making its next semantic change legible, each section follows a mode-fitting progression—including framework → explanation/evidence → judgment/action when it serves the objective—and the final arc resolves the communication objective before a genuine ending lowers information load. Same section, equal weight or density, one style, and prior-page precedent do not establish a semantic sub-arc. Repair the existing roster, `Layout` recommendations, and `page_rhythm` choices in place. This is judgment, not quota; preserve intentional continuity, legitimately all-`dense` material, and 1:1/literal order. Do not invent filler pages to manufacture rhythm; a `breathing` page marks a meaningful pause—chapter transition, standalone emphasis, or SCQA bridge—and must stand alone. Create no field, lock row, artifact, or second review/execution pass.
- **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 adopt, adapt, or decline the composition while preserving the hook 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 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 adopt, adapt, or decline the composition while preserving the hook and explicit constraints. With no suitable image, recommend a native-SVG hook instead of a generic title treatment. Beautify 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. - **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 adopt, adapt, or decline the latter while preserving the takeaway and explicit constraints. 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. - **Closing impact (only when the deck closes)**: For a genuine conclusion / CTA / final takeaway, name the binding takeaway plus a recommended composition; Executor may adopt, adapt, or decline the latter while preserving the takeaway and explicit constraints. 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. - **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.
@@ -28,14 +28,13 @@ rhythm, and style; apply those that materially help.
| Aesthetic fit | Locked or Quick-resolved `visual_style` / `visual_style_behavior` | | Aesthetic fit | Locked or Quick-resolved `visual_style` / `visual_style_behavior` |
| Per-page choice | Content purpose, hierarchy, legibility, semantics, and rhythm | | Per-page choice | Content purpose, hierarchy, legibility, semantics, and rhythm |
**Mandatory — job-first effect selection**: establish the editable semantic **Reference — job-first effect selection**: establish the editable semantic
skeleton first, then diagnose effect jobs before treating the page as complete. skeleton first; the Visual Job Router below lists the visual jobs an effect can
Plain construction remains valid only when that diagnostic finds no unresolved serve.
visual job.
| Pass | Decision | | Pass | Decision |
|---|---| |---|---|
| Skeleton / diagnose | Establish native information, relationships, and hierarchy. Before completion, check image/text integration, plane separation, focus, state/direction, material/style, and the recurring motif; keep plain construction when none needs treatment. | | Skeleton / diagnose | Establish native information, relationships, and hierarchy. Image/text integration, plane separation, focus, state/direction, material/style, and the recurring motif are the jobs an effect can serve. |
| Surface / select | Name the target, confirm its owning subsection and fidelity, and let the Router recall candidates. Choose a compatible technique that fully performs the job; prefer simpler/native-stable alternatives only when communication is equal. `Approximate` requires review, not automatic rejection. | | Surface / select | Name the target, confirm its owning subsection and fidelity, and let the Router recall candidates. Choose a compatible technique that fully performs the job; prefer simpler/native-stable alternatives only when communication is equal. `Approximate` requires review, not automatic rejection. |
| Integrate / stop | Align paint, contour, light, hierarchy, and z-order; combine only techniques with different jobs. Check legibility, editability, density, fidelity, and style; simplify failures, use legal alternatives, and bake only the smallest pixel-dependent layer. Keep authoritative text/data native. | | Integrate / stop | Align paint, contour, light, hierarchy, and z-order; combine only techniques with different jobs. Check legibility, editability, density, fidelity, and style; simplify failures, use legal alternatives, and bake only the smallest pixel-dependent layer. Keep authoritative text/data native. |
@@ -60,7 +59,7 @@ contracts are the boundary; membership in this table is not.
| Diagnosed visual problem | Candidate technique | Authority / stop | | Diagnosed visual problem | Candidate technique | Authority / stop |
|---|---|---| |---|---|---|
| Meaningful direction, continuous value, or center focus is missing | Linear/radial gradient or channel alpha | §6.2 / §6.3; otherwise keep solid paint | | Meaningful direction, continuous value, or center focus is missing | Linear/radial gradient or channel alpha | §6.2 / §6.3; otherwise keep solid paint |
| Picture/card/overlay elevation or boundary is unclear | Object or picture/carrier shadow, restrained glow, or hairline | §6.4; equal peers stay flat; one light direction | | Picture/card/overlay elevation or boundary is unclear | Object or picture/carrier shadow, restrained glow, or hairline | §6.4; one light direction |
| Native copy and image do not integrate | Scrim, fade, wash, vignette, off-center spotlight, or faux glass | §6.5 and the Image-Treatment Implementation Map; verify contrast; no backdrop blur | | Native copy and image do not integrate | Scrim, fade, wash, vignette, off-center spotlight, or faux glass | §6.5 and the Image-Treatment Implementation Map; verify contrast; no backdrop blur |
| Relationship state, direction, continuity, or boundary is unclear | Draft/optional/future → dash; direction → marker; undirected → solid; continuous flow → gradient stroke; repeated boundary → frame/contour/crop edge; exact grid → multi-subpath | §6.6 / §6.3; every line needs a job | | Relationship state, direction, continuity, or boundary is unclear | Draft/optional/future → dash; direction → marker; undirected → solid; continuous flow → gradient stroke; repeated boundary → frame/contour/crop edge; exact grid → multi-subpath | §6.6 / §6.3; every line needs a job |
| Short display text needs notation, silhouette, or material/image emphasis | Removed/former → strike; eyebrow distinction → tracking; display silhouette → outline/gradient; justified material/image emphasis → native picture/texture fill; luminous metric → glow; semantic list → native bullet | §6.7 / §6.3 / §6.4; no decorative body-copy treatment | | Short display text needs notation, silhouette, or material/image emphasis | Removed/former → strike; eyebrow distinction → tracking; display silhouette → outline/gradient; justified material/image emphasis → native picture/texture fill; luminous metric → glow; semantic list → native bullet | §6.7 / §6.3 / §6.4; no decorative body-copy treatment |
@@ -341,14 +340,13 @@ uses one deliberate alternative direction)**: every `feOffset` shadow on one
slide shares the same `dx`/`dy` direction (default `dx="0"`, slide shares the same `dx`/`dy` direction (default `dx="0"`,
`dy="4"``dy="8"`, light from upper front). Contradictory shadow directions `dy="4"``dy="8"`, light from upper front). Contradictory shadow directions
make one plane read as several incompatible surfaces. A deliberate upward make one plane read as several incompatible surfaces. A deliberate upward
paper-layer treatment flips every affected layer together; never mix paper-layer treatment flips every affected layer together, so one plane keeps
directions on the same plane. one light direction.
**Reference — not a constraint**: use no more elevation categories than the **Reference — not a constraint**: use no more elevation categories than the
hierarchy needs; a page may reuse one category across several related objects. hierarchy needs; a page may reuse one category across several related objects.
Do not lift every peer card or stack strong shadow, border, gradient, and tint Same-family colored shadow is reserved for a focal accent.
on one container. Same-family colored shadow is reserved for a focal accent. On dark backgrounds a light hairline or restrained glow separates surfaces; glow on body copy reduces legibility.
On dark backgrounds, prefer a light hairline or restrained glow; never glow body copy.
For older/strict renderers, replace a filter with two or three offset For older/strict renderers, replace a filter with two or three offset
translucent shapes behind the object: translucent shapes behind the object:
alpha `0.030.05`, increasing offset/radius, and optional same-family tint near alpha `0.030.05`, increasing offset/radius, and optional same-family tint near
@@ -638,12 +636,9 @@ respective sections; they do not weaken those contracts.
| Shadow/glow | §6.4 filter on `<text>` only | Shape shadow / run glow; `Approximate` | | Shadow/glow | §6.4 filter on `<text>` only | Shape shadow / run glow; `Approximate` |
| Native bullet | Leading `· • ● ▪ ■ ◆ ◇ ◦ ‣` + non-empty content | `·`/`•``•`; others unchanged; color/alpha from marker run; font/size follow text | | Native bullet | Leading `· • ● ▪ ■ ◆ ◇ ◦ ‣` + non-empty content | `·`/`•``•`; others unchanged; color/alpha from marker run; font/size follow text |
**Default — lift key information (may override when uniform treatment is **Inline emphasis**: bold or accent-colored `<tspan>` runs lift numerical
deliberate)**: In prose, lift numerical results, explicit contrasts, and one or results, explicit contrasts, or a load-bearing noun inside prose; green/red
two load-bearing nouns per sentence with bold `<tspan>` runs in the locked or conventionally read as polarity.
Quick-resolved accent. Keep connectives, routine verbs, non-load-bearing nouns,
decorative adjectives, and structural copy neutral; reserve green/red for
actual polarity.
```xml ```xml
<!-- Uniform: the two results disappear into the sentence. --> <!-- Uniform: the two results disappear into the sentence. -->
@@ -657,8 +652,7 @@ actual polarity.
</text> </text>
``` ```
**Default — semantic underline (may override when another cue is clear)**: **Underline**: conventionally marks links, key terms, or local emphasis.
Reserve it for links, key terms, or local emphasis—not decoration.
```xml ```xml
<!-- Key term --> <!-- Key term -->
@@ -683,12 +677,11 @@ text-on-path authoring.
baseline-shift="sub" font-size="14">2</tspan></text> baseline-shift="sub" font-size="14">2</tspan></text>
``` ```
Use strikethrough for removed/former values; it is ordinary notation, not a Strikethrough conventionally marks removed/former values; it is ordinary notation, not a
style-exclusive effect. Imported double underline/strike normalizes to single. style-exclusive effect. Imported double underline/strike normalizes to single.
Bullet detection allows optional leading whitespace, requires non-empty content, Bullet detection allows optional leading whitespace, requires non-empty content,
and leaves non-leading decorative glyphs as ordinary text. and leaves non-leading decorative glyphs as ordinary text.
Keep body tracking normal; CJK tracking defaults near/below 2% of font size and CJK tracking defaults near/below 2% of font size and above 5% triggers review. Text outline is solid only. `textPath`, masks, blend
above 5% triggers review. Text outline is solid only. `textPath`, masks, blend
modes, generated effects, and text-image knockouts are outside editable text. modes, generated effects, and text-image knockouts are outside editable text.
--- ---
@@ -969,12 +962,12 @@ back-to-front and omit every layer without a distinct job.
| Page / deck job | Back-to-front stack | Stop | | Page / deck job | Back-to-front stack | Stop |
|---|---|---| |---|---|---|
| Cover | Hero field → optional scrim/wash → purposeful opening/contour → native title, optionally paired with a prepared decorative-lettering image | Stop when copy is safe and title/field read together | | Cover | Hero field → optional scrim/wash → purposeful opening/contour → native title, optionally paired with a prepared decorative-lettering image | Stop when copy is safe and title/field read together |
| Divider | Image band or quiet field → restrained wash → recurring geometry → number/title | Reuse deck language; add no effect family | | Divider | Image band or quiet field → restrained wash → recurring geometry → number/title | Reuse deck language |
| Text-led explanation | Quiet field → recurring material/contour → native hierarchy → optional local emphasis | Emphasis clarifies the argument, never decorates body copy | | Text-led explanation | Quiet field → recurring material/contour → native hierarchy → optional local emphasis | Emphasis sits on the argument's load-bearing runs |
| Process / system | Context field → native relation lines → nodes/labels → optional state/direction focus | Every connector stays semantic; atmosphere must not obscure flow | | Process / system | Context field → native relation lines → nodes/labels → optional state/direction focus | Every connector stays semantic |
| Evidence / metric | Context field → local contrast → native leaders/labels/metric → optional focus/elevation | Claims stay native; atmosphere must not weaken evidence | | Evidence / metric | Context field → local contrast → native leaders/labels/metric → optional focus/elevation | Claims stay native |
| Comparison | Matched planes → optional shared wash/divider → matched labels → one difference marker | Keep crop, elevation, and paint symmetric unless asymmetry is the claim | | Comparison | Matched planes → optional shared wash/divider → matched labels → one difference marker | Keep crop, elevation, and paint symmetric unless asymmetry is the claim |
| Closing / CTA | Receded field → echoed contour/gradient → native action → optional raised accent | Add no effect family or competing image | | Closing / CTA | Receded field → echoed contour/gradient → native action → optional raised accent | Keep the native action legible |
| Cross-page motif | Reuse contour, gradient direction, line language, texture, or light logic; vary scale, crop, or position by page job | Preserve recognition without copying the page or adding novelty effects | | Cross-page motif | Reuse contour, gradient direction, line language, texture, or light logic; vary scale, crop, or position by page job | Preserve recognition without copying the page or adding novelty effects |
--- ---
@@ -23,21 +23,21 @@ Generate reusable structured page templates inside the workspace selected by Cre
When the workflow provides a PPTX reference source, the effective input package comes from the unified `pptx_template_import.py` preparation workspace and becomes: When the workflow provides a PPTX reference source, the effective input package comes from the unified `pptx_template_import.py` preparation workspace and becomes:
- finalized template brief - finalized template brief
- `manifest.json` single source of truth for source-deck facts (slide size, theme, per-master themes, assets, asset map, placeholders, layouts, masters, slides, SVG file paths, page-type candidates) - `analysis/manifest.json` — source-deck facts (slide size, theme, per-master themes, resources, image map, placeholders, layouts, masters, slides, SVG file paths, page-type candidates)
- `native_structure.json` — stable source master/layout keys, picker names, parent-master relationships, placeholder type/index/geometry, source hash, and source-graph quality facts - `analysis/native_structure.json` — stable source master/layout keys, picker names, parent-master relationships, placeholder type/index/geometry, source hash, and source-graph quality facts
- `source_template.pptx` — byte-preserved analysis copy for visual/package cross-checking; never a final template asset - `sources/source.pptx` — byte-preserved backing package for visual/package cross-checking; never a final template asset
- `conversion-report.json` — source-recovery and fidelity diagnostics, when present - `validation/conversion-report.json` — source-recovery and fidelity diagnostics, when present
- exported `assets/` - exported `images/` plus other populated semantic resource directories
- `svg/master_*.svg` / `svg/layout_*.svg` — immutable layered native-payload backing; every master / layout in the deck rendered once, including ones no sample slide references - `svg/master_*.svg` / `svg/layout_*.svg` — immutable layered native-payload backing; every master / layout in the deck rendered once, including ones no sample slide references
- `svg/slide_NN.svg` — immutable slide-local native-payload backing; do not bulk-read because opaque native payload is retained - `svg/slide_NN.svg` — immutable slide-local native-payload backing; do not bulk-read because opaque native payload is retained
- `svg/inheritance.json` — which layout / master each slide consumes - `svg/inheritance.json` — which layout / master each slide consumes
- optional `svg-flat/slide_NN.svg` — immutable complete-page verification backing generated only when explicitly requested; do not use it as the editable source - optional `svg-flat/slide_NN.svg` — immutable complete-page verification backing generated only when explicitly requested; do not use it as the editable source
- `authoring-svg/` and optional `authoring-svg-flat/`lightweight non-destructive IR bundles created by `svg_authoring_view.py`; each contains editable SVGs, model-readable `authoring_summary.json`, and tool-only `authoring_manifest.json` - `authoring-svg/` and optional `authoring-svg-flat/`new compact SVG bundles generated from parsed PPTX evidence by `pptx_template_import.py` for Type A input or by the standalone `svg_authoring_view.py` migration path; the layered bundle is Template_Designer's editable authoring surface and also contains model-readable `authoring_summary.json` plus tool-only `authoring_manifest.json`
- optional screenshots for visual cross-checking - optional screenshots for visual cross-checking
PPTX import interpretation: PPTX import interpretation:
- Placeholder guides in master / layout SVGs are layout signals. Use `manifest.json` placeholder records for type / index / geometry / base style; do not copy dashed guide boxes into final templates unless the visual design truly uses dashed boxes. - Placeholder guides in master / layout SVGs are layout signals. Use `analysis/manifest.json` placeholder records for type / index / geometry / base style; do not copy dashed guide boxes into final templates unless the visual design truly uses dashed boxes.
- Charts, SmartArt, diagrams, and OLE objects may appear as typed placeholders in layered SVGs. In flat SVGs they may show preview images. Treat them as source intent markers, not reusable decorative assets. - Charts, SmartArt, diagrams, and OLE objects may appear as typed placeholders in layered SVGs. In flat SVGs they may show preview images. Treat them as source intent markers, not reusable decorative assets.
- The asset filenames referenced by SVGs are governed by the manifest asset map. Prefer those references over inventing duplicate asset names. - The asset filenames referenced by SVGs are governed by the manifest asset map. Prefer those references over inventing duplicate asset names.
@@ -45,8 +45,8 @@ Input priority for PPTX-backed template creation depends on the AI-derived inter
| Mode | Authoritative inputs | Model-facing inputs | | Mode | Authoritative inputs | Model-facing inputs |
|---|---|---| |---|---|---|
| `standard` / `fidelity` | Finalized brief for the newly designed output; `manifest.json` for factual canvas/theme/assets | `authoring-svg/authoring_summary.json`, layered authoring SVGs, optional flat spot checks, and exported assets as visual references. Do not read `authoring_manifest.json`. Source Master/Layout topology is informational only and is not mined into output structure. | | `standard` / `fidelity` | Finalized brief for the newly designed output; `analysis/manifest.json` for factual canvas/theme/resources | `authoring-svg/authoring_summary.json`, every layered source Master/Layout as structural and visual evidence, layered source Slides, optional flat spot checks, and exported resources. Do not read `authoring_manifest.json`. `standard` authors a compact result; `fidelity` authors broader useful source-aligned coverage. Neither copies source identities merely because they exist. |
| `mirror` | `manifest.json`, `native_structure.json`, and `svg/inheritance.json`; the compiler validates the tool-only authoring manifest | `authoring-svg/authoring_summary.json` plus layered authoring SVGs as the editable preservation IR; optional `authoring-svg-flat/` for complete-page verification; matching lossless `svg/` and optional `svg-flat/` only as immutable backing. | | `mirror` | Inline native Chart/Table JSON plus `analysis/manifest.json`, `analysis/native_structure.json`, and `svg/inheritance.json`; the publisher validates the tool-only authoring manifest | `authoring-svg/authoring_summary.json` plus every reachable layered compact SVG as the editable authoring source; optional `authoring-svg-flat/` for complete-page verification; matching lossless `svg/` only for source/package validation and supported non-visible payload recovery, never visible-subtree copying. |
**Mandatory — authored construction bundle**: As soon as `replication_mode` **Mandatory — authored construction bundle**: As soon as `replication_mode`
resolves to `standard` or `fidelity`, and before selecting any page or template resolves to `standard` or `fidelity`, and before selecting any page or template
@@ -56,7 +56,7 @@ retain both for the active authoring context. Do not load this bundle for
`mirror`; it preserves source-owned geometry and never selects or authors `mirror`; it preserves source-owned geometry and never selects or authors
replacement contours. replacement contours.
Use the compact facts in `manifest.json` for orientation. Open screenshots or the original PPTX only for visual cross-checking. Use the compact facts in `analysis/manifest.json` for orientation. Open screenshots or the original PPTX only for visual cross-checking.
**Native structure output**: Always set `native_structure_mode: structured`. **Native structure output**: Always set `native_structure_mode: structured`.
@@ -67,16 +67,19 @@ behavior. Deck identity owns paint, typeface identity, and fixed identity
assets; its application context describes the recurring presentation family. Under assets; its application context describes the recurring presentation family. Under
downstream `layout` scope, resolve final placeholder formatting from the Layout downstream `layout` scope, resolve final placeholder formatting from the Layout
roles plus the confirmed identity, reading mode, and type scale; downstream roles plus the confirmed identity, reading mode, and type scale; downstream
`mirror` scope preserves literal source formatting and text topology. Compile `mirror` scope preserves source structure and comparable presentation while
allowing compact SVG spelling. Compile
the applicable rules into the same native graph without merging their source the applicable rules into the same native graph without merging their source
ownership. ownership.
| Mode | Output structure contract | | Mode | Output structure contract |
|---|---| |---|---|
| `standard` / `fidelity` | Author project-canonical SVG prototypes and an intentional new Master/Layout/slot system. Source visual language and assets may guide the design, but source ownership, keys, picker names, parent relationships, placeholders, and repeated Slide-local elements do not define or seed the output topology. Choose page-fit contours from the full native vocabulary before their authoring forms; keep exact native atoms independent, materialize a Boolean result only where one contour requires it, and use freeform last. | | `standard` / `fidelity` | Review the complete source Master/Layout inventory, then author complete project-canonical Slide SVG prototypes and an intentional new Master/Layout/slot system. Every retained Layout has at least one Slide prototype. `standard` stays compact; `fidelity` retains broader useful source-aligned families. Source identities do not define the output topology. Choose page-fit contours from the full native vocabulary before their authoring forms; keep exact native atoms independent, materialize a Boolean result only where one contour requires it, and use freeform last. |
| `mirror` | Materialize a new workspace from the validated source graph one-to-one: keep the Master/Layout identities and parentage, slide assignments, placeholder type/index/bounds, and supported visual/native-object facts that are actually present. Edit the authoring IR; materialization may rehydrate converter-supported native payload only for unchanged source refs. Mechanical normalization maps fixed-layer source groups into the direct atoms required by the current explicit SVG contract while preserving ownership, paint order, and appearance; it must not invent missing facts or semantically redesign the graph. | | `mirror` | Review and author one complete compact SVG per validated source Slide, retaining only its referenced Layout and parent Master. Keep reachable identities, parentage, assignment, placeholder facts, inline JSON native authority, and source meaning. Presentation should remain recognizably similar, but SVG nodes and code need not be isomorphic. Publication completes inherited context and maps fixed-layer groups into direct atoms without inventing facts or semantically redesigning the reachable graph. |
Every page remains a complete standalone SVG preview. Every output is a complete standalone Slide SVG preview that resolves Master +
Layout + Slide context. Explicit layer markers retain ownership; standalone
Master/Layout definition SVGs are not template artifacts.
**Authored preset rule**: In `standard` / `fidelity`, when one registered **Authored preset rule**: In `standard` / `fidelity`, when one registered
PowerPoint preset exactly expresses one complete object, use PowerPoint preset exactly expresses one complete object, use
@@ -88,28 +91,26 @@ Master/Layout fixed-layer ownership. This is the only `<g>` exception to the
fixed-layer atomicity rule; ordinary groups remain forbidden there. Preset fixed-layer atomicity rule; ordinary groups remain forbidden there. Preset
paint comes from the confirmed brief and `<design_spec_path>` paint comes from the confirmed brief and `<design_spec_path>`
color scheme. Do not copy an expanded import carrier/preview/fingerprint color scheme. Do not copy an expanded import carrier/preview/fingerprint
bundle into an authored template. `mirror` instead preserves the supported bundle into an authored template. `mirror` instead uses the new compact parsed
expanded lossless source representation. The exact syntax and validation SVG as its visible authoring source and never transplants the expanded lossless
visible subtree. The exact syntax and validation
contract remain owned by contract remain owned by
[`shared-standards-core.md`](./shared-standards-core.md) and the native-shape reference. [`shared-standards-core.md`](./shared-standards-core.md) and the native-shape reference.
When one preset is insufficient, apply the same reference's compound-page gate: When one preset is insufficient, apply the same reference's compound-page gate:
keep faithful atoms independent unless one contour requires Boolean keep faithful atoms independent unless one contour requires Boolean
materialization, then use freeform only if neither construction succeeds. materialization, then use freeform only if neither construction succeeds.
**Hard rule — complete mirror graph**: Preserve every supported source Layout represented by the validated import, **Hard rule — reachable mirror graph**: Emit exactly one complete source-page
including Layouts unused by source Slides. Emit one complete source-page prototype per source Slide and preserve only the transitive chain `Slide →
prototype per source Slide and one definition-only Layout → Master`. Source Master/Layout identities outside that closure remain
`layout_<layout_key>.svg` prototype for each otherwise unrepresented Layout. analysis evidence and produce no SVG. Use `standard` / `fidelity` when useful
The definition prototype carries the exact parent Master identity, Layout unreferenced source structures must be re-authored as complete Slide prototypes.
identity, fixed atoms, and placeholder contract but is not a generated page.
This also retains a source Master that is reachable only through unused Layouts.
Never silently drop or merge an identity, and never invent a carrier page.
**Hard rule — no duplicate authored Layout contracts**: In `standard` / `fidelity`, distinct output Layout keys must differ in fixed Layout atoms or slot topology/type/index/bounds/binding. Topic, sample wording, or Slide-local content alone never justifies another authored key. Mirror keeps source Layout identities even when two source contracts are visibly equivalent. **Hard rule — no duplicate authored Layout contracts**: In `standard` / `fidelity`, distinct output Layout keys must differ in fixed Layout atoms or slot topology/type/index/bounds/binding. Topic, sample wording, or Slide-local content alone never justifies another authored key. Mirror keeps distinct reachable source Layout identities even when two source contracts are visibly equivalent.
**Downstream boundary**: Stage 1 independently confirms the current communication contract. Strategist then inspects the installed prototypes, the Deck's descriptive application context, and the current content to author one application plan. It records `mirror`, `layout`, or `style` and, where applicable, `strict` or `adaptive` only as internal exporter values. Explicit user language overrides AI judgment, but the confirmation UI never asks the user to choose these implementation labels. Template_Designer does not preselect that project-level plan. **Downstream boundary**: Stage 1 independently confirms the current communication contract. Strategist then inspects the installed prototypes, the Deck's descriptive application context, and the current content to author one application plan. It records `mirror`, `layout`, or `style` and, where applicable, `strict` or `adaptive` only as internal exporter values. Explicit user language overrides AI judgment, but the confirmation UI never asks the user to choose these implementation labels. Template_Designer does not preselect that project-level plan.
For `mirror`, `<design_spec_path> §V` must be followed by a `Source Preservation Map` that records each source slide's Master/Layout assignment and output file. The map is evidence of one-to-one preservation, not a design-decision log. `standard` and `fidelity` record only their newly authored output roster and structure; do not add a source-topology disposition table. For `mirror`, `<design_spec_path> §V` must be followed by a `Source Preservation Map` that records each source Slide's retained Master/Layout assignment and output file. When source identities fall outside the reachable closure, one sentence may note that they exist but were not materialized; no per-identity analysis is required. The map is execution evidence, not a design-decision log. `standard` and `fidelity` record only their newly authored output roster and structure.
--- ---
@@ -121,9 +122,9 @@ The output page set is determined by the confirmed natural-language creation int
|------|-------------|--------| |------|-------------|--------|
| `standard` (default internal strategy) | The requested result is a clean, reusable, compact system | Cover, chapter, ending, optional TOC, and one or a small explicitly required set of distinct content Layouts; typically 46 prototypes | | `standard` (default internal strategy) | The requested result is a clean, reusable, compact system | Cover, chapter, ending, optional TOC, and one or a small explicitly required set of distinct content Layouts; typically 46 prototypes |
| `fidelity` | The natural-language intent calls for broader, source-aligned but newly designed coverage | Canonical roles plus intentionally designed variants that cover the useful source composition range | | `fidelity` | The natural-language intent calls for broader, source-aligned but newly designed coverage | Canonical roles plus intentionally designed variants that cover the useful source composition range |
| `mirror` | The natural-language intent calls for preserving validated native source facts | One SVG prototype materialized from the authoring IR per source slide, named `<NNN>_<page_type>.svg` by source order | | `mirror` | The natural-language intent calls for preserving validated native source facts and a similar presentation | One compact SVG prototype reviewed/authored from parsed evidence per source slide, named `<NNN>_<page_type>.svg` by source order |
**Hard rule — mode controls authorship**: `standard` and `fidelity` create new SVG documents and their own Master/Layout system. `mirror` maps the validated imported source contract into a new workspace and must not reauthor, distill, reinterpret, or supplement its structure. **Hard rule — mode controls authorship**: `standard` and `fidelity` inspect the complete source structure but create new SVG documents and their own Master/Layout system. `mirror` also authors compact new SVG from parsed evidence, but it must retain the validated reachable identities, assignments, slots, meaning, and similar presentation rather than distilling, supplementing, or redesigning that closure. Code and node identity are not preservation requirements.
### Standard mode ### Standard mode
@@ -166,20 +167,21 @@ Extension page types beyond the canonical four (transition / appendix / disclaim
### Mirror mode ### Mirror mode
When the derived implementation writes `replication_mode: mirror`, materialize a new template workspace from validated imported facts rather than designing a new system: When the derived implementation writes `replication_mode: mirror`, author a new compact template workspace from validated parsed evidence rather than designing a different system:
- Kind eligibility: Create Layout mirror is legal only when the validated source contract is already brand-neutral and application-neutral. If supported source facts retain organization-specific identity or reusable application policy, stop and return to Create Template dispatch: use `standard` / `fidelity` to author a new Layout, or Create Deck to retain those facts. Removing, repainting, retyping, or discarding application rules is never mirror. - Kind eligibility: Create Layout mirror is legal only when the validated source contract is already brand-neutral and application-neutral. If supported source facts retain organization-specific identity or reusable application policy, stop and return to Create Template dispatch: use `standard` / `fidelity` to author a new Layout, or Create Deck to retain those facts. Removing, repainting, retyping, or discarding application rules is never mirror.
- Model-facing authoring source: `authoring-svg/authoring_summary.json`, layered `authoring-svg/*.svg`, `svg/inheritance.json`, and `native_structure.json`. Do not read `authoring-svg/authoring_manifest.json`; materialization validates it internally. When present, use `authoring-svg-flat/` only for full-page verification. Matching lossless `svg/` and optional `svg-flat/` files are immutable backing; materialization resolves only the layered backing. - Model-facing authoring source: `authoring-svg/authoring_summary.json`, every reachable layered `authoring-svg/*.svg`, `svg/inheritance.json`, and `analysis/native_structure.json`. Template_Designer must actually inspect and, where needed, redraw/normalize these new compact SVGs before publication. Do not read `authoring-svg/authoring_manifest.json`; the publisher validates it internally. When present, use `authoring-svg-flat/` only for full-page verification. Matching lossless `svg/` files are immutable source/package evidence and non-visible payload backing, never visible authoring input.
- Precondition: the import evidence identifies every source Master/Layout, parent relationship, picker name, placeholder contract, and fixed visual layer. Stop only when required facts or supported geometry are missing; unused identities are not a stop condition. - Precondition: the import evidence identifies every source Slide and its referenced Layout/Master, picker names, placeholder contract, and fixed visual layers. Stop when required reachable facts or supported mirrored geometry are missing; unused source identities are out of scope.
- Output: `<template_workspace>/templates/<NNN>_<page_type>.svg` for every source slide, plus `layout_<layout_key>.svg` for every source Layout unused by all source slides. `<NNN>` is the zero-padded source slide index (3 digits) and `<page_type>` is derived from `manifest.json` `pageTypeCandidates``cover` / `toc` / `chapter` / `content` / `ending`. When the page-type heuristic is ambiguous, fall back to `content`. Preserve source slide order via the numeric prefix; definition-only files are not generated pages. - Output: `<template_workspace>/templates/<NNN>_<page_type>.svg` for every source Slide and no standalone Master/Layout SVG. `<NNN>` is the zero-padded source slide index (3 digits) and `<page_type>` comes from `analysis/manifest.json` `pageTypeCandidates``cover` / `toc` / `chapter` / `content` / `ending`, falling back to `content`. Preserve source Slide order.
- Required preservation: preserve source Master/Layout keys and picker names, Layout-to-Master parentage, slide assignments, placeholder type/index/bounds, supported native-object metadata, geometry, decoration, sprite-sheet wrappers, original example text, chart previews, fonts, effects, and paint order whenever the importer represents them. - Context completion: each source-page SVG resolves Master + Layout + Slide context while retaining explicit layer markers, so completion does not flatten ownership.
- Allowed normalization: add or normalize explicit root declarations and asset paths, and recursively expand fixed Master/Layout group wrappers into direct atoms. The mapping must remain one-to-one at the ownership level and must not change paint order or appearance. - Required preservation: within the reachable closure, preserve source Master/Layout keys and picker names, Layout-to-Master parentage, slide assignments, placeholder type/index/bounds, original example meaning, sprite-sheet crop behavior, and supported native facts. Imported/template-owned Chart/Table inline JSON is authoritative; its compact SVG preview may be approximate.
- Forbidden: commonality extraction, semantic synthesis, merging, splitting, promotion, demotion, renaming, re-parenting, decorative simplification, placeholder invention, or replacement of supported source-native metadata / SVG fallback with a model-authored approximation. - Allowed authoring: redraw or normalize visible SVG geometry, paint spelling, grouping, root declarations, asset paths, and fixed-layer wrappers when the resulting presentation remains similar and ownership/paint intent stays intact. Complete inherited context and emit direct structural atoms; code, node count, and exact path identity need not match the import.
- `<design_spec_path>` §V Page Roster lists every emitted file and marks definition-only prototypes explicitly. `Source Preservation Map` records each source-slide assignment plus every unused Layout definition and its parent Master. - Forbidden: commonality extraction, semantic synthesis, promotion/demotion, renaming, re-parenting, placeholder invention, changes to authoritative Chart/Table JSON without matching intent, or any visible redesign that changes the source communication result.
- `<design_spec_path>` §V gives each emitted source-Slide prototype a roster row. If relevant, one sentence notes source Master/Layout identities outside the mirror closure; `Source Preservation Map` records each retained source-Slide assignment.
**Mirror consumption boundary**: `replication_mode: mirror` describes source-to-workspace fidelity and only makes literal downstream reuse technically possible. Strategist independently derives the application plan from the current communication contract, content, actual prototype roster, and any explicit natural-language instruction. It may select, repeat, skip, reorder, or reorganize prototypes; no internal scope forces source page count, source order, or one output slide per source slide. **Mirror consumption boundary**: `replication_mode: mirror` describes source-to-workspace fidelity and only makes literal downstream reuse technically possible. Strategist independently derives the application plan from the current communication contract, content, actual prototype roster, and any explicit natural-language instruction. It may select, repeat, skip, reorder, or reorganize prototypes; no internal scope forces source page count, source order, or one output slide per source slide.
**What mirror is not**: a redesign, topology-cleanup, or recovery mode. It may mechanically transcode the imported representation into the current explicit SVG/package contract, so byte identity is not promised. Charts, SmartArt, OLE objects, and EMF / WMF media that fail to round-trip in `pptx_template_import.py` will fail the same way in mirror. If the import workspace has missing media or unsupported objects, mirror inherits those gaps — report them before materialization begins. **What mirror is not**: a redesign, topology-cleanup, or recovery mode. It is a new compact SVG authoring pass over parsed evidence, followed by deterministic validation/publication; neither byte identity nor SVG-code identity is promised. Charts, SmartArt, OLE objects, and EMF / WMF media that fail to enter the parsed evidence cannot be recovered by mirror. If the import workspace has missing media or unsupported objects, mirror inherits those gaps — report them before authoring begins.
--- ---
@@ -260,9 +262,9 @@ page_count: <N>
- Optional XML snippet for any reusable component unique to this template - Optional XML snippet for any reusable component unique to this template
## V. Page Roster ## V. Page Roster
One row per emitted SVG describing what this template's version of cover / chapter / content / ending looks like: background treatment, decorative anchors, layout rhythm, image behavior, content density, intended role, reusable slots, and structural capacity. Do not add required/optional/repeatable status or fixed/replaceable/example-only content policy. For `standard` / `fidelity`, record the newly authored Layout key and PowerPoint picker name. For `mirror`, record the preserved source Master/Layout keys and picker names without redesigning them. Roster entries must match the actual SVG files on disk. One row per complete Slide SVG describing what this template's version of cover / chapter / content / ending looks like: background treatment, decorative anchors, layout rhythm, image behavior, content density, intended role, reusable slots, and structural capacity. Do not add required/optional/repeatable status or fixed/replaceable/example-only content policy. For `standard` / `fidelity`, record the newly authored Layout key and PowerPoint picker name. For `mirror`, record the preserved reachable Master/Layout keys and picker names without redesigning them. Roster entries must match every SVG on disk.
For `mirror`, add `### Source Preservation Map` immediately after the roster with columns `Source slide`, `Source Master`, `Source Layout`, `Output SVG`, and `Preservation status`. This is a one-to-one mapping record. Do not add synthesis rationale or source-structure disposition rows to `standard` / `fidelity` templates. For `mirror`, add `### Source Preservation Map` immediately after the roster with columns `Source slide`, `Source Master`, `Source Layout`, `Output SVG`, and `Preservation status`. When relevant, add one sentence that unreferenced source identities were not materialized by mirror; do not add individual disposition rows or synthesis rationale. Do not add source-structure disposition rows to `standard` / `fidelity` templates.
## VI. Assets (omit when none) ## VI. Assets (omit when none)
Logos, cover backgrounds, brand textures bundled with the template package — file name, dimensions, intended usage. Logos, cover backgrounds, brand textures bundled with the template package — file name, dimensions, intended usage.
@@ -328,7 +330,6 @@ Sections to **omit** from template `design_spec.md` (sourced elsewhere — listi
|---|---| |---|---|
| Always-on SVG rules and conditional-module routing | `shared-standards-core.md` | | Always-on SVG rules and conditional-module routing | `shared-standards-core.md` |
| Generic layout pattern library (centered card / three-column / timeline / …) | `strategist.md` §4 | | Generic layout pattern library (centered card / three-column / timeline / …) | `strategist.md` §4 |
| Generic spacing bands (margin 40-60px, card gap 20-32px, etc.) | `strategist.md` §4 |
| Generic font-size hierarchy (cover 2.5-5x body, page title 1.5-2x, …) | `strategist.md` §g | | Generic font-size hierarchy (cover 2.5-5x body, page title 1.5-2x, …) | `strategist.md` §g |
| Canonical placeholder table (`{{TITLE}}`, `{{PAGE_NUM}}`, …) | §4 below | | Canonical placeholder table (`{{TITLE}}`, `{{PAGE_NUM}}`, …) | §4 below |
| Content methodology (pyramid / SCQA / MECE) | `strategist.md` | | Content methodology (pyramid / SCQA / MECE) | `strategist.md` |
@@ -353,14 +354,14 @@ Templates must strictly follow the finalized template brief and the generated `<
If PPTX import output exists: If PPTX import output exists:
- Prefer imported theme colors and fonts over visually guessed values - Prefer imported theme colors and fonts over visually guessed values
- Reuse exported `assets/` images directly — `<image>` references in `svg/` already point at canonical files - Reuse exported `images/` directly — raster images and SVG/EMF/WMF image media use the same canonical pool, and `<image>` references in `svg/` already point at it
- Treat page-type candidates from `manifest.pageTypeCandidates` as hints, not guarantees - Treat page-type candidates from `analysis/manifest.json.pageTypeCandidates` as hints, not guarantees
**Precondition**: **Precondition**:
- For `standard`, inspect enough lightweight complete-page IR documents to understand the requested visual direction and reusable assets; do not analyze source topology. - For `standard`, inspect the complete lightweight source Master/Layout inventory plus enough complete-page IR documents to understand the requested visual direction, structural vocabulary, and reusable assets. Author a compact new structure; source identities are evidence, not output requirements.
- For `fidelity`, inspect every lightweight complete-page IR document so the newly designed roster covers the useful source composition range; do not derive output ownership from source Master/Layout recurrence. - For `fidelity`, inspect every lightweight source Master/Layout and complete-page IR document so the newly designed roster covers the useful source structure and composition range. Author broader source-aligned families without automatically copying every source identity.
- For `mirror`, verify every authoring Master, Layout, and Slide listed by `authoring_summary.json` against `native_structure.json` and `svg/inheritance.json`, then materialize from the IR with matching lossless payload backing. The compiler validates each machine-manifest record. Before materialization begins, report the verified source slide indexes. - For `mirror`, verify every source Slide and its referenced Layout/Master against `authoring_summary.json`, `analysis/native_structure.json`, and `svg/inheritance.json`; then review/author every reachable compact SVG and publish only that graph. Lossless backing may validate provenance and recover supported non-visible payload, but never replaces the visible authored tree. Before authoring begins, report source Slide indexes plus retained and omitted Master/Layout identities.
### 2.1 PPTX Import Mode Rule ### 2.1 PPTX Import Mode Rule
@@ -368,30 +369,33 @@ The imported PPTX has a different authority level in each replication mode.
| Mode | Required behavior | | Mode | Required behavior |
|---|---| |---|---|
| `standard` | Use source visuals/assets as references, then author the project-canonical roster and its Master/Layout/slot structure from the confirmed brief. Do not preserve or distill source topology. | | `standard` | Review the complete source Master/Layout and visual evidence, then author a compact project-canonical roster and its Master/Layout/slot structure from the confirmed brief. Do not preserve source identities merely because they exist. |
| `fidelity` | Use the complete visual roster as design reference, then author a broader canonical roster and its own Master/Layout/slot structure. Match the source visual language closely, but do not cluster, merge, or split source Layouts into output families. | | `fidelity` | Review the complete source Master/Layout and visual roster, then author a broader canonical roster and its own useful source-aligned Master/Layout/slot families. Match the source visual language closely without implying one-to-one identity retention. |
| `mirror` | Preserve validated source pages, inheritance, placeholders, native objects, and visuals from the lossless import while creating a new workspace. Do not simplify, redesign, rename structure, infer new common structure, or fill gaps. | | `mirror` | Preserve validated source Slides and their reachable inheritance, placeholders, native facts, meaning, and similar presentation while authoring a compact new workspace. Complete each standalone SVG's inherited context; visible SVG may be redrawn/normalized without code isomorphism, but retained structure cannot be renamed and semantic gaps cannot be invented. |
**Hard rule — mirror materialization is mechanical**: Mirror may normalize namespaces, **Hard rule — mirror publication is mechanical, visual authoring is not**:
portable asset paths, explicit root declarations, and fixed-layer group wrappers Template_Designer owns the compact visible SVG created from parsed evidence.
required by the current compiler. Expanding a source Master/Layout group must The materializer validates source identity/SHA, refs, graph, assignments, and
produce direct atoms with the same ownership, transforms, paint order, and closure; composes inherited context; strips IR-only refs; and publishes that
appearance. A maintainability preference is not authority to alter the source current tree. It must never replace an unchanged visible subtree with lossless
template. source XML. Redrawing for compactness is allowed only while structure, meaning,
ownership, and a similar presentation remain intact.
### 2.2 Native Shape Payload and Authoring IR ### 2.2 Native Shape Payload and Authoring IR
| Representation | Purpose | Payload rule | | Representation | Purpose | Payload rule |
|---|---|---| |---|---|---|
| Lossless import SVG | Native-payload backing | Retain complete imported metadata, native object boundaries, hidden carriers, and source-scope identity. Keep it immutable and resolve it only through validated source refs. | | Lossless import SVG | Immutable source/package evidence | Retain complete imported metadata, native object boundaries, hidden carriers, and source-scope identity for validation and supported non-visible payload recovery. Never copy its ordinary visible subtree into final templates. |
| Authoring IR bundle | Editable template-creation source | Omit opaque native payload and duplicate hidden carriers from model context; retain visible shape intent and stable document-local source refs. Models read `authoring_summary.json`; tools read `authoring_manifest.json` for source paths and initial hashes. | | Authoring IR bundle | Editable template-creation source | New compact SVG generated from parsed PPTX evidence. Omit opaque native payload and duplicate hidden carriers from model context; retain visible shape intent and stable document-local source refs. Models read `authoring_summary.json`; tools read `authoring_manifest.json` for source paths and initial hashes. |
| `standard` / `fidelity` output | Newly authored contract | Use editable basic primitives directly and `preset_shape_svg.py` compact canonical `<g>` output for exact preset matches. Keep faithful atoms independently composed when one contour is unnecessary; use `shape_boolean_svg.py` only where one compound closed contour must become an object, then allow a necessary freeform only if neither construction is faithful. Paint comes from the confirmed brief / `<design_spec_path>`. Reuse exported image/vector assets, not opaque source shape payload or source topology. | | `standard` / `fidelity` output | Newly authored contract | Use editable basic primitives directly and `preset_shape_svg.py` compact canonical `<g>` output for exact preset matches. Keep faithful atoms independently composed when one contour is unnecessary; use `shape_boolean_svg.py` only where one compound closed contour must become an object, then allow a necessary freeform only if neither construction is faithful. Paint comes from the confirmed brief / `<design_spec_path>`. Reuse exported image/vector assets, not opaque source shape payload or source topology. |
| `mirror` output | Materialized preserved contract | Preserve currently supported imported metadata on unchanged Slide-local/slot refs, use the edited SVG fallback otherwise, and normalize fixed structural layers into semantic atoms. Strip IR-only source refs from final templates. | | `mirror` output | Authored compact preservation contract | Publish the reviewed current authoring SVG, preserve validated structure/native facts, recover only supported non-visible semantics, and normalize fixed structural layers into semantic atoms. Strip IR-only source refs; never rehydrate ordinary visible source subtrees. |
**Validation**: Mirror does not silently use stale metadata. Materialization **Validation**: Mirror does not silently use stale metadata. Materialization
validates source-document hashes and each referenced object's initial authoring validates source-document hashes, known refs, graph/assignment closure, and
hash before reusing native payload. If an imported object cannot use the classifies authoring subtree hashes; a changed subtree is a legitimate authored
converter's supported native metadata after normalization, keep its current SVG fallback and report the edit, not permission to copy the old visible tree back. If an imported object
cannot use the converter's supported non-visible metadata after normalization,
keep its current SVG fallback and report the
limitation. For exact registered preset matches, `standard` / `fidelity` limitation. For exact registered preset matches, `standard` / `fidelity`
regenerate the compact helper group instead of transplanting opaque source regenerate the compact helper group instead of transplanting opaque source
payload; otherwise they keep faithful atoms independently composed unless one payload; otherwise they keep faithful atoms independently composed unless one
@@ -405,7 +409,7 @@ Chart/Table replacement markers.
|---|---| |---|---|
| Master/Layout identity | Root `data-pptx-master` / `data-pptx-master-name` plus `data-pptx-layout` / `data-pptx-layout-name`; authored keys for `standard` / `fidelity`, source keys for `mirror` | | Master/Layout identity | Root `data-pptx-master` / `data-pptx-master-name` plus `data-pptx-layout` / `data-pptx-layout-name`; authored keys for `standard` / `fidelity`, source keys for `mirror` |
| Authored Master/Layout visual | In `standard` / `fidelity`, use a direct atomic child with `data-pptx-layer="master|layout"` and `data-pptx-editable="false"`. An ordinary `<g>` is forbidden; one validated compact canonical authored-preset `<g>` is a semantic atom and is the sole group exception. | | Authored Master/Layout visual | In `standard` / `fidelity`, use a direct atomic child with `data-pptx-layer="master|layout"` and `data-pptx-editable="false"`. An ordinary `<g>` is forbidden; one validated compact canonical authored-preset `<g>` is a semantic atom and is the sole group exception. |
| Preserved source Master/Layout visual | In `mirror`, recursively expand each fixed-layer source group into direct atoms with the same Master/Layout ownership, transforms, styles, paint order, and appearance; semantic regrouping is forbidden | | Preserved source Master/Layout visual | In `mirror`, author direct atoms with the same Master/Layout ownership and comparable paint order/presentation. Compact grouping, geometry, and style spelling may differ; semantic regrouping or ownership changes are forbidden. |
| Content slot | Direct `<g id>` with `data-pptx-placeholder` and explicit `data-pptx-bounds`; `standard` / `fidelity` author the slot, while `mirror` preserves source type/index/bounds and carrier identity | | Content slot | Direct `<g id>` with `data-pptx-placeholder` and explicit `data-pptx-bounds`; `standard` / `fidelity` author the slot, while `mirror` preserves source type/index/bounds and carrier identity |
| Page-only background | Direct full-canvas solid rect with `data-pptx-layer="slide"` | | Page-only background | Direct full-canvas solid rect with `data-pptx-layer="slide"` |
| Structural page-frame hint | Optional `data-pptx-role` only when background/decoration/header/footer/logo/watermark/chrome/page-number behavior is not already expressed by layer/placeholder metadata; stable unique `id` required | | Structural page-frame hint | Optional `data-pptx-role` only when background/decoration/header/footer/logo/watermark/chrome/page-number behavior is not already expressed by layer/placeholder metadata; stable unique `id` required |
@@ -465,7 +469,7 @@ Use clear placeholder markers for replaceable content:
This is the **default vocabulary** used across template packages. Newly created templates SHOULD prefer these names so downstream projects find familiar slots; designers MAY substitute or extend them when a style genuinely needs different vocabulary (e.g. consulting decks lead with `{{KEY_MESSAGE}}` instead of `{{PAGE_TITLE}}`; a brand cover may need `{{BRAND_LOGO}}`). This is the **default vocabulary** used across template packages. Newly created templates SHOULD prefer these names so downstream projects find familiar slots; designers MAY substitute or extend them when a style genuinely needs different vocabulary (e.g. consulting decks lead with `{{KEY_MESSAGE}}` instead of `{{PAGE_TITLE}}`; a brand cover may need `{{BRAND_LOGO}}`).
`svg_quality_checker.py --template-mode` emits **advisory warnings** when a page lacks the conventional placeholder for its type. To silence those warnings — and document the template's actual contract — declare a `placeholders:` map in `<design_spec_path>` frontmatter: `svg_quality_checker.py --template-mode --canonical-authoring` emits **advisory warnings** when a page lacks the conventional placeholder for its type. To silence those warnings — and document the template's actual contract — declare a `placeholders:` map in `<design_spec_path>` frontmatter:
```yaml ```yaml
placeholders: placeholders:
@@ -573,13 +577,13 @@ Mirror mode emits one SVG per source slide, named by source order:
└── 050_ending.svg └── 050_ending.svg
``` ```
Filenames preserve the source slide order via the 3-digit prefix; `<page_type>` is derived from `manifest.json` `pageTypeCandidates`. Literal source text and validated native structure facts are preserved when the authoring IR is materialized into the new workspace; IR-only refs and its manifest are not copied into the template output. Filenames preserve the source slide order via the 3-digit prefix; `<page_type>` is derived from `analysis/manifest.json` `pageTypeCandidates`. Source meaning and validated native structure facts are preserved while Template_Designer authors the compact new SVG; code/node identity is not required, and IR-only refs plus its manifest are not copied into template output.
**Hard rule — common routing**: Keep `<design_spec_path>`, template SVGs, and non-bitmap template-source assets in `templates/`; place every bitmap in `images/`; place each imported vector exactly once in `icons/imported/` and reference it as `data-icon="imported/<name>"`. Never create `templates/icons/`. Write a review deck to `exports/` when explicitly requested and always for a multi-Master package gate. Create Template must not create optional directories or placeholder files solely to retain empty paths. An initialized project may already contain empty scaffolding; leave it untouched and omit it from completion unless real template files were written or adopted there. Do not branch asset placement by output scope. **Hard rule — common routing**: Keep `<design_spec_path>`, template SVGs, and non-bitmap template-source assets in `templates/`; place every bitmap in `images/`; place each imported vector exactly once in `icons/imported/` and reference it as `data-icon="imported/<name>"`. Never create `templates/icons/`. Write a review deck to `exports/` when explicitly requested and always for a multi-Master package gate. Create Template must not create optional directories or placeholder files solely to retain empty paths. An initialized project may already contain empty scaffolding; leave it untouched and omit it from completion unless real template files were written or adopted there. Do not branch asset placement by output scope.
### Template Preview ### Template Preview
When the user requests a PowerPoint review file or the validated roster declares multiple Masters, run `template_preview_pptx.py <template_workspace>` after SVG validation. The command creates `exports/` on demand and verifies one slide per SVG prototype plus the expected Master/Layout counts. In authored modes, it shortens canonical marker text only in ephemeral review copies so prompts remain readable without changing the source SVG, carrier typography, or placeholder frames. The first export refuses a collision; an intentional post-fix replacement uses `--force`. The review PPTX is derived evidence and never a template-application input. When the user requests a PowerPoint review file or the validated roster declares multiple Masters, run `template_preview_pptx.py <template_workspace>` after SVG validation. The default review keeps visible SVG Chart/Table fallbacks. To verify JSON-first native capability, write a separately named review with `--native-charts-and-tables -o <distinct_path>`; marker presence alone never activates replacement. The command creates `exports/` on demand and verifies one slide per SVG prototype plus the expected Master/Layout counts. In authored modes, it shortens canonical marker text only in ephemeral review copies so prompts remain readable without changing the source SVG, carrier typography, or placeholder frames. The first export refuses a collision; an intentional post-fix replacement uses `--force`. The review PPTX is derived evidence and never a template-application input.
When a review deck was generated, include its path in the completion summary. Omit `exports/` only for an unrequested one-Master package. When a review deck was generated, include its path in the completion summary. Omit `exports/` only for an unrequested one-Master package.
@@ -631,10 +635,10 @@ templates/
- [x] Naming convention applied (standard / fidelity: letter-suffix variants; mirror: `<NNN>_<page_type>.svg`) - [x] Naming convention applied (standard / fidelity: letter-suffix variants; mirror: `<NNN>_<page_type>.svg`)
- [x] Templates follow design spec (colors, fonts, layout) - [x] Templates follow design spec (colors, fonts, layout)
- [x] Deck Template Overview and factual Page Roster describe the recurring application and actual prototypes without mandatory use policy; Layout output contains no application or identity contract - [x] Deck Template Overview and factual Page Roster describe the recurring application and actual prototypes without mandatory use policy; Layout output contains no application or identity contract
- [x] `standard` / `fidelity` SVGs and Master/Layout contracts were newly authored; `mirror` SVGs were materialized from the authoring IR while preserving the source graph without semantic redesign - [x] `standard` / `fidelity` inspected complete source Master/Layout evidence and represented each retained Layout through a newly authored Slide prototype; `mirror` SVGs preserve only source Slides and their reachable structure without semantic redesign
- [x] Placeholder markers are clear and standardized for `standard` / `fidelity`; preview-only sample text remains readable without changing source markers, while mirror preserves literal source text plus source placeholder type/index/bounds - [x] Placeholder markers are clear and standardized for `standard` / `fidelity`; preview-only sample text remains readable without changing source markers, while mirror preserves literal source text plus source placeholder type/index/bounds
- [x] Every SVG is a complete preview with explicit root Master/Layout identity and `native_structure_mode: structured`; authored modes use canonical fixed layers/slots, while mirror preserves source ownership and mechanically expands fixed-layer groups into direct atoms - [x] Every SVG is a complete preview with explicit root Master/Layout identity and `native_structure_mode: structured`; authored modes use canonical fixed layers/slots, while mirror preserves source ownership and mechanically expands fixed-layer groups into direct atoms
- [x] Authored `standard` / `fidelity` Layout keys are non-duplicative; mirror keeps distinct source Layout identities even when their current visible contracts are equivalent - [x] Authored `standard` / `fidelity` Layout keys are non-duplicative; mirror keeps distinct reachable source Layout identities even when their current visible contracts are equivalent
- [x] Template creation used the authoring IR; lossless expanded imports remained immutable payload backing for mirror materialization, while `standard` / `fidelity` used helper-generated compact canonical preset groups and `<design_spec_path>` paint - [x] Template creation used the authoring IR; lossless expanded imports remained immutable payload backing for mirror materialization, while `standard` / `fidelity` used helper-generated compact canonical preset groups and `<design_spec_path>` paint
- [x] Both scopes route bitmaps to `images/` and keep one canonical copy of every imported vector under `icons/imported/` - [x] Both scopes route bitmaps to `images/` and keep one canonical copy of every imported vector under `icons/imported/`
- [ ] **Next step**: Validate assets, export review evidence when requested or required for multiple Masters, then register only library scope - [ ] **Next step**: Validate assets, export review evidence when requested or required for multiple Masters, then register only library scope
@@ -29,46 +29,46 @@ Each style keeps its own authoritative file with: shape & decoration, typography
### 1.1 Corporate / product ### 1.1 Corporate / product
| Visual style | Character | Typical context | Paired rendering | Illus. | | Visual style | Character | Paired rendering | Illus. |
|---|---|---|---|---| |---|---|---|---|
| [`swiss-minimal`](./swiss-minimal.md) | Grid-locked, sharp, aggressive whitespace, near-zero ornament | High-end consulting, architecture, type-led | `minimalist-swiss` | sparse | | [`swiss-minimal`](./swiss-minimal.md) | Grid-locked, sharp, aggressive whitespace, near-zero ornament | `minimalist-swiss` | sparse |
| [`soft-rounded`](./soft-rounded.md) | Rounded cards, gentle elevation, approachable | Product, SaaS, training, consumer | `flat` | supportive | | [`soft-rounded`](./soft-rounded.md) | Rounded cards, gentle elevation, approachable | `flat` | supportive |
| [`glassmorphism`](./glassmorphism.md) | Translucent glass panels, gradient light, floating depth | Modern SaaS, fintech, product launches, AI demos | `glassmorphism` | sparse | | [`glassmorphism`](./glassmorphism.md) | Translucent glass panels, gradient light, floating depth | `glassmorphism` | sparse |
| [`dark-tech`](./dark-tech.md) | Dark canvas, glow accents, geometric precision | Tech, AI, data products, launches | `digital-dashboard` | sparse | | [`dark-tech`](./dark-tech.md) | Dark canvas, glow accents, geometric precision | `digital-dashboard` | sparse |
| [`blueprint`](./blueprint.md) | Schematic line work on dark paper, isometric, annotated | Technical briefings, architecture, engineering | `blueprint` | supportive | | [`blueprint`](./blueprint.md) | Schematic line work on dark paper, isometric, annotated | `blueprint` | supportive |
### 1.2 Editorial / publication ### 1.2 Editorial / publication
| Visual style | Character | Typical context | Paired rendering | Illus. | | Visual style | Character | Paired rendering | Illus. |
|---|---|---|---|---| |---|---|---|---|
| [`editorial`](./editorial.md) | Magazine hierarchy, rules & columns, serif/sans interplay | Finance, journalism, analysis, explainers | `editorial` | supportive | | [`editorial`](./editorial.md) | Magazine hierarchy, rules & columns, serif/sans interplay | `editorial` | supportive |
| [`photo-editorial`](./photo-editorial.md) | Full-bleed photography dominates, text points & captions | Architecture, design, fashion, culture, travel / destination, photo-led | `corporate-photo` | sparse | | [`photo-editorial`](./photo-editorial.md) | Full-bleed photography dominates, text points & captions | `corporate-photo` | sparse |
| [`data-journalism`](./data-journalism.md) | Multi-column micro-charts, sidebars, source lines, dense | Finance, market reviews, research, data reports | `editorial` | sparse | | [`data-journalism`](./data-journalism.md) | Multi-column micro-charts, sidebars, source lines, dense | `editorial` | sparse |
| [`brutalist`](./brutalist.md) | Newsprint density, ruled boxes, raw structure, flat | Annual reviews, research digests, manifestos | `screen-print` / `editorial` | supportive | | [`brutalist`](./brutalist.md) | Newsprint density, ruled boxes, raw structure, flat | `screen-print` / `editorial` | supportive |
### 1.3 Expressive / print ### 1.3 Expressive / print
| Visual style | Character | Typical context | Paired rendering | Illus. | | Visual style | Character | Paired rendering | Illus. |
|---|---|---|---|---| |---|---|---|---|
| [`memphis`](./memphis.md) | Clashing color blocks, geometric confetti, bold outlines | Festivals, consumer, youth, launch hype | `flat` | core | | [`memphis`](./memphis.md) | Clashing color blocks, geometric confetti, bold outlines | `flat` | core |
| [`zine`](./zine.md) | Riso misregistration, halftone, limited palette, print grit | Culture, design talks, indie brands | `screen-print` | core | | [`zine`](./zine.md) | Riso misregistration, halftone, limited palette, print grit | `screen-print` | core |
| [`vintage-poster`](./vintage-poster.md) | Mid-century flat blocks, halftone, retro-geometric warmth | Heritage brands, historic hospitality identities, cultural retrospectives, anniversaries | `vintage-poster` | core | | [`vintage-poster`](./vintage-poster.md) | Mid-century flat blocks, halftone, retro-geometric warmth | `vintage-poster` | core |
| [`paper-cut`](./paper-cut.md) | Layered cut-paper sheets, soft inter-layer shadow, tactile | Cultural / folk, children, festival, sustainability | `paper-cut` | core | | [`paper-cut`](./paper-cut.md) | Layered cut-paper sheets, soft inter-layer shadow, tactile | `paper-cut` | core |
### 1.4 Hand-drawn / brush ### 1.4 Hand-drawn / brush
| Visual style | Character | Typical context | Paired rendering | Illus. | | Visual style | Character | Paired rendering | Illus. |
|---|---|---|---|---| |---|---|---|---|
| [`sketch-notes`](./sketch-notes.md) | Warm paper, doodle line work, soft pastel blocks | Education, training, onboarding, knowledge | `sketch-notes` | core | | [`sketch-notes`](./sketch-notes.md) | Warm paper, doodle line work, soft pastel blocks | `sketch-notes` | core |
| [`ink-notes`](./ink-notes.md) | Pale field, black hand-ink, sparse semantic accent | Methodology, before/after, manifestos | `ink-notes` | supportive | | [`ink-notes`](./ink-notes.md) | Pale field, black hand-ink, sparse semantic accent | `ink-notes` | supportive |
| [`chalkboard`](./chalkboard.md) | Dark slate, chalk strokes, powdery pastel accents | Teaching, tutorials, classroom, academic | `chalkboard` | core | | [`chalkboard`](./chalkboard.md) | Dark slate, chalk strokes, powdery pastel accents | `chalkboard` | core |
| [`ink-wash`](./ink-wash.md) | Rice-paper whitespace, brush marks, seal accent, still | Cultural, philosophy, heritage, 新中式 | `ink-notes` / `watercolor` | supportive | | [`ink-wash`](./ink-wash.md) | Rice-paper whitespace, brush marks, seal accent, still | `ink-notes` / `watercolor` | supportive |
### 1.5 Specialty ### 1.5 Specialty
| Visual style | Character | Typical context | Paired rendering | Illus. | | Visual style | Character | Paired rendering | Illus. |
|---|---|---|---|---| |---|---|---|---|
| [`pixel-art`](./pixel-art.md) | Strict pixel grid, blocky forms, limited palette, flat | Gaming, retro-tech, nostalgic, game-flavored | `pixel-art` | core | | [`pixel-art`](./pixel-art.md) | Strict pixel grid, blocky forms, limited palette, flat | `pixel-art` | core |
--- ---
@@ -1,6 +1,6 @@
# PPT Master Toolset # PPT Master Toolset
This directory contains user-facing scripts for conversion, project setup, direct PPTX template filling, SVG processing, export, recorded narration, and image generation. This directory contains user-facing scripts for conversion, project setup, SVG processing, source-preserving PPTX editing, export, recorded narration, and image generation.
## Directory Layout ## Directory Layout
@@ -11,6 +11,7 @@ This directory contains user-facing scripts for conversion, project setup, direc
- `scripts/image_backends/`: internal provider implementations used by `image_gen.py` - `scripts/image_backends/`: internal provider implementations used by `image_gen.py`
- `scripts/tts_backends/`: internal TTS provider implementations used by `notes_to_audio.py` - `scripts/tts_backends/`: internal TTS provider implementations used by `notes_to_audio.py`
- `scripts/template_import/`: internal PPTX reference-preparation helpers used by `pptx_template_import.py` - `scripts/template_import/`: internal PPTX reference-preparation helpers used by `pptx_template_import.py`
- `scripts/pptx_ooxml/`: shared OOXML intake, cloning, and package primitives
- `scripts/svg_finalize/`: internal post-processing helpers used by `finalize_svg.py` - `scripts/svg_finalize/`: internal post-processing helpers used by `finalize_svg.py`
- `scripts/docs/`: topic-focused script documentation - `scripts/docs/`: topic-focused script documentation
- `scripts/prompt_audit.py` + `scripts/prompt_audit_manifest.json`: maintainer-only prompt budget/governance lint (see [`docs/prompt_audit.md`](docs/prompt_audit.md)); the manifest is audit-only and never loaded as prompt context - `scripts/prompt_audit.py` + `scripts/prompt_audit_manifest.json`: maintainer-only prompt budget/governance lint (see [`docs/prompt_audit.md`](docs/prompt_audit.md)); the manifest is audit-only and never loaded as prompt context
@@ -51,8 +52,8 @@ python3 scripts/update_repo.py
| Area | Primary scripts | Documentation | | Area | Primary scripts | Documentation |
|------|-----------------|---------------| |------|-----------------|---------------|
| Conversion | `source_to_md.py`, `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py`, `pptx_to_svg.py` | [docs/conversion.md](./docs/conversion.md) | | Conversion | `source_to_md.py`, `source_to_md/pdf_to_md.py`, `source_to_md/doc_to_md.py`, `source_to_md/excel_to_md.py`, `source_to_md/ppt_to_md.py`, `source_to_md/web_to_md.py`, `pptx_intake.py`, `pptx_to_svg.py` | [docs/conversion.md](./docs/conversion.md) |
| Project management | `project_manager.py`, `workflow_log.py`, `workflow_transcript.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `template_fill_pptx.py`, `native_enhance_pptx.py`, `pptx_delivery_check.py` | [docs/project.md](./docs/project.md) | | Project management | `project_manager.py`, `workflow_log.py`, `workflow_transcript.py`, `batch_validate.py`, `generate_examples_index.py`, `error_helper.py`, `pptx_template_import.py`, `pptx_delivery_check.py` | [docs/project.md](./docs/project.md) |
| SVG pipeline | `preset_shape_svg.py`, `shape_boolean_svg.py`, `svg_authoring_view.py`, `compact_svg_coordinates.py`, `mirror_template_materialize.py`, `finalize_svg.py`, `svg_to_pptx.py`, `template_preview_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `extract_svg_pictures.py`, `animation_config.py`, `notes_to_audio.py`, `narration_sync.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md); [native shape authoring](../references/native-shape-authoring.md) | | SVG pipeline | `preset_shape_svg.py`, `shape_boolean_svg.py`, `svg_authoring_view.py`, `authoring_roundtrip.py`, `compact_svg_coordinates.py`, `compact_svg_styles.py`, `stamp_native_fallbacks.py`, `mirror_template_materialize.py`, `finalize_svg.py`, `svg_to_pptx.py`, `template_preview_pptx.py`, `total_md_split.py`, `svg_quality_checker.py`, `extract_svg_assets.py`, `extract_svg_pictures.py`, `animation_config.py`, `notes_to_audio.py`, `narration_sync.py` | [docs/svg-pipeline.md](./docs/svg-pipeline.md); [native shape authoring](../references/native-shape-authoring.md) |
| PPTX transitions | `pptx_transitions.py` | [docs/pptx-transitions.md](./docs/pptx-transitions.md) | | PPTX transitions | `pptx_transitions.py` | [docs/pptx-transitions.md](./docs/pptx-transitions.md) |
| PPTX animations | `pptx_animations.py`, `animation_config.py` | [docs/pptx-animations.md](./docs/pptx-animations.md) | | PPTX animations | `pptx_animations.py`, `animation_config.py` | [docs/pptx-animations.md](./docs/pptx-animations.md) |
| Animation resources | `sound_sync.py` | [sound vocabulary and sync](../templates/sounds/README.md); [docs/pptx-animations.md](./docs/pptx-animations.md) | | Animation resources | `sound_sync.py` | [sound vocabulary and sync](../templates/sounds/README.md); [docs/pptx-animations.md](./docs/pptx-animations.md) |
@@ -118,46 +119,104 @@ python3 scripts/pptx_template_import.py <template.pptx> --manifest-only
python3 scripts/pptx_template_import.py <template.pptx> --inheritance-mode both python3 scripts/pptx_template_import.py <template.pptx> --inheritance-mode both
python3 scripts/svg_authoring_view.py <imported-svg-or-dir> -o <output-dir> --projection-kind layered python3 scripts/svg_authoring_view.py <imported-svg-or-dir> -o <output-dir> --projection-kind layered
python3 scripts/svg_authoring_view.py <authoring-dir> --refresh-summary python3 scripts/svg_authoring_view.py <authoring-dir> --refresh-summary
python3 scripts/compact_svg_coordinates.py <template_workspace>/templates --inplace --keep-native-frames python3 scripts/svg_authoring_view.py <authoring-dir> --adopt-object <from.svg>:<element-id> --into <target.svg>
python3 scripts/stamp_native_fallbacks.py <svg-file-or-directory> --write
python3 scripts/svg_quality_checker.py <template_workspace>/templates --template-mode --canonical-authoring
python3 scripts/mirror_template_materialize.py <import_workspace> <template_workspace> python3 scripts/mirror_template_materialize.py <import_workspace> <template_workspace>
python3 scripts/svg_to_pptx.py <import_workspace> --roundtrip
python3 scripts/template_preview_pptx.py <template_workspace> python3 scripts/template_preview_pptx.py <template_workspace>
python3 scripts/template_preview_pptx.py <legacy_template_workspace> --visual-only
``` ```
Template import defaults to the canonical layered `svg/` tree. Use Template import defaults to the canonical layered `svg/` backing tree and
creates compact `authoring-svg/` in the same transaction. Use
`--inheritance-mode both` only when a separate self-contained `svg-flat/` `--inheritance-mode both` only when a separate self-contained `svg-flat/`
verification tree is required. No derived narrative digest is generated verification tree plus `authoring-svg-flat/` is required. No derived narrative
because `manifest.json` already owns those facts. digest is generated because `analysis/manifest.json` already owns those facts.
`svg_authoring_view.py` creates a lightweight, non-destructive editable IR `pptx_template_import.py` creates the lightweight authoring bundle in the same
bundle from PPTX-imported SVGs. It removes embedded `txbody` payloads, transaction as its immutable backing. `svg_authoring_view.py` remains the
duplicate hidden geometry carriers, and import-identity attributes from the standalone projection entry point for external SVG and migrations. Before the
copy while retaining visible fallback geometry, text, images, stable element transaction publishes, it also factors eligible non-semantic decoration into
ids, root Master/Layout markers, selected native-shape intent, and `icons/imported/`. Recognized native shapes become one visible geometry carrier
document-local `data-pptx-source-ref` values. plus at most one structured text body; recognized tables keep
one compact semantic JSON payload plus a preview cache. The projection removes
duplicate render geometry and import-only identity/payload attributes while
retaining text, images, stable ids, root Master/Layout markers, native-shape
intent, and document-local `data-pptx-source-ref` values. It also promotes a
common page font to the root and removes inherited presentation declarations
that merely repeat the root/group value.
Relative local image references are rewritten so the projected copy still Relative local image references are rewritten so the projected copy still
renders from its new location. The bundle's `authoring_summary.json` is the renders from its new location. The bundle's `authoring_summary.json` is the
model-readable current-file index; `authoring_manifest.json` records model-readable current-file index; `authoring_manifest.json` records
source/authoring hashes and object paths for tools without duplicating opaque source/authoring hashes and object paths for tools without duplicating opaque
payload and does not enter model context. Imported model-facing frames and safe payload and does not enter model context. Only unsupported, text-free,
schema-free source ornaments may become compact `native-restore` image proxies whose
hashed SVG previews live under `images/source-object-previews/`. Unchanged
proxies restore the original native PowerPoint objects; complete removal deletes
a Slide-local source object, while inherited-proxy removal and any proxy or
preview edit fail export. Imported
model-facing frames and safe
transform page coordinates use at most two decimals; immutable lossless SVGs transform page coordinates use at most two decimals; immutable lossless SVGs
retain the original precision. In-place vector/picture extraction retain the original precision. In-place vector/picture extraction
refreshes the summary automatically; use `--refresh-summary` after other direct refreshes the summary automatically; use `--refresh-summary` after other direct
IR edits. The full imported SVG remains unchanged as native-payload backing. IR edits. The full imported SVG remains unchanged as native-payload backing.
Template creation edits the IR and materializes validated `templates/*.svg`; Template creation edits the IR and materializes validated `templates/*.svg`;
the IR directory itself is not a final template or direct release export the layered IR directory itself is not a final template or direct release
source. export source. A complete-page flat IR may be selected with
`--roundtrip`: `authoring_roundtrip.py` reads `authoring-svg-flat/`, regenerates its
deterministic extraction baseline, restores unchanged refs from the immutable
layered backing, and sends the temporary result through preserve export while
leaving edited/deleted/new authoring content in place.
`mirror_template_materialize.py` is the deterministic Type A mirror compiler. An imported round-trip workspace may optionally add root `page_plan.json` to
It consumes only the layered `authoring-svg/` IR as editable input, loads its select, reorder, repeat, or omit source slides during export. The v1 shape is
tool-only manifest internally, and validates it against immutable `svg/`, minimal: top-level `schema: "ppt-master.roundtrip-page-plan.v1"` plus a
`native_structure.json`, non-empty ordered `pages` array; every entry requires one-based
`svg/inheritance.json`, `source_template.pptx`, and any extracted-vector `source_slide` and may name a unique `authoring-svg-flat/` filename in `svg`.
inventory, then publishes a complete structured template roster atomically. A copied SVG is diffed against the baseline for its declared source slide.
Move a cross-page object with
`svg_authoring_view.py <authoring-dir> --adopt-object <from.svg>:<element-id>
--into <target.svg>` rather than pasting raw SVG. The helper rebuilds the copy
without source identity, inlines source-owned imported vectors, refuses source
proxies, resolves id collisions, and refreshes `authoring_summary.json`.
`notes/<svg-stem>.md` implicitly overrides notes for that output page; when it
is absent, source notes travel with the cloned page. Unchanged repeats clone
their private notes/chart/diagram/embedding parts, while media may stay shared;
slide-jump targets must map to exactly one output page. Without the file, the
identity round trip is unchanged. See
[`docs/svg-pipeline.md`](docs/svg-pipeline.md#round-trip-deck-page-plans) for
the schema, sidecar keying, fail-closed rules, and export receipt.
Run `python3 scripts/svg_quality_checker.py <workspace> --roundtrip` before a
round-trip export. The mode resolves the same identity or `page_plan.json`
output roster as the exporter and checks only new or changed text for supported
font stacks and sizes, estimated canvas containment, and horizontal capacity
against its owning `data-pptx-frame` or nearest rect fallback. Capacity is the
single-line width of each positioned line; the gate does not model vertical
wrapping. Explicit frame-width or canvas overflow is blocking, warnings are
advisory, and unchanged source refs, source proxies, plus
generated-project/template-only contracts are skipped.
`mirror_template_materialize.py` is the deterministic Type A mirror
validator/publisher. Template_Designer first reviews and authors the compact
layered `authoring-svg/` tree. The command loads its tool-only manifest and
validates it against immutable `svg/`,
`analysis/native_structure.json`,
`svg/inheritance.json`, `sources/source.pptx`, and any extracted-vector
inventory, then publishes the current visible authoring tree atomically. It
never replaces an unchanged visible subtree with lossless source XML; that
backing supplies provenance and supported non-visible semantics only. Mirror retains
only the Layout/Master chain reachable from each source Slide. Every output SVG
resolves Master + Layout + Slide context while keeping layer ownership explicit;
source identities unused by every Slide produce no SVG.
For a PPTX-backed mirror, `templates/source_themes.json` carries the exact Theme
bytes for each retained Master; it is validated tool input, not an SVG prototype
or model-editing surface.
Unchanged supported Slide-local/slot refs may recover native payload; edited Unchanged supported Slide-local/slot refs may recover native payload; edited
refs keep their current SVG fallback. Fixed Master/Layout wrappers are expanded refs keep their current SVG fallback. Fixed Master/Layout wrappers are expanded
mechanically into direct atoms, source visibility flags become canonical root mechanically into direct atoms, source visibility flags become canonical root
metadata, and imported vectors are copied once to `icons/imported/`. Large metadata, and decoration-only imported vectors are copied once to
`icons/imported/`. Semantic objects remain inline. Large
opaque `txBody`, shape-style, and custom-geometry payloads are deduplicated into opaque `txBody`, shape-style, and custom-geometry payloads are deduplicated into
`templates/native_payloads.json.gz`; repeated native restoration attributes `templates/native_payloads.json.gz`; repeated native restoration attributes
are stored there as short `data-pptx-native-ref` records. Structural metadata are stored there as short `data-pptx-native-ref` records. Structural metadata
@@ -168,36 +227,12 @@ readable. The v1 execution manifest points to per-prototype
tool metadata and are not injected into model context. Checker and export tool metadata and are not injected into model context. Checker and export
validate output attributes, topology, and resource hashes against the complete validate output attributes, topology, and resource hashes against the complete
prototype internally. Bitmap assets prototype internally. Bitmap assets
go to `images/`; other referenced source assets go to `templates/assets/`. and Office vector image media go to `images/`; audio, video, and opaque source
payloads go to their semantic workspace directories.
The destination must be empty, and the command does not write The destination must be empty, and the command does not write
`templates/design_spec.md`; Template_Designer owns that authored brief. `templates/design_spec.md`; Template_Designer owns that authored brief.
`template_preview_pptx.py` reads a template workspace, exports every public `templates/*.svg` prototype as one structured review slide, and verifies the resulting Master/Layout package. In a project root containing Layout and Deck specs, it previews the active Layout roster. Canonical definition-only `layout_<layout_key>.svg` prototypes are registered as reusable Layouts through internal carrier slides that are removed before publication; they never increase the review deck's visible slide count. This is an on-demand review action: its default output is `exports/<template_id>_template_preview.pptx`, and that directory need not exist before the command runs. It refuses an existing output unless an intentional re-export passes `--force`. `--visual-only` is an explicit migration aid for legacy SVG rosters: it creates a slide-local visual review deck without validating or claiming a reusable Master/Layout contract. This diagnostic path does not require a project `spec_lock.md`; it may retain generic theme/text defaults inside its clean one-Master/one-Layout shell. New structured templates use the default mode when a review deck is requested. `template_preview_pptx.py` reads a template workspace, exports every complete `templates/*.svg` Slide prototype as one structured review slide, and verifies the resulting Master/Layout package. In a project root containing Layout and Deck specs, it previews the active Layout roster. Standalone `layout_<layout_key>.svg` definition files are rejected; every reusable Layout must be represented by a complete Slide prototype. This is an on-demand review action: its default output is `exports/<template_id>_template_preview.pptx`, and that directory need not exist before the command runs. It refuses an existing output unless an intentional re-export passes `--force`.
Template fill (direct PPTX, no SVG conversion):
```bash
python3 scripts/project_manager.py init <project_name>
python3 scripts/project_manager.py import-sources <project_path> <source.pptx> <material...>
# Manual fallback when import-sources did not produce analysis/<stem>.slide_library.json:
python3 scripts/template_fill_pptx.py analyze <project_path>/sources/<source.pptx> -o <project_path>/analysis/<stem>.slide_library.json
python3 scripts/template_fill_pptx.py scaffold <project_path>/analysis/<stem>.slide_library.json -o <project_path>/analysis/fill_plan.json --slides "1,3,4"
python3 scripts/template_fill_pptx.py check-plan <project_path>/analysis/<stem>.slide_library.json <project_path>/analysis/fill_plan.json -o <project_path>/analysis/check_report.json
python3 scripts/template_fill_pptx.py apply <project_path>/sources/<source.pptx> <project_path>/analysis/fill_plan.json -o <project_path>/exports/filled.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 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):
```bash
python3 scripts/native_enhance_pptx.py init <source.pptx> --name <project_slug>
python3 scripts/native_enhance_pptx.py plan <project_path>
python3 scripts/native_enhance_pptx.py validate <project_path>
python3 scripts/native_enhance_pptx.py apply <project_path>
python3 scripts/pptx_delivery_check.py <finished.pptx>
```
Native preset shape authoring (one or more registry-backed fragments on stdout): Native preset shape authoring (one or more registry-backed fragments on stdout):
@@ -273,22 +308,32 @@ result remains editable freeform geometry but is no longer editable text. See
[`references/native-shape-authoring.md`](../references/native-shape-authoring.md) [`references/native-shape-authoring.md`](../references/native-shape-authoring.md)
§6 for the closed operand and failure contract. §6 for the closed operand and failure contract.
Create-template/source normalization (optional; never part of automatic export): External-source migration and explicit picture normalization:
```bash ```bash
python3 scripts/extract_svg_assets.py <layered_svg_dir> --icons-dir <icons_dir> --icon-namespace imported --inplace --id-prefix layered python3 scripts/extract_svg_assets.py <layered_svg_dir> --icons-dir <icons_dir> --icon-namespace imported --inplace --id-prefix layered
python3 scripts/extract_svg_assets.py <flat_svg_dir> --icons-dir <icons_dir> --icon-namespace imported --reuse-inventory <layered_inventory.json> --inplace --id-prefix flat python3 scripts/extract_svg_assets.py <flat_svg_dir> --icons-dir <icons_dir> --icon-namespace imported --reuse-inventory <layered_inventory.json> --inplace --id-prefix flat
python3 scripts/extract_svg_pictures.py "<svg_file>" --select "<group_id>" --resource-root "<workspace>" --images-dir "<picture_assets_dir>" --inplace # optional create-template normalization: one selected group -> one SVG picture python3 scripts/extract_svg_pictures.py "<svg_file>" --select "<group_id>" --resource-root "<workspace>" --images-dir "<picture_assets_dir>" --inplace # optional create-template normalization: one selected group -> one SVG picture
python3 scripts/compact_svg_coordinates.py <template_workspace>/templates --inplace --keep-native-frames python3 scripts/svg_quality_checker.py <template_workspace>/templates --template-mode --canonical-authoring
python3 scripts/mirror_template_materialize.py <import_workspace> <template_workspace> # Type A mirror only; destination owns no roster python3 scripts/mirror_template_materialize.py <import_workspace> <template_workspace> # Type A mirror only; destination owns no roster
``` ```
`extract_svg_assets.py` fingerprints each extracted subtree before generated-ID PPTX template import and round-trip import run vector readability extraction in
namespacing. Process the layered authoring view first, then pass its inventory to their staging transaction before the first authoring bundle is published. The
manual extraction commands above are only for external SVG/migration input.
`extract_svg_assets.py` extracts only non-semantic decoration. Any subtree that
contains a semantic object, text, table, chart, relationship, or other
meaning-bearing authoring content stays inline. Each imported asset and its
placeholder declare `data-pptx-asset-role="decoration"`; the v2 inventory
records the same role, and round-trip/template consumers reject missing or
different roles. The extractor fingerprints each eligible subtree before
generated-ID namespacing. Process the layered authoring view first, then pass its inventory to
the flat view with `--reuse-inventory`; matching flat subtrees reference the the flat view with `--reuse-inventory`; matching flat subtrees reference the
existing layered asset instead of creating a duplicate file. Only unmatched existing layered asset instead of creating a duplicate file. Only unmatched
flat-only vectors create new assets. Create-template stores these assets once in flat-only vectors create new assets. Create-template stores these assets once in
`<workspace>/icons/imported/` and writes `data-icon="imported/<name>"` references. `<workspace>/icons/imported/` and writes decoration-marked
`data-icon="imported/<name>"` references.
Inventories retain any `data-pptx-source-ref` values carried by the extracted Inventories retain any `data-pptx-source-ref` values carried by the extracted
subtree, so re-inlining preserves authoring-manifest object identity. subtree, so re-inlining preserves authoring-manifest object identity.
Rerunning a namespaced pass against an already rewritten projection inventories Rerunning a namespaced pass against an already rewritten projection inventories
@@ -311,13 +356,13 @@ embedded.
`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. `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.
For SVG-authoring routes, `svg_output/` is the complete visible page-design source: every exported text, image, shape, background, and template-derived layout element is present in the page SVG or explicitly referenced by it. Export may translate represented content into Master/Layout/Slide parts or native objects, but it does not retrieve missing visible content from templates or planning files. Speaker notes, animation, narration, transitions, `template-fill-pptx`, and `native-enhance-pptx` remain separately owned capabilities. For SVG-authoring routes, `svg_output/` is the complete visible page-design source: every exported text, image, shape, background, and template-derived layout element is present in the page SVG or explicitly referenced by it. Export may translate represented content into Master/Layout/Slide parts or native objects, but it does not retrieve missing visible content from templates or planning files. Speaker notes, animation, narration, and transitions use dedicated sidecars or assets; Edit Native PPTX owns source-preserving existing-deck edits.
Native `svg_to_pptx.py` release export reads the project's explicit structure mode. Free-design, Brand-only, Style-only, and other `template_reuse_scope: style` projects use `flat`, omit Master/Layout mappings and SVG structure metadata, keep every represented object Slide-local, and materialize one clean project-owned Master plus one Blank Layout from the current color/typography lock. Stock content placeholders and unused built-in Layouts are removed; only the standard date/footer/slide-number capability hooks remain. A Deck/Layout application uses `structured` only when Strategist derives `template_reuse_scope: mirror|layout`: each project supplies unique Master/Layout definitions and one Layout assignment per generated page before SVG generation, and every SVG root repeats its assigned identity. A template-backed definition may remain unused and still register without a published carrier slide. Fixed Master/Layout visuals are direct semantic atoms; ordinary groups are invalid there, while one validated compact authored-preset `<g>` is the sole group exception because it compiles to one native shape. Reusable slots are top-level groups with positive design-zone bounds plus one compatible carrier. Composite `object` regions use explicit proxy binding, and zero-slot Layouts are valid. Native `svg_to_pptx.py` release export reads the project's explicit structure mode. Free-design, Brand-only, Style-only, and other `template_reuse_scope: style` projects use `flat`, omit Master/Layout mappings and SVG structure metadata, keep every represented object Slide-local, and materialize one clean project-owned Master plus one Blank Layout from the current color/typography lock. Stock content placeholders and unused built-in Layouts are removed; only the standard date/footer/slide-number capability hooks remain. A Deck/Layout application uses `structured` in Default when Strategist derives `template_reuse_scope: mirror|layout` with complete lock rosters, or in Quick when every page of the installed Layout/Deck roster declares the complete lockless Master/Layout/slot contract: each project supplies unique Master/Layout definitions and one Layout assignment per generated page before SVG generation, and every SVG root repeats its assigned identity. An unselected complete template Slide may still supply a reusable Layout definition without becoming a published page. Fixed Master/Layout visuals are direct semantic atoms; ordinary groups are invalid there, while one validated compact authored-preset `<g>` is the sole group exception because it compiles to one native shape. Reusable slots are top-level groups with positive design-zone bounds plus one compatible carrier. Composite `object` regions use explicit proxy binding, and zero-slot Layouts are valid.
Structured template export compiles only the declared structure, maps locked typography/colors into PowerPoint defaults, creates the named Master/Layout parts, and reads the package back before publication. It never clusters pages, promotes repeated chrome heuristically, or invents placeholders. Flat export is the normal free-design/Brand-only/Style-only/style-scope route: it creates only the clean project-owned shell and performs no promotion or deduplication of Slide content. Structured template export compiles only the declared structure, maps locked typography/colors into PowerPoint defaults, creates the named Master/Layout parts, and reads the package back before publication. It never clusters pages, promotes repeated chrome heuristically, or invents placeholders. Flat export is the normal free-design/Brand-only/Style-only/style-scope route: it creates only the clean project-owned shell and performs no promotion or deduplication of Slide content.
Template `page_layouts` records authoring-input provenance, `pptx_masters` / `pptx_layouts` own unique reusable definitions, and `page_pptx_layouts` owns page assignment. Strict preserves its Master/Layout/slot contract; adaptive retains its Master and may use a new Layout key only when fixed Layout atoms or slot topology/bounds change. `standard` / `fidelity` author new SVGs and a new Master/Layout/slot contract. `mirror` materializes a new workspace from the complete validated source identity graph—including unused Layout definitions—without semantic synthesis or gap filling, while mechanically expanding fixed-layer group wrappers into the direct atoms required by the structured contract. Template `page_layouts` records authoring-input provenance, `pptx_masters` / `pptx_layouts` own unique reusable definitions, and `page_pptx_layouts` owns page assignment. Strict preserves its Master/Layout/slot contract; adaptive retains its Master and may use a new Layout key only when fixed Layout atoms or slot topology/bounds change. `standard` / `fidelity` inspect complete source structure evidence and author compact or broader useful Slide rosters. `mirror` materializes source Slides and their reachable identity graph without semantic synthesis or gap filling, while completing inherited context and mechanically expanding fixed-layer group wrappers into direct atoms.
Legacy structured/template contracts using `baseline`, `template`, `preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled`/`utility`, direct atomic placeholders, or incomplete root Master identity must be replaced by a new workspace created through [`create-template`](../workflows/create-template.md). Generate new structured SVG pages from that workspace; do not upgrade the existing PPTX/SVG in place. Explicit flat free-design/Brand-only/Style-only projects intentionally omit root Master identity. Legacy structured/template contracts using `baseline`, `template`, `preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled`/`utility`, direct atomic placeholders, or incomplete root Master identity must be replaced by a new workspace created through [`create-template`](../workflows/create-template.md). Generate new structured SVG pages from that workspace; do not upgrade the existing PPTX/SVG in place. Explicit flat free-design/Brand-only/Style-only projects intentionally omit root Master identity.
@@ -339,7 +384,16 @@ Supported parsed column/bar/line/area, pie/doughnut, scatter, and bubble charts
The ChartEx importer accepts exactly the validated treemap, sunburst, histogram, pareto, box-whisker, waterfall, and funnel data models. Supported hierarchy/category/value/series/subtotal data round-trips to native output; source style, axes, labels, and binning may normalize. Numeric caches must be non-empty and finite with exact contiguous point topology. This is not arbitrary ChartEx import or presentation fidelity, and the ChartEx native writer still only promises valid payload palette entries rather than full source styling. The ChartEx importer accepts exactly the validated treemap, sunburst, histogram, pareto, box-whisker, waterfall, and funnel data models. Supported hierarchy/category/value/series/subtotal data round-trips to native output; source style, axes, labels, and binning may normalize. Numeric caches must be non-empty and finite with exact contiguous point topology. This is not arbitrary ChartEx import or presentation fidelity, and the ChartEx native writer still only promises valid payload palette entries rather than full source styling.
Active imported table/chart markers carry `data-pptx-fallback-sha256`. Visible fallback edits, reachable SVG fragment-definition changes, marker-local reference-target changes, and marker transforms make the baseline stale: the mandatory quality checker warns, default export remains available, and `--native-charts-and-tables` fails instead of discarding the SVG edit. Generated authoring and reusable templates omit import provenance and a static baseline without warning. Hashless legacy imported markers that still carry PPTX import provenance remain convertible with a checker/replacement-route warning. Legacy `data-pptx-native*`, `data-pptx-visual-status`, and `data-pptx-route-status` spellings and the `--native-objects` option remain read-compatible; generated output and canonical commands use the replacement/fallback names and `--native-charts-and-tables`. Imported/template-owned table/chart markers carry
`data-pptx-native-authority="json"`; their inline JSON is authoritative and the
visible fallback is a derived preview, so fallback freshness does not veto
native export. Free-designed markers omit the authority attribute and are
SVG-first. After their visible fallback and JSON are synchronized, run
`stamp_native_fallbacks.py ... --write`; missing, invalid, or stale baselines
leave default fallback export available but make `--native-charts-and-tables`
fail closed. Only that explicit flag activates Chart/Table replacement; marker
presence, semantic tables, and imported chart packages do not. Legacy marker
spellings and `--native-objects` remain read-compatible.
Exporter-canonical classic charts also recover canonical solid series/slice Exporter-canonical classic charts also recover canonical solid series/slice
colors and exact one- or two-paragraph title styling; two paragraphs retain colors and exact one- or two-paragraph title styling; two paragraphs retain
@@ -46,10 +46,6 @@ _REQUIRED_GATE_FILES = (
"scripts/svg_quality/cli.py", "scripts/svg_quality/cli.py",
"scripts/svg_to_pptx.py", "scripts/svg_to_pptx.py",
"scripts/svg_to_pptx/pptx_package/cli.py", "scripts/svg_to_pptx/pptx_package/cli.py",
"scripts/template_fill_pptx.py",
"scripts/template_fill_pptx/cli.py",
"scripts/native_enhance_pptx.py",
"scripts/native_enhance_pptx_core.py",
"scripts/register_template.py", "scripts/register_template.py",
"scripts/template_preview_pptx.py", "scripts/template_preview_pptx.py",
) )
@@ -4,7 +4,7 @@ PPT Master - Beautify Inventory Builder
Mechanically merge a source deck's extracts into one per-slide ledger for the Mechanically merge a source deck's extracts into one per-slide ledger for the
beautify-pptx profile: text blocks + tables + charts + SmartArt structure (from a beautify-pptx profile: text blocks + tables + charts + SmartArt structure (from a
`template_fill_pptx.py analyze` slide_library.json) joined with the images `<stem>.slide_library.json` produced by `pptx_intake.py`) joined with the images
bound to each slide (from a `ppt_to_md.py` image_manifest.json). The deterministic bound to each slide (from a `ppt_to_md.py` image_manifest.json). The deterministic
join only `ignored` and `needs_confirmation` are emitted empty for the agent join only `ignored` and `needs_confirmation` are emitted empty for the agent
to fill with judgment (hidden shapes, combo charts, overcrowded pages, ...). to fill with judgment (hidden shapes, combo charts, overcrowded pages, ...).
@@ -1,15 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
PPT Master - SVG Coordinate Compactor PPT Master - Legacy SVG Coordinate Migration
Compact model-facing page-space SVG coordinates to at most two decimal places Diagnose or migrate older model-facing page-space SVG coordinates without
without rounding normalized crop ratios or transform linear coefficients. rounding normalized crop ratios or transform linear coefficients. New
authoring code calls the tree-level implementation before its first write.
Usage: Usage:
python3 scripts/compact_svg_coordinates.py <svg-file-or-directory> [--inplace] python3 scripts/compact_svg_coordinates.py <svg-file-or-directory> [--inplace]
Examples: Examples:
python3 scripts/compact_svg_coordinates.py projects/example/templates --inplace python3 scripts/compact_svg_coordinates.py imported/legacy-templates --inplace
python3 scripts/compact_svg_coordinates.py imported/authoring-svg python3 scripts/compact_svg_coordinates.py imported/authoring-svg
Dependencies: Dependencies:
@@ -246,8 +247,8 @@ def _write_atomic(path: Path, payload: str) -> None:
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=( description=(
"Compact safe page-space SVG coordinates to at most two decimal " "Diagnose or migrate older page-space SVG coordinates to at most "
"places. Runs as a dry-run unless --inplace is supplied." "two decimal places. Runs as a dry-run unless --inplace is supplied."
), ),
) )
parser.add_argument("input", type=Path, help="SVG file or directory") parser.add_argument("input", type=Path, help="SVG file or directory")
@@ -0,0 +1,585 @@
#!/usr/bin/env python3
"""
PPT Master - Legacy SVG Inherited Style Migration
Diagnose or migrate older SVG authoring files to root/group defaults plus local
overrides. New authoring code calls the tree-level implementation before its
first write; the CLI is not a standard post-generation step.
Usage:
python3 scripts/compact_svg_styles.py <svg-file-or-directory> [--inplace]
Examples:
python3 scripts/compact_svg_styles.py projects/example/svg_output --inplace
python3 scripts/compact_svg_styles.py imported/authoring-svg-flat
Dependencies:
None (standard library only).
"""
from __future__ import annotations
import argparse
import json
import os
import re
import stat
import sys
import tempfile
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from xml.etree import ElementTree as ET
from console_encoding import configure_utf8_stdio
from svg_to_pptx.drawingml.utils import INHERITABLE_ATTRS
configure_utf8_stdio()
SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
INHERITABLE_ATTRIBUTES = tuple(INHERITABLE_ATTRS)
_DEFINITION_SUBTREES = frozenset({
"clipPath",
"defs",
"filter",
"linearGradient",
"marker",
"mask",
"pattern",
"radialGradient",
"symbol",
})
_UNSAFE_PRESENTATION_VALUE_TOKENS = (
"!important",
"var(",
)
_CSS_WIDE_VALUES = frozenset({
"inherit",
"initial",
"revert",
"revert-layer",
"unset",
})
_CONTEXT_DEPENDENT_VALUES = frozenset({
"context-fill",
"context-stroke",
"currentcolor",
})
_URL_FUNCTION_RE = re.compile(r"url\([^)]*\)", re.IGNORECASE)
ET.register_namespace("", SVG_NS)
@dataclass
class StyleCompactionStats:
"""Count semantics-preserving authoring-style reductions."""
root_font_defaults: int = 0
root_style_declarations_normalized: int = 0
container_style_declarations_normalized: int = 0
group_defaults_promoted: int = 0
shadowed_attributes_removed: int = 0
redundant_attributes_removed: int = 0
redundant_style_declarations_removed: int = 0
@property
def changed_declarations(self) -> int:
return (
self.root_font_defaults
+ self.root_style_declarations_normalized
+ self.container_style_declarations_normalized
+ self.group_defaults_promoted
+ self.shadowed_attributes_removed
+ self.redundant_attributes_removed
+ self.redundant_style_declarations_removed
)
def merge(self, other: "StyleCompactionStats") -> None:
self.root_font_defaults += other.root_font_defaults
self.root_style_declarations_normalized += (
other.root_style_declarations_normalized
)
self.container_style_declarations_normalized += (
other.container_style_declarations_normalized
)
self.group_defaults_promoted += other.group_defaults_promoted
self.shadowed_attributes_removed += other.shadowed_attributes_removed
self.redundant_attributes_removed += other.redundant_attributes_removed
self.redundant_style_declarations_removed += (
other.redundant_style_declarations_removed
)
def as_dict(self) -> dict[str, int]:
return {
"root_font_defaults": self.root_font_defaults,
"root_style_declarations_normalized": (
self.root_style_declarations_normalized
),
"container_style_declarations_normalized": (
self.container_style_declarations_normalized
),
"group_defaults_promoted": self.group_defaults_promoted,
"shadowed_attributes_removed": self.shadowed_attributes_removed,
"redundant_attributes_removed": self.redundant_attributes_removed,
"redundant_style_declarations_removed": (
self.redundant_style_declarations_removed
),
"changed_declarations": self.changed_declarations,
}
@dataclass(frozen=True)
class _StyleDeclaration:
raw: str
name: str
value: str
def _local_name(name: object) -> str:
return name.rsplit("}", 1)[-1] if isinstance(name, str) else ""
def is_canonical_presentation_value(
value: str,
*,
property_name: str | None = None,
) -> bool:
"""Return whether a value can move to a presentation attribute safely."""
normalized = value.strip().lower()
context_dependent = False
if property_name in {"fill", "stroke"}:
outside_urls = _URL_FUNCTION_RE.sub(" ", normalized)
tokens = {
token for token in re.split(r"[\s,]+", outside_urls)
if token
}
context_dependent = bool(tokens & _CONTEXT_DEPENDENT_VALUES)
return (
bool(normalized)
and normalized not in _CSS_WIDE_VALUES
and not context_dependent
and not any(
token in normalized
for token in _UNSAFE_PRESENTATION_VALUE_TOKENS
)
)
def _style_declarations(value: str | None) -> list[_StyleDeclaration] | None:
if value is None:
return []
declarations: list[_StyleDeclaration] = []
for raw in value.split(";"):
stripped = raw.strip()
if not stripped:
continue
if ":" not in stripped:
return None
raw_name, raw_value = stripped.split(":", 1)
name = raw_name.strip().lower()
normalized_value = raw_value.strip()
if not name or not normalized_value:
return None
declarations.append(
_StyleDeclaration(
raw=stripped,
name=name,
value=normalized_value,
)
)
return declarations
def _style_values(
declarations: list[_StyleDeclaration],
) -> dict[str, str]:
return {
declaration.name: declaration.value
for declaration in declarations
}
def _write_style(
element: ET.Element,
declarations: list[_StyleDeclaration],
) -> None:
if declarations:
element.set("style", ";".join(item.raw for item in declarations))
else:
element.attrib.pop("style", None)
def _effective_value(
element: ET.Element,
name: str,
parents: dict[ET.Element, ET.Element],
cache: dict[tuple[int, str], str | None],
) -> str | None:
key = (id(element), name)
if key in cache:
return cache[key]
declarations = _style_declarations(element.get("style"))
if declarations is None:
cache[key] = None
return None
style_value = _style_values(declarations).get(name)
if style_value is not None:
cache[key] = style_value
return style_value
attribute_value = element.get(name)
if attribute_value is not None:
cache[key] = attribute_value
return attribute_value
parent = parents.get(element)
resolved = (
_effective_value(parent, name, parents, cache)
if parent is not None
else None
)
cache[key] = resolved
return resolved
def _normalize_root_font_family(
root: ET.Element,
stats: StyleCompactionStats,
) -> None:
declarations = _style_declarations(root.get("style"))
if declarations is None:
return
style_values = _style_values(declarations)
style_family = style_values.get("font-family")
if style_family is not None:
if not is_canonical_presentation_value(
style_family,
property_name="font-family",
):
return
root.set("font-family", style_family)
retained = [
item for item in declarations
if item.name != "font-family"
]
_write_style(root, retained)
stats.root_style_declarations_normalized += 1
return
if root.get("font-family") is not None:
return
parents = {
child: parent
for parent in root.iter()
for child in parent
}
# Include definition text conservatively. A local <use> can make it
# visible, and promoting a font while ignoring that text could change its
# inherited rendering. Unused definitions are pruned by import projection;
# legacy migration prefers no promotion over a visual change.
text_elements = [
element
for element in root.iter()
if _local_name(element.tag) == "text"
and "".join(element.itertext()).strip()
]
if not text_elements:
return
cache: dict[tuple[int, str], str | None] = {}
families = [
_effective_value(
element,
"font-family",
parents,
cache,
)
for element in text_elements
]
if any(family is None or not family.strip() for family in families):
return
counts = Counter(str(family) for family in families)
common = min(
counts,
key=lambda family: (-counts[family], len(family), family),
)
root.set("font-family", common)
stats.root_font_defaults += 1
def _normalize_container_inherited_styles(
root: ET.Element,
stats: StyleCompactionStats,
) -> None:
"""Spell inherited root/group defaults as presentation attributes."""
for element in root.iter():
if _local_name(element.tag) not in {"svg", "g"}:
continue
declarations = _style_declarations(element.get("style"))
if declarations is None:
continue
retained: list[_StyleDeclaration] = []
for declaration in declarations:
if (
declaration.name not in INHERITABLE_ATTRIBUTES
or not is_canonical_presentation_value(
declaration.value,
property_name=declaration.name,
)
):
retained.append(declaration)
continue
element.set(declaration.name, declaration.value)
stats.container_style_declarations_normalized += 1
_write_style(element, retained)
def _promote_common_group_defaults(
element: ET.Element,
stats: StyleCompactionStats,
) -> None:
"""Factor proven direct-child repetition into an existing SVG group."""
if _local_name(element.tag) in _DEFINITION_SUBTREES:
return
for child in element:
_promote_common_group_defaults(child, stats)
if _local_name(element.tag) != "g":
return
children = [
child for child in element
if isinstance(child.tag, str)
and _local_name(child.tag) not in {
"desc",
"metadata",
"title",
}
]
if len(children) < 2:
return
element_styles = _style_declarations(element.get("style"))
if element_styles is None:
return
element_style_values = _style_values(element_styles)
for name in INHERITABLE_ATTRIBUTES:
if element.get(name) is not None or name in element_style_values:
continue
declarations_by_child: list[list[_StyleDeclaration]] = []
values: list[str] = []
for child in children:
declarations = _style_declarations(child.get("style"))
if declarations is None:
break
declarations_by_child.append(declarations)
value = _style_values(declarations).get(name)
if value is None:
value = child.get(name)
if value is None or not is_canonical_presentation_value(
value,
property_name=name,
):
break
values.append(value)
if len(values) != len(children) or len(set(values)) != 1:
continue
element.set(name, values[0])
stats.group_defaults_promoted += 1
for child, declarations in zip(children, declarations_by_child):
removed_style = sum(item.name == name for item in declarations)
if removed_style:
_write_style(
child,
[item for item in declarations if item.name != name],
)
stats.redundant_style_declarations_removed += removed_style
if child.get(name) is not None:
child.attrib.pop(name, None)
stats.redundant_attributes_removed += 1
def _remove_redundant_inherited_styles(
element: ET.Element,
inherited: dict[str, str],
stats: StyleCompactionStats,
) -> None:
if _local_name(element.tag) in _DEFINITION_SUBTREES:
return
declarations = _style_declarations(element.get("style"))
if declarations is None:
return
style_values = _style_values(declarations)
remove_style_names: set[str] = set()
effective = dict(inherited)
for name in INHERITABLE_ATTRIBUTES:
style_value = style_values.get(name)
attribute_value = element.get(name)
if style_value is not None:
if attribute_value is not None:
element.attrib.pop(name, None)
stats.shadowed_attributes_removed += 1
if (
style_value == inherited.get(name)
and is_canonical_presentation_value(
style_value,
property_name=name,
)
):
remove_style_names.add(name)
stats.redundant_style_declarations_removed += sum(
item.name == name for item in declarations
)
else:
effective[name] = style_value
continue
if attribute_value is None:
continue
if (
attribute_value == inherited.get(name)
and is_canonical_presentation_value(
attribute_value,
property_name=name,
)
):
element.attrib.pop(name, None)
stats.redundant_attributes_removed += 1
else:
effective[name] = attribute_value
if remove_style_names:
_write_style(
element,
[
item for item in declarations
if item.name not in remove_style_names
],
)
for child in element:
_remove_redundant_inherited_styles(child, effective, stats)
def compact_svg_style_tree(root: ET.Element) -> StyleCompactionStats:
"""Compact inherited declarations without changing effective SVG styles."""
if _local_name(root.tag) != "svg":
raise ValueError("Style compaction requires an SVG root element")
stats = StyleCompactionStats()
_normalize_container_inherited_styles(root, stats)
_normalize_root_font_family(root, stats)
_promote_common_group_defaults(root, stats)
_remove_redundant_inherited_styles(root, {}, stats)
return stats
def _svg_files(input_path: Path) -> list[Path]:
if input_path.is_file():
return [input_path] if input_path.suffix.lower() == ".svg" else []
return sorted(
path for path in input_path.rglob("*.svg")
if path.is_file()
)
def _compact_svg_bytes(
path: Path,
) -> tuple[bytes, StyleCompactionStats]:
original = path.read_bytes()
parser = ET.XMLParser(
target=ET.TreeBuilder(insert_comments=True, insert_pis=True),
)
root = ET.fromstring(original, parser=parser)
stats = compact_svg_style_tree(root)
if stats.changed_declarations == 0:
return original, stats
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS)
payload = ET.tostring(
root,
encoding="utf-8",
xml_declaration=original.lstrip().startswith(b"<?xml"),
)
if not payload.endswith(b"\n"):
payload += b"\n"
return payload, stats
def _write_atomic(path: Path, payload: bytes) -> None:
mode = stat.S_IMODE(path.stat().st_mode)
with tempfile.NamedTemporaryFile(
mode="wb",
prefix=f".{path.name}.",
suffix=".tmp",
dir=path.parent,
delete=False,
) as handle:
temporary_path = Path(handle.name)
handle.write(payload)
try:
temporary_path.chmod(mode)
os.replace(temporary_path, path)
except OSError:
temporary_path.unlink(missing_ok=True)
raise
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Diagnose or migrate older SVG by promoting a common page font "
"and removing redundant inherited presentation declarations."
),
)
parser.add_argument("input", type=Path, help="SVG file or directory")
parser.add_argument(
"--inplace",
action="store_true",
help="Atomically replace changed SVG files",
)
return parser
def main(argv: Optional[list[str]] = None) -> int:
args = build_parser().parse_args(argv)
input_path = args.input.resolve()
svg_files = _svg_files(input_path)
if not svg_files:
print(f"[ERROR] No SVG files found: {input_path}", file=sys.stderr)
return 1
prepared: list[tuple[Path, bytes, StyleCompactionStats]] = []
total = StyleCompactionStats()
try:
for path in svg_files:
payload, stats = _compact_svg_bytes(path)
prepared.append((path, payload, stats))
total.merge(stats)
except (OSError, ET.ParseError, ValueError) as exc:
print(f"[ERROR] SVG style compaction failed: {exc}", file=sys.stderr)
return 1
changed_files = 0
if args.inplace:
for path, payload, _stats in prepared:
if payload == path.read_bytes():
continue
_write_atomic(path, payload)
changed_files += 1
else:
changed_files = sum(
payload != path.read_bytes()
for path, payload, _stats in prepared
)
print(json.dumps({
"input": str(input_path),
"inplace": bool(args.inplace),
"file_count": len(prepared),
"changed_files": changed_files,
"styles": total.as_dict(),
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -105,6 +105,7 @@ _PALETTE_ROLES = (
'body_text', 'body_text',
) )
_TYPOGRAPHY_SIZE_ROLES = ('title', 'subtitle', 'annotation') _TYPOGRAPHY_SIZE_ROLES = ('title', 'subtitle', 'annotation')
_DESIGN_SPEC_DEPTH_VALUES = {'brief', 'complete'}
_HEX_COLOR_RE = re.compile(r'#?(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\Z') _HEX_COLOR_RE = re.compile(r'#?(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\Z')
# Static option universe served at /api/catalogs (canvas synced live from config). # Static option universe served at /api/catalogs (canvas synced live from config).
@@ -162,6 +163,13 @@ def _read_json_object(path: Path, retries: int = 2, delay: float = 0.08) -> dict
raise last_error raise last_error
def _read_result_object(path: Path, retries: int = 2, delay: float = 0.08) -> dict:
"""Read result.json and apply the legacy Design Spec depth default."""
data = _read_json_object(path, retries=retries, delay=delay)
data.setdefault('design_spec_depth', 'complete')
return data
def _write_json_atomic(path: Path, data: dict) -> None: def _write_json_atomic(path: Path, data: dict) -> None:
"""Write a JSON object with replace semantics so waiters never see a partial file.""" """Write a JSON object with replace semantics so waiters never see a partial file."""
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
@@ -206,13 +214,11 @@ _TEMPLATE_SPEC_NAME_RE = re.compile(
def _template_design_specs(workspace_root: Path) -> list[Path]: def _template_design_specs(workspace_root: Path) -> list[Path]:
"""Return every Design Spec one template workspace root exposes. """Return every Design Spec one template workspace root exposes.
A single-kind workspace keeps the exact ``templates/design_spec.md``, and a A single-kind workspace keeps the exact ``templates/design_spec.md``. A
compatible legacy-flat root keeps ``design_spec.md`` beside its pages. A
multi-kind workspace keeps one ``templates/design_spec.<kind>.<id>.md`` per multi-kind workspace keeps one ``templates/design_spec.<kind>.<id>.md`` per
kind the same shape the apply stage installs into a consuming project so kind the same shape the apply stage installs into a consuming project.
one root can carry, for example, a Brand plus a Style. Order is stable so Order is stable so candidate keys and the options digest do not depend on
candidate keys and the options digest do not depend on directory listing directory listing order.
order.
""" """
templates_dir = workspace_root / 'templates' templates_dir = workspace_root / 'templates'
current = templates_dir / 'design_spec.md' current = templates_dir / 'design_spec.md'
@@ -231,12 +237,9 @@ def _template_design_specs(workspace_root: Path) -> list[Path]:
return [current] return [current]
if multi: if multi:
return multi return multi
legacy = workspace_root / 'design_spec.md'
if legacy.is_file():
return [legacy]
raise ValueError( raise ValueError(
'template workspace is missing templates/design_spec.md, ' 'template workspace is missing templates/design_spec.md, '
'templates/design_spec.<kind>.<id>.md, or legacy design_spec.md: ' 'or templates/design_spec.<kind>.<id>.md: '
f'{workspace_root}' f'{workspace_root}'
) )
@@ -1204,7 +1207,7 @@ def _result_stage(result_file: Path) -> Optional[str]:
if not result_file.is_file(): if not result_file.is_file():
return None return None
try: try:
data = _read_json_object(result_file) data = _read_result_object(result_file)
except (OSError, json.JSONDecodeError, ValueError): except (OSError, json.JSONDecodeError, ValueError):
return None return None
stage = _stage_key(data.get('stage')) stage = _stage_key(data.get('stage'))
@@ -1395,6 +1398,28 @@ def _stage2_production_recommendations_error(
or not isinstance(refine_spec.get('value'), bool) or not isinstance(refine_spec.get('value'), bool)
): ):
return 'Stage 2 recommendations must include refine_spec.value as a boolean' return 'Stage 2 recommendations must include refine_spec.value as a boolean'
design_spec_depth = recommendations.get('design_spec_depth')
design_spec_depth_value = (
design_spec_depth.get('value')
if isinstance(design_spec_depth, dict)
else None
)
if (
not isinstance(design_spec_depth_value, str)
or design_spec_depth_value not in _DESIGN_SPEC_DEPTH_VALUES
):
return (
'Stage 2 recommendations must include design_spec_depth.value as '
'"brief" or "complete"'
)
if design_spec_depth_value == 'brief' and (
generation_mode == 'split' or refine_spec['value']
):
return (
'Stage 2 recommendations must set design_spec_depth.value to '
'"complete" when recommend.generation_mode is "split" or '
'refine_spec.value is true'
)
if _uses_ai_images(recommendations): if _uses_ai_images(recommendations):
image_ai_path = recommend.get('image_ai_path') image_ai_path = recommend.get('image_ai_path')
if not isinstance(image_ai_path, str) or not image_ai_path.strip(): if not isinstance(image_ai_path, str) or not image_ai_path.strip():
@@ -1412,6 +1437,22 @@ def _stage2_production_result_error(result: dict) -> Optional[str]:
return 'final Stage 2 payload must include non-empty generation_mode' return 'final Stage 2 payload must include non-empty generation_mode'
if not isinstance(result.get('refine_spec'), bool): if not isinstance(result.get('refine_spec'), bool):
return 'final Stage 2 payload must include refine_spec as a boolean' return 'final Stage 2 payload must include refine_spec as a boolean'
design_spec_depth = result.get('design_spec_depth')
if (
not isinstance(design_spec_depth, str)
or design_spec_depth not in _DESIGN_SPEC_DEPTH_VALUES
):
return (
'final Stage 2 payload must include design_spec_depth as "brief" '
'or "complete"'
)
if design_spec_depth == 'brief' and (
generation_mode == 'split' or result['refine_spec']
):
return (
'final Stage 2 payload must set design_spec_depth to "complete" '
'when generation_mode is "split" or refine_spec is true'
)
if _uses_ai_images(result): if _uses_ai_images(result):
image_ai_path = result.get('image_ai_path') image_ai_path = result.get('image_ai_path')
if not isinstance(image_ai_path, str) or not image_ai_path.strip(): if not isinstance(image_ai_path, str) or not image_ai_path.strip():
@@ -1777,7 +1818,7 @@ def _submission_stage_error(
if rec_stage_number == 2: if rec_stage_number == 2:
try: try:
previous_result = _read_json_object(confirm_dir / RESULT_NAME) previous_result = _read_result_object(confirm_dir / RESULT_NAME)
except (OSError, json.JSONDecodeError, ValueError): except (OSError, json.JSONDecodeError, ValueError):
previous_result = {} previous_result = {}
language_source = ( language_source = (
@@ -2157,7 +2198,7 @@ def _normalize_proactive_execution_result(
def _merge_confirmed_choices(data: dict, result_file: Path) -> None: def _merge_confirmed_choices(data: dict, result_file: Path) -> None:
"""Fold already-confirmed choices into later-stage recommendations.""" """Fold already-confirmed choices into later-stage recommendations."""
try: try:
res = _read_json_object(result_file) res = _read_result_object(result_file)
except (OSError, json.JSONDecodeError, ValueError): except (OSError, json.JSONDecodeError, ValueError):
return return
if _result_stage(result_file) != 'stage1': if _result_stage(result_file) != 'stage1':
@@ -2195,7 +2236,7 @@ def _apply_locked_recommendations(
previous = {} previous = {}
if carry_previous: if carry_previous:
try: try:
previous = _read_json_object(previous_result_file) previous = _read_result_object(previous_result_file)
except (OSError, json.JSONDecodeError, ValueError): except (OSError, json.JSONDecodeError, ValueError):
previous = {} previous = {}
previous_locks = previous.get(_LOCKED_RECOMMENDATIONS_KEY) previous_locks = previous.get(_LOCKED_RECOMMENDATIONS_KEY)
@@ -2809,7 +2850,7 @@ def create_app(
previous_result = {} previous_result = {}
if rec_stage_number >= 2: if rec_stage_number >= 2:
try: try:
previous_result = _read_json_object(result_file) previous_result = _read_result_object(result_file)
except (OSError, json.JSONDecodeError, ValueError): except (OSError, json.JSONDecodeError, ValueError):
pass pass
main_language = None main_language = None
@@ -64,6 +64,12 @@
sec_proactive_execution: "Proactive execution", sec_proactive_execution: "Proactive execution",
sec_mode: "Generation mode", sec_mode: "Generation mode",
sec_refine: "Review the Design Spec first", sec_refine: "Review the Design Spec first",
sec_design_spec_depth: "Design Spec depth",
design_spec_depth_brief: "Brief",
design_spec_depth_brief_desc: "A short block list per page; no full page copy.",
design_spec_depth_complete: "Complete",
design_spec_depth_complete_desc: "Full page briefs with complete wording.",
design_spec_depth_locked: "Locked to Complete because split mode or Design Spec refinement is enabled.",
sec_design_directions: "Coherent design directions", sec_design_directions: "Coherent design directions",
design_directions_hint: "The recommended complete direction is applied first. Choose another or fine-tune the projected fields below; use Restore to return an adjusted direction to its authored bundle.", design_directions_hint: "The recommended complete direction is applied first. Choose another or fine-tune the projected fields below; use Restore to return an adjusted direction to its authored bundle.",
direction_active: "Applied", direction_active: "Applied",
@@ -72,8 +78,8 @@
direction_restore: "Restore authored direction", direction_restore: "Restore authored direction",
scheme_component_options: "Project-specific custom choices · select a card to edit", scheme_component_options: "Project-specific custom choices · select a card to edit",
sec_template_application: "Template application", sec_template_application: "Template application",
template_application_hint: "The AI recommends how to apply the installed template to this deck. Revise the plan directly in natural language.", template_application_hint: "After reading every installed template SVG, the AI proposes one natural-language application plan. Edit it directly; this is not a mode selector.",
placeholder_template_application: "Describe which template pages or prototypes to use, skip, repeat, or reorder; what must stay; and what may be replaced or reorganized.", placeholder_template_application: "Name exact SVG files for page-specific rules; describe what to use, skip, repeat, or reorder, what stays fixed, and what may be replaced or reorganized.",
sub_mode: "Narrative mode", sub_mode: "Narrative mode",
sub_visual: "Visual style", sub_visual: "Visual style",
sub_divergence: "Material divergence (how freely to reshape vs. stay close to the source)", sub_divergence: "Material divergence (how freely to reshape vs. stay close to the source)",
@@ -247,6 +253,12 @@
sec_proactive_execution: "能動的な実行", sec_proactive_execution: "能動的な実行",
sec_mode: "生成モード", sec_mode: "生成モード",
sec_refine: "先に設計仕様を確認", sec_refine: "先に設計仕様を確認",
sec_design_spec_depth: "設計仕様の詳細度",
design_spec_depth_brief: "簡潔",
design_spec_depth_brief_desc: "各ページを短いブロック一覧で記し、全文は書きません。",
design_spec_depth_complete: "完全",
design_spec_depth_complete_desc: "完全な文言を含む各ページの詳細なブリーフを記載します。",
design_spec_depth_locked: "分割モードまたは設計仕様のレビューが有効なため、「完全」に固定されています。",
sec_design_directions: "統合デザイン方針", sec_design_directions: "統合デザイン方針",
design_directions_hint: "おすすめの全体案が最初に適用されています。別案を選ぶか、下の各項目を微調整できます。調整後は「元の案に戻す」で最初の組み合わせを復元できます。", design_directions_hint: "おすすめの全体案が最初に適用されています。別案を選ぶか、下の各項目を微調整できます。調整後は「元の案に戻す」で最初の組み合わせを復元できます。",
direction_active: "適用中", direction_active: "適用中",
@@ -255,8 +267,8 @@
direction_restore: "元の案に戻す", direction_restore: "元の案に戻す",
scheme_component_options: "プロジェクト専用カスタム案 · カードを選んで編集", scheme_component_options: "プロジェクト専用カスタム案 · カードを選んで編集",
sec_template_application: "テンプレートの適用方法", sec_template_application: "テンプレートの適用方法",
template_application_hint: "AIが現在の内容に合わせたテンプレートの使い方を提案します。自然言語で直接修正できます。", template_application_hint: "AIがインストール済みテンプレートの全SVGを確認し、自然言語の適用方針を1段落で提案します。モード選択ではなく、文章を直接修正できます。",
placeholder_template_application: "使用・省略・反復・並べ替えするページやプロトタイプ、保持する要素、差し替え・再構成できる内容を記述します。", placeholder_template_application: "ページ固有の規則は正確なSVGファイル名で示し、使用・省略・反復・並べ替え、固定する要素、差し替え・再構成できる内容を記述します。",
sub_mode: "ナラティブモード", sub_mode: "ナラティブモード",
sub_visual: "ビジュアルスタイル", sub_visual: "ビジュアルスタイル",
sub_divergence: "素材からの発散度(どこまで自由に再構成するか、原文に忠実か)", sub_divergence: "素材からの発散度(どこまで自由に再構成するか、原文に忠実か)",
@@ -430,6 +442,12 @@
sec_proactive_execution: "主动执行", sec_proactive_execution: "主动执行",
sec_mode: "生成模式", sec_mode: "生成模式",
sec_refine: "先审核设计规范", sec_refine: "先审核设计规范",
sec_design_spec_depth: "设计规范深度",
design_spec_depth_brief: "简要",
design_spec_depth_brief_desc: "每页只写简短的内容块列表,不写整页文案。",
design_spec_depth_complete: "完整",
design_spec_depth_complete_desc: "写入包含完整文案的逐页简报。",
design_spec_depth_locked: "分段模式或设计规范审核已开启,因此固定为“完整”。",
sec_design_directions: "成套设计方向", sec_design_directions: "成套设计方向",
design_directions_hint: "AI 最倾向的成套方案已默认应用;你可以改选其他方案,或在下方微调各项。调整后可用“恢复原方案”还原整套预设。", design_directions_hint: "AI 最倾向的成套方案已默认应用;你可以改选其他方案,或在下方微调各项。调整后可用“恢复原方案”还原整套预设。",
direction_active: "已应用", direction_active: "已应用",
@@ -438,8 +456,8 @@
direction_restore: "恢复原方案", direction_restore: "恢复原方案",
scheme_component_options: "项目专属自定义方案 · 选中卡片后可编辑", scheme_component_options: "项目专属自定义方案 · 选中卡片后可编辑",
sec_template_application: "模板应用方式", sec_template_application: "模板应用方式",
template_application_hint: "AI 会根据当前内容推荐如何使用已安装模板;你可以直接用自然语言修改。", template_application_hint: "AI 会先阅读已安装模板的全部 SVG,再给出一段自然语言应用方案;这不是模式选择,你可以直接修改文字。",
placeholder_template_application: "说明使用、跳过、重复或重排哪些模板页面/原型,哪些内容必须保留,哪些可以替换或重组。", placeholder_template_application: "页面级规则请写明精确 SVG 文件名;说明使用、跳过、重复或重排哪些原型,哪些内容固定,哪些可以替换或重组。",
sub_mode: "叙事模式", sub_mode: "叙事模式",
sub_visual: "视觉风格", sub_visual: "视觉风格",
sub_divergence: "材料发散度(多大程度重塑,还是贴近源材料)", sub_divergence: "材料发散度(多大程度重塑,还是贴近源材料)",
@@ -613,6 +631,12 @@
sec_proactive_execution: "主動執行", sec_proactive_execution: "主動執行",
sec_mode: "生成模式", sec_mode: "生成模式",
sec_refine: "先審閱設計規範", sec_refine: "先審閱設計規範",
sec_design_spec_depth: "設計規範深度",
design_spec_depth_brief: "簡要",
design_spec_depth_brief_desc: "每頁只寫簡短的內容區塊清單,不寫整頁文案。",
design_spec_depth_complete: "完整",
design_spec_depth_complete_desc: "寫入包含完整文案的逐頁簡報。",
design_spec_depth_locked: "分段模式或設計規範審閱已開啟,因此固定為「完整」。",
sec_design_directions: "成套設計方向", sec_design_directions: "成套設計方向",
design_directions_hint: "AI 最傾向的成套方案已預設套用;你可以改選其他方案,或在下方微調各項。調整後可用「還原原始方案」還原整套預設。", design_directions_hint: "AI 最傾向的成套方案已預設套用;你可以改選其他方案,或在下方微調各項。調整後可用「還原原始方案」還原整套預設。",
direction_active: "已套用", direction_active: "已套用",
@@ -621,8 +645,8 @@
direction_restore: "還原原始方案", direction_restore: "還原原始方案",
scheme_component_options: "專案專屬自訂方案 · 選取卡片後可編輯", scheme_component_options: "專案專屬自訂方案 · 選取卡片後可編輯",
sec_template_application: "範本套用方式", sec_template_application: "範本套用方式",
template_application_hint: "AI 會根據目前內容推薦如何使用已安裝範本;你可以直接用自然語言修改。", template_application_hint: "AI 會先閱讀已安裝範本的全部 SVG,再提出一段自然語言套用方案;這不是模式選擇,你可以直接修改文字。",
placeholder_template_application: "說明使用、過、重複或重排哪些範本頁面/原型,哪些內容必須保留,哪些可以替換或重組。", placeholder_template_application: "頁面級規則請寫明精確 SVG 檔名;說明使用、過、重複或重排哪些原型,哪些內容固定,哪些可以替換或重組。",
sub_mode: "敘事模式", sub_mode: "敘事模式",
sub_visual: "視覺風格", sub_visual: "視覺風格",
sub_divergence: "材料發散度(多大程度重塑,還是貼近源材料)", sub_divergence: "材料發散度(多大程度重塑,還是貼近源材料)",
@@ -1426,6 +1450,7 @@
function enumField(parent, list, recommendedId, getVal, setVal, opts2) { function enumField(parent, list, recommendedId, getVal, setVal, opts2) {
list = list || []; list = list || [];
opts2 = opts2 || {}; opts2 = opts2 || {};
var disabled = opts2.disabled === true;
var grouped = list.length && list[0] && list[0].items; var grouped = list.length && list[0] && list[0].items;
var flat = grouped ? list.reduce(function (a, g) { return a.concat(g.items || []); }, []) : list; var flat = grouped ? list.reduce(function (a, g) { return a.concat(g.items || []); }, []) : list;
var ids = flat.map(function (o) { return o.id; }); var ids = flat.map(function (o) { return o.id; });
@@ -1508,7 +1533,13 @@
} }
chip.appendChild(copy); chip.appendChild(copy);
if (!isCustom && o.id === cur) chip.classList.add("selected"); if (!isCustom && o.id === cur) chip.classList.add("selected");
if (disabled) {
chip.setAttribute("aria-disabled", "true");
chip.style.cursor = "not-allowed";
chip.style.opacity = "0.65";
}
chip.addEventListener("click", function () { chip.addEventListener("click", function () {
if (disabled) return;
deselect(); deselect();
chip.classList.add("selected"); chip.classList.add("selected");
if (!aiCustom) customInput.style.display = "none"; if (!aiCustom) customInput.style.display = "none";
@@ -2685,6 +2716,9 @@
// Replaced when the final plan's image-production section mounts; image-use // Replaced when the final plan's image-production section mounts; image-use
// edits call it so the conditional AI path stays synchronized on the page. // edits call it so the conditional AI path stays synchronized on the page.
var refreshImageProduction = function () {}; var refreshImageProduction = function () {};
// Replaced when the Design Spec depth section mounts; generation/refinement
// edits call it so the forced-complete coupling stays synchronized.
var refreshDesignSpecDepth = function () {};
// Replaced when the typography section mounts; the canvas section calls it so // Replaced when the typography section mounts; the canvas section calls it so
// the body-size hint tracks the chosen canvas dimensions. // the body-size hint tracks the chosen canvas dimensions.
var refreshBodySizeHint = function () {}; var refreshBodySizeHint = function () {};
@@ -3830,7 +3864,11 @@
setSectionNote(sec, STATE.generation_mode === "split" ? t("mode_split_desc") : t("mode_continuous_desc")); setSectionNote(sec, STATE.generation_mode === "split" ? t("mode_split_desc") : t("mode_continuous_desc"));
} }
enumField(sec, CAT.generation_mode, recOrFirst("generation_mode", CAT.generation_mode), enumField(sec, CAT.generation_mode, recOrFirst("generation_mode", CAT.generation_mode),
function () { return STATE.generation_mode; }, function (v) { STATE.generation_mode = v; refresh(); }); function () { return STATE.generation_mode; }, function (v) {
STATE.generation_mode = v;
refresh();
refreshDesignSpecDepth();
});
refresh(); refresh();
host.appendChild(sec); host.appendChild(sec);
} }
@@ -3881,11 +3919,57 @@
} }
enumField(sec, opts, STATE.refine_spec ? "on" : "off", enumField(sec, opts, STATE.refine_spec ? "on" : "off",
function () { return STATE.refine_spec ? "on" : "off"; }, function () { return STATE.refine_spec ? "on" : "off"; },
function (v) { STATE.refine_spec = (v === "on"); refresh(); }); function (v) {
STATE.refine_spec = (v === "on");
refresh();
refreshDesignSpecDepth();
});
refresh(); refresh();
host.appendChild(sec); host.appendChild(sec);
} }
function designSpecDepthCatalog() {
if (CAT.design_spec_depth && CAT.design_spec_depth.length) {
return CAT.design_spec_depth;
}
return [
{
id: "brief",
label: t("design_spec_depth_brief"),
desc: t("design_spec_depth_brief_desc")
},
{
id: "complete",
label: t("design_spec_depth_complete"),
desc: t("design_spec_depth_complete_desc")
}
];
}
function renderDesignSpecDepth(host) {
var sec = section("D", "sec_design_spec_depth");
var body = el("div", "design-spec-depth-body");
var recommended = REC.design_spec_depth && REC.design_spec_depth.value;
if (recommended !== "brief" && recommended !== "complete") recommended = "brief";
sec.appendChild(body);
refreshDesignSpecDepth = function () {
var locked = STATE.generation_mode === "split" || STATE.refine_spec;
if (locked) STATE.design_spec_depth = "complete";
body.innerHTML = "";
enumField(
body,
designSpecDepthCatalog(),
locked ? null : recommended,
function () { return STATE.design_spec_depth; },
function (value) { STATE.design_spec_depth = value; },
{ disabled: locked }
);
setSectionNote(sec, locked ? t("design_spec_depth_locked") : "");
};
refreshDesignSpecDepth();
host.appendChild(sec);
}
// Two-stage confirmation: communication contract, then complete final plan. // Two-stage confirmation: communication contract, then complete final plan.
var STAGE = 1; var STAGE = 1;
@@ -3917,6 +4001,7 @@
refreshStylePreview = function () {}; refreshStylePreview = function () {};
refreshImageStrategyPreview = function () {}; refreshImageStrategyPreview = function () {};
refreshImageProduction = function () {}; refreshImageProduction = function () {};
refreshDesignSpecDepth = function () {};
refreshBodySizeHint = function () {}; refreshBodySizeHint = function () {};
refreshSizeInputs = function () {}; refreshSizeInputs = function () {};
DIRECTION_COMPONENT_PAINTERS = []; DIRECTION_COMPONENT_PAINTERS = [];
@@ -3951,6 +4036,7 @@
renderProactiveExecution(host); renderProactiveExecution(host);
renderMode(host); renderMode(host);
renderRefine(host); renderRefine(host);
renderDesignSpecDepth(host);
var refreshDirectionIndicators = function () { var refreshDirectionIndicators = function () {
window.setTimeout(function () { window.setTimeout(function () {
refreshDesignDirectionState(); refreshDesignDirectionState();
@@ -4111,6 +4197,11 @@
STATE.generation_mode = pick("generation_mode", CAT.generation_mode); STATE.generation_mode = pick("generation_mode", CAT.generation_mode);
STATE.refine_spec = !!((REC.refine_spec && REC.refine_spec.value) || (REC.recommend && REC.recommend.refine_spec)); STATE.refine_spec = !!((REC.refine_spec && REC.refine_spec.value) || (REC.recommend && REC.recommend.refine_spec));
var designSpecDepth = REC.design_spec_depth && REC.design_spec_depth.value;
STATE.design_spec_depth = designSpecDepth === "complete" ? "complete" : "brief";
if (STATE.generation_mode === "split" || STATE.refine_spec) {
STATE.design_spec_depth = "complete";
}
} }
function initState() { function initState() {
@@ -1505,6 +1505,32 @@
"label_ja": "分割モード" "label_ja": "分割モード"
} }
], ],
"design_spec_depth": [
{
"id": "brief",
"label": "brief",
"label_zh": "简要",
"label_zh_tw": "簡要",
"label_en": "Brief",
"label_ja": "簡潔",
"desc_zh": "每页只写简短的内容块列表,不写整页文案。",
"desc_zh_tw": "每頁只寫簡短的內容區塊清單,不寫整頁文案。",
"desc_en": "A short block list per page; no full page copy.",
"desc_ja": "各ページを短いブロック一覧で記し、全文は書きません。"
},
{
"id": "complete",
"label": "complete",
"label_zh": "完整",
"label_zh_tw": "完整",
"label_en": "Complete",
"label_ja": "完全",
"desc_zh": "写入包含完整文案的逐页简报。",
"desc_zh_tw": "寫入包含完整文案的逐頁簡報。",
"desc_en": "Full page briefs with complete wording.",
"desc_ja": "完全な文言を含む各ページの詳細なブリーフを記載します。"
}
],
"delivery_purpose": [ "delivery_purpose": [
{ {
"id": "text", "id": "text",
@@ -12,7 +12,7 @@ The fixture deliberately closes the full planning and execution chain:
- `design_spec.md` carries `Motion suggestion`, one current §VIII image row, - `design_spec.md` carries `Motion suggestion`, one current §VIII image row,
and `Crop Policy`, with no native-shape planning field; and `Crop Policy`, with no native-shape planning field;
- `spec_lock.md` projects that row with optional layout pattern `#M1-11`; - `spec_lock.md` projects that row (`source`, `crop`); the `#M1-11` layout pattern stays in §VIII;
- both pages reuse one raster through ordinary, ellipse-preset, and custom-path - both pages reuse one raster through ordinary, ellipse-preset, and custom-path
independent nested crops; independent nested crops;
- `animations.json` pairs the main crop across adjacent Morph pages; - `animations.json` pairs the main crop across adjacent Morph pages;
@@ -198,7 +198,7 @@ preset = (
- library: none - library: none
- inventory: none - inventory: none
## images ## images
- scene: images/scene.png | source=user | pattern=#M1-11 same-source independent crops with a shaped detail | crop=adaptive - scene: images/scene.png | source=user | crop=adaptive
## page_rhythm ## page_rhythm
- P01: dense - P01: dense
- P02: dense - P02: dense
@@ -43,7 +43,8 @@ Interpret the instruction semantically: “confirm here”, “use the chat wind
keyword is required. Invoking a chat-question tool by itself does not select the keyword is required. Invoking a chat-question tool by itself does not select the
chat branch—the user's instruction does. Both branches preserve the same chat branch—the user's instruction does. Both branches preserve the same
Stage-1 communication/template decision, installation handoff, and template-aware Stage-1 communication/template decision, installation handoff, and template-aware
final Stage 2. final Stage 2; the chat branch records the same `design_spec_depth` value in its
confirmation summary.
**Chat/delegated Stage-1 listing**: Author the communication recommendation **Chat/delegated Stage-1 listing**: Author the communication recommendation
before reading the four indexes, then present that recommendation together with before reading the four indexes, then present that recommendation together with
@@ -115,7 +116,7 @@ python3 scripts/confirm_ui/server.py <project_path> --shutdown # Step 4 clean
- Without `--port`, binds the first free port from `127.0.0.1:5050`; the launch log prints the actual URL. `--port N` is exact and fails when unavailable. Auto-open is suppressed by `--no-browser`. - Without `--port`, binds the first free port from `127.0.0.1:5050`; the launch log prints the actual URL. `--port N` is exact and fails when unavailable. Auto-open is suppressed by `--no-browser`.
- In `--daemon` mode the launcher starts the child with browser opening suppressed, then accepts readiness only when `GET /api/health` identifies this confirm service, project, and child process. It opens the printed `http://127.0.0.1:<port>` URL only after that check. - In `--daemon` mode the launcher starts the child with browser opening suppressed, then accepts readiness only when `GET /api/health` identifies this confirm service, project, and child process. It opens the printed `http://127.0.0.1:<port>` URL only after that check.
- Confirm UI and live preview prefer the same memorable base port but keep separate processes and project-local locks (`.confirm_ui.lock` vs `live_preview/lock.json`). Normal Step 4 cleanup releases the confirm port before Step 6; concurrent projects may use different ports. - Confirm UI and live preview use different defaults (`5050` / `6060`) and separate project-local locks (`.confirm_ui.lock` / `live_preview/lock.json`). Step 4 shuts down the confirm service before ending; concurrent projects may use different ports.
- `--daemon` starts the Flask process in the background and returns after the health check. Every Default UI run launches directly into combined Stage 1 and keeps the same process live through final Stage 2. The wait budget defaults to **590 s** (`--wait-timeout`); on timeout the detached server remains live, and the caller re-checks both Stage-1 receipts before chat fallback. - `--daemon` starts the Flask process in the background and returns after the health check. Every Default UI run launches directly into combined Stage 1 and keeps the same process live through final Stage 2. The wait budget defaults to **590 s** (`--wait-timeout`); on timeout the detached server remains live, and the caller re-checks both Stage-1 receipts before chat fallback.
- `--wait-only` attaches to the page opened by `--daemon` and blocks until the requested receipt. If it is already persisted, the command 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` for the combined communication/template submission and the default/final wait for Stage 2. - `--wait-only` attaches to the page opened by `--daemon` and blocks until the requested receipt. If it is already persisted, the command 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` for the combined communication/template submission and the default/final wait for Stage 2.
- `--complete-template-selection` is agent-only. It validates the Stage-1 sidecar and writes the bound `template_handoff.json`; template mode additionally requires at least one project-local `templates/design_spec.<kind>.<id>.md`. Run it after installation/free-design closure and before writing Stage 2. `--reset-template-selection` removes exactly `template_options.json`, `template_selection.json`, and `template_handoff.json`; it does not alter Strategist files, installed template content, or `result.json`. The old `--*-template-phase` names are not aliases. - `--complete-template-selection` is agent-only. It validates the Stage-1 sidecar and writes the bound `template_handoff.json`; template mode additionally requires at least one project-local `templates/design_spec.<kind>.<id>.md`. Run it after installation/free-design closure and before writing Stage 2. `--reset-template-selection` removes exactly `template_options.json`, `template_selection.json`, and `template_handoff.json`; it does not alter Strategist files, installed template content, or `result.json`. The old `--*-template-phase` names are not aliases.
@@ -320,7 +321,7 @@ template-selection receipt.
- **Closed enumerable** — PPT reading mode (`delivery_purpose` compatibility key), 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. - **Closed enumerable** — PPT reading mode (`delivery_purpose` compatibility key), 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** — Final Stage 2 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. - **Proactive execution booleans** — Final Stage 2 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. - **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 exactly three complete candidates authored top-down from the project contract. Each has a unique stable id and bundles `custom` mode, `custom` visual style, color, typography, icon id, and `custom` generated-image rendering regardless of recommended image source. Its localized note is a compact, user-facing style summary. It may reuse localized display labels from `catalogs.visual_styles` when they describe the result concisely, but those labels are optional vocabulary rather than a selection constraint or required mapping. Otherwise it uses concise natural language and never forces the nearest label. The summary stays within one or two short sentences and does not expose catalog ids or reference mechanics. Each candidate is one complete design authored top-down within the confirmed contract, never assembled bottom-up from catalog picks; three exist so a single recommendation cannot lock the user in, while the fixed catalogs stay the manual lower layer. Its custom projections are unrestricted by catalog relationship and may carry one preset unchanged. The three candidates are plainly different designs at the whole-deck level, and that difference lives in the solutions rather than in one designated field: whichever components a candidate's design requires carry it, and mode, visual style, rendering, catalog bases, color, typography, and icons are each free to coincide. A different name, note, or reference count alone is not a difference, and candidates identical on every component are not three solutions. Do not force safe / shifted / bold archetypes. Where authoritative user/template truth fixes some components, the remaining open ones carry the difference; identical projections are valid only when nothing is open, stated as that boundary in the direction note. After completing all three bundles, Strategist compares them against the confirmed contract and source, then writes the strongest overall fit's zero-based index to `selected`; array position does not determine preference. That bundle becomes the initial default and applies its three custom projections coherently. The page can still render legacy top-level `color`, `typography`, and `image_strategy` candidates, but new staged recommendations use the coordinated bundle. - **Coordinated generative directions**`design_directions` carries exactly three complete candidates authored top-down from the project contract. Each has a unique stable id and bundles `custom` mode, `custom` visual style, color, typography, icon id, and `custom` generated-image rendering regardless of recommended image source. Its localized note is a compact, user-facing style summary. It may reuse localized display labels from `catalogs.visual_styles` when they describe the result concisely, but those labels are optional vocabulary rather than a selection constraint or required mapping. Otherwise it uses concise natural language and never forces the nearest label. The summary stays within one or two short sentences and does not expose catalog ids or reference mechanics. Each candidate is one complete design authored top-down within the confirmed contract, never assembled bottom-up from catalog picks; three exist so a single recommendation cannot lock the user in, while the fixed catalogs stay the manual lower layer. Its custom projections are unrestricted by catalog relationship and may carry one preset unchanged. The candidates are plainly different whole-deck designs; any component may carry the difference or coincide. Names, notes, or reference counts alone do not distinguish them, and identical projections are valid only when authoritative truth leaves nothing open, stated in the note instead of inventing variation. Do not force safe / shifted / bold archetypes. After completing all three bundles, Strategist writes the strongest overall fit's zero-based index to `selected` when no template is installed. With installed template state, every candidate obeys the same resolved context; `selected` identifies the viable candidate that most fully expresses it, while the other two vary only open dimensions—never by weakening template use or splitting segments across cards. Array position does not determine preference. That bundle becomes the initial default and applies its three custom projections coherently. The page can still render legacy top-level `color`, `typography`, and `image_strategy` candidates, but new staged recommendations use the coordinated bundle.
Direction-local custom projections apply to mode, visual style, and generated-image rendering; all three are editable after selection and a selected custom value cannot be blank. The original recommendation remains immutable so the active whole-direction card can explicitly restore every edited component without making an ordinary card click destructive. Legacy standalone `custom_candidates` remain readable but are optional and are not authored in new files. Color / typography keep their existing manual Custom cards. Image usage uses source ids plus `image_notes`; closed sets have no Custom path. Direction-local custom projections apply to mode, visual style, and generated-image rendering; all three are editable after selection and a selected custom value cannot be blank. The original recommendation remains immutable so the active whole-direction card can explicitly restore every edited component without making an ordinary card click destructive. Legacy standalone `custom_candidates` remain readable but are optional and are not authored in new files. Color / typography keep their existing manual Custom cards. Image usage uses source ids plus `image_notes`; closed sets have no Custom path.
@@ -332,7 +333,7 @@ Direction-local custom projections apply to mode, visual style, and generated-im
## Catalogs — `static/catalogs.json` (the finite option universe) ## Catalogs — `static/catalogs.json` (the finite option universe)
The front-end loads `/api/catalogs` (served by the confirm server) and falls back to the static `/static/catalogs.json` if that route is unavailable. `/api/catalogs` returns the static file **with the `canvas` list synced live from `config.py CANVAS_FORMATS`** — the set of formats and their `dim` come from config (single source of truth, zero drift), while four-language labels / use text stay in catalogs.json (a plain fallback label is synthesized for any new id config adds). Keys: `canvas`, `modes`, `visual_styles` (grouped), `icons`, `image_usage`, `image_ai_path`, `generation_mode`, `delivery_purpose`. `simple-icons` is content-driven and has no option. Each catalog entry is `{ "id", "label", "label_zh", "label_zh_tw", "label_en", "label_ja", ... }`; descriptions use `desc_zh` / `desc_zh_tw` / `desc_en` / `desc_ja`, and `visual_styles` groups use `group_zh` / `group_zh_tw` / `group_en` / `group_ja`. The front-end falls back to legacy `label` / `desc` / `group`, so old catalogs still load, but new user-facing catalog text must cover all four languages (zh / zh-TW / en / ja). English labels should mirror canonical reference names (`pyramid`, `swiss-minimal`, `Path A`, `continuous`, etc.); Simplified Chinese, Traditional Chinese, and Japanese labels should be translated for users. Descriptions render inline after the option title, not as a separate selected-option line. `visual_styles` is `[{ "group", "group_zh", "group_zh_tw", "group_en", "group_ja", "items": [...] }]`. For `canvas` you only need to maintain the four-language labels in catalogs.json; the format set and dimensions are authoritative in `config.py CANVAS_FORMATS`. The front-end loads `/api/catalogs` (served by the confirm server) and falls back to the static `/static/catalogs.json` if that route is unavailable. `/api/catalogs` returns the static file **with the `canvas` list synced live from `config.py CANVAS_FORMATS`** — the set of formats and their `dim` come from config (single source of truth, zero drift), while four-language labels / use text stay in catalogs.json (a plain fallback label is synthesized for any new id config adds). Keys: `canvas`, `modes`, `visual_styles` (grouped), `icons`, `image_usage`, `image_ai_path`, `generation_mode`, `design_spec_depth`, `delivery_purpose`. `simple-icons` is content-driven and has no option. Each catalog entry is `{ "id", "label", "label_zh", "label_zh_tw", "label_en", "label_ja", ... }`; descriptions use `desc_zh` / `desc_zh_tw` / `desc_en` / `desc_ja`, and `visual_styles` groups use `group_zh` / `group_zh_tw` / `group_en` / `group_ja`. The front-end falls back to legacy `label` / `desc` / `group`, so old catalogs still load, but new user-facing catalog text must cover all four languages (zh / zh-TW / en / ja). English labels should mirror canonical reference names (`pyramid`, `swiss-minimal`, `Path A`, `continuous`, etc.); Simplified Chinese, Traditional Chinese, and Japanese labels should be translated for users. Descriptions render inline after the option title, not as a separate selected-option line. `visual_styles` is `[{ "group", "group_zh", "group_zh_tw", "group_en", "group_ja", "items": [...] }]`. For `canvas` you only need to maintain the four-language labels in catalogs.json; the format set and dimensions are authoritative in `config.py CANVAS_FORMATS`.
## Round-trip data contract ## Round-trip data contract
@@ -370,7 +371,7 @@ run remains inactive. An existing `result.json` outside the current `stage1` /
| Recommendation file | Declared stage | Page renders | Button | On submit | | Recommendation file | Declared stage | Page renders | Button | On submit |
|---|---|---|---|---| |---|---|---|---|---|
| `recommendations.stage1.json` + `template_options.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; free-design/template mode and conditional candidate selectors | **Confirm contract & template choice** | writes Stage-1 `result.json` plus `template_selection.json` in one submission; the page stays open and polls while the agent installs/completes the handoff | | `recommendations.stage1.json` + `template_options.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; free-design/template mode and conditional candidate selectors | **Confirm contract & template choice** | writes Stage-1 `result.json` plus `template_selection.json` in one submission; the page stays open and polls while the agent installs/completes the handoff |
| `recommendations.stage2.json` | `"stage2"` | complete deck solution and production — conditional natural-language template application, reading mode, mode, page count, visual direction, color, icons, typography, image usage/rendering, conditional AI acquisition path, proactive notes/custom-animation/narration-audio toggles, generation mode, and Design Spec review toggle | **Confirm final plan** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down | | `recommendations.stage2.json` | `"stage2"` | complete deck solution and production — conditional natural-language template application, reading mode, mode, page count, visual direction, color, icons, typography, image usage/rendering, conditional AI acquisition path, proactive notes/custom-animation/narration-audio toggles, generation mode, Design Spec review toggle, and Design Spec depth | **Confirm final plan** | writes `result.json` `{ stage: "final", status: "confirmed", <all fields> }`, then shuts the page down |
In the UI branch, the AI authors Stage 1 without reading template candidates, In the UI branch, the AI authors Stage 1 without reading template candidates,
then launches the combined page. In chat/delegated confirmation it authors the then launches the combined page. In chat/delegated confirmation it authors the
@@ -443,12 +444,17 @@ The common paths — inform / explain / persuade / decide / align / teach / repo
After Stage 1 is confirmed, create `recommendations.stage2.json` with the complete solution; leave Stage 1 unchanged (the server folds confirmed communication fields back in when serving the page): After Stage 1 is confirmed, create `recommendations.stage2.json` with the complete solution; leave Stage 1 unchanged (the server folds confirmed communication fields back in when serving the page):
**Stage-2 production contract**: the server rejects the recommendation file **Stage-2 production contract**: the server rejects the recommendation file
unless `recommend.generation_mode` and boolean `refine_spec.value` are present; unless `recommend.generation_mode`, boolean `refine_spec.value`, and
`design_spec_depth.value` as `brief` or `complete` are present;
`recommend.image_ai_path` is additionally required when `image_usage` includes `recommend.image_ai_path` is additionally required when `image_usage` includes
`ai`. Final submission must retain the corresponding direct values `ai`. Final submission must retain the corresponding direct values
(`generation_mode`, boolean `refine_spec`, and conditional `image_ai_path`) or (`generation_mode`, boolean `refine_spec`, `design_spec_depth`, and conditional
confirmation is rejected. Formula realization is Executor-owned and is not a `image_ai_path`) or confirmation is rejected. `design_spec_depth: brief` is
Stage-2 choice. Legacy recommendation/result objects may contain rejected when `generation_mode` is `split` or `refine_spec` is `true`; those
conditions require `complete`. A legacy `result.json` without the field is read
as `complete`, while a Stage-2 recommendation without it is invalid. Formula
realization is Executor-owned and is not a Stage-2 choice. Legacy
recommendation/result objects may contain
`formula_policy`; the server tolerates the extra field but does not render or `formula_policy`; the server tolerates the extra field but does not render or
persist it in a new receipt. persist it in a new receipt.
@@ -471,6 +477,7 @@ persist it in a new receipt.
"proactive_custom_animations": { "value": false }, "proactive_custom_animations": { "value": false },
"proactive_narration_audio": { "value": false }, "proactive_narration_audio": { "value": false },
"refine_spec": { "value": false }, "refine_spec": { "value": false },
"design_spec_depth": { "value": "brief" },
"design_directions": { "design_directions": {
"selected": 1, "selected": 1,
"candidates": [ "candidates": [
@@ -514,7 +521,7 @@ The example shows one candidate's complete shape; the actual array repeats that
- `custom_candidates` is an optional legacy recommendation-only shape. New files place all three project-specific variants inside `design_directions`; each remains separately selectable and becomes editable in place. A custom behavior may use zero, one, or many exact catalog bases. Reference count has no fixed cap: every named id must contribute a distinct executable job, the behavior omits any id whose contribution it cannot state, and one basis never requires a decorative second. The UI rejects a selected blank and submits only the edited current value. Template-backed variants obey inherited identity, prototype capacity, and `template_application`. - `custom_candidates` is an optional legacy recommendation-only shape. New files place all three project-specific variants inside `design_directions`; each remains separately selectable and becomes editable in place. A custom behavior may use zero, one, or many exact catalog bases. Reference count has no fixed cap: every named id must contribute a distinct executable job, the behavior omits any id whose contribution it cannot state, and one basis never requires a decorative second. The UI rejects a selected blank and submits only the edited current value. Template-backed variants obey inherited identity, prototype capacity, and `template_application`.
- 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. - 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. - 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 a confirmed templates-mode handoff, write one editable prose field as top-level `template_application.value`. It summarizes **how to use** the already selected project-local template: actual page/prototype use and preservation/reorganization decisions. It never chooses, changes, or reinstalls a workspace. Omit it for free design. The UI returns the current string through final Stage 2; 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. - For a confirmed templates-mode handoff, write one editable prose paragraph as top-level `template_application.value`. It summarizes **how to use** the already selected project-local template after every installed template SVG has been inspected: actual prototype use and preservation/reorganization decisions, with the exact SVG basename for every prototype-specific rule. Explicit user instructions win; otherwise Strategist decides from the content and workspace, falling back to reference-led use when no stronger fit exists. Reference, augment-only, and replacement-only are useful interpretations, never submitted enum values or a fixed option menu. The field never chooses, changes, or reinstalls a workspace. Omit it for free design. The UI returns the current string through final Stage 2; 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.
Template-mode-only Stage-2 fragment: Template-mode-only Stage-2 fragment:
@@ -530,15 +537,15 @@ Template-mode-only Stage-2 fragment:
- `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. - `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.
- Final Stage 2 shows and submits `recommend.image_ai_path` as one of `auto` / `api` / `host-native` / `manual` only while its current `image_usage` includes `ai`; changing sources refreshes that production control on the same page. - Final Stage 2 shows and submits `recommend.image_ai_path` as one of `auto` / `api` / `host-native` / `manual` only while its current `image_usage` includes `ai`; changing sources refreshes that production control on the same page.
- **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. - **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_zh_tw` / `name_en` / `name_ja` and `note_zh` / `note_zh_tw` / `note_en` / `note_ja`; the page falls back to legacy `name` / `note` when present. Otherwise localized fields resolve in the page language first, then by the locale's fallback order: zh → en → ja → zh_tw, en → zh → ja → zh_tw, ja → en → zh → zh_tw, or zh_tw → zh → en → ja. Include all four variants in newly authored multilingual candidates so a persisted UI-language choice never hides recommendation text. - **Candidate display text is written once, in the confirmed UI language**: use the plain keys (`name`, `note`, `mode_behavior`, `visual_style_behavior`, `visual`, `mood`, `behavior`). The server accepts the plain key or any one of the `_zh` / `_zh_tw` / `_en` / `_ja` suffixed variants, and the page falls back across them, so authoring the same text in several languages only adds output.
- **Typography candidates** use concrete heading/body `primary`; non-English decks also use `english`, while English-primary decks omit it. `cjk` / `latin` remain legacy aliases. Localized `name` labels the pair and `css` only previews. Bundles differ overall; font pairs may repeat without blocking. Fixed pairs require `fixed: true`. Catalog `fonts` supplies language-filtered dropdowns plus Other without limiting recommendations; edits mark Custom and refresh the preview. Include topic samples. [`canvas-formats.md`](../../references/canvas-formats.md) § "Typography Scale Start" is the single owner of initial body anchors and sanity bands; the browser mirrors that rule, and submitted values remain px. - **Typography candidates** use concrete heading/body `primary`; non-English decks also use `english`, while English-primary decks omit it. `cjk` / `latin` remain legacy aliases. Localized `name` labels the pair and `css` only previews. Bundles differ overall; font pairs may repeat without blocking. Fixed pairs require `fixed: true`. Catalog `fonts` supplies language-filtered dropdowns plus Other without limiting recommendations; edits mark Custom and refresh the preview. Include topic samples. [`canvas-formats.md`](../../references/canvas-formats.md) § "Typography Scale Start" is the single owner of initial body anchors and sanity bands; the browser mirrors that rule, and submitted values remain 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: PPT uses `reading mode → body baseline`, non-PPT uses `canvas → body baseline`, then every canvas uses `body baseline → unpinned role sizes` (role ramp: `body ×` the §g ratios). Changing reading mode updates a PPT 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. A font-only selection preserves current sizes; applying a different complete direction, or using the active card's explicit restore action, restores that direction's typography baseline and derived unpinned sizes. This is a browser-only state update: it performs no fetch and asks the backend to author no new recommendations. 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; fresh Stage 2 preserves a candidate `body_size` as its baseline and derives only missing or unpinned role sizes from the same local ramp before first render. - **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: PPT uses `reading mode → body baseline`, non-PPT uses `canvas → body baseline`, then every canvas uses `body baseline → unpinned role sizes` (role ramp: `body ×` the §g ratios). Changing reading mode updates a PPT 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. A font-only selection preserves current sizes; applying a different complete direction, or using the active card's explicit restore action, restores that direction's typography baseline and derived unpinned sizes. This is a browser-only state update: it performs no fetch and asks the backend to author no new recommendations. 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; fresh Stage 2 preserves a candidate `body_size` as its baseline and derives only missing or unpinned role sizes from 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. - **`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. - **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 current `image_usage: ai`, but all three custom project candidates already exist in `design_directions` before that toggle. Turning AI on reveals those candidates immediately without a backend rerun, followed by the 20 fixed system styles. Selecting a project candidate expands its behavior editor in that card; a fixed preset submits its id, while a project candidate submits `rendering: "custom"` + edited non-empty `behavior`. Turning AI off omits `image_strategy` from the final result without deleting the authored recommendation candidates. Catalog-based custom behavior names exact ids for optional `image_rendering_references`; a novel behavior has none. The left preview follows selection. No image palette is written; deck colors remain authoritative, and legacy `image_strategy.palette` is ignored. Illustrated icons and decorative lettering are downstream AI carrier decisions; neither adds a Confirm UI field or `result.json` key. - **Generated-image direction** appears only for current `image_usage: ai`, but all three custom project candidates already exist in `design_directions` before that toggle. Turning AI on reveals those candidates immediately without a backend rerun, followed by the 20 fixed system styles. Selecting a project candidate expands its behavior editor in that card; a fixed preset submits its id, while a project candidate submits `rendering: "custom"` + edited non-empty `behavior`. Turning AI off omits `image_strategy` from the final result without deleting the authored recommendation candidates. Catalog-based custom behavior names exact ids for optional `image_rendering_references`; a novel behavior has none. The left preview follows selection. No image palette is written; deck colors remain authoritative, and legacy `image_strategy.palette` is ignored. Illustrated icons and decorative lettering are downstream AI carrier decisions; neither adds a Confirm UI field or `result.json` key.
- **`design_directions`** is the canonical Stage-2 starting set: exactly three top-down, project-fit bundles with stable ids, localized copy, custom mode/style/rendering, icons, complete language-aware typography, and HEX `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, `body_text`. The `selected` card carries the persistent Recommended marker and is applied first. A custom direction card uses its localized style-summary note—or the required behavior fallback—instead of requesting a preset-style preview. Newly authored notes may borrow localized catalog display labels where useful or use concise natural language freely; they never force an approximate label or expose internal catalog ids. Clicking an inactive card applies every field it owns; projected custom fields can then be edited in place and all lower controls may diverge. The active card shows an adjusted state and exposes an explicit restore action for its immutable authored bundle. `result.json` stores the edited current components, never a direction id. - **`design_directions`** is the canonical Stage-2 starting set: exactly three top-down, project-fit bundles with stable ids, localized copy, custom mode/style/rendering, icons, complete language-aware typography, and HEX `background`, `secondary_bg`, `primary`, `accent`, `secondary_accent`, `body_text`. The `selected` card carries the persistent Recommended marker and is applied first. A custom direction card uses its localized style-summary note—or the required behavior fallback—instead of requesting a preset-style preview. Newly authored notes may borrow localized catalog display labels where useful or use concise natural language freely; they never force an approximate label or expose internal catalog ids. Clicking an inactive card applies every field it owns; projected custom fields can then be edited in place and all lower controls may diverge. The active card shows an adjusted state and exposes an explicit restore action for its immutable authored bundle. `result.json` stores the edited current components, never 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. - `recommend.generation_mode`, `refine_spec`, and `design_spec_depth` are Stage-2 production fields. `design_spec_depth.value` is exactly `brief` or `complete`: `brief` records each page as a short block list without full page copy, while `complete` records full page briefs. `split` generation or enabled refinement locks the field to `complete` in the UI, and the server rejects a recommendation or final `brief` value under either condition.
- `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. - `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. Edit Native PPTX uses its source-backed round-trip plan instead of this confirmation flow and does not surface the field.
- `lang` is the soft UI-language default (`zh` / `zh-TW` / `en` / `ja`); the persisted user choice wins. It never sets `primary_language`. - `lang` is the soft UI-language default (`zh` / `zh-TW` / `en` / `ja`); the persisted user choice wins. It never sets `primary_language`.
### Output — `result.json` (written on submit, read by the AI) ### Output — `result.json` (written on submit, read by the AI)
@@ -572,6 +579,7 @@ Template-mode-only Stage-2 fragment:
"proactive_narration_audio": false, "proactive_narration_audio": false,
"generation_mode": "continuous", "generation_mode": "continuous",
"refine_spec": false, "refine_spec": false,
"design_spec_depth": "brief",
"stage": "final", "stage": "final",
"status": "confirmed", "status": "confirmed",
"confirmed_at": "2026-06-15T11:44:44" "confirmed_at": "2026-06-15T11:44:44"
@@ -594,12 +602,14 @@ named catalog sources. These lists have no fixed item limit. One item may carry
the complete preset behavior unchanged; with several, each owns a distinct the complete preset behavior unchanged; with several, each owns a distinct
executable contribution. Genuinely novel custom behavior has no reference list. executable contribution. Genuinely novel custom behavior has no reference list.
The Stage-1 intermediate write retains the communication contract for Stage 2. The Stage-1 intermediate write retains the communication contract for Stage 2.
Legacy final results without `design_spec_depth` resolve to `complete` when read
by the server.
**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. **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`. - 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. - `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.
- Stage-1 **Confirm contract & template choice** writes the Stage-1 `result.json` and `template_selection.json` together, then keeps the page open while the agent installs the selection. A successful Stage-1 wait is explicitly intermediate and cannot end the task. In the same active run, the agent runs `--complete-template-selection`, writes fresh Stage 2 only after that handoff, and enters the final wait while the page keeps polling. Stage-2 **Confirm final plan** saves the final `result.json` and shuts the server down (auto-close). The AI reads each receipt at its owning boundary; chat fallback mirrors the same decisions without UI artifacts. Either way, Step 4 ends with `--shutdown` so a never-confirmed page cannot retain its selected port ahead of Step 6 live preview. - Stage-1 **Confirm contract & template choice** writes the Stage-1 `result.json` and `template_selection.json` together, then keeps the page open while the agent installs the selection. A successful Stage-1 wait is explicitly intermediate and cannot end the task. In the same active run, the agent runs `--complete-template-selection`, writes fresh Stage 2 only after that handoff, and enters the final wait while the page keeps polling. Stage-2 **Confirm final plan** saves the final `result.json` and shuts the server down (auto-close). The AI reads each receipt at its owning boundary; chat fallback mirrors the same decisions without UI artifacts. Either way, Step 4 ends with `--shutdown` so a never-confirmed page does not outlive Step 4.
## Scope ## Scope
@@ -231,12 +231,11 @@ Outputs (per source deck, prefixed by file stem):
- `<stem>.slide_library.json` — text slots, geometry, native tables, native chart display caches, and SmartArt nodes/connections - `<stem>.slide_library.json` — text slots, geometry, native tables, native chart display caches, and SmartArt nodes/connections
- `source_profile.json` — the single multi-deck index: a compact Strategist-facing digest per deck (over identity, tables, charts, SmartArt, and page types) under `decks[]`, with prefixed artifact pointers - `source_profile.json` — the single multi-deck index: a compact Strategist-facing digest per deck (over identity, tables, charts, SmartArt, and page types) under `decks[]`, with prefixed artifact pointers
`project_manager.py import-sources` runs this automatically for PPTX/PPTM/PPSX/PPSM/POTX/POTM inputs and stores the bundle directly under `analysis/`. Multi-deck per project: importing several PPTX files gives each its own `<stem>.*` artifacts and a `decks[]` entry in the shared `source_profile.json` index (re-importing the same stem replaces its entry). The beautify profile and Fill Native PPTX route stay single-deck and read one chosen deck's `<stem>.*` artifacts. `project_manager.py import-sources` runs this automatically for PPTX/PPTM/PPSX/PPSM/POTX/POTM inputs and stores the bundle directly under `analysis/`. Multi-deck per project: importing several PPTX files gives each its own `<stem>.*` artifacts and a `decks[]` entry in the shared `source_profile.json` index (re-importing the same stem replaces its entry). The beautify profile stays single-deck and reads one chosen deck's `<stem>.*` artifacts.
Usage boundary: Usage boundary:
- Standard generation uses these fields as facts and recommendation candidates; it does not inherit source slide coordinates or page order by default. - Standard generation uses these fields as facts and recommendation candidates; it does not inherit source slide coordinates or page order by default.
- Beautify promotes selected identity/content fields into locked constraints after confirmation and redraws SmartArt meaning with ordinary editable shapes. - Beautify promotes selected identity/content fields into locked constraints after confirmation and redraws SmartArt meaning with ordinary editable shapes.
- Template-fill uses the slide library as the native PPTX fill contract; SmartArt is inventory-only and remains unchanged.
## `pptx_to_svg.py` ## `pptx_to_svg.py`
@@ -368,8 +367,10 @@ claiming native reconstruction. It records `formula-not-reconstructed`;
### Native table and chart import claims ### Native table and chart import claims
Supported text-grid tables and conservative classic-chart caches carry a Supported text-grid tables and conservative classic-chart caches carry a
`data-pptx-replace-with` claim beside their SVG fallback, with the replacement `data-pptx-replace-with` claim plus
payload in a child `<metadata type="application/json">`. The parent claim `data-pptx-native-authority="json"` beside their SVG preview, with the
authoritative replacement payload in a child
`<metadata type="application/json">`. The parent claim
selects the table or chart schema. Table import requires selects the table or chart schema. Table import requires
exact physical row/grid topology and accepts canonical rectangular merges, exact physical row/grid topology and accepts canonical rectangular merges,
safe solid/no-fill per-side borders, plain multi-paragraph cells, and a closed safe solid/no-fill per-side borders, plain multi-paragraph cells, and a closed
@@ -413,21 +414,16 @@ warning when the SVG fallback itself is complete. Imported table/chart groups
under this contract carry `data-pptx-import-source="pptx"`, whether active or under this contract carry `data-pptx-import-source="pptx"`, whether active or
fallback-only; generated authoring omits this provenance attribute. fallback-only; generated authoring omits this provenance attribute.
Active imported markers also carry `data-pptx-fallback-sha256`, computed over JSON-first imported markers do not use preview freshness to veto native export;
their canonical fallback plus reachable document-level SVG fragment definitions. their preview may be normalized or approximate. Free-designed Chart/Table
A later visible edit, reachable definition change, local reference-target markers omit the authority attribute and are SVG-first. After their visible
change, or marker transform makes the replacement metadata stale. The mandatory fallback and JSON are synchronized, `stamp_native_fallbacks.py --write` records
quality checker reports the mismatch; default export keeps the edited fallback, `data-pptx-fallback-sha256` over the canonical fallback plus reachable
while `--native-charts-and-tables` fails before replacement so it cannot discard that edit. document-level SVG definitions. A later visible/reference/transform edit makes
`visibility:hidden` content, marker-local unused definitions, and explicitly that baseline stale. Default export keeps the edited fallback; canonical check
referenced document-level target roots (even when hidden) are included and `--native-charts-and-tables` fail before replacement. A missing/invalid
conservatively; marker-local `display:none` subtrees are excluded, and external SVG-first baseline also fails native replacement. The hash detects later edits;
file bytes are not read. it does not prove that independently authored JSON matches the SVG.
Generated authoring and reusable templates omit import provenance and do not
preseed a static fallback hash; that state is normal and does not warn. A legacy
imported marker that still carries PPTX import provenance but lacks the hash
remains native-compatible and warns in the checker/native route that stale
detection is unavailable.
Legacy `data-pptx-native*`, `data-pptx-visual-status`, and Legacy `data-pptx-native*`, `data-pptx-visual-status`, and
`data-pptx-route-status` spellings remain read-compatible. New importer output `data-pptx-route-status` spellings remain read-compatible. New importer output
@@ -205,6 +205,7 @@ with TemporaryDirectory(prefix="ppt-master-multilingual-smoke-") as tmp:
marker, marker,
context, context,
{ {
"schema": "ppt-master.semantic-table.v2",
"x": 10, "x": 10,
"y": 10, "y": 10,
"width": 600, "width": 600,
@@ -251,13 +251,6 @@ the existing `tmRoot`, allocates fresh ids, and preserves object animation.
For bounce timing it updates both p14 Choice and Fallback; unsupported nested For bounce timing it updates both p14 Choice and Fallback; unsupported nested
timing containers still fail safely instead of being duplicated. timing containers still fail safely instead of being duplicated.
Direct-PPTX routes run the structural package validator with generated-effect
enforcement disabled. This permits preservation of source/extension effects and
legacy group build rows while still rejecting corrupt timing IDs or missing
targets. Template fill and native enhancement fingerprint the source
object-animation tree before and after their allowed edits; any semantic change
fails. These routes have no object-animation write ownership.
The conversion trace is also the authoritative input for downstream video The conversion trace is also the authoritative input for downstream video
motion. `video_motion_plan.py` preserves the resolved effect/options, direction, motion. `video_motion_plan.py` preserves the resolved effect/options, direction,
row order, base and repeat-aware playback duration, absolute offset, object row order, base and repeat-aware playback duration, absolute offset, object
@@ -11,8 +11,6 @@ read-back validation for every PPTX route.
| Page transition registry | scripts/pptx_transitions.py | | Page transition registry | scripts/pptx_transitions.py |
| In-slide object animation | scripts/pptx_animations.py | | In-slide object animation | scripts/pptx_animations.py |
| Generated PPTX adapter | svg_to_pptx/pptx_package/builder.py | | Generated PPTX adapter | svg_to_pptx/pptx_package/builder.py |
| Template Fill adapter | template_fill_pptx/transitions.py |
| Native Enhance adapter | native_enhance_pptx_core.py |
| Public workflow | references/animations.md | | Public workflow | references/animations.md |
**Hard rule**: adapters resolve route policy, then call the shared core. They **Hard rule**: adapters resolve route policy, then call the shared core. They
@@ -233,8 +231,8 @@ roster expectations are then refreshed. Package read-back requires:
undeclared shared `!!` name on a Morph edge. undeclared shared `!!` name on a Morph edge.
Morph without an explicit pair block retains PowerPoint's automatic matching Morph without an explicit pair block retains PowerPoint's automatic matching
behavior. Explicit pairing is generated-route authoring; direct-PPTX routes behavior. Explicit pairing is generated-route authoring; source-preserving
continue to preserve existing object names and transition XML. round-trip export keeps existing object names and transition XML.
--- ---
@@ -244,13 +242,10 @@ continue to preserve existing object names and transition XML.
|---|---|---|---| |---|---|---|---|
| Generated PPTX CLI | fade, 0.4s; no sound | click | auto-advance maps to both; an optional sidecar sound is project-local | | Generated PPTX CLI | fade, 0.4s; no sound | click | auto-advance maps to both; an optional sidecar sound is project-local |
| Recorded narration | Preserve resolved enter | narration | none remains visually none | | Recorded narration | Preserve resolved enter | narration | none remains visually none |
| Template Fill | preserve source | preserve source | explicit effects replace; legacy advance_after maps to both | | Edit Native PPTX (`svg_to_pptx.py --roundtrip`) | preserve source | preserve source | `-t` or `animations.json` rows replace per output page as an overlay; `--use-narration-timings` derives advance from narration |
| 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 changes source transitions only when its CLI or per-slide plan The public `create_pptx_with_native_svg` Python API retains its legacy 0.5s
selects a replacement, removal, or timed advance. Native Enhance uses its default; the generated-deck CLI explicitly passes 0.4s.
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.
--- ---
@@ -257,21 +257,21 @@ python3 scripts/pptx_template_import.py <template.pptx> --inheritance-mode layer
``` ```
Notes: Notes:
- Extracts reusable media assets from `ppt/media/` - Extracts package resources into semantic workspace directories
- Summarizes slide size, theme colors, font metadata, and per-master theme metadata - Summarizes slide size, theme colors, font metadata, and per-master theme metadata
- Resolves slide / layout / master relationships from OOXML relationships; every master and layout is included even when no sample slide currently references it - Resolves slide / layout / master relationships from OOXML relationships; every master and layout is included even when no sample slide currently references it
- Generates `manifest.json` (single source of truth for slide size, theme, per-master themes, assets, layouts, masters, placeholders, slides, SVG file paths, and page-type candidates), `native_structure.json`, `source_template.pptx`, `assets/`, `conversion-report.json`, and shape-level SVGs under `svg/` - Generates `analysis/manifest.json` (source facts and resource inventory), `analysis/native_structure.json`, `sources/source.pptx`, `validation/conversion-report.json`, populated semantic resource directories, and shape-level SVGs under `svg/`
- **SVG output defaults to the layered authoring source** (`--inheritance-mode layered`): - **SVG output defaults to the layered authoring source** (`--inheritance-mode layered`):
- `svg/` — layered template view for designers: every master and layout in the deck rendered once as `svg/master_*.svg` / `svg/layout_*.svg` (including ones no sample slide currently references); `svg/slide_NN.svg` contains only that slide's own shapes; `svg/inheritance.json` records parentage plus source-owned `showInheritedShapes` / `showMasterShapes` booleans. - `svg/` — layered template view for designers: every master and layout in the deck rendered once as `svg/master_*.svg` / `svg/layout_*.svg` (including ones no sample slide currently references); `svg/slide_NN.svg` contains only that slide's own shapes; `svg/inheritance.json` records parentage plus source-owned `showInheritedShapes` / `showMasterShapes` booleans.
- `svg-flat/` — optional verification view emitted only by `--inheritance-mode both`: each `slide_NN.svg` is self-contained (the effective visible Master/Layout contributions plus Slide-local content painted into one file), so opening any slide in isolation shows the full page like PowerPoint would. Background inheritance remains independent of inherited-shape visibility. Useful for previews, screenshots, and "did this slide actually render correctly" sanity checks. - `svg-flat/` — optional verification view emitted only by `--inheritance-mode both`: each `slide_NN.svg` is self-contained (the effective visible Master/Layout contributions plus Slide-local content painted into one file), so opening any slide in isolation shows the full page like PowerPoint would. Background inheritance remains independent of inherited-shape visibility. Useful for previews, screenshots, and "did this slide actually render correctly" sanity checks.
- `manifest.json` records `svgFile` for slides / layouts / masters, `flatSvgFile` for slides when `svg-flat/` exists, placeholder type / index / geometry / base style, an asset map used by SVG `href` values, and common assets reused through slide / layout / master inheritance. Placeholder semantics keep `subTitle`, `obj`, `media`, and `dt` distinct as `subtitle`, `object`, `media`, and `date`. - `analysis/manifest.json` records `svgFile` for slides / layouts / masters, `flatSvgFile` for slides when `svg-flat/` exists, placeholder type / index / geometry / base style, a resource map used by SVG `href` values, and common images reused through slide / layout / master inheritance. Placeholder semantics keep `subTitle`, `obj`, `media`, and `dt` distinct as `subtitle`, `object`, `media`, and `date`.
- `conversion-report.json` owns tolerant source-recovery diagnostics; it is not a cache or a duplicate of the structural manifests - `validation/conversion-report.json` owns tolerant source-recovery diagnostics; it is not a cache or a duplicate of the structural manifests
- Layered slide SVGs keep only the slide's own background; inherited master / layout backgrounds stay in the corresponding master / layout SVGs - Layered slide SVGs keep only the slide's own background; inherited master / layout backgrounds stay in the corresponding master / layout SVGs
- Placeholder guides are intentionally lightweight in `svg/` master / layout files; `svg-flat/` hides those guides and is the visual preview source - Placeholder guides are intentionally lightweight in `svg/` master / layout files; `svg-flat/` hides those guides and is the visual preview source
- Charts, SmartArt, diagrams, and OLE objects become typed placeholders in `svg/`; `svg-flat/` shows a preview image with a corner badge when one exists, otherwise a visible placeholder. Tables are converted into real SVG content. - Charts, SmartArt, diagrams, and OLE objects become typed placeholders in `svg/`; `svg-flat/` shows a preview image with a corner badge when one exists, otherwise a visible placeholder. Tables are converted into real SVG content.
- Pass `--inheritance-mode both` to add `svg-flat/`, or `--inheritance-mode flat` for the legacy round-trip view (single self-contained `svg/` tree without master/layout/inheritance files). - Pass `--inheritance-mode both` to add `svg-flat/`, or `--inheritance-mode flat` for a self-contained projection-only `svg/` tree without master/layout/inheritance files. Imported-deck round-trip uses the separate `authoring-svg-flat/` contract.
- SVG export reads OOXML directly via `pptx_to_svg` — no PowerPoint or Keynote dependency, runs on any platform - SVG export reads OOXML directly via `pptx_to_svg` — no PowerPoint or Keynote dependency, runs on any platform
- `<image>` elements in `svg/` reference files in `assets/` directly; pass `--embed-images` to inline as data URIs instead - `<image>` elements in `svg/` reference files in `images/` directly; raster images and SVG/EMF/WMF image media share that directory. Pass `--embed-images` to inline them as data URIs instead.
- External linked images and missing media are strict failures. Office vector media such as EMF / WMF are converted to PNG previews when the local toolchain can do so; otherwise the import fails instead of silently dropping content. - External linked images and missing media are strict failures. Office vector media such as EMF / WMF are converted to PNG previews when the local toolchain can do so; otherwise the import fails instead of silently dropping content.
- Required in `/create-template` whenever the reference source is `.pptx` - Required in `/create-template` whenever the reference source is `.pptx`
- Default output directory is `<pptx_stem>_template_import/` - Default output directory is `<pptx_stem>_template_import/`
@@ -6,7 +6,15 @@
These tools cover post-processing, SVG validation, speaker notes, recorded narration, and PPTX export. These tools cover post-processing, SVG validation, speaker notes, recorded narration, and PPTX export.
The supported delivery contract has one PPTX path: `svg_output/` → the project SVG-to-DrawingML converter → native PPTX. The mandatory `finalize_svg.py` step separately creates self-contained `svg_final/` visual previews, which may be opened directly or inserted into PowerPoint as SVG pictures. There is no SVG-image PPTX output, and PowerPoint's manual Convert-to-Shape operation is unsupported. The normal release contract has one PPTX path: `svg_output/` → the project
SVG-to-DrawingML converter → native PPTX. An explicit dangerous compatibility
path may apply its supported in-memory normalizations to the default
`svg_output/` or to a project-relative directory selected with `-s`, then enter
the same strict DrawingML converter.
The mandatory `finalize_svg.py` step separately creates self-contained
`svg_final/` visual previews, which may be opened directly or inserted into
PowerPoint as SVG pictures. There is no SVG-image PPTX output, and PowerPoint's
manual Convert-to-Shape operation is unsupported.
## `svg_authoring_view.py` ## `svg_authoring_view.py`
@@ -26,27 +34,172 @@ output directory contains the editable SVGs, one model-readable
The projected copy: The projected copy:
- removes embedded `txbody` metadata; - translates each recognized native shape into one visible geometry carrier
- removes hidden native geometry carriers while retaining and unwrapping their plus at most one structured text body;
visible preview geometry; - translates each recognized native table into one inline
- removes source-object identity/style/hash attributes that are only useful to `ppt-master.semantic-table.v2` payload with table/cell/run defaults and named
an exact import round trip; cell styles, plus an external authoring-preview cache;
- keeps visible paths, text, images, stable ids, Master/Layout root markers, - removes duplicate render geometry, embedded `txbody`, and import-only
selected native-shape intent, and a document-local `data-pptx-source-ref` on identity/style/hash payloads;
- keeps visible semantic paths, text, images, stable ids, Master/Layout root
markers, native-shape intent, and a document-local `data-pptx-source-ref` on
each imported logical object; each imported logical object;
- rewrites relative local asset references for the projection's new location; - rewrites relative local asset references for the projection's new location;
- compacts imported model-facing frames and safe transform page coordinates to - compacts model-facing coordinates, promotes a common page font to the root,
at most two decimals. and removes descendant presentation declarations equal to inherited values.
PPTX authoring publication applies two object-level reductions inside its
staging transaction, before the editable bundle first appears:
- non-semantic vector decorations that cross the readability threshold may
move to `icons/imported/*.svg`; the asset, placeholder, and v2 inventory all
declare the fixed `decoration` role. Any subtree containing semantic
authoring content remains inline;
- unsupported, text-free, schema-free source ornaments with no semantic marker
may become
`<image data-pptx-source-proxy="native-restore">` references whose hashed SVG
previews live under `images/source-object-previews/`.
The live editor expands `data-icon` references for complete-page preview. Read
an imported vector asset only when editing that decoration. An unchanged asset
restores its native source objects; editing the asset rebuilds every slide whose
placeholder references that vector edit unit. A source proxy remains atomic: leave it unchanged
to restore the original native PowerPoint object. A complete Slide-local proxy
may be removed to delete that object; an inherited Master/Layout proxy must
remain because one flat page cannot delete shared structure. Editing the proxy
or its preview asset fails round-trip export instead of silently rasterizing or
flattening the object.
The summary stores the current SVG roster plus compact per-file canvas, size, The summary stores the current SVG roster plus compact per-file canvas, size,
text, image, vector, placeholder, icon, and source-ref counts. Models read the text, image, vector, placeholder, icon, source-ref, and source-proxy counts.
summary and editable SVGs; they do not read the machine manifest. The manifest Models read the summary and editable SVGs; they do not read the machine
stores relative source/authoring filenames, source and initial authoring hashes, manifest. The manifest stores relative source/authoring filenames, source and initial authoring
and source element paths. It deliberately does not copy the opaque payload. hashes, source element paths, and immutable preview hashes for source proxies.
The authoring bundle is the editable source for template creation; the complete It deliberately does not copy the opaque payload.
imported SVG remains immutable native-payload backing. Final The layered authoring bundle remains the editable source for template creation;
`templates/*.svg` files are materialized and validated from that pair. The IR the complete imported SVG remains immutable native-payload backing. Final
directory itself is not a supported direct input to `svg_to_pptx.py`. `templates/*.svg` files are materialized and validated from that pair. A
complete-page `authoring-svg-flat/` bundle is the user's editable source for an
imported-deck round-trip. `pptx_template_import.py` publishes its compact
layered authoring bundle and decoration inventory in the same transaction as
its immutable backing. `pptx_to_svg.py --roundtrip` places image media in
`images/`, decoration-only vectors in `icons/imported/`, cues/audio/video/notes in their named
directories, opaque payloads in `native-payloads/`, the source package in
`sources/`, and tool-owned backing/contracts in `analysis/`; `assets/` is
invalid. `svg_to_pptx.py --roundtrip` always reads `authoring-svg-flat/`,
restores unchanged source refs from `analysis/roundtrip-svg/`, expands imported
vector edit units from `icons/imported/`, and retains edits/deletions/new
content without rewriting the bundle. Unchanged slides and resources pass
through byte-for-byte. A page edit rebuilds that output page; a changed
materialized or derived resource rebuilds every output page that references it.
Changed materialized bytes must still match the source package part's extension
and Content-Type. Resource hrefs resolve exactly relative to the page or
extracted asset and must remain inside the workspace.
### Round-trip deck page plans
`page_plan.json` is an optional, model-authored file at the root of a
`pptx_to_svg.py --roundtrip` workspace. Without it, export uses the existing
identity roster and preserves the no-plan package behavior. With it, the
`pages` array is the complete output order and may select, reorder, repeat, or
omit source slides:
```json
{
"schema": "ppt-master.roundtrip-page-plan.v1",
"pages": [
{"source_slide": 3},
{"source_slide": 1, "svg": "intro.svg"},
{"source_slide": 3, "svg": "intro_b.svg"}
]
}
```
`source_slide` is the one-based source presentation index. `svg` defaults to
that source slide's canonical imported filename, normally `slide_03.svg`, and
must name one file directly inside `authoring-svg-flat/`. Each output entry
must use a different SVG filename. To author independent edits from one source
page, copy its SVG to a new filename and list that filename on the repeated
entry. The exporter always compares the copy with the baseline regenerated
from its declared `source_slide`, so source-ref restoration, proxy checks, and
edit detection remain source-correct. Every extra authoring SVG must appear in
the plan; an unknown, duplicate, or cross-owned canonical filename fails.
Move an object between pages before export with the authoring helper:
```bash
python3 scripts/svg_authoring_view.py <authoring-dir> \
--adopt-object <from.svg>:<element-id> --into <target.svg>
```
The helper appends a copy to the target page, removes its source/native restore
transport, gives colliding ids fresh local names, inlines any source-owned
`icons/imported/` vector reference, and refreshes the page-plan-aware summary.
It refuses a source proxy because that atomic object cannot leave its source
page. Raw cross-page source refs remain invalid and export reports that the page
contains unknown source refs.
An unchanged planned page keeps the source slide XML and receives its own
relationship graph. Repeated pages clone notes slides, charts, diagrams,
embeddings, and other private structured parts under unique part names while
ordinary media may remain shared. An edited copy overlays only its edited
owners onto its cloned source page. Same-deck slide-jump links follow the
page-plan contract: a target must map to exactly one output page. An
omitted or repeated destination is an error; external links remain unchanged.
Omitting a source slide deliberately drops its private video, audio, or opaque
native payloads; a kept slide still fails if rebuilding it would discard such
relationships.
With a plan present, presentation-level `sectionLst` and custom-show rosters
are dropped, output `p:sldId` values are renumbered, and the slide count in
`docProps/app.xml` is updated.
Output-page sidecars are keyed by the authoring SVG stem. A repeated copy
inherits its source row from `animations.json` unless that output stem has its
own row. Canonical pages keep identity notes semantics: a manifest `notes.file`
must equal `notes/<svg-stem>.md`; deleting that file removes the source notes,
different bytes override them, and matching bytes remain unchanged. A canonical
page without source notes treats a present stem-keyed file as an addition. For
a copied SVG, a present `notes/<svg-stem>.md` overrides its inherited source
notes and an absent file keeps them. Deleting inherited source notes only on a
copy is not supported in v1. The same output-stem rule applies to narration
audio.
When a round-trip recorded-narration export omits `--animation-config`, it uses the workspace `animations.json` when present and otherwise applies no sidecar while preserving source motion.
Narration audio is keyed by the output SVG stem. A copied output page uses its
own stem-keyed notes when present and otherwise inherits the declared source
slide's canonical notes.
`-t`, `-a`, `--recorded-narration`, `--use-narration-timings`,
`--no-animations`, and `--no-notes` continue to resolve per output page.
Import and export from the repository root:
```bash
python3 skills/ppt-master/scripts/pptx_to_svg.py source.pptx \
-o /path/to/workspace --inheritance-mode both --roundtrip
python3 skills/ppt-master/scripts/svg_to_pptx.py /path/to/workspace \
--roundtrip -o /path/to/output.pptx
```
Successful round-trip export prints one deck receipt:
`Round-trip export summary: output_pages=N passthrough=P
cloned_passthrough=C patched=M rebuilt=R`. `patched` keeps source shape XML
while order, notes, or motion may change; `rebuilt` means visible authoring or
one of its referenced resources changed.
Without `-o`, round-trip export names the deck `<workspace-directory-name>_<timestamp>[<flavor-suffix>].pptx` under `exports/`.
Before export, run `python3 scripts/svg_quality_checker.py <workspace> --roundtrip`
as the round-trip text-capacity gate. It resolves the output roster from
`authoring-svg-flat/` and optional `page_plan.json`, then applies the shared
font-family, font-size, text-width, and canvas metrics only to new text or
changed source-ref objects. The gate asserts horizontal capacity: it estimates
single-line width for each positioned line and does not model vertical
wrapping. Width beyond an explicit ancestor `data-pptx-frame` is blocking;
overflow against the nearest-rect-sibling fallback is advisory, while bounds
leaving the page canvas remain blocking. Other advisories remain non-blocking.
Unchanged source refs, source proxies, and generated-project-only spec,
template, canonical-authoring, and resource-manifest checks are excluded.
Regenerate the summary after direct edits that do not pass through one of the Regenerate the summary after direct edits that do not pass through one of the
in-place normalization tools: in-place normalization tools:
@@ -318,19 +471,50 @@ python3 scripts/compact_svg_coordinates.py <template-directory> \
``` ```
The default run is a dry-run JSON report. `--inplace` atomically replaces only The default run is a dry-run JSON report. `--inplace` atomically replaces only
changed SVG files. The shared create-template final pass uses changed legacy SVG files. `--keep-native-frames` compacts `data-pptx-bounds`,
`--keep-native-frames`: it compacts `data-pptx-bounds`, translation values, translation values, rotation centers, and matrix `e/f`, while preserving canonical
rotation centers, and
matrix `e/f`, while preserving canonical
authored-preset or inline native frames. `svg_authoring_view.py` separately authored-preset or inline native frames. `svg_authoring_view.py` separately
compacts imported model-facing frames because unchanged mirror refs can recover compacts imported model-facing frames because the compact authoring tree owns
their exact coordinates from immutable lossless backing. visible coordinates; lossless backing only validates identity and recovers
supported non-visible semantics.
The compactor never rounds path/points geometry, normalized crop or nested The compactor never rounds path/points geometry, normalized crop or nested
`viewBox` ratios, gradient offsets, opacity, scale arguments, rotation angles, `viewBox` ratios, gradient offsets, opacity, scale arguments, rotation angles,
or matrix `a/b/c/d` coefficients. Type A mirror materialization invokes the or matrix `a/b/c/d` coefficients. Type A mirror materialization invokes the
same compactor before native-record externalization; `standard` and `fidelity` same tree-level implementation before its first write. The CLI is a migration
use the shared final pass before template validation. and diagnostic tool; standard authoring is checked read-only.
## `compact_svg_styles.py`
Diagnose or migrate older authoring SVG to shared root/group defaults plus
local overrides:
```bash
python3 scripts/compact_svg_styles.py <svg-file-or-directory>
python3 scripts/compact_svg_styles.py <svg-file-or-directory> --inplace
```
The default run reports proposed changes without writing. `--inplace` prepares
the complete input set first and then atomically replaces changed files. When
every rendered `<text>` has a resolvable typeface, the most common page
`font-family` becomes one root declaration. Descendants retain only true
exceptions. The same pass removes any supported inheritable presentation
attribute or inline declaration that exactly repeats its effective parent
value; it never invents a paint, size, weight, or other non-font default.
PPTX import projections and mirror materialization call the same tree-level
implementation before publishing their authoring SVG. Standard workflows do
not rewrite completed SVG: they pass `--canonical-authoring` to
`svg_quality_checker.py`, which reports any remaining deterministic change as an
advisory warning (run `compact_svg_styles.py <svg_output> --inplace` on
authored project pages and rerun the final gate to normalize, or keep the
explicit form). Structured template rosters keep their explicit form: per-slide
compaction would make shared Master/Layout atoms diverge and shift native
fallback hashes, so the normalizer is not applied to them; mirror
materialization compacts its own tree before publication. SVG-to-PPTX accepts
valid explicit declarations either way; canonical compact
authoring is a generated-source contract, not a compatibility restriction on
external SVG input.
## `extract_svg_assets.py` ## `extract_svg_assets.py`
@@ -347,35 +531,60 @@ python3 scripts/extract_svg_assets.py <flat_svg_dir> \
--inplace --id-prefix flat --inplace --id-prefix flat
``` ```
`pptx_template_import.py` and `pptx_to_svg.py --roundtrip` invoke the same
extractor automatically inside their staging transactions. They use the
imported namespace and record thresholds in the adjacent vector inventory so
template materialization and round-trip export can regenerate the same
baseline before comparing edits. The CLI form is for external SVG and legacy
migration input, not a standard post-generation rewrite.
The first pass records a source fingerprint before namespacing each extracted The first pass records a source fingerprint before namespacing each extracted
asset's internal ids. The second pass reuses a fingerprint-matched asset and asset's internal ids. The second pass reuses a fingerprint-matched asset and
writes no duplicate SVG file. Unmatched flat-only subtrees still extract writes no duplicate SVG file. Unmatched flat-only subtrees still extract
normally. Use `--clean-stale` on both import-workspace passes to remove stale normally. Use `--clean-stale` on both import-workspace passes to remove stale
generated files for their respective prefixes. In create-template workspaces, generated files for their respective prefixes. In create-template workspaces,
`imported` is the fixed namespace: assets live once under `icons/imported/`, and `imported` is the fixed decoration-only namespace: assets live once under
the working SVGs reference them as `data-icon="imported/<name>"`. Inventory `icons/imported/`, and the working SVGs reference them as
entries retain source refs from each extracted subtree, allowing expansion to `data-icon="imported/<name>"`. Each asset root and placeholder declares
reconnect the authoring-manifest mapping. A rerun on an `data-pptx-asset-role="decoration"`; the v2 inventory repeats that role. The
extractor and both consumers fail closed if a semantic marker or semantic
descendant crosses this boundary. Inventory entries may retain source refs from
eligible decoration subtrees, allowing expansion to reconnect the
authoring-manifest mapping. A rerun on an
already rewritten namespaced projection inventories those references and does already rewritten namespaced projection inventories those references and does
not progressively extract their remaining parent or sibling geometry. An not progressively extract their remaining parent or sibling geometry. An
in-place pass over an authoring bundle refreshes `authoring_summary.json` in-place pass over an authoring bundle refreshes `authoring_summary.json`
automatically. automatically.
## `stamp_native_fallbacks.py`
After an SVG-first Chart/Table fallback and its inline JSON projection are
updated together, validate and bind the visible subtree explicitly:
```bash
python3 scripts/stamp_native_fallbacks.py <svg-or-directory> --write
```
Omit `--write` for a read-only preview. The command prevalidates every direct
Chart/Table marker, skips JSON-first markers, and atomically adds/updates only
`data-pptx-fallback-sha256` without reformatting the document. The fingerprint
detects later visible edits; it is not a semantic-equivalence proof.
## `mirror_template_materialize.py` ## `mirror_template_materialize.py`
Compile one Type A PPTX import workspace into a deterministic structured mirror Validate and publish one Type A PPTX import workspace as a deterministic
template after the layered authoring IR has been reviewed and edited: structured mirror after Template_Designer has reviewed/authored the new compact
layered SVG:
```bash ```bash
python3 scripts/mirror_template_materialize.py \ python3 scripts/mirror_template_materialize.py \
<import_workspace> <template_workspace> <import_workspace> <template_workspace>
``` ```
The command treats `<import_workspace>/authoring-svg/` as the sole editable The command treats `<import_workspace>/authoring-svg/` as the sole visible
source. It reads the tool-only layered authoring manifest internally and editable source. It reads the tool-only layered authoring manifest internally
validates it against immutable lossless SVG and validates source SVG/PPTX hashes, known refs, the source Slide roster and reachable Master/Layout
hashes, source PPTX hash, complete Master/Layout/Slide graph, inheritance graph, inheritance visibility facts, source-ref closure, and extracted-vector inventory before it
visibility facts, source-ref closure, and extracted-vector inventory before it
writes anything. It accepts an absent/empty destination or a project writes anything. It accepts an absent/empty destination or a project
`templates/` containing unique qualified Brand/Style specs plus, for a `templates/` containing unique qualified Brand/Style specs plus, for a
Layout-over-Deck transition, one qualified Deck spec with no staged roster. A Layout-over-Deck transition, one qualified Deck spec with no staged roster. A
@@ -385,12 +594,20 @@ new Layout or Deck must be composed with the other structural kind. It stages th
result before atomic publication, so a failed preflight cannot leave a partial result before atomic publication, so a failed preflight cannot leave a partial
template. template.
Materialization preserves source page order and emits one definition-only Materialization preserves source Slide order and only the Layout/Master chains
`layout_<layout_key>.svg` for every source Layout unused by all source Slides. reachable from those Slides. Each source Slide becomes one standalone prototype
with Master + Layout + Slide context resolved. Explicit layer markers preserve
ownership; source Master/Layout identities unused by every Slide produce no SVG.
The v2 report lists source counts plus retained and omitted structure keys.
For PPTX-backed mirror input, `templates/source_themes.json` stores the exact
Theme bytes keyed by retained Master. Structured export validates that sidecar
against the Master roster and installs one Theme per Master; it is not an SVG
prototype or page-authoring input.
It mechanically expands fixed Master/Layout group wrappers into direct atoms, It mechanically expands fixed Master/Layout group wrappers into direct atoms,
rehydrates only unchanged converter-supported Slide-local/slot refs, keeps the publishes the current compact visible authoring tree for both changed and
current SVG fallback for edited refs, preserves explicit text hard breaks, and unchanged refs, recovers only supported non-visible semantics such as explicit
removes every IR-only source ref. Imported axis-flipped groups retain their text hard breaks, and removes every IR-only source ref. It never replaces an
ordinary visible subtree with lossless source XML. Imported axis-flipped groups retain their
geometry reflection while descendant SVG text receives a matching geometry reflection while descendant SVG text receives a matching
counter-reflection, preserving PowerPoint's upright glyph appearance in browser counter-reflection, preserving PowerPoint's upright glyph appearance in browser
previews. Supported opaque `p:txBody`, previews. Supported opaque `p:txBody`,
@@ -418,17 +635,20 @@ text/tspan topology and attributes. These records are deterministic tool
diagnostics, not page-authoring inputs. Page-context emits only the complete diagnostics, not page-authoring inputs. Page-context emits only the complete
prototype's path and SHA for that reference, so the model reads the SVG once prototype's path and SHA for that reference, so the model reads the SVG once
per execution context and reuses it until the SHA changes. The model chooses per execution context and reuses it until the SHA changes. The model chooses
semantics and edits only existing visible text values, while checker and semantics and edits only permitted visible text values; a direct JSON-first
Chart/Table may regenerate its derived preview children while keeping marker,
metadata, bounds, and structure. Checker and
structured export validate output attributes, text/tspan topology, and structured export validate output attributes, text/tspan topology, and
referenced-resource hashes against referenced-resource hashes against
the prototype. the prototype.
The output routes reusable vectors once to `icons/imported/`, bitmaps to The output routes reusable decoration vectors once to `icons/imported/`, image media to
`images/`, and other referenced files to `templates/assets/`. The JSON report `images/`, audio and video to their semantic directories, and opaque referenced
files to `native-payloads/imported/`. The JSON report
reports payload occurrence, native-record, unique-byte, and compressed-store reports payload occurrence, native-record, unique-byte, and compressed-store
counts and is written to stdout only. The command intentionally does not create counts and is written to stdout only. The command intentionally does not create
`templates/design_spec.md`; Template_Designer writes the package-specific rules `templates/design_spec.md`; Template_Designer writes the package-specific rules
and page roster after materialization. This compiler is for Type A mirror materialization, and page roster after publication. This validator/publisher is for Type A mirror,
not `standard` / `fidelity`, loose Type B SVGs, ordinary generation, finalize, not `standard` / `fidelity`, loose Type B SVGs, ordinary generation, finalize,
or export. or export.
@@ -523,7 +743,7 @@ It aggregates:
- `align_embed_images.py` (`crop-images` / `fix-aspect` / `embed-images` aliases route here) - `align_embed_images.py` (`crop-images` / `fix-aspect` / `embed-images` aliases route here)
- `flatten_tspan.py` - `flatten_tspan.py`
`svg_final/` remains a required Step 7.2 artifact even though the native exporter reads `svg_output/`. It is the self-contained visual reference and may be manually inserted as an SVG picture. `svg_final/` is an optional Step 7.2 preview artifact; the native exporter reads `svg_output/` and never requires it. It is the self-contained visual reference and may be manually inserted as an SVG picture.
## `svg_to_pptx.py` ## `svg_to_pptx.py`
@@ -553,10 +773,13 @@ 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> --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 structured # deck/layout template override
python3 scripts/svg_to_pptx.py <project_path> --pptx-structure flat # free-design/brand-only override python3 scripts/svg_to_pptx.py <project_path> --pptx-structure flat # free-design/brand-only override
# Exact source-structure diagnostic emitted by pptx_to_svg.py --roundtrip:
python3 scripts/svg_to_pptx.py <pptx_import_output> -s svg --roundtrip
# Template-import visual round-trip diagnostic only: # Template-import visual round-trip diagnostic only:
python3 scripts/svg_to_pptx.py <template_import_output> -s svg-flat python3 scripts/svg_to_pptx.py <template_import_output> -s svg-flat
# Editable authoring-svg-flat/ -> source-preserving PPTX round-trip:
python3 scripts/svg_to_pptx.py <pptx_import_output> --roundtrip
# The same compatibility mode defaults to svg_output/ when -s is omitted:
python3 scripts/svg_to_pptx.py <project_path> \
--enable-dangerous-nonconforming-svg-export
# Post-processed-source comparison diagnostic only (never a release export): # Post-processed-source comparison diagnostic only (never a release export):
python3 scripts/svg_to_pptx.py <project_path> -s final python3 scripts/svg_to_pptx.py <project_path> -s final
python3 scripts/svg_to_pptx.py <project_path> --no-notes python3 scripts/svg_to_pptx.py <project_path> --no-notes
@@ -593,14 +816,17 @@ export:
```bash ```bash
python3 scripts/svg_quality_checker.py <project_path> \ python3 scripts/svg_quality_checker.py <project_path> \
--quick-generate --stage final --json --quick-generate --canonical-authoring --stage final --json
python3 scripts/svg_to_pptx.py <project_path> --quick-generate python3 scripts/svg_to_pptx.py <project_path> --quick-generate
``` ```
This direct-export flag takes `svg_output/` as its authored page source, resolves This direct-export flag takes `svg_output/` as its authored page source, resolves
valid project-local resources referenced by those pages, infers one consistent valid project-local resources referenced by those pages, infers one consistent
canvas, uses flat converter-default package scaffolding, and does not read or canvas, and does not read or require `spec_lock.md`. It infers one all-page PPTX
require `spec_lock.md`. Notes, motion, narration, native objects, conversion structure mode from the authored SVGs: no structure metadata creates clean flat
package scaffolding; any structure metadata requires every page to satisfy the
complete Master/Layout/slot contract and creates structured output. A mixed or
partial roster fails closed. Notes, motion, narration, native objects, conversion
trace, and other ordinary exporter capabilities remain available; notes, trace, and other ordinary exporter capabilities remain available; notes,
custom object animation, and narration start off in Quick and may be enabled custom object animation, and narration start off in Quick and may be enabled
when needed. The exporter refuses a missing, blocking, non-final, or stale when needed. The exporter refuses a missing, blocking, non-final, or stale
@@ -621,6 +847,7 @@ Behavior:
- `exports/` contains only final PPTX deliverables; machine-readable quality and postflight reports belong in `validation/`. - `exports/` contains only final PPTX deliverables; machine-readable quality and postflight reports belong in `validation/`.
- The default Generate flow always runs `finalize_svg.py` before export. This directory is the self-contained SVG visual preview; it is not packaged as a second PPTX. Quick-generate deliberately skips it. - The default Generate flow always runs `finalize_svg.py` before export. This directory is the self-contained SVG visual preview; it is not packaged as a second PPTX. Quick-generate deliberately skips it.
- In both Generate profiles, explicit `-o/--output` changes the native PPTX destination and skips `backup/`; the postflight report still uses the output stem under the project `validation/` directory. - In both Generate profiles, explicit `-o/--output` changes the native PPTX destination and skips `backup/`; the postflight report still uses the output stem under the project `validation/` directory.
- A custom `-s/--source` also skips `backup/`: that directory remains the caller-owned SVG source and is never copied under a misleading `backup/<timestamp>/svg_output/` name. Default or explicit `-s output` export retains the normal SVG backup behavior.
- Postflight reruns ZIP integrity and published Slide count. Internal relationships, - Postflight reruns ZIP integrity and published Slide count. Internal relationships,
structured-package validation, transitions, and animations are enforced before the structured-package validation, transitions, and animations are enforced before the
builder publishes the PPTX and are reported as `enforced-at-build`, not as repeated builder publishes the PPTX and are reported as `enforced-at-build`, not as repeated
@@ -635,8 +862,8 @@ 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. - `--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. - `--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. - 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. - Native release export reads `svg_output/`; `-s <directory>` selects another project-relative SVG source. `-s final` remains an explicit diagnostic comparison against post-processed SVGs and does not change artifact ownership. `--enable-dangerous-nonconforming-svg-export` is a separate, explicitly requested flat compatibility path for either the default or selected source; it forces flat structure, restores no imported source object, and cannot combine with `--roundtrip` or `--quick-generate`.
- `-s svg --roundtrip` consumes only the validated source package, structure sidecar, and layered `slide_*.svg` files emitted by `pptx_to_svg.py --roundtrip`. An unchanged imported chart with a closed validated source package automatically restores its original chart XML, style/color parts, workbook, and theme override without enabling ordinary semantic Chart/Table replacement. Its visible-fallback fingerprint remains authoritative: editing the SVG fallback disables stale exact replacement instead of discarding that edit. - `--roundtrip` accepts only `authoring-svg-flat/` and the source/contracts emitted by `pptx_to_svg.py --roundtrip`; predecessor root sidecars and alternate `-s` inputs fail. It restores unchanged refs from `analysis/roundtrip-svg/`, preserves unchanged Slide XML/relationships and source resources byte-for-byte, rebuilds a page whose authoring changed, and rebuilds every output page that references a changed resource. Closed unchanged chart packages recover exactly; editing their fallback disables stale replacement. Optional root `page_plan.json` uses the versioned deck-plan contract above; the no-plan path remains the identity export. Explicit `-t <effect>` without `--transition-duration` on a source without transitions uses the default duration.
- `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. - `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 - On every SVG-authoring route, each file in `svg_output/` is the complete visible
page-design source. Templates and locks may guide authoring, but finalize/export page-design source. Templates and locks may guide authoring, but finalize/export
@@ -644,20 +871,20 @@ Behavior:
narration, transitions, and direct native-PPTX workflows keep their separate narration, transitions, and direct native-PPTX workflows keep their separate
inputs and package-level processing. inputs and package-level processing.
- For PPTX template-import workspaces, use `-s svg-flat` when you need a visual round-trip check. The layered `svg/` tree is the machine-readable template source and intentionally does not inline inherited master / layout decoration into each slide. - For PPTX template-import workspaces, use `-s svg-flat` when you need a visual round-trip check. The layered `svg/` tree is the machine-readable template source and intentionally does not inline inherited master / layout decoration into each slide.
- Native mode is strict about unsupported visual SVG elements: if a visual element cannot be represented or safely preserved, export fails with the SVG file, element tag, and position instead of silently dropping content. - Native mode is strict about unsupported visual SVG elements: if a visual element cannot be represented or safely preserved, export fails with the SVG file, element tag, and position instead of silently dropping content. Dangerous compatibility export first applies the registry in `svg_compatibility.py`; it currently lowers a filter on an otherwise attribute-free one-child group whose child is a supported native filter target. The complete strict preflight then runs normally; every remaining contract, resource, conversion, relationship, or package error still blocks export.
- Omitting `--pptx-structure` reads `spec_lock.md`. Free-design, brand-only, and `template_reuse_scope: style` releases declare `mode: flat`, omit Master/Layout mappings and SVG structure metadata, and materialize one clean project-owned Master plus one Blank Layout from the current lock. Deck/layout templates use `mode: structured` only for `template_reuse_scope: mirror|layout`, with complete unique `pptx_masters` / `pptx_layouts` rosters and one `page_pptx_layouts` assignment per page. A template-backed Layout definition may remain unused by pages and still register in the final package. - Default export omitting `--pptx-structure` reads `spec_lock.md`. Free-design, brand-only, and `template_reuse_scope: style` releases declare `mode: flat`, omit Master/Layout mappings and SVG structure metadata, and materialize one clean project-owned Master plus one Blank Layout from the current lock. Deck/layout templates use `mode: structured` only for `template_reuse_scope: mirror|layout`, with complete unique `pptx_masters` / `pptx_layouts` rosters and one `page_pptx_layouts` assignment per page. A template-backed Layout definition may remain unused by pages and still register in the final package.
- On structured template routes, every page root repeats Master/Layout keys and picker names. Master/Layout fixed visuals are direct semantic atoms. Ordinary layer `<g>` elements are invalid; one validated compact authored-preset `<g>` emitted by `preset_shape_svg.py` is the sole group exception because it compiles to one native shape. - On structured template routes, every page root repeats Master/Layout keys and picker names. Master/Layout fixed visuals are direct semantic atoms. Ordinary layer `<g>` elements are invalid; one validated compact authored-preset `<g>` emitted by `preset_shape_svg.py` is the sole group exception because it compiles to one native shape.
- Every visible direct root `<g>` except a compact helper-authored preset atom requires root-coordinate `data-pptx-bounds`; nested bounds are ignored. The text-free preset atom remains top-level when standalone, uses `data-pptx-frame`, and never carries bounds. Frame/native metadata never replaces bounds on any other group; placeholder bounds also define the slot frame. Checker compares root bounds with `viewBox`, descendant text with its module using DrawingML wrapping headroom, and every estimable visible text carrier directly with the root `viewBox` before that headroom. Images, shapes, paths, `<use>`, effects, and object frames are excluded from module containment. Per side, ≤`1px` is ignored; module overflow ≤`5%` warns and >`5%` fails, while larger page text overflow always fails. Bounds never clip/reflow; unestimable visible text warns. A wholly off-canvas direct-root Morph endpoint may opt out of page containment with `data-pptx-morph-staging="true"`; it still needs valid module bounds, retained Morph uses an explicit pair, and partial overflow remains blocking. - Every visible direct root `<g>` except a compact helper-authored preset atom requires root-coordinate `data-pptx-bounds`; nested bounds are ignored. The text-free preset atom remains top-level when standalone, uses `data-pptx-frame`, and never carries bounds. Frame/native metadata never replaces bounds on any other group; placeholder bounds also define the slot frame. Checker fails ordinary direct-root module pairs whose intersection exceeds `1px` on both axes; complete structured slots, registered structural-role groups, and wholly off-canvas Morph staging groups are excluded, while ordinary Slide-local groups remain checked on structured pages. Checker compares root bounds with `viewBox`, estimable descendant text—including the canonical direct first line plus later positioned tspan form—with its module using DrawingML wrapping headroom, and every estimable visible text carrier directly with the root `viewBox` before that headroom. Images, shapes, paths, `<use>`, effects, and object frames are excluded from module containment. Per side, ≤`1px` is ignored; module overflow ≤`5%` warns and >`5%` fails, while larger page text overflow always fails. Bounds never clip/reflow; unestimable visible text warns. A wholly off-canvas direct-root Morph endpoint may opt out of page containment with `data-pptx-morph-staging="true"`; it still needs valid module bounds, retained Morph uses an explicit pair, and partial overflow remains blocking.
- Missing required root bounds fails on final pages/templates and under `--template-mode`; references warn until adapted. - Missing required root bounds fails on final pages/templates and under `--template-mode`; references warn until adapted.
- On structured template routes, each normal slot is a direct root `<g id>` with semantic type, positive design-zone bounds, and exactly one compatible carrier. Composite `object` slots use explicit proxy binding; zero-slot Layouts are valid. Flat pages keep all SVG objects Slide-local. - On structured template routes, each normal slot is a direct root `<g id>` with semantic type, positive design-zone bounds, and exactly one compatible carrier. Composite `object` slots use explicit proxy binding; zero-slot Layouts are valid. Flat pages keep all SVG objects Slide-local.
- Flat export maps locked typography/colors into a clean project-owned theme/Master, removes stock content placeholders and unused built-in Layouts, retains only the standard date/footer/slide-number capability hooks, and keeps one Blank Layout without promoting Slide content. Structured export additionally creates one reusable Layout per declared key and reopens the package to verify the full Presentation → Master → Layout → Slide graph, fixed-object order, placeholder identities/bounds, carrier bindings, hidden proxies, and zero-slot Layouts. - Flat export maps locked typography/colors into a clean project-owned theme/Master, removes stock content placeholders and unused built-in Layouts, retains only the standard date/footer/slide-number capability hooks, and keeps one Blank Layout without promoting Slide content. Structured export additionally creates one reusable Layout per declared key and reopens the package to verify the full Presentation → Master → Layout → Slide graph, fixed-object order, placeholder identities/bounds, carrier bindings, hidden proxies, and zero-slot Layouts.
- Template `page_layouts` remains input provenance. Strict preserves the prototype contract; adaptive retains its Master and may use a new Layout identity only when Strategist declared it in the plan and lock. Construction cannot allocate or mutate Layout identity downstream. - Template `page_layouts` remains input provenance. Strict preserves the prototype contract; adaptive keeps its Master; new Layouts require Default plan/lock or Quick's frozen Template Application. Construction cannot allocate or mutate Layout identity downstream.
- Legacy structured/template contracts using `baseline`, `template`, `preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled`/`utility`, direct atomic placeholders, or incomplete Master identity are rejected with a pointer to [`create-template`](../../workflows/create-template.md). Create a new workspace and generate new structured SVG pages; do not upgrade the existing project in place. Explicit flat free-design/brand-only projects intentionally omit Master identity. - Legacy structured/template contracts using `baseline`, `template`, `preserve`, `layout_strategy`, `data-pptx-layout-kind`, `distilled`/`utility`, direct atomic placeholders, or incomplete Master identity are rejected with a pointer to [`create-template`](../../workflows/create-template.md). Create a new workspace and generate new structured SVG pages; do not upgrade the existing project in place. Explicit flat free-design/brand-only projects intentionally omit Master identity.
- Native output uses content-hash media filenames, so identical images are reused and different images cannot overwrite each other by sharing a basename. - Native output uses content-hash media filenames, so identical images are reused and different images cannot overwrite each other by sharing a basename.
- `[Content_Types].xml` is generated from the actual media extensions written into the PPTX. Unknown media extensions fail unless Python's `mimetypes` can identify them. - `[Content_Types].xml` is generated from the actual media extensions written into the PPTX. Unknown media extensions fail unless Python's `mimetypes` can identify them.
- Native export writes to a temporary file first and publishes the requested PPTX only after conversion succeeds. A failed conversion does not replace the main output file. - Native export writes to a temporary file first and publishes the requested PPTX only after conversion succeeds. A failed conversion does not replace the main output file.
- `--conversion-trace` without a path writes `validation/<output_stem>.trace.json`. `--conversion-trace <path>` respects the explicit destination; relative paths are resolved from the project root, so `exports/<name>.trace.json` remains available when intentionally requested. - `--conversion-trace` without a path writes `validation/<output_stem>.trace.json`. `--conversion-trace <path>` respects the explicit destination; relative paths are resolved from the project root, so `exports/<name>.trace.json` remains available when intentionally requested.
- Formal default and `--quick-generate` release export compute the exact SVG source fingerprint and refuse a missing, unreadable, unsupported, non-final, blocking, stale, or unverifiable final quality report before PPTX creation. A project without `validation/svg_quality_report.json` exits nonzero with the `not-provided` gate status; run the final checker against its current `svg_output/` first. An explicit non-`output` `--source` remains a diagnostic override and bypasses this release gate; postflight still records any verifiable report linkage. - Formal default and `--quick-generate` release export compute the exact SVG source fingerprint and refuse a missing, unreadable, unsupported, non-final, blocking, stale, or unverifiable final quality report before PPTX creation. A project without `validation/svg_quality_report.json` exits nonzero with the `not-provided` gate status; run the final checker against its current `svg_output/` first. An explicit non-`output` `--source` remains outside this release gate. Dangerous compatibility export also stays outside it even when reading the default `svg_output/`: it automatically writes a conversion trace, marks postflight `passed-with-warnings`, and records its normalization count; it never claims that the source passed the normal authoring quality gate.
- The final quality report carries an informational `carrier_receipt` aggregate plus each page's `files[].info.carrier_receipt`: actual text/image/icon counts, SVG geometry, native preset names, marker use, native Chart/Table/Formula markers, and largest image-frame share. The terminal prints only the compact aggregate. These facts never affect exit status, create coverage quotas, or score design; the active Generate profile compares them with its retained page decisions before export. - The final quality report carries an informational `carrier_receipt` aggregate plus each page's `files[].info.carrier_receipt`: actual text/image/icon counts, SVG geometry, native preset names, marker use, native Chart/Table/Formula markers, and largest image-frame share. The terminal prints only the compact aggregate. These facts never affect exit status, create coverage quotas, or score design; the active Generate profile compares them with its retained page decisions before export.
- After publication, native export writes `validation/<output_stem>.report.json`. The report distinguishes authored Slides from internal Layout definitions, reruns ZIP integrity and published Slide-count checks, records slide/layout/master/notes part counts, labels relationship/structured/transition/animation validation as enforced at build time, links the final SVG quality report only when its SHA-256 source fingerprint matches the exact export inputs, and surfaces stale/unverified gates, unresolved template tokens, generic-only font stacks, and external image references. A matching final quality report with introduced warnings yields `passed-with-warnings` and a `quality_introduced_warnings=<N>` receipt instead of a clean `passed` claim. - After publication, native export writes `validation/<output_stem>.report.json`. The report distinguishes authored Slides from internal Layout definitions, reruns ZIP integrity and published Slide-count checks, records slide/layout/master/notes part counts, labels relationship/structured/transition/animation validation as enforced at build time, links the final SVG quality report only when its SHA-256 source fingerprint matches the exact export inputs, and surfaces stale/unverified gates, unresolved template tokens, generic-only font stacks, and external image references. A matching final quality report with introduced warnings yields `passed-with-warnings` and a `quality_introduced_warnings=<N>` receipt instead of a clean `passed` claim.
- By default, a successful command also prints a compact receipt instead of requiring a report read: `[POSTFLIGHT] status=<...> quality_gate=<...> slides=<N> warning_categories=<N>`, followed by one compact line per warning category and the `[PPTX]` / `[REPORT]` paths. Resource-warning lines carry counts; a non-passing quality gate carries its status. Routine agents use this receipt and do not load either complete validation JSON into model context. Full reports remain cold audit artifacts; failure investigation and explicit audits extract only the required fields. `--quiet` keeps suppressing successful-run output. - By default, a successful command also prints a compact receipt instead of requiring a report read: `[POSTFLIGHT] status=<...> quality_gate=<...> slides=<N> warning_categories=<N>`, followed by one compact line per warning category and the `[PPTX]` / `[REPORT]` paths. Resource-warning lines carry counts; a non-passing quality gate carries its status. Routine agents use this receipt and do not load either complete validation JSON into model context. Full reports remain cold audit artifacts; failure investigation and explicit audits extract only the required fields. `--quiet` keeps suppressing successful-run output.
@@ -727,6 +954,24 @@ Requirements:
- Heading text matches the SVG filename - Heading text matches the SVG filename
- Sections are separated by `---` - Sections are separated by `---`
## Measuring and wrapping text before authoring
`text_measure.py` imports the same single-line DrawingML width estimator used by
the SVG quality checker.
- `measure` prints one `width<TAB>text` line per input, or a JSON array with
`--json`.
- `wrap` prints greedy word- or CJK-cluster-wrapped SVG text content; `--y`
includes the outer `<text>` element, and `--json` prints line metrics.
- `box` prints a `data-pptx-bounds` attribute plus numeric `top` and `bottom`, or
a JSON bounds object with `--json`.
```bash
python3 scripts/text_measure.py measure "Editable DrawingML text" --size 22
python3 scripts/text_measure.py wrap "Editable DrawingML text stays measurable" --size 22 --max-width 240 --x 96 --dy 30 --y 140
python3 scripts/text_measure.py box "First line" "Second line" --x 96 --y 140 --size 22 --lines 2 --dy 30
```
## `svg_quality_checker.py` ## `svg_quality_checker.py`
Validate SVG technical compliance. Validate SVG technical compliance.
@@ -737,6 +982,7 @@ python3 scripts/svg_quality_checker.py projects/project/svg_output
python3 scripts/svg_quality_checker.py projects/project python3 scripts/svg_quality_checker.py projects/project
python3 scripts/svg_quality_checker.py projects/project --stage first-page python3 scripts/svg_quality_checker.py projects/project --stage first-page
python3 scripts/svg_quality_checker.py projects/project --stage final --json python3 scripts/svg_quality_checker.py projects/project --stage final --json
python3 scripts/svg_quality_checker.py projects/project --canonical-authoring --stage final --json
python3 scripts/svg_quality_checker.py projects/project --format ppt169 python3 scripts/svg_quality_checker.py projects/project --format ppt169
python3 scripts/svg_quality_checker.py --all projects python3 scripts/svg_quality_checker.py --all projects
python3 scripts/svg_quality_checker.py projects/project --export python3 scripts/svg_quality_checker.py projects/project --export
@@ -834,7 +1080,10 @@ python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg
python3 scripts/svg_finalize/embed_icons.py --dry-run svg_output/*.svg python3 scripts/svg_finalize/embed_icons.py --dry-run svg_output/*.svg
``` ```
Replaces `<use data-icon="chunk-filled/name" .../>`, `<use data-icon="tabler-filled/name" .../>` and `<use data-icon="tabler-outline/name" .../>` placeholders with actual SVG path elements. Use for manual icon embedding checks outside `finalize_svg.py`. Replaces project-local `<use data-icon="library/name" .../>` placeholders with
SVG paths. The exact case-sensitive file must exist under the workspace
`icons/`; bare, aliased, template-source, and unsynced references fail. Use
this only for manual checks outside `finalize_svg.py`.
## SVG Compatibility Contract ## SVG Compatibility Contract
@@ -2,16 +2,15 @@
""" """
PPT Master - Large Vector Asset Extractor PPT Master - Large Vector Asset Extractor
Factor large inline vector groups (complex illustrations) out of working SVGs Factor large non-semantic vector decorations out of working SVGs into project
into project icon assets, leaving a one-line `<use data-icon="namespace/id"/>` icon assets, leaving a compact decoration-marked `<use data-icon>` placeholder
placeholder behind so the working SVG stays readable (structure, not a wall of behind. Semantic objects and semantic descendants always remain inline. The
`<path>`). Visually lossless and reversible: the existing icon embedding path existing icon embedding path re-inlines each asset before export, so the
re-inlines each asset before export, so the exported PPTX remains native shapes, exported PPTX remains native shapes rather than an embedded picture.
not an embedded picture.
Because re-inlining restores the extracted vector subtree, the detection Because re-inlining restores the extracted decoration subtree, the detection
threshold is a readability convenience it changes which blobs are factored threshold is a readability convenience. It never expands the eligible asset
out, not whether the export stays editable. role beyond decoration.
Usage: Usage:
python3 scripts/extract_svg_assets.py <svg_dir> [options] python3 scripts/extract_svg_assets.py <svg_dir> [options]
@@ -47,23 +46,45 @@ from urllib.parse import urlsplit, urlunsplit
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from console_encoding import configure_utf8_stdio from console_encoding import configure_utf8_stdio
from pptx_shapes import (
NATIVE_FALLBACK_SHA256_ATTR,
svg_native_fallback_fingerprint,
)
from svg_authoring_view import ( from svg_authoring_view import (
AUTHORING_MANIFEST_NAME, AUTHORING_MANIFEST_NAME,
SEMANTIC_OBJECT_ATTRIBUTE,
write_authoring_summary, write_authoring_summary,
) )
from svg_authoring_contract import normalize_compact_authoring_tree
configure_utf8_stdio() configure_utf8_stdio()
SVG_NS = "http://www.w3.org/2000/svg" SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
DRAWABLE = {"path", "polygon", "polyline", "rect", "circle", "ellipse", "line"} DRAWABLE = {"path", "polygon", "polyline", "rect", "circle", "ellipse", "line"}
SEMANTIC_CONTENT = {"text", "tspan", "foreignObject"} SEMANTIC_CONTENT = {"text", "tspan", "foreignObject"}
SEMANTIC_MARKER_ATTRIBUTES = {
"data-pptx-carrier",
"data-pptx-inline-formula",
"data-pptx-layer",
"data-pptx-page-role",
"data-pptx-placeholder",
"data-pptx-replace-with",
"data-pptx-role",
"data-pptx-shape-hyperlink",
SEMANTIC_OBJECT_ATTRIBUTE,
}
DEFINITION_CONTAINERS = {"defs"} DEFINITION_CONTAINERS = {"defs"}
DEFAULT_MIN_DRAWABLES = 20 DEFAULT_MIN_DRAWABLES = 20
DEFAULT_MIN_BYTES = 3000 DEFAULT_MIN_BYTES = 3000
DEFAULT_MIN_DECORATION_BYTES = 3000 DEFAULT_MIN_DECORATION_BYTES = 3000
SOURCE_REF_ATTRIBUTE = "data-pptx-source-ref" SOURCE_REF_ATTRIBUTE = "data-pptx-source-ref"
ASSET_ROLE_ATTRIBUTE = "data-pptx-asset-role"
DECORATION_ASSET_ROLE = "decoration"
VECTOR_INVENTORY_SCHEMA = "vector_asset_inventory.v2"
ICON_NAMESPACE_RE = re.compile(r"^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$") ICON_NAMESPACE_RE = re.compile(r"^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$")
URL_REF_RE = re.compile(r"url\(\s*(['\"]?)#([^)'\"]\S*?)\1\s*\)") URL_REF_RE = re.compile(r"url\(\s*(['\"]?)#([^)'\"]\S*?)\1\s*\)")
_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
def _local(tag: object) -> str: def _local(tag: object) -> str:
@@ -76,10 +97,14 @@ def _drawable_count(elem: ET.Element) -> int:
def _xml_size(elem: ET.Element) -> int: def _xml_size(elem: ET.Element) -> int:
if not any(item.get(SOURCE_REF_ATTRIBUTE) for item in elem.iter()): if not any(item.get(SOURCE_REF_ATTRIBUTE) for item in elem.iter()):
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS)
return len(ET.tostring(elem, encoding="utf-8")) return len(ET.tostring(elem, encoding="utf-8"))
measured = copy.deepcopy(elem) measured = copy.deepcopy(elem)
for item in measured.iter(): for item in measured.iter():
item.attrib.pop(SOURCE_REF_ATTRIBUTE, None) item.attrib.pop(SOURCE_REF_ATTRIBUTE, None)
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS)
return len(ET.tostring(measured, encoding="utf-8")) return len(ET.tostring(measured, encoding="utf-8"))
@@ -92,6 +117,20 @@ def _has_semantic_content(elem: ET.Element) -> bool:
return any(_local(e.tag) in SEMANTIC_CONTENT for e in elem.iter()) return any(_local(e.tag) in SEMANTIC_CONTENT for e in elem.iter())
def _has_semantic_object(elem: ET.Element) -> bool:
"""Semantic authoring objects must never move into imported decoration."""
return any(_is_semantic_owner(item) for item in elem.iter())
def _is_semantic_owner(elem: ET.Element) -> bool:
if any(elem.get(name) is not None for name in SEMANTIC_MARKER_ATTRIBUTES):
return True
return (
_local(elem.tag) == "metadata"
and elem.get("type") == "application/json"
)
def _is_existing_placeholder(elem: ET.Element) -> bool: def _is_existing_placeholder(elem: ET.Element) -> bool:
return _local(elem.tag) == "use" and elem.get("data-icon") is not None return _local(elem.tag) == "use" and elem.get("data-icon") is not None
@@ -104,6 +143,7 @@ def _is_extractable_subtree(elem: ET.Element) -> bool:
"""Pure vector subtrees can be moved; semantic content must stay inline.""" """Pure vector subtrees can be moved; semantic content must stay inline."""
if ( if (
_local(elem.tag) in DEFINITION_CONTAINERS _local(elem.tag) in DEFINITION_CONTAINERS
or _has_semantic_object(elem)
or _has_icon_placeholder(elem) or _has_icon_placeholder(elem)
or _is_chart_group(elem) or _is_chart_group(elem)
or _has_semantic_content(elem) or _has_semantic_content(elem)
@@ -251,6 +291,8 @@ def _definition_signature(elem: ET.Element) -> bytes:
"""Return definition semantics without its document-local id.""" """Return definition semantics without its document-local id."""
normalized = copy.deepcopy(elem) normalized = copy.deepcopy(elem)
normalized.attrib.pop("id", None) normalized.attrib.pop("id", None)
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS)
return ET.tostring(normalized, encoding="utf-8") return ET.tostring(normalized, encoding="utf-8")
@@ -331,7 +373,10 @@ def _find_extractable(root: ET.Element, min_drawables: int, min_bytes: int) -> l
found: list[ET.Element] = [] found: list[ET.Element] = []
def walk(elem: ET.Element) -> None: def walk(elem: ET.Element) -> None:
if _local(elem.tag) in DEFINITION_CONTAINERS: if (
_local(elem.tag) in DEFINITION_CONTAINERS
or _is_semantic_owner(elem)
):
return return
for child in list(elem): for child in list(elem):
if _local(child.tag) != "g": if _local(child.tag) != "g":
@@ -339,6 +384,7 @@ def _find_extractable(root: ET.Element, min_drawables: int, min_bytes: int) -> l
continue continue
if ( if (
not _is_chart_group(child) not _is_chart_group(child)
and not _has_semantic_object(child)
and not _has_semantic_content(child) and not _has_semantic_content(child)
and not _has_icon_placeholder(child) and not _has_icon_placeholder(child)
and _large_enough(child, min_drawables, min_bytes) and _large_enough(child, min_drawables, min_bytes)
@@ -375,7 +421,10 @@ def _find_extractable_runs(
found.append((parent, list(run))) found.append((parent, list(run)))
def walk(elem: ET.Element) -> None: def walk(elem: ET.Element) -> None:
if _local(elem.tag) in DEFINITION_CONTAINERS: if (
_local(elem.tag) in DEFINITION_CONTAINERS
or _is_semantic_owner(elem)
):
return return
run: list[ET.Element] = [] run: list[ET.Element] = []
for child in list(elem): for child in list(elem):
@@ -399,8 +448,20 @@ def _asset_svg(
height: str | None, height: str | None,
) -> bytes: ) -> bytes:
"""Standalone, independently-viewable SVG carrying the group in page coords.""" """Standalone, independently-viewable SVG carrying the group in page coords."""
semantic_inputs = [group, *dependencies]
if any(
_has_semantic_object(item)
or _has_semantic_content(item)
or _is_chart_group(item)
for item in semantic_inputs
):
raise ValueError(
"Semantic authoring content must remain inline and cannot become "
"imported decoration assets"
)
svg = ET.Element(f"{{{SVG_NS}}}svg") svg = ET.Element(f"{{{SVG_NS}}}svg")
svg.set("data-icon-style", "preserve-color") svg.set("data-icon-style", "preserve-color")
svg.set(ASSET_ROLE_ATTRIBUTE, DECORATION_ASSET_ROLE)
if view_box: if view_box:
svg.set("viewBox", view_box) svg.set("viewBox", view_box)
if width: if width:
@@ -412,6 +473,9 @@ def _asset_svg(
for dependency in dependencies: for dependency in dependencies:
defs.append(dependency) defs.append(dependency)
svg.append(group) svg.append(group)
normalize_compact_authoring_tree(svg)
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS)
return ET.tostring(svg, encoding="utf-8", xml_declaration=True) return ET.tostring(svg, encoding="utf-8", xml_declaration=True)
@@ -548,6 +612,11 @@ def _existing_placeholder_entries(
asset = _icon_asset_for_namespace(icon_name, icon_namespace) asset = _icon_asset_for_namespace(icon_name, icon_namespace)
if asset is None: if asset is None:
continue continue
if elem.get(ASSET_ROLE_ATTRIBUTE) != DECORATION_ASSET_ROLE:
raise ValueError(
f"Imported vector placeholder {icon_name!r} must declare "
f"{ASSET_ROLE_ATTRIBUTE}={DECORATION_ASSET_ROLE!r}"
)
if asset in known_assets: if asset in known_assets:
continue continue
@@ -560,6 +629,7 @@ def _existing_placeholder_entries(
"icon": icon_name, "icon": icon_name,
"asset": asset, "asset": asset,
"source": "existing-placeholder", "source": "existing-placeholder",
"role": DECORATION_ASSET_ROLE,
"asset_exists": (icons_dir / asset).exists(), "asset_exists": (icons_dir / asset).exists(),
}, },
) )
@@ -575,9 +645,19 @@ def _existing_placeholder_entries(
).hexdigest() ).hexdigest()
try: try:
root = ET.parse(asset_path).getroot() root = ET.parse(asset_path).getroot()
except ET.ParseError: except ET.ParseError as exc:
pass raise ValueError(
f"Imported vector asset is invalid SVG XML: "
f"{asset_path}: {exc}"
) from exc
else: else:
role = root.get(ASSET_ROLE_ATTRIBUTE)
if role != DECORATION_ASSET_ROLE:
raise ValueError(
f"Imported vector asset must declare "
f"{ASSET_ROLE_ATTRIBUTE}={DECORATION_ASSET_ROLE!r}: "
f"{asset_path}"
)
entry["drawable_count"] = _drawable_count(root) entry["drawable_count"] = _drawable_count(root)
entry["byte_count"] = _xml_size(root) entry["byte_count"] = _xml_size(root)
entry["elements"] = _tag_histogram(root) entry["elements"] = _tag_histogram(root)
@@ -602,6 +682,14 @@ def _load_reusable_assets(inventory_path: Path, icons_dir: Path) -> dict[str, di
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise ValueError(f"invalid reuse inventory JSON: {inventory_path}: {exc}") from exc raise ValueError(f"invalid reuse inventory JSON: {inventory_path}: {exc}") from exc
if payload.get("schema") != VECTOR_INVENTORY_SCHEMA:
raise ValueError(
f"unsupported reuse inventory schema: {payload.get('schema')!r}"
)
if payload.get("asset_role") != DECORATION_ASSET_ROLE:
raise ValueError(
"reuse inventory must declare asset_role='decoration'"
)
entries = payload.get("assets") entries = payload.get("assets")
if not isinstance(entries, list): if not isinstance(entries, list):
raise ValueError(f"reuse inventory has no assets list: {inventory_path}") raise ValueError(f"reuse inventory has no assets list: {inventory_path}")
@@ -615,11 +703,16 @@ def _load_reusable_assets(inventory_path: Path, icons_dir: Path) -> dict[str, di
asset_sha256 = entry.get("asset_sha256") asset_sha256 = entry.get("asset_sha256")
asset = entry.get("asset") asset = entry.get("asset")
icon = entry.get("icon") icon = entry.get("icon")
role = entry.get("role")
if not all( if not all(
isinstance(value, str) and value isinstance(value, str) and value
for value in (source_sha256, asset_sha256, asset, icon) for value in (source_sha256, asset_sha256, asset, icon)
): ):
continue continue
if role != DECORATION_ASSET_ROLE:
raise ValueError(
f"reusable asset {icon!r} is not a decoration asset"
)
fingerprinted += 1 fingerprinted += 1
asset_path = icons_dir / asset asset_path = icons_dir / asset
if not asset_path.is_file(): if not asset_path.is_file():
@@ -631,6 +724,16 @@ def _load_reusable_assets(inventory_path: Path, icons_dir: Path) -> dict[str, di
raise ValueError( raise ValueError(
f"reusable asset hash does not match its inventory: {asset_path}" f"reusable asset hash does not match its inventory: {asset_path}"
) )
try:
asset_root = ET.parse(asset_path).getroot()
except ET.ParseError as exc:
raise ValueError(
f"reusable asset is not valid SVG XML: {asset_path}: {exc}"
) from exc
if asset_root.get(ASSET_ROLE_ATTRIBUTE) != DECORATION_ASSET_ROLE:
raise ValueError(
f"reusable asset lacks its decoration role: {asset_path}"
)
current = reusable.get(source_sha256) current = reusable.get(source_sha256)
if current is None or asset < str(current["asset"]): if current is None or asset < str(current["asset"]):
reusable[source_sha256] = entry reusable[source_sha256] = entry
@@ -652,6 +755,39 @@ def _rewritten_path(svg_path: Path, rewritten_dir: Path | None, inplace: bool) -
return rewritten_dir / svg_path.name return rewritten_dir / svg_path.name
def _fresh_native_fallback_markers(root: ET.Element) -> set[ET.Element]:
"""Snapshot native markers whose visible fallback is fresh before extraction."""
fresh: set[ET.Element] = set()
for element in root.iter():
expected = element.get(NATIVE_FALLBACK_SHA256_ATTR)
if (
expected is None
or expected != expected.strip()
or not _SHA256_RE.fullmatch(expected)
):
continue
if svg_native_fallback_fingerprint(
element,
document_root=root,
) == expected.lower():
fresh.add(element)
return fresh
def _normalize_authoring_tree(
root: ET.Element,
fresh_native_markers: set[ET.Element],
) -> None:
"""Normalize first, then hash the final visible native fallbacks."""
normalize_compact_authoring_tree(root)
live_elements = set(root.iter())
for marker in fresh_native_markers & live_elements:
marker.set(
NATIVE_FALLBACK_SHA256_ATTR,
svg_native_fallback_fingerprint(marker, document_root=root),
)
def extract_file( def extract_file(
svg_path: Path, svg_path: Path,
icons_dir: Path, icons_dir: Path,
@@ -668,6 +804,7 @@ def extract_file(
ET.register_namespace("", SVG_NS) ET.register_namespace("", SVG_NS)
tree = ET.parse(svg_path) tree = ET.parse(svg_path)
root = tree.getroot() root = tree.getroot()
fresh_native_markers = _fresh_native_fallback_markers(root)
view_box = root.get("viewBox") view_box = root.get("viewBox")
width = root.get("width") width = root.get("width")
height = root.get("height") height = root.get("height")
@@ -678,8 +815,11 @@ def extract_file(
# progressively factoring their remaining parent/sibling geometry. # progressively factoring their remaining parent/sibling geometry.
if _has_namespace_placeholder(root, icon_namespace): if _has_namespace_placeholder(root, icon_namespace):
if definitions_changed or not inplace: if definitions_changed or not inplace:
_normalize_authoring_tree(root, fresh_native_markers)
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace) rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
rewritten.parent.mkdir(parents=True, exist_ok=True) rewritten.parent.mkdir(parents=True, exist_ok=True)
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS)
tree.write(rewritten, encoding="utf-8", xml_declaration=True) tree.write(rewritten, encoding="utf-8", xml_declaration=True)
return [] return []
@@ -712,8 +852,11 @@ def extract_file(
if not targets: if not targets:
if definitions_changed or not inplace: if definitions_changed or not inplace:
_normalize_authoring_tree(root, fresh_native_markers)
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace) rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
rewritten.parent.mkdir(parents=True, exist_ok=True) rewritten.parent.mkdir(parents=True, exist_ok=True)
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS)
tree.write(rewritten, encoding="utf-8", xml_declaration=True) tree.write(rewritten, encoding="utf-8", xml_declaration=True)
return [] return []
@@ -745,6 +888,7 @@ def extract_file(
reused_asset = str(reusable["asset"]) reused_asset = str(reusable["asset"])
placeholder = ET.Element(f"{{{SVG_NS}}}use") placeholder = ET.Element(f"{{{SVG_NS}}}use")
placeholder.set("data-icon", reused_icon) placeholder.set("data-icon", reused_icon)
placeholder.set(ASSET_ROLE_ATTRIBUTE, DECORATION_ASSET_ROLE)
for node in nodes: for node in nodes:
if node in parent: if node in parent:
parent.remove(node) parent.remove(node)
@@ -755,6 +899,7 @@ def extract_file(
"icon": reused_icon, "icon": reused_icon,
"asset": reused_asset, "asset": reused_asset,
"source": "reused-inventory", "source": "reused-inventory",
"role": DECORATION_ASSET_ROLE,
"source_sha256": source_sha256, "source_sha256": source_sha256,
"asset_sha256": reusable["asset_sha256"], "asset_sha256": reusable["asset_sha256"],
"reused_from_svg": reusable.get("svg"), "reused_from_svg": reusable.get("svg"),
@@ -789,6 +934,7 @@ def extract_file(
placeholder = ET.Element(f"{{{SVG_NS}}}use") placeholder = ET.Element(f"{{{SVG_NS}}}use")
placeholder.set("data-icon", icon_reference) placeholder.set("data-icon", icon_reference)
placeholder.set(ASSET_ROLE_ATTRIBUTE, DECORATION_ASSET_ROLE)
for node in nodes: for node in nodes:
if node in parent: if node in parent:
parent.remove(node) parent.remove(node)
@@ -800,6 +946,7 @@ def extract_file(
"icon": icon_reference, "icon": icon_reference,
"asset": asset, "asset": asset,
"source": "extracted", "source": "extracted",
"role": DECORATION_ASSET_ROLE,
"source_sha256": source_sha256, "source_sha256": source_sha256,
"asset_sha256": hashlib.sha256(asset_bytes).hexdigest(), "asset_sha256": hashlib.sha256(asset_bytes).hexdigest(),
"drawable_count": _drawable_count(group), "drawable_count": _drawable_count(group),
@@ -811,15 +958,20 @@ def extract_file(
}) })
_optimize_definitions(root) _optimize_definitions(root)
_normalize_authoring_tree(root, fresh_native_markers)
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace) rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
rewritten.parent.mkdir(parents=True, exist_ok=True) rewritten.parent.mkdir(parents=True, exist_ok=True)
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS)
tree.write(rewritten, encoding="utf-8", xml_declaration=True) tree.write(rewritten, encoding="utf-8", xml_declaration=True)
return entries return entries
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Factor large inline vector groups out of SVGs into reusable assets.", description=(
"Factor large non-semantic SVG decorations into reusable assets."
),
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
) )
parser.add_argument("svg_dir", help="Directory of working SVGs (e.g. import_ws/svg or project/svg_output)") parser.add_argument("svg_dir", help="Directory of working SVGs (e.g. import_ws/svg or project/svg_output)")
@@ -886,40 +1038,65 @@ def build_parser() -> argparse.ArgumentParser:
return parser return parser
def main(argv: Optional[list[str]] = None) -> int: def extract_directory(
args = build_parser().parse_args(argv) svg_dir: Path,
svg_dir = Path(args.svg_dir) icons_dir: Path,
if not svg_dir.is_dir(): icon_namespace: str,
print(f"[ERROR] svg_dir not found: {svg_dir}", file=sys.stderr) *,
return 1 min_drawables: int = DEFAULT_MIN_DRAWABLES,
min_bytes: int = DEFAULT_MIN_BYTES,
min_decoration_bytes: int = DEFAULT_MIN_DECORATION_BYTES,
inplace: bool = False,
id_prefix: str = "",
rewritten_dir: Path | None = None,
inventory_path: Path | None = None,
reuse_inventory_path: Path | None = None,
clean_stale: bool = False,
skip_unparseable: bool = False,
) -> tuple[dict, Path | None, tuple[tuple[Path, str], ...]]:
"""Extract one SVG directory and publish its deterministic inventory.
icons_dir = Path(args.icons_dir) if args.icons_dir else svg_dir.parent / "icons" Thresholds select vector groups or decoration runs. In-place authoring
icons_dir.mkdir(parents=True, exist_ok=True) bundles also refresh their model-readable summary. Returns the inventory,
icon_namespace = args.icon_namespace.strip() optional summary path, and any parse failures skipped by the CLI mode.
"""
svg_dir = Path(svg_dir)
icons_dir = Path(icons_dir)
if not svg_dir.is_dir():
raise ValueError(f"svg_dir not found: {svg_dir}")
if icon_namespace and not ICON_NAMESPACE_RE.fullmatch(icon_namespace): if icon_namespace and not ICON_NAMESPACE_RE.fullmatch(icon_namespace):
print( raise ValueError(
"[ERROR] --icon-namespace must be one lower-case ASCII directory name " "icon_namespace must be one lower-case ASCII directory name "
"using only letters, digits, '_' or '-'", "using only letters, digits, '_' or '-'"
file=sys.stderr,
) )
return 1 if min(min_drawables, min_bytes, min_decoration_bytes) < 0:
reusable_assets: dict[str, dict] = {} raise ValueError("vector extraction thresholds must be non-negative")
reuse_inventory_path = Path(args.reuse_inventory) if args.reuse_inventory else None
if reuse_inventory_path is not None: icons_dir.mkdir(parents=True, exist_ok=True)
try: reusable_assets = (
reusable_assets = _load_reusable_assets(reuse_inventory_path, icons_dir) _load_reusable_assets(reuse_inventory_path, icons_dir)
except ValueError as exc: if reuse_inventory_path is not None
print(f"[ERROR] {exc}", file=sys.stderr) else {}
return 1 )
rewritten_dir = Path(args.rewritten_dir) if args.rewritten_dir else None
inventory_path = ( inventory_path = (
Path(args.inventory) Path(inventory_path)
if args.inventory if inventory_path is not None
else svg_dir.parent / f"{svg_dir.name}_vector_asset_inventory.json" else svg_dir.parent / f"{svg_dir.name}_vector_asset_inventory.json"
) )
svg_paths = sorted(svg_dir.glob("*.svg"))
def manifest_path(path: Path | None) -> str | None:
if path is None:
return None
try:
return path.resolve().relative_to(
inventory_path.parent.resolve()
).as_posix()
except ValueError:
return str(path)
svg_paths = sorted(svg_dir.glob("*.svg"))
inventory: list[dict] = [] inventory: list[dict] = []
skipped: list[tuple[Path, str]] = []
for svg_path in svg_paths: for svg_path in svg_paths:
try: try:
inventory.extend( inventory.extend(
@@ -927,20 +1104,24 @@ def main(argv: Optional[list[str]] = None) -> int:
svg_path, svg_path,
icons_dir, icons_dir,
icon_namespace, icon_namespace,
args.min_drawables, min_drawables,
args.min_bytes, min_bytes,
args.min_decoration_bytes, min_decoration_bytes,
args.inplace, inplace,
args.id_prefix, id_prefix,
rewritten_dir, rewritten_dir,
reusable_assets, reusable_assets,
) )
) )
except ET.ParseError as exc: except ET.ParseError as exc:
print(f"[WARN] skip unparseable {svg_path.name}: {exc}", file=sys.stderr) if not skip_unparseable:
raise
skipped.append((svg_path, str(exc)))
extracted_count = sum(entry.get("source") == "extracted" for entry in inventory) extracted_count = sum(entry.get("source") == "extracted" for entry in inventory)
reused_count = sum(entry.get("source") == "reused-inventory" for entry in inventory) reused_count = sum(
entry.get("source") == "reused-inventory" for entry in inventory
)
known_assets = {str(entry["asset"]) for entry in inventory} known_assets = {str(entry["asset"]) for entry in inventory}
inventory.extend( inventory.extend(
_existing_placeholder_entries( _existing_placeholder_entries(
@@ -952,31 +1133,38 @@ def main(argv: Optional[list[str]] = None) -> int:
) )
stale_removed: list[str] = [] stale_removed: list[str] = []
if args.clean_stale: if clean_stale:
keep_assets = {str(entry["asset"]) for entry in inventory} keep_assets = {str(entry["asset"]) for entry in inventory}
keep_assets.update(_referenced_icon_assets(svg_paths, icon_namespace)) keep_assets.update(_referenced_icon_assets(svg_paths, icon_namespace))
stale_removed = _clean_stale_assets( stale_removed = _clean_stale_assets(
icons_dir, icons_dir,
icon_namespace, icon_namespace,
svg_paths, svg_paths,
args.id_prefix, id_prefix,
keep_assets, keep_assets,
) )
manifest = { manifest = {
"schema": "vector_asset_inventory.v1", "schema": VECTOR_INVENTORY_SCHEMA,
"svg_dir": str(svg_dir), "svg_dir": manifest_path(svg_dir),
"icons_dir": str(icons_dir), "icons_dir": manifest_path(icons_dir),
"icon_namespace": icon_namespace or None, "icon_namespace": icon_namespace or None,
"asset_role": DECORATION_ASSET_ROLE,
"rewritten_dir": ( "rewritten_dir": (
None None
if args.inplace if inplace
else str(_rewritten_path(svg_dir / "_sample.svg", rewritten_dir, False).parent) else manifest_path(
_rewritten_path(
svg_dir / "_sample.svg",
rewritten_dir,
False,
).parent
)
), ),
"reuse_inventory": str(reuse_inventory_path) if reuse_inventory_path is not None else None, "reuse_inventory": manifest_path(reuse_inventory_path),
"min_drawables": args.min_drawables, "min_drawables": min_drawables,
"min_bytes": args.min_bytes, "min_bytes": min_bytes,
"min_decoration_bytes": args.min_decoration_bytes, "min_decoration_bytes": min_decoration_bytes,
"extracted_count": extracted_count, "extracted_count": extracted_count,
"reused_count": reused_count, "reused_count": reused_count,
"asset_count": len(inventory), "asset_count": len(inventory),
@@ -984,18 +1172,55 @@ def main(argv: Optional[list[str]] = None) -> int:
"assets": inventory, "assets": inventory,
} }
inventory_path.parent.mkdir(parents=True, exist_ok=True) inventory_path.parent.mkdir(parents=True, exist_ok=True)
inventory_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") inventory_path.write_text(
summary_path: Path | None = None json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
if args.inplace and (svg_dir / AUTHORING_MANIFEST_NAME).is_file(): encoding="utf-8",
try: )
summary_path = write_authoring_summary(svg_dir) summary_path = (
except (OSError, ValueError) as exc: write_authoring_summary(svg_dir)
print( if inplace and (svg_dir / AUTHORING_MANIFEST_NAME).is_file()
f"[ERROR] vector extraction succeeded but authoring summary " else None
f"refresh failed: {exc}", )
file=sys.stderr, return manifest, summary_path, tuple(skipped)
)
return 1
def main(argv: Optional[list[str]] = None) -> int:
args = build_parser().parse_args(argv)
svg_dir = Path(args.svg_dir)
icons_dir = Path(args.icons_dir) if args.icons_dir else svg_dir.parent / "icons"
icon_namespace = args.icon_namespace.strip()
reuse_inventory_path = Path(args.reuse_inventory) if args.reuse_inventory else None
rewritten_dir = Path(args.rewritten_dir) if args.rewritten_dir else None
inventory_path = (
Path(args.inventory)
if args.inventory
else svg_dir.parent / f"{svg_dir.name}_vector_asset_inventory.json"
)
try:
manifest, summary_path, skipped = extract_directory(
svg_dir,
icons_dir,
icon_namespace,
min_drawables=args.min_drawables,
min_bytes=args.min_bytes,
min_decoration_bytes=args.min_decoration_bytes,
inplace=args.inplace,
id_prefix=args.id_prefix,
rewritten_dir=rewritten_dir,
inventory_path=inventory_path,
reuse_inventory_path=reuse_inventory_path,
clean_stale=args.clean_stale,
skip_unparseable=True,
)
except (OSError, ValueError) as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
for svg_path, reason in skipped:
print(f"[WARN] skip unparseable {svg_path.name}: {reason}", file=sys.stderr)
extracted_count = int(manifest["extracted_count"])
reused_count = int(manifest["reused_count"])
inventory = list(manifest["assets"])
stale_removed = list(manifest["stale_removed"])
print( print(
f"[OK] extracted {extracted_count} new asset(s), reused {reused_count} asset(s), " f"[OK] extracted {extracted_count} new asset(s), reused {reused_count} asset(s), "
f"inventoried {len(inventory)} asset reference(s) -> " f"inventoried {len(inventory)} asset reference(s) -> "
@@ -51,7 +51,7 @@ configure_utf8_stdio()
# Import finalize helpers from the internal package. # Import finalize helpers from the internal package.
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from resource_paths import icon_search_dirs_for_project # noqa: E402 from resource_paths import icon_dir_for_project # noqa: E402
from svg_finalize.align_embed_images import ( from svg_finalize.align_embed_images import (
align_and_embed_images_in_svg, align_and_embed_images_in_svg,
count_office_vector_refs_in_svg, count_office_vector_refs_in_svg,
@@ -182,7 +182,6 @@ def _process_candidate_directory(
max_dimension: int | None, max_dimension: int | None,
image_scale: float, image_scale: float,
icons_dir: Path, icons_dir: Path,
icons_fallback_dir: Path | None,
) -> bool: ) -> bool:
"""Run every selected finalization pass against one unpublished candidate.""" """Run every selected finalization pass against one unpublished candidate."""
# Core normalization: downstream image/rect processors read XML geometry. # Core normalization: downstream image/rect processors read XML geometry.
@@ -207,7 +206,6 @@ def _process_candidate_directory(
icons_dir, icons_dir,
dry_run=False, dry_run=False,
verbose=False, verbose=False,
fallback_dir=icons_fallback_dir,
) )
icons_count += count icons_count += count
for svg_file in candidate_dir.glob('*.svg'): for svg_file in candidate_dir.glob('*.svg'):
@@ -342,7 +340,7 @@ def finalize_project(
""" """
svg_output = project_dir / 'svg_output' svg_output = project_dir / 'svg_output'
svg_final = project_dir / 'svg_final' svg_final = project_dir / 'svg_final'
icons_dir, icons_fallback_dir = icon_search_dirs_for_project(project_dir) icons_dir = icon_dir_for_project(project_dir)
# Check if svg_output exists # Check if svg_output exists
if not svg_output.exists(): if not svg_output.exists():
@@ -381,7 +379,6 @@ def finalize_project(
max_dimension=max_dimension, max_dimension=max_dimension,
image_scale=image_scale, image_scale=image_scale,
icons_dir=icons_dir, icons_dir=icons_dir,
icons_fallback_dir=icons_fallback_dir,
) )
except Exception as exc: except Exception as exc:
safe_print( safe_print(
@@ -35,23 +35,34 @@ from console_encoding import configure_utf8_stdio
configure_utf8_stdio() configure_utf8_stdio()
_LIB_ALIASES = {"chunk": "chunk-filled"}
_STYLISTIC_LIBRARIES = { _STYLISTIC_LIBRARIES = {
"chunk-filled", "chunk-filled",
"phosphor-duotone", "phosphor-duotone",
"tabler-filled", "tabler-filled",
"tabler-outline", "tabler-outline",
} }
_SYNC_LIBRARIES = _STYLISTIC_LIBRARIES | {"simple-icons"}
_GLOBAL_ICONS_DIR = Path(__file__).resolve().parent.parent / "templates" / "icons" _GLOBAL_ICONS_DIR = Path(__file__).resolve().parent.parent / "templates" / "icons"
def _split_name(icon_name: str) -> tuple[str, str]: def _split_name(icon_name: str) -> tuple[str, str]:
"""`lib/name` -> (lib, name), applying the chunk→chunk-filled alias.""" """Validate and split one complete bundled ``library/name`` id."""
if "/" not in icon_name: if icon_name.count("/") != 1:
# legacy un-prefixed names live in chunk-filled/ raise ValueError(
return "chunk-filled", icon_name f"icon id must use the complete library/name form: {icon_name!r}"
)
lib, name = icon_name.split("/", 1) lib, name = icon_name.split("/", 1)
return _LIB_ALIASES.get(lib, lib), name if lib not in _SYNC_LIBRARIES:
raise ValueError(f"unsupported bundled icon library: {lib!r}")
if (
not name
or name in {".", ".."}
or "/" in name
or "\\" in name
or Path(name).name != name
):
raise ValueError(f"invalid icon name: {name!r}")
return lib, name
def sync_icons(project_path: Path, icon_names: list[str], global_dir: Path = _GLOBAL_ICONS_DIR) -> tuple[list[str], list[str]]: def sync_icons(project_path: Path, icon_names: list[str], global_dir: Path = _GLOBAL_ICONS_DIR) -> tuple[list[str], list[str]]:
@@ -97,7 +108,11 @@ def main(argv: Optional[list[str]] = None) -> int:
print(f"[ERROR] project not found: {project}", file=sys.stderr) print(f"[ERROR] project not found: {project}", file=sys.stderr)
return 1 return 1
requested_libraries = {_split_name(raw)[0] for raw in args.icons} try:
requested_libraries = {_split_name(raw)[0] for raw in args.icons}
except ValueError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
stylistic_libraries = sorted(requested_libraries & _STYLISTIC_LIBRARIES) stylistic_libraries = sorted(requested_libraries & _STYLISTIC_LIBRARIES)
if len(stylistic_libraries) > 1: if len(stylistic_libraries) > 1:
print( print(
@@ -441,6 +441,7 @@ def _stage_and_validate_image(
min_width: int, min_width: int,
min_height: int, min_height: int,
enforce_thumbnail_floor: bool = True, enforce_thumbnail_floor: bool = True,
metadata_dimensions: Optional[tuple[int, int]] = None,
) -> tuple[Path, tuple[int, int]]: ) -> tuple[Path, tuple[int, int]]:
"""Materialize and validate beside the target without changing the canonical.""" """Materialize and validate beside the target without changing the canonical."""
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -460,8 +461,28 @@ def _stage_and_validate_image(
min_height=min_height, min_height=min_height,
enforce_thumbnail_floor=enforce_thumbnail_floor, enforce_thumbnail_floor=enforce_thumbnail_floor,
): ):
actual_dimensions = _measure_actual_image(temp_path)
if actual_dimensions is None:
measured = "measured dimensions unavailable (file unreadable)"
else:
measured = (
f"measured {actual_dimensions[0]}x{actual_dimensions[1]}"
)
details = [
measured,
f"required minimum {min_width}x{min_height}",
]
if (
metadata_dimensions is not None
and metadata_dimensions != actual_dimensions
):
details.append(
"metadata claimed "
f"{metadata_dimensions[0]}x{metadata_dimensions[1]}"
)
raise DownloadQualityError( raise DownloadQualityError(
"downloaded image did not satisfy the requested dimensions/readability" "downloaded image did not satisfy the requested "
f"dimensions/readability: {'; '.join(details)}"
) )
actual_dimensions = _measure_actual_image(temp_path) actual_dimensions = _measure_actual_image(temp_path)
if actual_dimensions is None: if actual_dimensions is None:
@@ -485,6 +506,7 @@ def _stage_validated_image(
min_width: int, min_width: int,
min_height: int, min_height: int,
enforce_thumbnail_floor: bool = True, enforce_thumbnail_floor: bool = True,
metadata_dimensions: Optional[tuple[int, int]] = None,
) -> tuple[Path, tuple[int, int]]: ) -> tuple[Path, tuple[int, int]]:
"""Download and validate beside the target without changing the canonical.""" """Download and validate beside the target without changing the canonical."""
return _stage_and_validate_image( return _stage_and_validate_image(
@@ -497,6 +519,7 @@ def _stage_validated_image(
min_width=min_width, min_width=min_width,
min_height=min_height, min_height=min_height,
enforce_thumbnail_floor=enforce_thumbnail_floor, enforce_thumbnail_floor=enforce_thumbnail_floor,
metadata_dimensions=metadata_dimensions,
) )
@@ -507,6 +530,7 @@ def _stage_validated_candidate_copy(
min_width: int, min_width: int,
min_height: int, min_height: int,
enforce_thumbnail_floor: bool = True, enforce_thumbnail_floor: bool = True,
metadata_dimensions: Optional[tuple[int, int]] = None,
) -> tuple[Path, tuple[int, int]]: ) -> tuple[Path, tuple[int, int]]:
"""Stage a saved pool candidate, preserving target-extension correctness.""" """Stage a saved pool candidate, preserving target-extension correctness."""
return _stage_and_validate_image( return _stage_and_validate_image(
@@ -518,6 +542,7 @@ def _stage_validated_candidate_copy(
min_width=min_width, min_width=min_width,
min_height=min_height, min_height=min_height,
enforce_thumbnail_floor=enforce_thumbnail_floor, enforce_thumbnail_floor=enforce_thumbnail_floor,
metadata_dimensions=metadata_dimensions,
) )
@@ -1389,6 +1414,12 @@ def promote_candidate(
) )
return 1 return 1
metadata_dimensions = (
(selected_candidate.width, selected_candidate.height)
if selected_candidate.width > 0 and selected_candidate.height > 0
else None
)
staged_path: Optional[Path] = None staged_path: Optional[Path] = None
try: try:
legacy_src_path = cand_dir / candidate_filename legacy_src_path = cand_dir / candidate_filename
@@ -1401,6 +1432,7 @@ def promote_candidate(
dst_path, dst_path,
min_width=min_width, min_width=min_width,
min_height=min_height, min_height=min_height,
metadata_dimensions=metadata_dimensions,
) )
else: else:
staged_path, actual_dimensions = _stage_validated_image( staged_path, actual_dimensions = _stage_validated_image(
@@ -1408,6 +1440,7 @@ def promote_candidate(
dst_path, dst_path,
min_width=min_width, min_width=min_width,
min_height=min_height, min_height=min_height,
metadata_dimensions=metadata_dimensions,
) )
item_args = argparse.Namespace( item_args = argparse.Namespace(
@@ -1,42 +0,0 @@
#!/usr/bin/env python3
"""
PPT Master - Native Enhance PPTX Entrypoint
Public CLI wrapper for native enhancement of existing PPTX decks. It delegates
to the shared native core for delivery checks, notes, narration, timings, and
global or per-slide transitions while keeping the stable workflow command.
Usage:
python3 scripts/native_enhance_pptx.py init <source.pptx> [--name project_name]
python3 scripts/native_enhance_pptx.py plan <project_path>
python3 scripts/native_enhance_pptx.py validate <project_path> [--materials {all,notes}]
python3 scripts/native_enhance_pptx.py apply <project_path>
Examples:
python3 scripts/native_enhance_pptx.py init projects/source.pptx --name fire_station
python3 scripts/native_enhance_pptx.py plan projects/fire_station_native_enhance_20260626
python3 scripts/native_enhance_pptx.py apply projects/fire_station_native_enhance_20260626
Dependencies:
Same as native_enhance_pptx_core.py.
"""
from __future__ import annotations
import sys
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from attribution_guard import require_skill_integrity # noqa: E402
from console_encoding import configure_utf8_stdio # noqa: E402
from native_enhance_pptx_core import main # noqa: E402
configure_utf8_stdio()
if __name__ == "__main__":
require_skill_integrity()
raise SystemExit(main())
@@ -1,37 +0,0 @@
#!/usr/bin/env python3
"""
PPT Master - Native Narration PPTX Compatibility Entrypoint
Backward-compatible CLI for callers that still use the retired narration
script name. New calls use native_enhance_pptx.py.
Usage:
python3 scripts/native_narration_pptx.py init <source.pptx> [--name project_name]
python3 scripts/native_narration_pptx.py plan <project_path>
python3 scripts/native_narration_pptx.py validate <project_path>
python3 scripts/native_narration_pptx.py apply <project_path>
Examples:
python3 scripts/native_narration_pptx.py validate projects/native_enhance_project
Dependencies:
Same as native_enhance_pptx_core.py.
"""
from __future__ import annotations
import sys
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from native_enhance_pptx_core import main # noqa: E402
configure_utf8_stdio()
if __name__ == "__main__":
raise SystemExit(main())
@@ -38,8 +38,13 @@ from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import authoring_roundtrip
from console_encoding import configure_utf8_stdio from console_encoding import configure_utf8_stdio
from config import load_prefixed_env_file from config import load_prefixed_env_file
from pptx_workspace import (
AUTHORING_SVG_FLAT_DIR,
ROUNDTRIP_MANIFEST_PATH,
)
from slide_roster import discover_slide_svgs from slide_roster import discover_slide_svgs
from tts_backends import ( from tts_backends import (
backend_cosyvoice, backend_cosyvoice,
@@ -137,7 +142,11 @@ def _prepare_audio_jobs(
def _expected_note_roster(project: Path) -> list[NoteRosterEntry]: def _expected_note_roster(project: Path) -> list[NoteRosterEntry]:
"""Resolve the owning route's complete per-slide notes roster.""" """Resolve the owning route's complete per-slide notes roster.
Round-trip workspaces follow their validated page plan or identity roster,
including source-note inheritance for copied output pages.
"""
notes_dir = project / "notes" notes_dir = project / "notes"
svg_files = discover_slide_svgs(project / "svg_output") svg_files = discover_slide_svgs(project / "svg_output")
if svg_files: if svg_files:
@@ -204,6 +213,43 @@ def _expected_note_roster(project: Path) -> list[NoteRosterEntry]:
) )
return note_roster return note_roster
roundtrip_manifest_path = project / ROUNDTRIP_MANIFEST_PATH
authoring_dir = project / AUTHORING_SVG_FLAT_DIR
if roundtrip_manifest_path.is_file() and authoring_dir.is_dir():
try:
_, _, documents, _, _ = authoring_roundtrip._load_documents(
project.resolve(),
authoring_dir.resolve(),
)
pages, _ = authoring_roundtrip._load_page_plan(
project.resolve(),
authoring_dir.resolve(),
documents,
)
except authoring_roundtrip.AuthoringRoundtripError as exc:
raise ValueError(f"invalid round-trip notes roster: {exc}") from exc
note_roster: list[NoteRosterEntry] = []
missing_stems: list[str] = []
for page in pages:
note_path = notes_dir / f"{page.svg_stem}.md"
if not note_path.is_file() and page.svg_name != page.source_svg_name:
source_stem = Path(page.source_svg_name).stem
note_path = notes_dir / f"{source_stem}.md"
if not note_path.is_file():
missing_stems.append(page.svg_stem)
continue
note_roster.append(NoteRosterEntry(
note_path=note_path,
output_stem=page.svg_stem,
))
if missing_stems:
raise ValueError(
"round-trip per-slide notes are incomplete; missing stems: "
+ ", ".join(missing_stems)
)
return note_roster
return [ return [
NoteRosterEntry( NoteRosterEntry(
note_path=path, note_path=path,
@@ -3569,9 +3569,11 @@ def validate_pptx_animation_package(
pptx_path: str | Path, pptx_path: str | Path,
*, *,
require_supported_effects: bool = False, require_supported_effects: bool = False,
skip_slide_numbers: set[int] | None = None,
) -> None: ) -> None:
"""Validate timing placement and shape references for every slide part.""" """Validate generated timing, excluding byte-preserved source slides."""
path = Path(pptx_path) path = Path(pptx_path)
skipped = skip_slide_numbers or set()
errors: list[str] = [] errors: list[str] = []
try: try:
with zipfile.ZipFile(path) as package: with zipfile.ZipFile(path) as package:
@@ -3581,6 +3583,9 @@ def validate_pptx_animation_package(
if re.fullmatch(r'ppt/slides/slide\d+\.xml', name) if re.fullmatch(r'ppt/slides/slide\d+\.xml', name)
) )
for name in names: for name in names:
match = re.search(r'slide(\d+)\.xml$', name)
if match is not None and int(match.group(1)) in skipped:
continue
slide_data = package.read(name) slide_data = package.read(name)
try: try:
root = ET.fromstring(slide_data) root = ET.fromstring(slide_data)
@@ -44,7 +44,7 @@ if str(_SCRIPTS_DIR) not in sys.path:
from console_encoding import configure_utf8_stdio # noqa: E402 from console_encoding import configure_utf8_stdio # noqa: E402
from beautify_identity import extract_identity # noqa: E402 from beautify_identity import extract_identity # noqa: E402
from template_fill_pptx.analyzer import analyze_pptx # noqa: E402 from pptx_ooxml.analyzer import analyze_pptx # noqa: E402
configure_utf8_stdio() configure_utf8_stdio()
@@ -182,16 +182,12 @@ def build_source_profile(
"standard_generation": ( "standard_generation": (
"Use identity and slide-library fields as source facts and recommendation " "Use identity and slide-library fields as source facts and recommendation "
"candidates only; do not preserve original page count, order, or coordinates " "candidates only; do not preserve original page count, order, or coordinates "
"unless the user selected the beautify profile or Fill Native PPTX route." "unless the user selected the beautify profile."
), ),
"beautify": ( "beautify": (
"Promote source text, page order, page count, colors, fonts, and font sizes " "Promote source text, page order, page count, colors, fonts, and font sizes "
"into locked constraints after user confirmation." "into locked constraints after user confirmation."
), ),
"template_fill": (
"Use slide slots, tables, charts, diagrams, and geometry as the native PPTX "
"fill contract; diagrams are inventory-only and remain unchanged."
),
}, },
"artifacts": { "artifacts": {
"identity": f"{stem}.identity.json", "identity": f"{stem}.identity.json",
@@ -0,0 +1,20 @@
"""Reusable OOXML readers and source-preserving package primitives."""
from __future__ import annotations
from .analyzer import analyze_pptx
from .chart_read import empty_chart_data, read_chart_data
from .clone import clone_presentation_slides, deep_clone_slide_private_parts
from .diagram_read import read_smartart_diagrams, smartart_to_markdown
from .package import prune_unreferenced_directory_parts
__all__ = [
"analyze_pptx",
"clone_presentation_slides",
"deep_clone_slide_private_parts",
"empty_chart_data",
"prune_unreferenced_directory_parts",
"read_chart_data",
"read_smartart_diagrams",
"smartart_to_markdown",
]
@@ -103,14 +103,14 @@ def _analyze_charts(zf: zipfile.ZipFile, slide_root: ET.Element, slide_ref: Slid
payload["plot_types"] = ["chartEx"] payload["plot_types"] = ["chartEx"]
payload["edit_capability"] = _unsupported_chart_capability( payload["edit_capability"] = _unsupported_chart_capability(
"chart_edit_chartex_unsupported", "chart_edit_chartex_unsupported",
"template-fill chart edits do not support ChartEx", "OOXML chart edits do not support ChartEx",
) )
charts.append(payload) charts.append(payload)
continue continue
if chart_kind != "classic": if chart_kind != "classic":
payload["edit_capability"] = _unsupported_chart_capability( payload["edit_capability"] = _unsupported_chart_capability(
"chart_edit_plot_type_unsupported", "chart_edit_plot_type_unsupported",
"template-fill chart edits require a classic DrawingML chart reference", "OOXML chart edits require a classic DrawingML chart reference",
) )
charts.append(payload) charts.append(payload)
continue continue
@@ -126,12 +126,12 @@ def _analyze_charts(zf: zipfile.ZipFile, slide_root: ET.Element, slide_ref: Slid
payload.update(empty_chart_data()) payload.update(empty_chart_data())
payload["edit_capability"] = _unsupported_chart_capability( payload["edit_capability"] = _unsupported_chart_capability(
"chart_edit_part_unavailable", "chart_edit_part_unavailable",
"template-fill could not read the classic chart part", "PPTX intake could not read the classic chart part",
) )
else: else:
payload["edit_capability"] = _unsupported_chart_capability( payload["edit_capability"] = _unsupported_chart_capability(
"chart_edit_relationship_unsupported", "chart_edit_relationship_unsupported",
"template-fill chart edits require a classic chart relationship", "OOXML chart edits require a classic chart relationship",
) )
charts.append(payload) charts.append(payload)
return charts return charts
@@ -205,10 +205,10 @@ def _fill_risk(
charts: list[dict[str, Any]], charts: list[dict[str, Any]],
diagrams: list[dict[str, Any]], diagrams: list[dict[str, Any]],
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Return a fill_risk descriptor when the slide has non-text content that text-fill cannot replace. """Return a fill-risk descriptor for non-text content needing explicit edits.
Tables and charts may be covered by explicit edits. SmartArt is inventory-only: Tables and charts may be covered by explicit edits. SmartArt is
template-fill preserves it unchanged, so its source text may show through. inventory-only, so source text may remain visible in preserved objects.
""" """
kinds: list[str] = [] kinds: list[str] = []
if tables: if tables:
@@ -234,7 +234,7 @@ def _fill_risk(
def analyze_pptx(pptx_path: Path) -> dict[str, Any]: def analyze_pptx(pptx_path: Path) -> dict[str, Any]:
"""Extract a slide library with text replacement slots.""" """Extract a slide library with text, geometry, and native-object facts."""
with zipfile.ZipFile(pptx_path) as zf: with zipfile.ZipFile(pptx_path) as zf:
pres_root = _read_xml(zf, "ppt/presentation.xml") pres_root = _read_xml(zf, "ppt/presentation.xml")
slide_refs = _parse_slide_refs(zf) slide_refs = _parse_slide_refs(zf)
@@ -294,37 +294,9 @@ def analyze_pptx(pptx_path: Path) -> dict[str, Any]:
slides.append(slide) slides.append(slide)
return { return {
"schema": "template_fill_pptx_library.v1", "schema": "pptx_intake_slide_library.v1",
"source_pptx": str(pptx_path), "source_pptx": str(pptx_path),
"slide_count": len(slides), "slide_count": len(slides),
"canvas_px": _canvas_px(pres_root), "canvas_px": _canvas_px(pres_root),
"slides": slides, "slides": slides,
"plan_contract": {
"schema": "template_fill_pptx_plan.v1",
"slides": [
{
"source_slide": 1,
"purpose": "封面 / 章节 / 内容 / 结尾",
"replacements": [
{
"slot_id": "s01_sh2",
"text": "替换后的文字",
}
],
"table_edits": [
{
"table_id": "s01_tbl3",
"cells": [{"row": 0, "col": 0, "text": "替换后的单元格"}],
}
],
"chart_edits": [
{
"chart_id": "s01_ch4",
"categories": ["A", "B"],
"series": [{"name": "系列1", "values": [1, 2]}],
}
],
}
],
},
} }
@@ -1,8 +1,7 @@
"""Read native PowerPoint chart display caches for slide-library analysis. """Read native PowerPoint chart display caches for PPTX intake.
The template-fill workflow edits chart data from explicit fill plans. This This module reads the data currently visible in a chart XML part while keeping
module only reads the data currently visible in a chart XML part, keeping workbook parsing out of the slide-library analyzer.
workbook parsing out of the analyzer.
""" """
from __future__ import annotations from __future__ import annotations
@@ -0,0 +1,817 @@
"""Clone a page-plan slide and its structured private dependency parts.
When the same source slide is reused for several output slides, copying its
relationships verbatim leaves every clone pointing at one shared set of private
parts custom-data tags, per-slide theme overrides, SmartArt diagrams. The
pages are not really independent: editing one output slide's structure would
bleed into its siblings.
This helper gives each cloned slide its own copy of every private dependency and
rewrites the relationship targets. Cloning is recursive, so a private part's
own sub-parts (e.g. a diagram data part's drawing) are cloned too.
Two classes of target are deliberately left shared:
* **Shared structure** slide layout / master / theme / notes master.
* **Media blobs** targets under ``ppt/media/`` remain shared. Embeddings,
model3d, customXml, tags, comments, ink, notes, charts, diagrams, and their
dependency graphs remain private even when a ``Default`` extension rule
supplies their content type.
Slide relationships are remapped separately after the final page roster is
known, so recursive private-part cloning skips only that back-reference type.
"""
from __future__ import annotations
import copy
import posixpath
import tempfile
import zipfile
from pathlib import Path
from typing import Callable
from xml.parsers import expat
from xml.etree import ElementTree as ET
from hyperlink_contract import SLIDE_JUMP_ACTION
from pptx_opc_validation import (
canonical_opc_part_path,
resolve_internal_opc_target,
verify_internal_relationships,
)
from .ooxml import (
CT_NS,
NS,
NOTES_SLIDE_CONTENT_TYPE,
REL_NS,
SLIDE_REL_TYPE,
SlideRef,
_normalize_part,
_parse_slide_refs,
_qn,
_rels_name_for_part,
_xml_bytes,
)
from .package import (
_add_content_type_override,
_add_slide_override,
_content_type_root,
_empty_relationships_root,
_max_numeric_rid,
_prune_unreferenced_parts,
_relative_target,
)
_REL_TYPE_BASE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"
# Structure shared across every slide: never cloned, target kept as-is. Note that
# ``themeOverride`` is a distinct, per-slide private type and is NOT listed here.
SHARED_REL_TYPES = frozenset(
_REL_TYPE_BASE + name
for name in ("slideLayout", "slideMaster", "notesMaster", "theme")
)
# Slide relationships are remapped against the complete page plan separately.
SKIPPED_REL_TYPES = frozenset({SLIDE_REL_TYPE})
def _make_part_allocator(entries: dict[str, bytes]) -> Callable[[str], str]:
"""Return a function that mints a fresh part name beside a source part.
Names keep the source extension (so a content-type ``Default`` still covers
media) and are unique against both existing entries and earlier allocations.
"""
used = set(entries)
def allocate(source_part: str) -> str:
directory = posixpath.dirname(source_part)
stem, ext = posixpath.splitext(posixpath.basename(source_part))
index = 1
while True:
candidate = posixpath.join(directory, f"{stem}_tf{index}{ext}")
if candidate not in used:
used.add(candidate)
return candidate
index += 1
return allocate
def _override_content_type(content_root: ET.Element, part: str) -> str | None:
"""Return the part's explicit content-type ``Override``, or ``None``."""
part_pn = "/" + part.lstrip("/")
for override in content_root.findall(_qn(CT_NS, "Override")):
if override.attrib.get("PartName") == part_pn:
return override.attrib.get("ContentType")
return None
def _part_content_type(content_root: ET.Element, part: str) -> str | None:
override = _override_content_type(content_root, part)
if override is not None:
return override
extension = posixpath.splitext(part)[1].lstrip(".").lower()
if not extension:
return None
for default in content_root.findall(_qn(CT_NS, "Default")):
if (default.attrib.get("Extension") or "").lower() == extension:
return default.attrib.get("ContentType")
return None
def _part_is_xml(content_root: ET.Element, part: str) -> bool:
content_type = (_part_content_type(content_root, part) or "").lower()
return (
content_type.endswith("+xml")
or content_type in {"application/xml", "text/xml"}
or posixpath.splitext(part)[1].lower() in {".xml", ".vml"}
)
def _is_shared(rel_type: str | None) -> bool:
return bool(rel_type) and rel_type in SHARED_REL_TYPES
def _clone_part_private_deps(
rels_root: ET.Element,
*,
owner_part: str,
entries: dict[str, bytes],
content_root: ET.Element,
allocate: Callable[[str], str],
cloned: dict[str, str],
skipped_rel_types: frozenset[str],
) -> None:
"""Rewrite ``rels_root`` in place, cloning each private target it references.
``cloned`` maps an already-handled source part to its clone so a single slide
that references the same asset twice reuses one copy.
"""
for rel in rels_root.findall(_qn(REL_NS, "Relationship")):
if (rel.attrib.get("TargetMode") or "").strip().lower() == "external":
continue
rel_type = rel.attrib.get("Type")
target = rel.attrib.get("Target")
if not target:
raise RuntimeError(
f"{owner_part} relationship {rel.attrib.get('Id')!r} has no Target"
)
source_part = _normalize_part(target, owner_part)
if source_part not in entries:
raise RuntimeError(
f"{owner_part} relationship {rel.attrib.get('Id')!r} targets "
f"missing part {source_part}"
)
if _is_shared(rel_type) or rel_type in skipped_rel_types:
continue
if source_part.startswith("ppt/media/"):
continue
content_type = _override_content_type(content_root, source_part)
new_part = cloned.get(source_part)
if new_part is None:
new_part = allocate(source_part)
entries[new_part] = entries[source_part]
cloned[source_part] = new_part
if content_type is not None:
_add_content_type_override(content_root, new_part, content_type)
sub_rels_name = _rels_name_for_part(source_part)
sub_rels_data = entries.get(sub_rels_name)
if sub_rels_data is not None:
try:
sub_rels_root = ET.fromstring(sub_rels_data)
except ET.ParseError as exc:
raise RuntimeError(
f"Cannot parse relationships part {sub_rels_name}: {exc}"
) from exc
_clone_part_private_deps(
sub_rels_root,
owner_part=new_part,
entries=entries,
content_root=content_root,
allocate=allocate,
cloned=cloned,
skipped_rel_types=skipped_rel_types,
)
entries[_rels_name_for_part(new_part)] = _xml_bytes(sub_rels_root)
rel.set("Target", _relative_target(owner_part, new_part))
def deep_clone_slide_private_parts(
slide_rels_root: ET.Element,
*,
new_slide_part: str,
entries: dict[str, bytes],
content_root: ET.Element,
allocate: Callable[[str], str],
skipped_rel_types: frozenset[str] = SKIPPED_REL_TYPES,
) -> dict[str, str]:
"""Give one cloned slide private copies of its private dependency parts.
Mutates ``slide_rels_root`` (rewriting targets) and ``entries`` (adding the
cloned parts and their content-type overrides). ``allocate`` is shared across
every slide in the run so minted names never collide. Returns the complete
source-part to clone-part map for this output slide.
"""
cloned: dict[str, str] = {}
_clone_part_private_deps(
slide_rels_root,
owner_part=new_slide_part,
entries=entries,
content_root=content_root,
allocate=allocate,
cloned=cloned,
skipped_rel_types=skipped_rel_types,
)
return cloned
_SLIDE_LAYOUT_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
)
_SLIDE_MASTER_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"
)
def _slide_jump_relationship_ids(
part_root: ET.Element,
*,
owner_label: str,
) -> set[str]:
"""Return relationship ids used by same-deck click or mouse-over actions."""
relationship_attr = _qn(NS["r"], "id")
relationship_ids: set[str] = set()
for tag in ("hlinkClick", "hlinkMouseOver"):
for link in part_root.iter(_qn(NS["a"], tag)):
if (link.attrib.get("action") or "").strip() != SLIDE_JUMP_ACTION:
continue
relationship_id = (
link.attrib.get(relationship_attr) or ""
).strip()
if not relationship_id:
raise RuntimeError(
f"{owner_label} has a {tag} slide jump without a "
"relationship id"
)
relationship_ids.add(relationship_id)
return relationship_ids
def _remap_slide_jump_relationships(
part_root: ET.Element,
relationships_root: ET.Element,
*,
source_owner_part: str,
output_owner_part: str,
owner_label: str,
outputs_by_source_part: dict[str, list[str]],
source_ref_by_part: dict[str, SlideRef],
self_source_part: str | None = None,
self_output_part: str | None = None,
fail_on_non_jump_slide_relationships: bool = False,
allow_self_non_jump_retarget: bool = False,
) -> bool:
"""Point referenced slide-jump relationships at final output slides."""
relationship_ids = _slide_jump_relationship_ids(
part_root,
owner_label=owner_label,
)
relationships = {
rel.attrib.get("Id", ""): rel
for rel in relationships_root.findall(_qn(REL_NS, "Relationship"))
}
changed = False
for relationship_id in sorted(relationship_ids):
rel = relationships.get(relationship_id)
if rel is None:
raise RuntimeError(
f"{owner_label} has a slide jump with missing relationship "
f"{relationship_id!r}"
)
if rel.attrib.get("Type") != SLIDE_REL_TYPE:
raise RuntimeError(
f"{owner_label} slide jump {relationship_id!r} does not use "
"a slide relationship"
)
if (rel.attrib.get("TargetMode") or "").strip().lower() == "external":
raise RuntimeError(
f"{owner_label} has an external slide relationship"
)
target = rel.attrib.get("Target")
if not target:
raise RuntimeError(
f"{owner_label} has a slide relationship without a target"
)
source_target_part = _normalize_part(target, source_owner_part)
if (
self_source_part is not None
and self_output_part is not None
and source_target_part == self_source_part
):
output_target_part = self_output_part
else:
output_targets = outputs_by_source_part.get(source_target_part, [])
source_target_ref = source_ref_by_part.get(source_target_part)
target_label = (
f"source slide {source_target_ref.index}"
if source_target_ref is not None
else source_target_part
)
if not output_targets:
raise RuntimeError(
f"{owner_label} links to omitted {target_label}; "
"include the target exactly once or remove the link"
)
if len(output_targets) > 1:
raise RuntimeError(
f"{owner_label} links to repeated {target_label}; "
"the output target is ambiguous"
)
output_target_part = output_targets[0]
output_target = _relative_target(output_owner_part, output_target_part)
if rel.attrib.get("Target") != output_target:
rel.set("Target", output_target)
changed = True
if fail_on_non_jump_slide_relationships:
for relationship_id, rel in sorted(relationships.items()):
if (
relationship_id in relationship_ids
or rel.attrib.get("Type") != SLIDE_REL_TYPE
):
continue
if (rel.attrib.get("TargetMode") or "").strip().lower() == "external":
raise RuntimeError(
f"{owner_label} has external non-jump slide relationship "
f"{relationship_id!r}; page-plan export refuses slide "
"relationships without a slide-jump action"
)
target = rel.attrib.get("Target")
if not target:
raise RuntimeError(
f"{owner_label} has non-jump slide relationship "
f"{relationship_id!r} without a target"
)
source_target_part = _normalize_part(target, source_owner_part)
source_target_ref = source_ref_by_part.get(source_target_part)
target_label = (
f"source slide {source_target_ref.index}"
if source_target_ref is not None
else source_target_part
)
is_self_target = (
self_source_part is not None
and self_output_part is not None
and source_target_part == self_source_part
)
if is_self_target:
output_targets = [self_output_part]
else:
output_targets = outputs_by_source_part.get(
source_target_part,
[],
)
if not output_targets:
raise RuntimeError(
f"{owner_label} has non-jump slide relationship "
f"{relationship_id!r} to omitted {target_label}; "
"page-plan export refuses slide relationships without a "
"slide-jump action"
)
if len(output_targets) > 1:
raise RuntimeError(
f"{owner_label} has non-jump slide relationship "
f"{relationship_id!r} to repeated {target_label}; "
"the output target is ambiguous"
)
output_target = _relative_target(
output_owner_part,
output_targets[0],
)
if rel.attrib.get("Target") != output_target:
if is_self_target and allow_self_non_jump_retarget:
rel.set("Target", output_target)
changed = True
continue
raise RuntimeError(
f"{owner_label} has non-jump slide relationship "
f"{relationship_id!r} to {target_label} that would require "
"retargeting after page planning; page-plan export refuses "
"slide relationships without a slide-jump action"
)
return changed
def _remap_reachable_shared_layer_slide_jumps(
entries: dict[str, bytes],
output_slides: list[tuple[str, str]],
*,
outputs_by_source_part: dict[str, list[str]],
source_ref_by_part: dict[str, SlideRef],
fail_on_non_jump_slide_relationships: bool = False,
) -> None:
"""Remap links inherited from layouts and masters used by output slides."""
pending: list[str] = []
for part_name, rels_name in output_slides:
rels_data = entries.get(rels_name)
if not rels_data:
continue
rels_root = ET.fromstring(rels_data)
for rel in rels_root.findall(_qn(REL_NS, "Relationship")):
if rel.attrib.get("Type") != _SLIDE_LAYOUT_REL_TYPE:
continue
target = rel.attrib.get("Target")
if target:
pending.append(_normalize_part(target, part_name))
visited: set[str] = set()
while pending:
part_name = pending.pop()
if part_name in visited:
continue
visited.add(part_name)
part_data = entries.get(part_name)
rels_name = _rels_name_for_part(part_name)
rels_data = entries.get(rels_name)
if not part_data or not rels_data:
continue
part_root = ET.fromstring(part_data)
rels_root = ET.fromstring(rels_data)
changed = _remap_slide_jump_relationships(
part_root,
rels_root,
source_owner_part=part_name,
output_owner_part=part_name,
owner_label=part_name,
outputs_by_source_part=outputs_by_source_part,
source_ref_by_part=source_ref_by_part,
fail_on_non_jump_slide_relationships=(
fail_on_non_jump_slide_relationships
),
)
if changed:
entries[rels_name] = _xml_bytes(rels_root)
for rel in rels_root.findall(_qn(REL_NS, "Relationship")):
if rel.attrib.get("Type") != _SLIDE_MASTER_REL_TYPE:
continue
target = rel.attrib.get("Target")
if target:
pending.append(_normalize_part(target, part_name))
def _remap_cloned_xml_part_slide_jumps(
entries: dict[str, bytes],
cloned_parts: dict[str, str],
content_root: ET.Element,
*,
output_part: str,
outputs_by_source_part: dict[str, list[str]],
source_ref_by_part: dict[str, SlideRef],
source_slide_part: str,
) -> None:
"""Validate and remap slide links in every cloned XML dependency."""
for source_part, cloned_part in sorted(cloned_parts.items()):
if not _part_is_xml(content_root, cloned_part):
continue
try:
part_root = ET.fromstring(entries[cloned_part])
except ET.ParseError as exc:
raise RuntimeError(
f"Cannot parse cloned XML part {cloned_part}: {exc}"
) from exc
rels_name = _rels_name_for_part(cloned_part)
rels_data = entries.get(rels_name)
if rels_data is None:
rels_root = _empty_relationships_root()
else:
try:
rels_root = ET.fromstring(rels_data)
except ET.ParseError as exc:
raise RuntimeError(
f"Cannot parse relationships part {rels_name}: {exc}"
) from exc
changed = _remap_slide_jump_relationships(
part_root,
rels_root,
source_owner_part=source_part,
output_owner_part=cloned_part,
owner_label=f"Cloned part {cloned_part}",
outputs_by_source_part=outputs_by_source_part,
source_ref_by_part=source_ref_by_part,
self_source_part=source_slide_part,
self_output_part=output_part,
fail_on_non_jump_slide_relationships=True,
allow_self_non_jump_retarget=(
_part_content_type(content_root, cloned_part)
== NOTES_SLIDE_CONTENT_TYPE
),
)
if changed:
if rels_data is None:
raise RuntimeError(
f"Cloned part {cloned_part} changed a missing relationship part"
)
entries[rels_name] = _xml_bytes(rels_root)
def _validate_internal_relationship_targets(entries: dict[str, bytes]) -> None:
"""Fail before cloning when any internal relationship target is absent."""
canonical_parts = {
canonical
for part_name in entries
if (canonical := canonical_opc_part_path(part_name)) is not None
}
for rels_name in sorted(
name for name in entries if name.endswith(".rels")
):
try:
rels_root = ET.fromstring(entries[rels_name])
except ET.ParseError as exc:
raise RuntimeError(
f"Cannot parse relationships part {rels_name}: {exc}"
) from exc
for rel in rels_root.findall(_qn(REL_NS, "Relationship")):
if (rel.attrib.get("TargetMode") or "").strip().lower() == "external":
continue
target = (rel.attrib.get("Target") or "").strip()
if not target:
raise RuntimeError(
f"{rels_name} relationship {rel.attrib.get('Id')!r} "
"has no Target"
)
resolved = resolve_internal_opc_target(rels_name, target)
if resolved is None:
raise RuntimeError(
f"{rels_name} relationship {rel.attrib.get('Id')!r} has "
f"invalid Target {target!r}"
)
if resolved not in canonical_parts:
raise RuntimeError(
f"{rels_name} relationship {rel.attrib.get('Id')!r} targets "
f"missing part {resolved}"
)
def _remove_stale_slide_order_metadata(presentation_root: ET.Element) -> None:
"""Drop source-only custom-show and section rosters after page planning."""
custom_shows = presentation_root.find("p:custShowLst", NS)
if custom_shows is not None:
presentation_root.remove(custom_shows)
for extension_list in presentation_root.findall(".//p:extLst", NS):
for extension in list(extension_list):
if any(
isinstance(child.tag, str)
and child.tag.rsplit("}", 1)[-1] == "sectionLst"
for child in extension.iter()
):
extension_list.remove(extension)
def _update_app_slide_count(entries: dict[str, bytes], slide_count: int) -> None:
app_part = "docProps/app.xml"
payload = entries.get(app_part)
if payload is None:
return
declaration: dict[str, object] = {
"seen": False,
"encoding": None,
}
def xml_decl(
_version: str,
encoding: str | None,
_standalone: int,
) -> None:
declaration["seen"] = True
declaration["encoding"] = encoding
declaration_parser = expat.ParserCreate()
declaration_parser.XmlDeclHandler = xml_decl
try:
declaration_parser.Parse(payload, True)
root = ET.fromstring(payload)
except (expat.ExpatError, ET.ParseError) as exc:
raise RuntimeError(f"Cannot parse {app_part}: {exc}") from exc
slides = next(
(
element
for element in root.iter()
if isinstance(element.tag, str)
and element.tag.rsplit("}", 1)[-1] == "Slides"
),
None,
)
if slides is None:
return
slides.text = str(slide_count)
encoding = declaration["encoding"]
entries[app_part] = ET.tostring(
root,
encoding=str(encoding or "utf-8"),
xml_declaration=bool(declaration["seen"]),
)
def _verify_entries_before_zip(
entries: dict[str, bytes],
output_path: Path,
) -> None:
"""Run the shared OPC verifier before publishing the cloned ZIP."""
with tempfile.TemporaryDirectory(
prefix=".pptx-clone-verify-",
dir=output_path.parent,
) as temporary:
extract_dir = Path(temporary)
for part_name, payload in entries.items():
part_path = (extract_dir / part_name).resolve()
try:
part_path.relative_to(extract_dir.resolve())
except ValueError as exc:
raise RuntimeError(
f"PPTX package part escapes the archive root: {part_name!r}"
) from exc
part_path.parent.mkdir(parents=True, exist_ok=True)
part_path.write_bytes(payload)
problems = verify_internal_relationships(extract_dir)
if problems:
preview = "; ".join(problems[:8])
suffix = "" if len(problems) <= 8 else f"; +{len(problems) - 8} more"
raise RuntimeError(
f"Cloned PPTX package has invalid relationships: {preview}{suffix}"
)
def clone_presentation_slides(
source_pptx: Path,
source_slides: tuple[int, ...],
output_path: Path,
*,
package_overrides: dict[str, bytes] | None = None,
) -> None:
"""Clone an ordered source-slide roster into canonical output slide parts.
Structured private dependencies, including notes, charts, diagrams, and
embeddings, are cloned per output page. Shared layout/master/theme parts and
ordinary media remain shared. Same-deck hyperlinks use the page-plan
omitted/ambiguous destination contract.
"""
if not source_slides:
raise RuntimeError("Round-trip page plan must contain at least one slide")
with zipfile.ZipFile(source_pptx) as archive:
entries = {
info.filename: archive.read(info.filename)
for info in archive.infolist()
if not info.is_dir()
}
slide_refs = {
slide.index: slide
for slide in _parse_slide_refs(archive)
}
for part_name, payload in (package_overrides or {}).items():
if part_name not in entries:
raise RuntimeError(
f"Round-trip resource override names a missing source part: {part_name}"
)
entries[part_name] = payload
_validate_internal_relationship_targets(entries)
missing = sorted(set(source_slides) - set(slide_refs))
if missing:
raise RuntimeError(
"Round-trip page plan references missing source slide(s): "
+ ", ".join(str(index) for index in missing)
)
presentation_root = ET.fromstring(entries["ppt/presentation.xml"])
presentation_rels_root = ET.fromstring(
entries["ppt/_rels/presentation.xml.rels"]
)
content_root = _content_type_root(
ET.fromstring(entries["[Content_Types].xml"])
)
slide_list = presentation_root.find("p:sldIdLst", NS)
if slide_list is None:
raise RuntimeError("Source presentation.xml has no p:sldIdLst")
source_slide_ids = {
entry.attrib.get(_qn(NS["r"], "id"), ""): copy.deepcopy(entry)
for entry in slide_list.findall("p:sldId", NS)
}
for child in list(slide_list):
slide_list.remove(child)
for rel in list(
presentation_rels_root.findall(_qn(REL_NS, "Relationship"))
):
if rel.attrib.get("Type") == SLIDE_REL_TYPE:
presentation_rels_root.remove(rel)
_remove_stale_slide_order_metadata(presentation_root)
next_rid = _max_numeric_rid(presentation_rels_root) + 1
allocate = _make_part_allocator(entries)
source_ref_by_part = {
reference.part_name: reference
for reference in slide_refs.values()
}
output_slides: list[tuple[str, str]] = []
outputs_by_source_part: dict[str, list[str]] = {}
for output_index, source_index in enumerate(source_slides, start=1):
source_ref = slide_refs[source_index]
output_part = f"ppt/slides/slide{output_index}.xml"
output_rels = _rels_name_for_part(output_part)
output_slides.append((output_part, output_rels))
outputs_by_source_part.setdefault(source_ref.part_name, []).append(
output_part
)
source_entries = dict(entries)
for output_index, source_index in enumerate(source_slides, start=1):
source_ref = slide_refs[source_index]
output_part, output_rels = output_slides[output_index - 1]
source_slide_xml = source_entries[source_ref.part_name]
source_rels_xml = source_entries.get(source_ref.rels_name)
slide_root = ET.fromstring(source_slide_xml)
relationships_root = (
ET.fromstring(source_rels_xml)
if source_rels_xml is not None
else _empty_relationships_root()
)
_remap_slide_jump_relationships(
slide_root,
relationships_root,
source_owner_part=source_ref.part_name,
output_owner_part=output_part,
owner_label=f"Source slide {source_index}",
outputs_by_source_part=outputs_by_source_part,
source_ref_by_part=source_ref_by_part,
self_source_part=source_ref.part_name,
self_output_part=output_part,
fail_on_non_jump_slide_relationships=True,
)
cloned_parts = deep_clone_slide_private_parts(
relationships_root,
new_slide_part=output_part,
entries=entries,
content_root=content_root,
allocate=allocate,
skipped_rel_types=frozenset({SLIDE_REL_TYPE}),
)
_remap_cloned_xml_part_slide_jumps(
entries,
cloned_parts,
content_root,
output_part=output_part,
outputs_by_source_part=outputs_by_source_part,
source_ref_by_part=source_ref_by_part,
source_slide_part=source_ref.part_name,
)
entries[output_part] = source_slide_xml
entries[output_rels] = _xml_bytes(relationships_root)
_add_slide_override(content_root, output_part)
relationship_id = f"rId{next_rid + output_index - 1}"
ET.SubElement(
presentation_rels_root,
_qn(REL_NS, "Relationship"),
{
"Id": relationship_id,
"Type": SLIDE_REL_TYPE,
"Target": f"slides/slide{output_index}.xml",
},
)
source_slide_id_template = source_slide_ids.get(source_ref.rel_id)
if source_slide_id_template is None:
raise RuntimeError(
f"Source slide {source_index} has no p:sldId entry"
)
source_slide_id = copy.deepcopy(source_slide_id_template)
source_slide_id.set("id", str(255 + output_index))
source_slide_id.set(_qn(NS["r"], "id"), relationship_id)
slide_list.append(source_slide_id)
_remap_reachable_shared_layer_slide_jumps(
entries,
output_slides,
outputs_by_source_part=outputs_by_source_part,
source_ref_by_part=source_ref_by_part,
fail_on_non_jump_slide_relationships=True,
)
entries["ppt/presentation.xml"] = _xml_bytes(presentation_root)
entries["ppt/_rels/presentation.xml.rels"] = _xml_bytes(
presentation_rels_root
)
_prune_unreferenced_parts(entries, content_root)
entries["[Content_Types].xml"] = _xml_bytes(content_root)
_update_app_slide_count(entries, len(source_slides))
output_path.parent.mkdir(parents=True, exist_ok=True)
_verify_entries_before_zip(entries, output_path)
with zipfile.ZipFile(
output_path,
"w",
compression=zipfile.ZIP_DEFLATED,
) as output:
for part_name, payload in entries.items():
output.writestr(part_name, payload)
@@ -1,4 +1,4 @@
"""Classify template-fill chart and table edits before package mutation.""" """Classify supported chart and table edits before package mutation."""
from __future__ import annotations from __future__ import annotations
@@ -78,19 +78,19 @@ def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]:
if "chartex" in root_namespace.lower(): if "chartex" in root_namespace.lower():
return _unsupported_chart_capability( return _unsupported_chart_capability(
"chart_edit_chartex_unsupported", "chart_edit_chartex_unsupported",
"template-fill chart edits do not support ChartEx", "OOXML chart edits do not support ChartEx",
) )
if root_namespace != NS["c"]: if root_namespace != NS["c"]:
return _unsupported_chart_capability( return _unsupported_chart_capability(
"chart_edit_plot_type_unsupported", "chart_edit_plot_type_unsupported",
"template-fill chart edits require a classic DrawingML chart part", "OOXML chart edits require a classic DrawingML chart part",
) )
plot_area = chart_root.find(".//c:plotArea", NS) plot_area = chart_root.find(".//c:plotArea", NS)
if plot_area is None: if plot_area is None:
return _unsupported_chart_capability( return _unsupported_chart_capability(
"chart_edit_plot_type_unsupported", "chart_edit_plot_type_unsupported",
"template-fill chart edits require a classic chart plotArea", "OOXML chart edits require a classic chart plotArea",
) )
plot_nodes = [ plot_nodes = [
@@ -101,13 +101,13 @@ def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]:
if len(plot_nodes) > 1: if len(plot_nodes) > 1:
return _unsupported_chart_capability( return _unsupported_chart_capability(
"chart_edit_multi_plot_unsupported", "chart_edit_multi_plot_unsupported",
"template-fill chart edits do not support multi-plot or combination charts", "OOXML chart edits do not support multi-plot or combination charts",
plot_count=len(plot_nodes), plot_count=len(plot_nodes),
) )
if not plot_nodes: if not plot_nodes:
return _unsupported_chart_capability( return _unsupported_chart_capability(
"chart_edit_plot_type_unsupported", "chart_edit_plot_type_unsupported",
"template-fill chart edits require exactly one recognized chart plot", "OOXML chart edits require exactly one recognized chart plot",
) )
plot = plot_nodes[0] plot = plot_nodes[0]
@@ -115,7 +115,7 @@ def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]:
if plot_type == "scatterChart": if plot_type == "scatterChart":
return _unsupported_chart_capability( return _unsupported_chart_capability(
"chart_edit_scatter_unsupported", "chart_edit_scatter_unsupported",
"template-fill chart edits do not support scatter xVal/yVal data", "OOXML chart edits do not support scatter xVal/yVal data",
plot_type=plot_type, plot_type=plot_type,
plot_count=1, plot_count=1,
data_model="xy", data_model="xy",
@@ -123,7 +123,7 @@ def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]:
if plot_type == "bubbleChart": if plot_type == "bubbleChart":
return _unsupported_chart_capability( return _unsupported_chart_capability(
"chart_edit_bubble_unsupported", "chart_edit_bubble_unsupported",
"template-fill chart edits do not support bubble xVal/yVal/bubbleSize data", "OOXML chart edits do not support bubble xVal/yVal/bubbleSize data",
plot_type=plot_type, plot_type=plot_type,
plot_count=1, plot_count=1,
data_model="bubble", data_model="bubble",
@@ -132,7 +132,7 @@ def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]:
if not series_nodes: if not series_nodes:
return _unsupported_chart_capability( return _unsupported_chart_capability(
"chart_edit_no_series", "chart_edit_no_series",
"template-fill chart edits require at least one editable series", "OOXML chart edits require at least one editable series",
plot_type=plot_type, plot_type=plot_type,
plot_count=1, plot_count=1,
data_model="category", data_model="category",
@@ -144,7 +144,7 @@ def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]:
): ):
return _unsupported_chart_capability( return _unsupported_chart_capability(
"chart_edit_data_model_unsupported", "chart_edit_data_model_unsupported",
"template-fill chart edits require c:cat/c:val series", "OOXML chart edits require c:cat/c:val series",
plot_type=plot_type, plot_type=plot_type,
plot_count=1, plot_count=1,
) )
@@ -153,7 +153,7 @@ def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]:
if category is None or values is None: if category is None or values is None:
return _unsupported_chart_capability( return _unsupported_chart_capability(
"chart_edit_data_model_unsupported", "chart_edit_data_model_unsupported",
"template-fill chart edits require c:cat/c:val on every series", "OOXML chart edits require c:cat/c:val on every series",
plot_type=plot_type, plot_type=plot_type,
plot_count=1, plot_count=1,
) )
@@ -163,7 +163,7 @@ def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]:
{ {
"code": "chart_edit_date_axis_flattened", "code": "chart_edit_date_axis_flattened",
"message": ( "message": (
"template-fill will flatten date-axis categories to the " "OOXML chart editing will flatten date-axis categories to the "
"replacement single-level category cache" "replacement single-level category cache"
), ),
} }
@@ -176,7 +176,7 @@ def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]:
{ {
"code": "chart_edit_multilevel_categories_flattened", "code": "chart_edit_multilevel_categories_flattened",
"message": ( "message": (
"template-fill will flatten multi-level categories to the " "OOXML chart editing will flatten multi-level categories to the "
"replacement single-level category cache" "replacement single-level category cache"
), ),
} }
@@ -1,4 +1,4 @@
"""Shared OOXML primitives for the template-fill pipeline. """Shared OOXML primitives for PPTX intake and source-preserving export.
Read-side helpers only: namespaces and content-type constants, part / Read-side helpers only: namespaces and content-type constants, part /
relationship resolution, EMU unit conversion, slide-shape discovery, and small relationship resolution, EMU unit conversion, slide-shape discovery, and small
@@ -108,10 +108,18 @@ def _emu_to_px(value: str | None) -> int | None:
def _parse_relationships(zf: zipfile.ZipFile) -> dict[str, dict[str, str]]: def _parse_relationships(zf: zipfile.ZipFile) -> dict[str, dict[str, str]]:
rels_root = _read_xml(zf, "ppt/_rels/presentation.xml.rels") rels_name = "ppt/_rels/presentation.xml.rels"
rels_root = _read_xml(zf, rels_name)
relationships: dict[str, dict[str, str]] = {} relationships: dict[str, dict[str, str]] = {}
seen_ids: set[str] = set()
for rel in rels_root.findall(_qn(REL_NS, "Relationship")): for rel in rels_root.findall(_qn(REL_NS, "Relationship")):
rel_id = rel.attrib.get("Id") rel_id = rel.attrib.get("Id")
if rel_id:
if rel_id in seen_ids:
raise RuntimeError(
f"Duplicate relationship Id {rel_id!r} in {rels_name}"
)
seen_ids.add(rel_id)
target = rel.attrib.get("Target") target = rel.attrib.get("Target")
rel_type = rel.attrib.get("Type") rel_type = rel.attrib.get("Type")
if rel_id and target and rel_type: if rel_id and target and rel_type:
@@ -1,4 +1,4 @@
"""Write-side OOXML package plumbing for the apply stage. """Write-side OOXML package plumbing for source-preserving slide cloning.
Content-type override insertion, relationship-element construction / lookup, and Content-type override insertion, relationship-element construction / lookup, and
part-number allocation used when cloning slides into a new package. part-number allocation used when cloning slides into a new package.
@@ -8,17 +8,17 @@ from __future__ import annotations
import posixpath import posixpath
import re import re
from pathlib import Path
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from .ooxml import ( from .ooxml import (
CT_NS, CT_NS,
NOTES_SLIDE_CONTENT_TYPE,
NS,
REL_NS, REL_NS,
SLIDE_CONTENT_TYPE, SLIDE_CONTENT_TYPE,
_normalize_part, _normalize_part,
_qn, _qn,
_rels_name_for_part, _rels_name_for_part,
_xml_bytes,
) )
@@ -44,35 +44,14 @@ def _add_slide_override(content_root: ET.Element, part_name: str) -> None:
_add_content_type_override(content_root, part_name, SLIDE_CONTENT_TYPE) _add_content_type_override(content_root, part_name, SLIDE_CONTENT_TYPE)
def _add_notes_override(content_root: ET.Element, part_name: str) -> None:
_add_content_type_override(content_root, part_name, NOTES_SLIDE_CONTENT_TYPE)
def _empty_relationships_root() -> ET.Element: def _empty_relationships_root() -> ET.Element:
return ET.Element(_qn(REL_NS, "Relationships")) return ET.Element(_qn(REL_NS, "Relationships"))
def _find_relationship(root: ET.Element, rel_id: str) -> ET.Element | None:
for rel in root.findall(_qn(REL_NS, "Relationship")):
if rel.attrib.get("Id") == rel_id:
return rel
return None
def _relative_target(from_part: str, to_part: str) -> str: def _relative_target(from_part: str, to_part: str) -> str:
return posixpath.relpath(to_part, posixpath.dirname(from_part)) return posixpath.relpath(to_part, posixpath.dirname(from_part))
def _max_slide_part_number(entries: dict[str, bytes]) -> int:
max_number = 0
pattern = re.compile(r"^ppt/slides/slide(\d+)\.xml$")
for name in entries:
match = pattern.match(name)
if match:
max_number = max(max_number, int(match.group(1)))
return max_number
def _max_numeric_rid(root: ET.Element) -> int: def _max_numeric_rid(root: ET.Element) -> int:
max_id = 0 max_id = 0
for rel in root.findall(_qn(REL_NS, "Relationship")): for rel in root.findall(_qn(REL_NS, "Relationship")):
@@ -83,16 +62,6 @@ def _max_numeric_rid(root: ET.Element) -> int:
return max_id return max_id
def _max_slide_id(sld_id_lst: ET.Element) -> int:
max_id = 255
for sld_id in sld_id_lst.findall("p:sldId", NS):
try:
max_id = max(max_id, int(sld_id.attrib.get("id", "0")))
except ValueError:
continue
return max_id
def _enqueue_rel_targets( def _enqueue_rel_targets(
entries: dict[str, bytes], entries: dict[str, bytes],
rels_part: str, rels_part: str,
@@ -100,14 +69,16 @@ def _enqueue_rel_targets(
queue: list[str], queue: list[str],
) -> None: ) -> None:
data = entries.get(rels_part) data = entries.get(rels_part)
if not data: if data is None:
return return
try: try:
root = ET.fromstring(data) root = ET.fromstring(data)
except ET.ParseError: except ET.ParseError as exc:
return raise RuntimeError(
f"Cannot parse relationships part {rels_part}: {exc}"
) from exc
for rel in root.findall(_qn(REL_NS, "Relationship")): for rel in root.findall(_qn(REL_NS, "Relationship")):
if rel.attrib.get("TargetMode") == "External": if (rel.attrib.get("TargetMode") or "").strip().lower() == "external":
continue continue
target = rel.attrib.get("Target") target = rel.attrib.get("Target")
if target: if target:
@@ -153,3 +124,25 @@ def _prune_unreferenced_parts(entries: dict[str, bytes], content_root: ET.Elemen
part_name = (override.attrib.get("PartName") or "").lstrip("/") part_name = (override.attrib.get("PartName") or "").lstrip("/")
if part_name and part_name not in reachable: if part_name and part_name not in reachable:
content_root.remove(override) content_root.remove(override)
def prune_unreferenced_directory_parts(package_root: Path) -> int:
"""Prune unreachable parts from one extracted OOXML package directory."""
entries = {
path.relative_to(package_root).as_posix(): path.read_bytes()
for path in package_root.rglob("*")
if path.is_file()
}
content_types = entries.get("[Content_Types].xml")
if content_types is None:
raise RuntimeError("Extracted PPTX package has no [Content_Types].xml")
content_root = _content_type_root(ET.fromstring(content_types))
before = set(entries)
_prune_unreferenced_parts(entries, content_root)
removed = before - set(entries)
for part_name in sorted(removed):
target = package_root.joinpath(*part_name.split("/"))
if target.is_file():
target.unlink()
(package_root / "[Content_Types].xml").write_bytes(_xml_bytes(content_root))
return len(removed)
@@ -3,12 +3,13 @@
Reads OOXML directly via `pptx_to_svg` and writes a reusable reference workspace: Reads OOXML directly via `pptx_to_svg` and writes a reusable reference workspace:
- `manifest.json` single source of truth for slide size, theme colors, fonts, - `analysis/manifest.json` source of truth for slide size, theme, resources,
asset inventory, and per-slide / per-layout / per-master metadata image inventory, and per-slide / per-layout / per-master metadata
- `native_structure.json` + `source_template.pptx` source-structure facts and - `analysis/native_structure.json` + `sources/source.pptx` source-structure
a byte-identical analysis copy used to rebuild explicit SVG structure facts and a byte-identical backing package
- `assets/` extracted reusable image assets - `images/`, `audio/`, `sounds/`, `video/`, and `native-payloads/` semantic
- `conversion-report.json` source-recovery and fidelity diagnostics emitted source resources, created only when populated
- `validation/conversion-report.json` source-recovery diagnostics emitted
with SVG conversion with SVG conversion
- `svg/` canonical layered template view (every master - `svg/` canonical layered template view (every master
and layout in the deck rendered once each as `master_*.svg` / and layout in the deck rendered once each as `master_*.svg` /
@@ -30,6 +31,14 @@ from xml.etree import ElementTree as ET
from zipfile import BadZipFile from zipfile import BadZipFile
from console_encoding import configure_utf8_stdio from console_encoding import configure_utf8_stdio
from pptx_workspace import (
AUTHORING_SVG_DIR,
AUTHORING_SVG_FLAT_DIR,
CONVERSION_REPORT_PATH,
TEMPLATE_MANIFEST_PATH,
reject_removed_workspace_layout,
template_manifest_path,
)
from template_import.manifest import build_manifest from template_import.manifest import build_manifest
from template_import.native_structure import ( from template_import.native_structure import (
CONTRACT_NAME, CONTRACT_NAME,
@@ -39,8 +48,55 @@ from template_import.native_structure import (
configure_utf8_stdio() configure_utf8_stdio()
_MANIFEST_NAME = "manifest.json" _MANIFEST_NAME = TEMPLATE_MANIFEST_PATH.as_posix()
_CONVERSION_REPORT_NAME = "conversion-report.json" _CONVERSION_REPORT_NAME = CONVERSION_REPORT_PATH.as_posix()
def _project_authoring_directory(
staged_dir: Path,
*,
source_dir: Path,
output_dir: Path,
projection_kind: str,
id_prefix: str,
reuse_inventory_path: Path | None = None,
) -> int:
"""Create one compact, readable authoring bundle in the transaction."""
from extract_svg_assets import extract_directory
from svg_authoring_view import project_svg_batch
source_root = staged_dir / source_dir
authoring_root = staged_dir / output_dir
sources = sorted(source_root.rglob("*.svg"))
if not sources:
raise ValueError(f"No SVG files found under {source_root}")
mapping = [
(source, authoring_root / source.relative_to(source_root))
for source in sources
]
reports = project_svg_batch(
mapping,
source_root,
authoring_root,
force=False,
projection_kind=projection_kind,
source_proxy_dir=(
staged_dir / "images" / "source-object-previews"
),
)
extract_directory(
authoring_root,
staged_dir / "icons",
"imported",
min_decoration_bytes=3000,
inplace=True,
id_prefix=id_prefix,
inventory_path=(
staged_dir / f"{output_dir.name}_vector_asset_inventory.json"
),
reuse_inventory_path=reuse_inventory_path,
)
return len(reports)
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
@@ -66,14 +122,14 @@ def parse_args() -> argparse.Namespace:
"--manifest-only", "--manifest-only",
action="store_true", action="store_true",
help=( help=(
"Only extract manifest.json + reusable assets + the native " "Only extract analysis manifests, semantic resources, and the "
"structure/source pair, without exporting slides to SVG" "source-package bundle, without exporting slides to SVG"
), ),
) )
parser.add_argument( parser.add_argument(
"--embed-images", "--embed-images",
action="store_true", action="store_true",
help="Inline images as data: URIs instead of writing files to assets/", help="Inline images as data: URIs instead of writing files to images/",
) )
parser.add_argument( parser.add_argument(
"--inheritance-mode", "--inheritance-mode",
@@ -86,42 +142,53 @@ def parse_args() -> argparse.Namespace:
"'both': also emit svg-flat/ with self-contained per-slide " "'both': also emit svg-flat/ with self-contained per-slide "
"verification files. In this mode svg/ still holds the layered " "verification files. In this mode svg/ still holds the layered "
"renderings (template designers see master/layout/slide as " "renderings (template designers see master/layout/slide as "
"separate files). 'flat': emit only self-contained slide SVGs " "separate files). 'flat': emit only projection-only, "
"in svg/, the round-trip view used by svg_to_pptx." "self-contained slide SVGs in svg/. Imported-deck round-trip "
"uses the separate authoring-svg-flat/ contract."
), ),
) )
return parser.parse_args() return parser.parse_args()
def _managed_asset_paths(output_dir: Path) -> set[Path]: def _managed_resource_paths(output_dir: Path) -> set[Path]:
"""Read the previous manifest's exact exported-asset roster.""" """Read the previous manifest's exact semantic-resource roster."""
manifest_path = output_dir / _MANIFEST_NAME manifest_path = template_manifest_path(output_dir)
try: try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError): except (OSError, UnicodeError, json.JSONDecodeError):
return set() return set()
if not isinstance(manifest, dict): if not isinstance(manifest, dict):
return set() return set()
assets = manifest.get("assets") resources = manifest.get("resources")
if not isinstance(assets, dict): if isinstance(resources, dict) and isinstance(resources.get("items"), list):
return set() paths: set[Path] = set()
export_dir = assets.get("exportDir") allowed_roots = {
asset_names = assets.get("allAssets") "audio",
if export_dir != "assets" or not isinstance(asset_names, list): "images",
return set() "native-payloads",
if any( "sounds",
not isinstance(name, str) "video",
or not name }
or name in {".", ".."} for item in resources["items"]:
or "/" in name if not isinstance(item, dict):
or "\\" in name return set()
for name in asset_names value = item.get("workspacePath")
): if not isinstance(value, str):
return set() return set()
return { path = Path(value)
Path("assets") / name if (
for name in asset_names path.drive
} or path.anchor
or path.is_absolute()
or not path.parts
or ".." in path.parts
or path.parts[0] not in allowed_roots
):
return set()
paths.add(path)
return paths
return set()
def main() -> int: def main() -> int:
@@ -145,7 +212,12 @@ def main() -> int:
print("Error: --skip-manifest and --manifest-only cannot be used together") print("Error: --skip-manifest and --manifest-only cannot be used together")
return 1 return 1
previous_assets = _managed_asset_paths(output_dir) try:
reject_removed_workspace_layout(output_dir)
except RuntimeError as exc:
print(f"Error: {exc}")
return 1
previous_resources = _managed_resource_paths(output_dir)
output_dir.parent.mkdir(parents=True, exist_ok=True) output_dir.parent.mkdir(parents=True, exist_ok=True)
staging_root = Path(tempfile.mkdtemp( staging_root = Path(tempfile.mkdtemp(
prefix=f".{output_dir.name}.import-", prefix=f".{output_dir.name}.import-",
@@ -171,6 +243,7 @@ def main() -> int:
print(f"Error: failed to extract PPTX metadata: {exc}") print(f"Error: failed to extract PPTX metadata: {exc}")
return 1 return 1
manifest_path.parent.mkdir(parents=True, exist_ok=True)
manifest_path.write_text( manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8", encoding="utf-8",
@@ -187,17 +260,19 @@ def main() -> int:
result = None result = None
total_bytes = 0 total_bytes = 0
authoring_files = 0
flat_authoring_files = 0
if not args.manifest_only: if not args.manifest_only:
from pptx_to_svg import convert_pptx_to_svg from pptx_to_svg import convert_pptx_to_svg
from pptx_to_svg.converter import ConvertOptions from pptx_to_svg.converter import ConvertOptions
options = ConvertOptions( options = ConvertOptions(
media_subdir="assets", images_subdir="images",
embed_images=args.embed_images, embed_images=args.embed_images,
keep_hidden=False, keep_hidden=False,
inheritance_mode=args.inheritance_mode, inheritance_mode=args.inheritance_mode,
asset_name_map=( asset_name_map=(
manifest.get("assets", {}).get("assetMap", {}) manifest.get("images", {}).get("imageMap", {})
if manifest else {} if manifest else {}
), ),
) )
@@ -210,6 +285,31 @@ def main() -> int:
len(art.svg.encode("utf-8")) len(art.svg.encode("utf-8"))
for art in result.slides for art in result.slides
) )
try:
authoring_files = _project_authoring_directory(
staged_dir,
source_dir=Path("svg"),
output_dir=AUTHORING_SVG_DIR,
projection_kind=(
"flat" if args.inheritance_mode == "flat" else "layered"
),
id_prefix="layered",
)
if args.inheritance_mode == "both":
flat_authoring_files = _project_authoring_directory(
staged_dir,
source_dir=Path("svg-flat"),
output_dir=AUTHORING_SVG_FLAT_DIR,
projection_kind="flat",
id_prefix="flat",
reuse_inventory_path=(
staged_dir
/ f"{AUTHORING_SVG_DIR.name}_vector_asset_inventory.json"
),
)
except (ET.ParseError, OSError, RuntimeError, ValueError) as exc:
print(f"Error: failed to create compact authoring SVG: {exc}")
return 1
from pptx_to_svg.converter import publish_staged_workspace from pptx_to_svg.converter import publish_staged_workspace
@@ -223,7 +323,7 @@ def main() -> int:
SOURCE_TEMPLATE_NAME, SOURCE_TEMPLATE_NAME,
_CONVERSION_REPORT_NAME, _CONVERSION_REPORT_NAME,
}, },
managed_relative_paths=previous_assets, managed_relative_paths=previous_resources,
) )
except (OSError, RuntimeError, ValueError) as exc: except (OSError, RuntimeError, ValueError) as exc:
print(f"Error: failed to publish PPTX template workspace: {exc}") print(f"Error: failed to publish PPTX template workspace: {exc}")
@@ -233,7 +333,7 @@ def main() -> int:
print(f"Imported PPTX template source: {pptx_path.name}") print(f"Imported PPTX template source: {pptx_path.name}")
print(f"Output directory: {output_dir}") print(f"Output directory: {output_dir}")
if manifest is not None: if manifest is not None:
print(f"Manifest: {manifest_path.name}") print(f"Manifest: {_MANIFEST_NAME}")
print(f"Native structure: {CONTRACT_NAME}") print(f"Native structure: {CONTRACT_NAME}")
print(f"Source package analysis copy: {SOURCE_TEMPLATE_NAME}") print(f"Source package analysis copy: {SOURCE_TEMPLATE_NAME}")
print( print(
@@ -241,8 +341,8 @@ def main() -> int:
f"{native_structure['strategy']['recommendedMode']}" f"{native_structure['strategy']['recommendedMode']}"
) )
print("Template output mode: explicit SVG structure") print("Template output mode: explicit SVG structure")
print(f"Assets exported: {len(manifest['assets']['allAssets'])}") print(f"Images exported: {len(manifest['images']['allImages'])}")
print(f"Common assets: {len(manifest['assets']['commonAssets'])}") print(f"Common images: {len(manifest['images']['commonImages'])}")
print(f"Slides analyzed: {len(manifest['slides'])}") print(f"Slides analyzed: {len(manifest['slides'])}")
print(f"Layouts (unique): {len(manifest.get('layouts', []))}") print(f"Layouts (unique): {len(manifest.get('layouts', []))}")
print(f"Masters (unique): {len(manifest.get('masters', []))}") print(f"Masters (unique): {len(manifest.get('masters', []))}")
@@ -250,12 +350,21 @@ def main() -> int:
print(f"Inheritance mode: {args.inheritance_mode}") print(f"Inheritance mode: {args.inheritance_mode}")
print(f"Exported SVG slides: {len(result.slides)}") print(f"Exported SVG slides: {len(result.slides)}")
print(
f"Compact authoring files: {authoring_files} "
f"({AUTHORING_SVG_DIR}/)"
)
if args.inheritance_mode in {"layered", "both"}: if args.inheritance_mode in {"layered", "both"}:
print(f"Exported masters: {len(result.masters)}") print(f"Exported masters: {len(result.masters)}")
print(f"Exported layouts: {len(result.layouts)}") print(f"Exported layouts: {len(result.layouts)}")
print("Inheritance graph: svg/inheritance.json") print("Inheritance graph: svg/inheritance.json")
if result.flat_slides: if result.flat_slides:
print(f"Flat companion slides: {len(result.flat_slides)} (svg-flat/)") print(f"Flat companion slides: {len(result.flat_slides)} (svg-flat/)")
if flat_authoring_files:
print(
f"Flat compact authoring files: {flat_authoring_files} "
f"({AUTHORING_SVG_FLAT_DIR}/)"
)
if result.diagnostics: if result.diagnostics:
print( print(
f"Source recovery warnings: {len(result.diagnostics)} " f"Source recovery warnings: {len(result.diagnostics)} "
@@ -3,20 +3,27 @@
Usage: Usage:
python3 pptx_to_svg.py <pptx_file> [-o <output_dir>] [--embed-images] python3 pptx_to_svg.py <pptx_file> [-o <output_dir>] [--embed-images]
[--media-subdir <name>] [--keep-hidden] [--images-subdir <name>] [--keep-hidden]
[--inheritance-mode {both,layered,flat}] [--inheritance-mode {both,layered,flat}]
[--roundtrip] [--roundtrip]
[--strict] [--strict]
Output structure (default --inheritance-mode both): Output structure (``--roundtrip``):
<output_dir>/ <output_dir>/
svg/ layered machine input: masters/layouts/slides authoring-svg-flat/ sole editable SVG page source
svg-flat/ self-contained visual preview slides icons/imported/ on-demand complex vector decorations
authoring-svg-flat_vector_asset_inventory.json
extracted-decoration source mapping
animations.json normalized transition/object-motion sidecar animations.json normalized transition/object-motion sidecar
<media_subdir>/ (default: assets/) images/ raster/SVG/EMF/WMF picture resources
image1.png sounds/ transition/object cue audio, when present
image2.png audio/ source narration/media audio, when present
... video/ source video bytes, when present
notes/ imported speaker notes, when present
native-payloads/ opaque source payloads, when present
analysis/ structure, manifests, and immutable SVG backing
validation/ conversion diagnostics
sources/source.pptx immutable backing package in --roundtrip
If -o is omitted, writes alongside the source file as <pptx_stem>_pptx_to_svg/. If -o is omitted, writes alongside the source file as <pptx_stem>_pptx_to_svg/.
@@ -37,6 +44,12 @@ from zipfile import BadZipFile
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
from console_encoding import configure_utf8_stdio from console_encoding import configure_utf8_stdio
from pptx_workspace import (
AUTHORING_SVG_FLAT_DIR,
CONVERSION_REPORT_PATH,
NATIVE_STRUCTURE_PATH,
SOURCE_PPTX_PATH,
)
from pptx_to_svg import convert_pptx_to_svg from pptx_to_svg import convert_pptx_to_svg
from pptx_to_svg.converter import ConvertOptions from pptx_to_svg.converter import ConvertOptions
@@ -83,9 +96,14 @@ def parse_args() -> argparse.Namespace:
help="Output directory (default: <pptx_stem>_pptx_to_svg beside source)", help="Output directory (default: <pptx_stem>_pptx_to_svg beside source)",
) )
parser.add_argument( parser.add_argument(
"--media-subdir", "--images-subdir",
default="assets", default="images",
help="Subdirectory for extracted media (default: assets)", help="Subdirectory for extracted image resources (default: images)",
)
parser.add_argument(
"--sounds-subdir",
default="sounds",
help="Subdirectory for transition/object cue audio (default: sounds)",
) )
parser.add_argument( parser.add_argument(
"--embed-images", "--embed-images",
@@ -105,7 +123,9 @@ def parse_args() -> argparse.Namespace:
"How to render inheritance. 'both' (default) writes layered SVGs " "How to render inheritance. 'both' (default) writes layered SVGs "
"under svg/ and complete preview slides under svg-flat/. " "under svg/ and complete preview slides under svg-flat/. "
"'layered' writes only svg/ plus inheritance.json. 'flat' writes " "'layered' writes only svg/ plus inheritance.json. 'flat' writes "
"self-contained slides under svg/ for backward compatibility." "self-contained slides under svg/. Round-trip import requires "
"'both' internally and publishes authoring-svg-flat/ as its sole "
"editable SVG source."
), ),
) )
parser.add_argument( parser.add_argument(
@@ -120,9 +140,9 @@ def parse_args() -> argparse.Namespace:
"--roundtrip", "--roundtrip",
action="store_true", action="store_true",
help=( help=(
"Also preserve a validated source package/Layout sidecar for the " "Create the source-preserving SVG round-trip workspace, including "
"diagnostic SVG-to-PPTX --roundtrip path. Requires layered or both " "authoring-svg-flat/, semantic resources, and immutable analysis "
"inheritance output." "backing. Requires --inheritance-mode both."
), ),
) )
return parser.parse_args() return parser.parse_args()
@@ -145,7 +165,8 @@ def main() -> int:
) )
options = ConvertOptions( options = ConvertOptions(
media_subdir=args.media_subdir, images_subdir=args.images_subdir,
sound_subdir=args.sounds_subdir,
embed_images=args.embed_images, embed_images=args.embed_images,
keep_hidden=args.keep_hidden, keep_hidden=args.keep_hidden,
inheritance_mode=args.inheritance_mode, inheritance_mode=args.inheritance_mode,
@@ -171,7 +192,7 @@ def main() -> int:
if result.diagnostics: if result.diagnostics:
print( print(
f"Warning: {len(result.diagnostics)} source construct(s) were " f"Warning: {len(result.diagnostics)} source construct(s) were "
"normalized, omitted, or replaced; see conversion-report.json.", "normalized, omitted, or replaced; see the validation report.",
file=sys.stderr, file=sys.stderr,
) )
for item in result.diagnostics[:20]: for item in result.diagnostics[:20]:
@@ -211,10 +232,11 @@ def main() -> int:
) )
print(f"Output: {output_dir}") print(f"Output: {output_dir}")
print(f"Animation config: {output_dir / 'animations.json'}") print(f"Animation config: {output_dir / 'animations.json'}")
print(f"Conversion report: {output_dir / 'conversion-report.json'}") print(f"Conversion report: {output_dir / CONVERSION_REPORT_PATH}")
if result.native_structure is not None: if result.native_structure is not None:
print(f"Round-trip source: {output_dir / 'source_template.pptx'}") print(f"Editable SVG source: {output_dir / AUTHORING_SVG_FLAT_DIR}")
print(f"Round-trip structure: {output_dir / 'native_structure.json'}") print(f"Round-trip source: {output_dir / SOURCE_PPTX_PATH}")
print(f"Round-trip structure: {output_dir / NATIVE_STRUCTURE_PATH}")
return 0 return 0
@@ -21,13 +21,14 @@ import re
import shutil import shutil
import tempfile import tempfile
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field, replace
from html import unescape from html import unescape
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from urllib.parse import unquote, urlsplit from urllib.parse import unquote, urlsplit, urlunsplit
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from xml.sax.saxutils import quoteattr from xml.sax.saxutils import quoteattr
from extract_svg_assets import extract_directory
from pptx_embedded_fonts import ( from pptx_embedded_fonts import (
FONT_BUNDLE_DIR, FONT_BUNDLE_DIR,
EmbeddedFontBundle, EmbeddedFontBundle,
@@ -35,6 +36,23 @@ from pptx_embedded_fonts import (
capture_embedded_fonts, capture_embedded_fonts,
write_embedded_font_bundle, write_embedded_font_bundle,
) )
from pptx_workspace import (
AUTHORING_SVG_DIR,
AUTHORING_SVG_FLAT_DIR,
CONVERSION_REPORT_PATH,
NATIVE_STRUCTURE_PATH,
ROUNDTRIP_FLAT_SVG_DIR,
ROUNDTRIP_LAYERED_SVG_DIR,
ROUNDTRIP_MANIFEST_PATH,
SOURCE_PPTX_PATH,
PackageResourceInventory,
conversion_report_path,
inventory_package_resources,
reject_removed_workspace_layout,
slide_animation_config_sha256,
write_workspace_resources,
)
from svg_authoring_view import project_svg_batch
from svg_to_pptx.animation_config import ( from svg_to_pptx.animation_config import (
validate_animation_config_errors, validate_animation_config_errors,
validate_transition_config, validate_transition_config,
@@ -63,6 +81,7 @@ from .ooxml_loader import (
SlideRef, SlideRef,
part_show_master_sp, part_show_master_sp,
) )
from .notes_import import ImportedSpeakerNote, import_speaker_notes
from .slide_to_svg import assemble_part_solo, assemble_slide from .slide_to_svg import assemble_part_solo, assemble_slide
from .transition_import import ( from .transition_import import (
TransitionImportError, TransitionImportError,
@@ -78,17 +97,24 @@ _MANAGED_FLAT_SVG_RE = re.compile(r"slide_\d+\.svg")
_MANAGED_TRANSITION_SOUND_RE = re.compile( _MANAGED_TRANSITION_SOUND_RE = re.compile(
r"transition_sound_[0-9a-f]{16}\.wav" r"transition_sound_[0-9a-f]{16}\.wav"
) )
_ROUNDTRIP_VECTOR_MIN_DRAWABLES = 2
_ROUNDTRIP_VECTOR_MIN_BYTES = 512
_ROUNDTRIP_VECTOR_MIN_DECORATION_BYTES = 512
_SVG_HREF_RE = re.compile( _SVG_HREF_RE = re.compile(
r"\b(?:href|xlink:href)\s*=\s*[\"']([^\"']+)[\"']" r"\b(?:href|xlink:href)\s*=\s*[\"']([^\"']+)[\"']"
) )
_SVG_HREF_ATTRIBUTE_RE = re.compile(
r"(?P<prefix>\b(?:href|xlink:href)\s*=\s*)"
r"(?P<quote>[\"'])(?P<value>[^\"']+)(?P=quote)"
)
def _validate_media_subdir(value: str) -> None: def _validate_resource_subdir(value: str) -> None:
"""Reject media output paths that can escape the conversion workspace.""" """Reject media output paths that can escape the conversion workspace."""
path = Path(value) path = Path(value)
if path.drive or path.anchor or path.is_absolute() or ".." in path.parts: if path.drive or path.anchor or path.is_absolute() or ".." in path.parts:
raise ValueError( raise ValueError(
f"media_subdir must stay within the output workspace: {value!r}" f"resource subdirectory must stay within the output workspace: {value!r}"
) )
@@ -166,8 +192,10 @@ def _extract_theme_info(
class ConvertOptions: class ConvertOptions:
"""Convert behavior knobs. """Convert behavior knobs.
media_subdir: where to write media files relative to output_dir. SVG image images_subdir: where to write image files relative to output_dir. SVG image
href will use './<media_subdir>/<filename>'. href will use './<images_subdir>/<filename>'.
sound_subdir: where to write transition/object cue audio relative to
output_dir. New workspaces use ``sounds``.
embed_images: when True, base64-encode images inline instead of writing embed_images: when True, base64-encode images inline instead of writing
files. Default False (matches svg_to_pptx default of external images). files. Default False (matches svg_to_pptx default of external images).
keep_hidden: include shapes marked hidden="1". Default False. keep_hidden: include shapes marked hidden="1". Default False.
@@ -181,17 +209,15 @@ class ConvertOptions:
svg/inheritance.json describing the reuse graph. Optimised for svg/inheritance.json describing the reuse graph. Optimised for
template authors who need to see "what is shared vs. unique". template authors who need to see "what is shared vs. unique".
- "flat": inline the inherited shapes visible under the source - "flat": inline the inherited shapes visible under the source
``showMasterSp`` flags. Used by svg_to_pptx round-trip and any caller ``showMasterSp`` flags for preview pages and screenshot pipelines.
that wants self-contained slides (preview pages, screenshot pipelines).
strict: stop on the first unsupported or malformed source construct. strict: stop on the first unsupported or malformed source construct.
Default False keeps usable content and records structured diagnostics. Default False keeps usable content and records structured diagnostics.
roundtrip: preserve a validated source-package structure sidecar and mark roundtrip: create the fixed semantic workspace and source-preserving
layered slide SVG roots with their exact source Layout identities. package contracts used by the editable ``authoring-svg-flat/`` route.
This is an opt-in diagnostic path for reconstructing the imported deck;
it does not make SVG a lossless container for arbitrary PPTX semantics.
""" """
media_subdir: str = "assets" images_subdir: str = "images"
sound_subdir: str = "sounds"
embed_images: bool = False embed_images: bool = False
keep_hidden: bool = False keep_hidden: bool = False
inheritance_mode: str = "both" inheritance_mode: str = "both"
@@ -242,6 +268,10 @@ class ConvertResult:
theme_fonts: dict[str, str] = field(default_factory=dict) theme_fonts: dict[str, str] = field(default_factory=dict)
theme_xml: bytes | None = None theme_xml: bytes | None = None
embedded_fonts: EmbeddedFontBundle | None = None embedded_fonts: EmbeddedFontBundle | None = None
resource_inventory: PackageResourceInventory = field(
default_factory=PackageResourceInventory
)
speaker_notes: tuple[ImportedSpeakerNote, ...] = ()
native_structure: dict[str, object] | None = None native_structure: dict[str, object] | None = None
source_pptx_path: Path | None = None source_pptx_path: Path | None = None
layouts: list[PartArtifact] = field(default_factory=list) layouts: list[PartArtifact] = field(default_factory=list)
@@ -335,6 +365,7 @@ def _roundtrip_native_structure(
"slides": [ "slides": [
{ {
"index": slide.index, "index": slide.index,
"slidePath": slide.part.path,
"layoutPath": slide.layout.path if slide.layout else None, "layoutPath": slide.layout.path if slide.layout else None,
"masterPath": slide.master.path if slide.master else None, "masterPath": slide.master.path if slide.master else None,
"showInheritedShapes": part_show_master_sp(slide.part), "showInheritedShapes": part_show_master_sp(slide.part),
@@ -467,13 +498,23 @@ def convert_pptx_to_svg(
f"inheritance_mode must be 'flat', 'layered', or 'both', " f"inheritance_mode must be 'flat', 'layered', or 'both', "
f"got {options.inheritance_mode!r}" f"got {options.inheritance_mode!r}"
) )
if options.roundtrip and options.inheritance_mode == "flat": if options.roundtrip and options.inheritance_mode != "both":
raise ValueError( raise ValueError(
"roundtrip requires inheritance_mode 'layered' or 'both' so source " "roundtrip requires inheritance_mode 'both' so the editable flat "
"Master/Layout visuals are not duplicated on regenerated slides" "authoring view and layered source backing are both complete"
)
if options.roundtrip and (
options.images_subdir != "images"
or options.sound_subdir != "sounds"
or options.embed_images
):
raise ValueError(
"roundtrip uses fixed images/ and sounds/ resource directories "
"and does not support inline images"
) )
if not options.embed_images: if not options.embed_images:
_validate_media_subdir(options.media_subdir) _validate_resource_subdir(options.images_subdir)
_validate_resource_subdir(options.sound_subdir)
emit_layered = options.inheritance_mode in {"layered", "both"} emit_layered = options.inheritance_mode in {"layered", "both"}
emit_flat = options.inheritance_mode in {"flat", "both"} emit_flat = options.inheritance_mode in {"flat", "both"}
result = ConvertResult( result = ConvertResult(
@@ -482,6 +523,12 @@ def convert_pptx_to_svg(
) )
with OoxmlPackage(pptx_path) as pkg: with OoxmlPackage(pptx_path) as pkg:
if pkg.zip is not None:
result.resource_inventory = inventory_package_resources(pkg.zip)
image_name_map = result.resource_inventory.image_name_map()
image_name_map.update(options.asset_name_map)
options = replace(options, asset_name_map=image_name_map)
result.speaker_notes = import_speaker_notes(pkg)
result.canvas_px = pkg.slide_size_px result.canvas_px = pkg.slide_size_px
# Default theme summary is kept for compatibility; conversion itself # Default theme summary is kept for compatibility; conversion itself
@@ -635,7 +682,8 @@ def _read_back_slide_transition(
transition = import_slide_transition( transition = import_slide_transition(
pkg, pkg,
slide, slide,
media_subdir=options.media_subdir, media_subdir=options.sound_subdir,
resource_path_map=result.resource_inventory.path_map(),
) )
except TransitionImportError as exc: except TransitionImportError as exc:
message = f"Slide transition was not reconstructed: {exc}" message = f"Slide transition was not reconstructed: {exc}"
@@ -742,7 +790,7 @@ def _convert_slide(
svg, media = assemble_slide( svg, media = assemble_slide(
pkg, slide, palette, pkg, slide, palette,
theme_fonts=theme_fonts, theme_fonts=theme_fonts,
media_subdir=options.media_subdir, media_subdir=options.images_subdir,
embed_images=options.embed_images, embed_images=options.embed_images,
keep_hidden=options.keep_hidden, keep_hidden=options.keep_hidden,
inheritance_mode=mode, inheritance_mode=mode,
@@ -841,7 +889,7 @@ def _render_part(
role=role, role=role,
parent_master=parent_master, parent_master=parent_master,
theme_fonts=theme_fonts, theme_fonts=theme_fonts,
media_subdir=options.media_subdir, media_subdir=options.images_subdir,
embed_images=options.embed_images, embed_images=options.embed_images,
keep_hidden=options.keep_hidden, keep_hidden=options.keep_hidden,
asset_name_map=options.asset_name_map, asset_name_map=options.asset_name_map,
@@ -873,11 +921,15 @@ def _path_lexists(path: Path) -> bool:
def _managed_svg_paths(output_dir: Path) -> list[Path]: def _managed_svg_paths(output_dir: Path) -> list[Path]:
"""Return converter-owned SVG files without traversing user directories.""" """Return converter-owned SVG files without traversing user directories."""
managed: list[Path] = [] managed: list[Path] = []
for dirname, filename_re in ( for relative_dir, filename_re, carries_inheritance in (
("svg", _MANAGED_PRIMARY_SVG_RE), (Path("svg"), _MANAGED_PRIMARY_SVG_RE, True),
("svg-flat", _MANAGED_FLAT_SVG_RE), (Path("svg-flat"), _MANAGED_FLAT_SVG_RE, False),
(ROUNDTRIP_LAYERED_SVG_DIR, _MANAGED_PRIMARY_SVG_RE, True),
(ROUNDTRIP_FLAT_SVG_DIR, _MANAGED_FLAT_SVG_RE, False),
(AUTHORING_SVG_DIR, _MANAGED_PRIMARY_SVG_RE, False),
(AUTHORING_SVG_FLAT_DIR, _MANAGED_FLAT_SVG_RE, False),
): ):
svg_dir = output_dir / dirname svg_dir = output_dir / relative_dir
if svg_dir.is_symlink(): if svg_dir.is_symlink():
managed.append(svg_dir) managed.append(svg_dir)
continue continue
@@ -890,14 +942,82 @@ def _managed_svg_paths(output_dir: Path) -> list[Path]:
and (path.is_file() or path.is_symlink()) and (path.is_file() or path.is_symlink())
) )
inheritance = svg_dir / "inheritance.json" inheritance = svg_dir / "inheritance.json"
if dirname == "svg" and _path_lexists(inheritance): if carries_inheritance and _path_lexists(inheritance):
managed.append(inheritance) managed.append(inheritance)
if relative_dir in {AUTHORING_SVG_DIR, AUTHORING_SVG_FLAT_DIR}:
for filename in (
"authoring_manifest.json",
"authoring_summary.json",
):
sidecar = svg_dir / filename
if _path_lexists(sidecar):
managed.append(sidecar)
return managed
def _managed_vector_asset_paths(output_dir: Path) -> set[Path]:
"""Return the previous converter-owned decoration inventory and assets."""
managed: set[Path] = set()
for authoring_dir in (AUTHORING_SVG_DIR, AUTHORING_SVG_FLAT_DIR):
inventory_path = (
output_dir / f"{authoring_dir.name}_vector_asset_inventory.json"
)
if not _path_lexists(inventory_path):
continue
managed.add(inventory_path)
if inventory_path.is_symlink() or not inventory_path.is_file():
continue
try:
payload = json.loads(inventory_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
continue
icons_dir_value = (
payload.get("icons_dir") if isinstance(payload, dict) else None
)
icons_dir = (
Path(icons_dir_value)
if isinstance(icons_dir_value, str) and icons_dir_value
else Path("icons")
)
if (
icons_dir.drive
or icons_dir.anchor
or icons_dir.is_absolute()
or ".." in icons_dir.parts
):
continue
assets = payload.get("assets") if isinstance(payload, dict) else None
if not isinstance(assets, list):
continue
for item in assets:
value = item.get("asset") if isinstance(item, dict) else None
if not isinstance(value, str):
continue
asset_path = Path(value)
if (
asset_path.drive
or asset_path.anchor
or asset_path.is_absolute()
or ".." in asset_path.parts
or not asset_path.parts
):
continue
path = (
asset_path
if asset_path.parts[0] == "icons"
else icons_dir / asset_path
)
if not path.parts or path.parts[0] != "icons":
continue
target = output_dir / path
if _path_lexists(target):
managed.add(target)
return managed return managed
def _managed_report_artifact_paths(output_dir: Path) -> set[Path]: def _managed_report_artifact_paths(output_dir: Path) -> set[Path]:
"""Return optional artifacts owned by the previous conversion report.""" """Return optional artifacts owned by the previous conversion report."""
report_path = output_dir / "conversion-report.json" report_path = conversion_report_path(output_dir)
if report_path.is_symlink() or not report_path.is_file(): if report_path.is_symlink() or not report_path.is_file():
return set() return set()
try: try:
@@ -911,10 +1031,14 @@ def _managed_report_artifact_paths(output_dir: Path) -> set[Path]:
return set() return set()
managed = {Path("animations.json")} managed = {Path("animations.json")}
if artifacts.get("sourceTemplate") == SOURCE_TEMPLATE_NAME: source_template = artifacts.get("sourceTemplate")
managed.add(Path(SOURCE_TEMPLATE_NAME)) if source_template == SOURCE_TEMPLATE_NAME:
if artifacts.get("nativeStructure") == NATIVE_STRUCTURE_NAME: managed.add(Path(str(source_template)))
managed.add(Path(NATIVE_STRUCTURE_NAME)) native_structure = artifacts.get("nativeStructure")
if native_structure == NATIVE_STRUCTURE_NAME:
managed.add(Path(str(native_structure)))
if artifacts.get("roundtripManifest") == ROUNDTRIP_MANIFEST_PATH.as_posix():
managed.add(ROUNDTRIP_MANIFEST_PATH)
embedded_font_paths = [artifacts.get("embeddedFontManifest")] embedded_font_paths = [artifacts.get("embeddedFontManifest")]
raw_font_parts = artifacts.get("embeddedFontParts") raw_font_parts = artifacts.get("embeddedFontParts")
if isinstance(raw_font_parts, list): if isinstance(raw_font_parts, list):
@@ -935,6 +1059,32 @@ def _managed_report_artifact_paths(output_dir: Path) -> set[Path]:
continue continue
managed.add(path) managed.add(path)
animation_media = artifacts.get("animationMedia") animation_media = artifacts.get("animationMedia")
managed_lists = [animation_media, artifacts.get("resources"), artifacts.get("notes")]
managed_resource_roots = {
"audio",
"images",
"native-payloads",
"notes",
"sounds",
"video",
}
for values in managed_lists:
if not isinstance(values, list):
continue
for value in values:
if not isinstance(value, str):
continue
path = Path(value)
if (
path.drive
or path.anchor
or path.is_absolute()
or not path.parts
or ".." in path.parts
or path.parts[0] not in managed_resource_roots
):
continue
managed.add(path)
if not isinstance(animation_media, list): if not isinstance(animation_media, list):
return managed return managed
for value in animation_media: for value in animation_media:
@@ -1143,8 +1293,12 @@ def publish_staged_workspace(
path.relative_to(output_dir) path.relative_to(output_dir)
for path in managed_svg for path in managed_svg
} }
relative_paths.update(
path.relative_to(output_dir)
for path in _managed_vector_asset_paths(output_dir)
)
relative_paths.update(_referenced_local_paths(output_dir, managed_svg)) relative_paths.update(_referenced_local_paths(output_dir, managed_svg))
relative_paths.add(Path("conversion-report.json")) relative_paths.add(CONVERSION_REPORT_PATH)
relative_paths.update(_managed_report_artifact_paths(output_dir)) relative_paths.update(_managed_report_artifact_paths(output_dir))
relative_paths.update(_validated_relative_paths(managed_root_files or set())) relative_paths.update(_validated_relative_paths(managed_root_files or set()))
relative_paths.update(_validated_relative_paths(managed_relative_paths or set())) relative_paths.update(_validated_relative_paths(managed_relative_paths or set()))
@@ -1190,15 +1344,51 @@ def _write_artifact_tree(
"""Write a complete converter roster into an empty staging directory. """Write a complete converter roster into an empty staging directory.
Layout: Layout:
- ``svg/`` primary view (layered when emitted, otherwise flat) - normal conversion uses ``svg/`` and optional ``svg-flat/``
- ``svg-flat/`` self-contained per-slide renders (only in "both" mode) - round-trip conversion keeps immutable SVG backing under ``analysis/``
- ``<media_subdir>/`` shared image assets, referenced by both views and publishes only ``authoring-svg-flat/`` as the editable page source
- ``images/`` shared image assets, referenced by both views
- semantic resource directories for sounds, audio, video, and opaque
native payloads when the source package contains them
""" """
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
svg_dir = output_dir / "svg" svg_dir = output_dir / (
svg_dir.mkdir(exist_ok=True) ROUNDTRIP_LAYERED_SVG_DIR if options.roundtrip else Path("svg")
media_dir = output_dir / options.media_subdir )
svg_dir.mkdir(parents=True, exist_ok=True)
media_dir = output_dir / options.images_subdir
sound_dir = output_dir / options.sound_subdir
media_written: dict[str, bytes] = {} media_written: dict[str, bytes] = {}
sounds_written: dict[str, bytes] = {}
def _svg_for_target(svg: str, target_dir: Path) -> str:
"""Rebase generated local hrefs from the normal one-level SVG root."""
source_dir = output_dir / "svg"
project_root = output_dir.resolve()
def replace_href(match: re.Match[str]) -> str:
raw = unescape(match.group("value"))
parsed = urlsplit(raw)
if (
not raw
or raw.startswith("#")
or parsed.scheme
or parsed.netloc
or not parsed.path
):
return match.group(0)
resolved = (source_dir / unquote(parsed.path)).resolve()
try:
resolved.relative_to(project_root)
except ValueError as exc:
raise RuntimeError(
f"Generated SVG resource escapes the workspace: {raw!r}"
) from exc
relative = os.path.relpath(resolved, target_dir).replace(os.sep, "/")
rebased = urlunsplit(("", "", relative, parsed.query, parsed.fragment))
return f"{match.group('prefix')}{quoteattr(rebased)}"
return _SVG_HREF_ATTRIBUTE_RE.sub(replace_href, svg)
def _collect_media(media: dict[str, bytes]) -> None: def _collect_media(media: dict[str, bytes]) -> None:
for filename, blob in media.items(): for filename, blob in media.items():
@@ -1213,18 +1403,34 @@ def _write_artifact_tree(
# Layered mode: write masters and layouts first so they sort ahead of slides. # Layered mode: write masters and layouts first so they sort ahead of slides.
for art in result.masters: for art in result.masters:
(svg_dir / art.filename).write_text(art.svg, encoding="utf-8") (svg_dir / art.filename).write_text(
_svg_for_target(art.svg, svg_dir),
encoding="utf-8",
)
_collect_media(art.media_files) _collect_media(art.media_files)
for art in result.layouts: for art in result.layouts:
(svg_dir / art.filename).write_text(art.svg, encoding="utf-8") (svg_dir / art.filename).write_text(
_svg_for_target(art.svg, svg_dir),
encoding="utf-8",
)
_collect_media(art.media_files) _collect_media(art.media_files)
# Slides (primary view). # Slides (primary view).
for art in result.slides: for art in result.slides:
target = svg_dir / f"slide_{art.index:02d}.svg" target = svg_dir / f"slide_{art.index:02d}.svg"
target.write_text(art.svg, encoding="utf-8") target.write_text(
_svg_for_target(art.svg, target.parent),
encoding="utf-8",
)
_collect_media(art.media_files) _collect_media(art.media_files)
_collect_media(result.animation_media_files) for filename, blob in result.animation_media_files.items():
_validate_media_filename(filename)
existing = sounds_written.get(filename)
if existing is not None and existing != blob:
raise RuntimeError(
f"Sound filename collision with different bytes: {filename}"
)
sounds_written[filename] = blob
# Inheritance graph alongside the layered SVGs (only meaningful when we # Inheritance graph alongside the layered SVGs (only meaningful when we
# actually emitted a layered view). # actually emitted a layered view).
@@ -1233,21 +1439,31 @@ def _write_artifact_tree(
# Flat companion view (only when result.flat_slides is populated). # Flat companion view (only when result.flat_slides is populated).
if result.flat_slides: if result.flat_slides:
flat_dir = output_dir / "svg-flat" flat_dir = output_dir / (
flat_dir.mkdir(exist_ok=True) ROUNDTRIP_FLAT_SVG_DIR if options.roundtrip else Path("svg-flat")
)
flat_dir.mkdir(parents=True, exist_ok=True)
for art in result.flat_slides: for art in result.flat_slides:
target = flat_dir / f"slide_{art.index:02d}.svg" target = flat_dir / f"slide_{art.index:02d}.svg"
target.write_text(art.svg, encoding="utf-8") target.write_text(
_svg_for_target(art.svg, target.parent),
encoding="utf-8",
)
_collect_media(art.media_files) _collect_media(art.media_files)
_write_animation_config(output_dir, result) _write_animation_config(output_dir, result)
_write_speaker_notes(output_dir, result)
if result.native_structure is not None: if result.native_structure is not None:
if result.source_pptx_path is None: if result.source_pptx_path is None:
raise RuntimeError( raise RuntimeError(
"Round-trip source structure is missing its source PPTX path" "Round-trip source structure is missing its source PPTX path"
) )
shutil.copy2(result.source_pptx_path, output_dir / SOURCE_TEMPLATE_NAME) source_target = output_dir / SOURCE_TEMPLATE_NAME
(output_dir / NATIVE_STRUCTURE_NAME).write_text( source_target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(result.source_pptx_path, source_target)
structure_target = output_dir / NATIVE_STRUCTURE_NAME
structure_target.parent.mkdir(parents=True, exist_ok=True)
structure_target.write_text(
json.dumps( json.dumps(
result.native_structure, result.native_structure,
ensure_ascii=False, ensure_ascii=False,
@@ -1269,6 +1485,11 @@ def _write_artifact_tree(
embedded_fonts_descriptor=embedded_fonts_descriptor, embedded_fonts_descriptor=embedded_fonts_descriptor,
embedded_font_paths=embedded_font_paths, embedded_font_paths=embedded_font_paths,
) )
write_workspace_resources(
output_dir,
result.resource_inventory,
include_images=not options.embed_images,
)
if media_written: if media_written:
media_dir.mkdir(parents=True, exist_ok=True) media_dir.mkdir(parents=True, exist_ok=True)
for filename, blob in media_written.items(): for filename, blob in media_written.items():
@@ -1284,6 +1505,250 @@ def _write_artifact_tree(
) )
continue continue
target.write_bytes(blob) target.write_bytes(blob)
if sounds_written:
sound_dir.mkdir(parents=True, exist_ok=True)
for filename, blob in sounds_written.items():
target = sound_dir / filename
if _path_lexists(target):
if (
target.is_symlink()
or not target.is_file()
or target.read_bytes() != blob
):
raise RuntimeError(
f"Sound filename collision with different bytes: {filename}"
)
continue
target.write_bytes(blob)
if result.native_structure is not None:
flat_dir = output_dir / ROUNDTRIP_FLAT_SVG_DIR
authoring_dir = output_dir / AUTHORING_SVG_FLAT_DIR
source_proxy_dir = media_dir / "source-object-previews"
mapping = [
(source, authoring_dir / source.name)
for source in sorted(flat_dir.glob("slide_*.svg"))
]
if len(mapping) != len(result.slides):
raise RuntimeError(
"Round-trip flat backing roster does not match the slide roster"
)
project_svg_batch(
mapping,
flat_dir,
authoring_dir,
force=False,
projection_kind="flat",
source_proxy_dir=source_proxy_dir,
)
extract_directory(
authoring_dir,
output_dir / "icons",
"imported",
min_drawables=_ROUNDTRIP_VECTOR_MIN_DRAWABLES,
min_bytes=_ROUNDTRIP_VECTOR_MIN_BYTES,
min_decoration_bytes=_ROUNDTRIP_VECTOR_MIN_DECORATION_BYTES,
inplace=True,
id_prefix="flat",
inventory_path=(
output_dir
/ f"{authoring_dir.name}_vector_asset_inventory.json"
),
)
_write_roundtrip_manifest(output_dir, result, options)
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _write_speaker_notes(output_dir: Path, result: ConvertResult) -> None:
"""Write imported notes into the standard per-slide Markdown contract."""
if not result.speaker_notes:
return
notes_dir = output_dir / "notes"
notes_dir.mkdir(parents=True, exist_ok=True)
combined = ["# Speaker Notes"]
for note in result.speaker_notes:
content = note.markdown.strip() + "\n"
(notes_dir / note.filename).write_text(content, encoding="utf-8")
combined.extend([
"",
f"## Slide {note.slide_index:02d}",
"",
note.markdown.strip(),
])
(notes_dir / "total.md").write_text(
"\n".join(combined).rstrip() + "\n",
encoding="utf-8",
)
def _write_roundtrip_manifest(
output_dir: Path,
result: ConvertResult,
options: ConvertOptions,
) -> None:
"""Record immutable source, editable sidecars, and semantic resources."""
source_path = output_dir / SOURCE_PPTX_PATH
animation_path = output_dir / "animations.json"
resource_manifest = result.resource_inventory.manifest(
include_images=not options.embed_images,
)
materialized_resource_paths = {
Path(str(item["workspacePath"]))
for item in resource_manifest["items"]
if isinstance(item, dict)
and item.get("materialized") is True
and isinstance(item.get("workspacePath"), str)
}
notes_by_index = {
note.slide_index: note
for note in result.speaker_notes
}
flat_by_index = {
slide.index: slide
for slide in result.flat_slides
}
native_slides = []
if isinstance(result.native_structure, dict):
raw_slides = result.native_structure.get("slides")
if isinstance(raw_slides, list):
native_slides = raw_slides
native_by_index = {
int(item.get("index")): item
for item in native_slides
if isinstance(item, dict) and isinstance(item.get("index"), int)
}
native_masters = (
result.native_structure.get("masters")
if isinstance(result.native_structure, dict)
else None
)
master_parts = {
str(item.get("key")): str(item.get("packagePart"))
for item in native_masters or []
if isinstance(item, dict)
and item.get("key")
and item.get("packagePart")
}
native_layouts = (
result.native_structure.get("layouts")
if isinstance(result.native_structure, dict)
else None
)
layout_parts = {
str(item.get("key")): str(item.get("packagePart"))
for item in native_layouts or []
if isinstance(item, dict)
and item.get("key")
and item.get("packagePart")
}
slides: list[dict[str, object]] = []
for slide in result.slides:
layered_path = ROUNDTRIP_LAYERED_SVG_DIR / f"slide_{slide.index:02d}.svg"
native_slide = native_by_index.get(slide.index, {})
row: dict[str, object] = {
"index": slide.index,
"sourcePart": native_slide.get("packagePart"),
"layoutPart": layout_parts.get(str(native_slide.get("layoutKey"))),
"masterPart": master_parts.get(str(native_slide.get("masterKey"))),
"layeredSvg": layered_path.as_posix(),
"layeredSvgSha256": _sha256_file(output_dir / layered_path),
"animationSha256": slide_animation_config_sha256(
result.animation_config,
f"slide_{slide.index:02d}",
),
}
referenced_svg_paths = [output_dir / layered_path]
if slide.index in flat_by_index:
flat_path = ROUNDTRIP_FLAT_SVG_DIR / f"slide_{slide.index:02d}.svg"
row["flatSvg"] = flat_path.as_posix()
row["flatSvgSha256"] = _sha256_file(output_dir / flat_path)
referenced_svg_paths.append(output_dir / flat_path)
authoring_path = AUTHORING_SVG_FLAT_DIR / f"slide_{slide.index:02d}.svg"
if (output_dir / authoring_path).is_file():
referenced_svg_paths.append(output_dir / authoring_path)
derived_paths = sorted(
_referenced_local_paths(output_dir, referenced_svg_paths)
- materialized_resource_paths,
key=lambda path: path.as_posix(),
)
derived_resources: list[dict[str, str]] = []
for relative in derived_paths:
target = output_dir / relative
if not target.is_file():
raise RuntimeError(
"Round-trip SVG references a missing derived resource: "
f"{relative.as_posix()}"
)
derived_resources.append({
"file": relative.as_posix(),
"sha256": _sha256_file(target),
})
row["derivedResources"] = derived_resources
note = notes_by_index.get(slide.index)
if note is not None:
note_path = Path("notes") / note.filename
row["notes"] = {
"file": note_path.as_posix(),
"sha256": _sha256_file(output_dir / note_path),
"sourcePart": note.source_part,
"sourceSha256": note.source_sha256,
}
slides.append(row)
payload = {
"schema": "ppt-master.roundtrip-workspace.v1",
"source": {
"file": SOURCE_PPTX_PATH.as_posix(),
"sha256": _sha256_file(source_path),
},
"structure": NATIVE_STRUCTURE_PATH.as_posix(),
"conversionReport": CONVERSION_REPORT_PATH.as_posix(),
"sidecars": {
"animations": {
"file": "animations.json",
"sha256": _sha256_file(animation_path),
},
"notesTotal": (
{
"file": "notes/total.md",
"sha256": _sha256_file(output_dir / "notes/total.md"),
}
if result.speaker_notes
else None
),
},
"directories": {
"authoringSvg": AUTHORING_SVG_FLAT_DIR.as_posix(),
"layeredSvg": ROUNDTRIP_LAYERED_SVG_DIR.as_posix(),
"flatSvg": (
ROUNDTRIP_FLAT_SVG_DIR.as_posix()
if result.flat_slides
else None
),
"images": options.images_subdir,
"sourceObjectPreviews": (
Path(options.images_subdir) / "source-object-previews"
).as_posix(),
"sounds": options.sound_subdir,
"audio": "audio",
"video": "video",
"notes": "notes",
"nativePayloads": "native-payloads",
},
"slides": slides,
"resources": resource_manifest,
}
target = output_dir / ROUNDTRIP_MANIFEST_PATH
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def _write_artifacts( def _write_artifacts(
@@ -1293,6 +1758,7 @@ def _write_artifacts(
) -> None: ) -> None:
"""Stage a complete conversion, then atomically publish its exact roster.""" """Stage a complete conversion, then atomically publish its exact roster."""
output_dir = output_dir.absolute() output_dir = output_dir.absolute()
reject_removed_workspace_layout(output_dir)
output_dir.parent.mkdir(parents=True, exist_ok=True) output_dir.parent.mkdir(parents=True, exist_ok=True)
staging_root = Path(tempfile.mkdtemp( staging_root = Path(tempfile.mkdtemp(
prefix=f".{output_dir.name}.convert-", prefix=f".{output_dir.name}.convert-",
@@ -1316,7 +1782,7 @@ def _write_conversion_report(
) -> None: ) -> None:
"""Write the user-visible tolerant-import report.""" """Write the user-visible tolerant-import report."""
animation_media = [ animation_media = [
(PurePosixPath(options.media_subdir) / filename).as_posix() (PurePosixPath(options.sound_subdir) / filename).as_posix()
for filename in sorted(result.animation_media_files) for filename in sorted(result.animation_media_files)
] ]
source_theme: dict[str, object] = { source_theme: dict[str, object] = {
@@ -1341,6 +1807,15 @@ def _write_conversion_report(
artifacts: dict[str, object] = { artifacts: dict[str, object] = {
"animationConfig": "animations.json", "animationConfig": "animations.json",
"animationMedia": animation_media, "animationMedia": animation_media,
"resources": [
resource.workspace_path
for resource in result.resource_inventory.resources
if not (options.embed_images and resource.kind == "image")
],
"notes": [
(Path("notes") / note.filename).as_posix()
for note in result.speaker_notes
] + (["notes/total.md"] if result.speaker_notes else []),
} }
if embedded_font_paths: if embedded_font_paths:
artifacts["embeddedFontManifest"] = embedded_font_paths[-1] artifacts["embeddedFontManifest"] = embedded_font_paths[-1]
@@ -1348,6 +1823,7 @@ def _write_conversion_report(
if result.native_structure is not None: if result.native_structure is not None:
artifacts["sourceTemplate"] = SOURCE_TEMPLATE_NAME artifacts["sourceTemplate"] = SOURCE_TEMPLATE_NAME
artifacts["nativeStructure"] = NATIVE_STRUCTURE_NAME artifacts["nativeStructure"] = NATIVE_STRUCTURE_NAME
artifacts["roundtripManifest"] = ROUNDTRIP_MANIFEST_PATH.as_posix()
report = { report = {
"schemaVersion": 1, "schemaVersion": 1,
"source": result.source_file, "source": result.source_file,
@@ -1360,7 +1836,9 @@ def _write_conversion_report(
"sourceDocument": source_document, "sourceDocument": source_document,
"diagnostics": [item.to_dict() for item in result.diagnostics], "diagnostics": [item.to_dict() for item in result.diagnostics],
} }
(output_dir / "conversion-report.json").write_text( report_path = output_dir / CONVERSION_REPORT_PATH
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", json.dumps(report, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8", encoding="utf-8",
) )
@@ -0,0 +1,114 @@
"""Import PowerPoint speaker notes into the project notes Markdown contract."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from xml.etree import ElementTree as ET
from .emu_units import NS
from .ooxml_loader import OoxmlPackage
_NOTES_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide"
)
_EXCLUDED_PLACEHOLDER_TYPES = frozenset({
"dt",
"ftr",
"hdr",
"sldImg",
"sldNum",
})
@dataclass(frozen=True)
class ImportedSpeakerNote:
"""One source notes part projected into editable Markdown."""
slide_index: int
source_part: str
source_sha256: str
markdown: str
@property
def filename(self) -> str:
"""Return the canonical index-based notes filename."""
return f"slide_{self.slide_index:02d}.md"
def _paragraph_text(paragraph: ET.Element) -> str:
pieces: list[str] = []
for node in paragraph.iter():
tag = node.tag.rsplit("}", 1)[-1] if isinstance(node.tag, str) else ""
if tag == "t" and node.text:
pieces.append(node.text)
elif tag == "br":
pieces.append("\n")
text = "".join(pieces).strip()
if not text:
return ""
paragraph_properties = paragraph.find("a:pPr", NS)
if (
paragraph_properties is not None
and paragraph_properties.find("a:buChar", NS) is not None
):
return f"- {text}"
return text
def _notes_markdown(root: ET.Element) -> str:
blocks: list[str] = []
for shape in root.findall(".//p:sp", NS):
placeholder = shape.find("p:nvSpPr/p:nvPr/p:ph", NS)
placeholder_type = placeholder.get("type") if placeholder is not None else None
if placeholder_type in _EXCLUDED_PLACEHOLDER_TYPES:
continue
text_body = shape.find("p:txBody", NS)
if text_body is None:
continue
paragraphs = [
text
for paragraph in text_body.findall("a:p", NS)
if (text := _paragraph_text(paragraph))
]
if paragraphs:
blocks.append("\n".join(paragraphs))
return "\n\n".join(blocks).strip()
def import_speaker_notes(pkg: OoxmlPackage) -> tuple[ImportedSpeakerNote, ...]:
"""Return every non-empty source speaker note in presentation order."""
notes: list[ImportedSpeakerNote] = []
for slide in pkg.iter_slides():
source_part = next(
(
relationship.get("target", "")
for relationship in slide.part.rels.values()
if relationship.get("type") == _NOTES_REL_TYPE
and not relationship.get("external")
),
"",
)
if not source_part:
continue
payload = pkg.read_part_bytes(source_part)
if payload is None:
continue
try:
root = ET.fromstring(payload)
except ET.ParseError:
continue
markdown = _notes_markdown(root)
if not markdown:
continue
notes.append(ImportedSpeakerNote(
slide_index=slide.index,
source_part=source_part,
source_sha256=hashlib.sha256(payload).hexdigest(),
markdown=markdown,
))
return tuple(notes)
__all__ = ["ImportedSpeakerNote", "import_speaker_notes"]
@@ -74,19 +74,24 @@ class PictureResult:
# the SVG. Filename is the basename inside the package's media dir. # the SVG. Filename is the basename inside the package's media dir.
media: dict[str, bytes] = field(default_factory=dict) media: dict[str, bytes] = field(default_factory=dict)
diagnostics: tuple[PictureDiagnostic, ...] = () diagnostics: tuple[PictureDiagnostic, ...] = ()
external_linked: bool = False
class MediaResolutionError(RuntimeError): class MediaResolutionError(RuntimeError):
"""Raised when a PPTX media relationship cannot be reproduced as SVG.""" """Raised when a PPTX media relationship cannot be reproduced as SVG."""
class LinkedImageResolutionError(MediaResolutionError):
"""Raised when an external linked image has no embedded preview."""
def convert_blip_fill( def convert_blip_fill(
blip_fill_elem: ET.Element, blip_fill_elem: ET.Element,
xfrm: Xfrm, xfrm: Xfrm,
slide_part: PartRef, slide_part: PartRef,
pkg: OoxmlPackage, pkg: OoxmlPackage,
*, *,
media_subdir: str = "assets", media_subdir: str = "images",
embed_inline: bool = False, embed_inline: bool = False,
asset_name_map: dict[str, str] | None = None, asset_name_map: dict[str, str] | None = None,
strict: bool = False, strict: bool = False,
@@ -103,10 +108,16 @@ def convert_blip_fill(
relationship_ids = blip_embed_relationship_ids(blip) relationship_ids = blip_embed_relationship_ids(blip)
linked_rid = blip.attrib.get(f"{{{NS['r']}}}link") linked_rid = blip.attrib.get(f"{{{NS['r']}}}link")
linked_relationship = slide_part.rels.get(linked_rid or "", {})
external_linked = linked_relationship.get("external") == "1"
if not relationship_ids: if not relationship_ids:
if external_linked:
raise LinkedImageResolutionError(
"Linked image relationships are not supported; embed the image in PowerPoint first"
)
if linked_rid: if linked_rid:
raise MediaResolutionError( raise MediaResolutionError(
"Linked image relationships are not supported; embed the image in PowerPoint first" "Linked image relationship cannot be resolved; embed the image in PowerPoint first"
) )
return PictureResult() return PictureResult()
@@ -130,7 +141,12 @@ def convert_blip_fill(
break break
if target is None or img_bytes is None: if target is None or img_bytes is None:
details = "; ".join(failures) details = "; ".join(failures)
raise MediaResolutionError( error_type = (
LinkedImageResolutionError
if external_linked
else MediaResolutionError
)
raise error_type(
f"No embedded image relationship can be read in {slide_part.path}: {details}" f"No embedded image relationship can be read in {slide_part.path}: {details}"
) )
@@ -196,6 +212,7 @@ def convert_blip_fill(
svg=svg, svg=svg,
media=media, media=media,
diagnostics=tuple(diagnostics), diagnostics=tuple(diagnostics),
external_linked=external_linked,
) )
@@ -205,7 +222,7 @@ def convert_picture(
slide_part: PartRef, slide_part: PartRef,
pkg: OoxmlPackage, pkg: OoxmlPackage,
*, *,
media_subdir: str = "assets", media_subdir: str = "images",
embed_inline: bool = False, embed_inline: bool = False,
asset_name_map: dict[str, str] | None = None, asset_name_map: dict[str, str] | None = None,
strict: bool = False, strict: bool = False,
@@ -251,6 +251,7 @@ def _walk_container(
container: ET.Element, container: ET.Element,
parent_group_xfrm: Xfrm | None, parent_group_xfrm: Xfrm | None,
ancestor_rotation: float = 0.0, ancestor_rotation: float = 0.0,
source_order_path: tuple[int, ...] = (),
placeholder_xfrms: dict[tuple[str | None, str | None], Xfrm] | None = None, placeholder_xfrms: dict[tuple[str | None, str | None], Xfrm] | None = None,
placeholder_lst_styles: dict[ placeholder_lst_styles: dict[
tuple[str | None, str | None], tuple[str | None, str | None],
@@ -264,6 +265,7 @@ def _walk_container(
"""Walk a p:spTree or p:grpSp subtree. Children kept in document (z) order. """Walk a p:spTree or p:grpSp subtree. Children kept in document (z) order.
""" """
nodes: list[ShapeNode] = [] nodes: list[ShapeNode] = []
source_order = 0
for child in list(container): for child in list(container):
if not isinstance(child.tag, str): if not isinstance(child.tag, str):
continue continue
@@ -277,6 +279,8 @@ def _walk_container(
kind_info = _KIND_MAP.get(local) kind_info = _KIND_MAP.get(local)
if kind_info is None: if kind_info is None:
continue continue
source_order += 1
child_order_path = (*source_order_path, source_order)
kind, nv_tag = kind_info kind, nv_tag = kind_info
( (
@@ -287,6 +291,10 @@ def _walk_container(
hyperlink_rid, hyperlink_rid,
hyperlink_action, hyperlink_action,
) = _read_nv_sp_pr(child, nv_tag) ) = _read_nv_sp_pr(child, nv_tag)
if not spid:
spid = "missing-" + "-".join(
str(value) for value in child_order_path
)
xfrm = parse_xfrm(_resolve_xfrm(child, kind)) xfrm = parse_xfrm(_resolve_xfrm(child, kind))
effective_rotation = (ancestor_rotation + xfrm.rot) % 360.0 effective_rotation = (ancestor_rotation + xfrm.rot) % 360.0
@@ -336,6 +344,7 @@ def _walk_container(
if kind == GROUP: if kind == GROUP:
node.children = _walk_container( node.children = _walk_container(
child, xfrm, effective_rotation, child, xfrm, effective_rotation,
source_order_path=child_order_path,
placeholder_xfrms=placeholder_xfrms, placeholder_xfrms=placeholder_xfrms,
placeholder_lst_styles=placeholder_lst_styles, placeholder_lst_styles=placeholder_lst_styles,
placeholder_body_properties=placeholder_body_properties, placeholder_body_properties=placeholder_body_properties,
@@ -85,6 +85,7 @@ from .ooxml_loader import (
inherited_shape_visibility, inherited_shape_visibility,
) )
from .pic_to_svg import ( from .pic_to_svg import (
LinkedImageResolutionError,
MediaResolutionError, MediaResolutionError,
PictureResult, PictureResult,
convert_blip_fill, convert_blip_fill,
@@ -110,6 +111,12 @@ from .txbody_to_svg import (
# AssemblyContext # AssemblyContext
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_SOURCE_PROXY_ATTRIBUTE = "data-pptx-source-proxy"
_SOURCE_PROXY_KIND = "native-restore"
_EXTERNAL_LINKED_IMAGE_PROXY_ATTRIBUTE = (
"data-pptx-external-linked-image-proxy"
)
@dataclass @dataclass
class AssemblyContext: class AssemblyContext:
"""Per-slide accumulator for unique IDs + media + defs.""" """Per-slide accumulator for unique IDs + media + defs."""
@@ -119,7 +126,7 @@ class AssemblyContext:
slide_part: PartRef slide_part: PartRef
slide_number: int | None = None slide_number: int | None = None
theme_fonts: dict[str, str] = field(default_factory=dict) theme_fonts: dict[str, str] = field(default_factory=dict)
media_subdir: str = "assets" media_subdir: str = "images"
embed_images: bool = False embed_images: bool = False
keep_hidden: bool = False keep_hidden: bool = False
strict: bool = False strict: bool = False
@@ -225,7 +232,7 @@ def assemble_slide(
palette: ColorPalette | None, palette: ColorPalette | None,
*, *,
theme_fonts: dict[str, str] | None = None, theme_fonts: dict[str, str] | None = None,
media_subdir: str = "assets", media_subdir: str = "images",
embed_images: bool = False, embed_images: bool = False,
keep_hidden: bool = False, keep_hidden: bool = False,
inheritance_mode: str = "flat", inheritance_mode: str = "flat",
@@ -337,7 +344,7 @@ def assemble_part_solo(
role: str, role: str,
parent_master: PartRef | None = None, parent_master: PartRef | None = None,
theme_fonts: dict[str, str] | None = None, theme_fonts: dict[str, str] | None = None,
media_subdir: str = "assets", media_subdir: str = "images",
embed_images: bool = False, embed_images: bool = False,
keep_hidden: bool = False, keep_hidden: bool = False,
asset_name_map: dict[str, str] | None = None, asset_name_map: dict[str, str] | None = None,
@@ -483,6 +490,7 @@ def _fallback_node_svg(
ctx: AssemblyContext, ctx: AssemblyContext,
*, *,
top_level: bool, top_level: bool,
source_proxy: bool = False,
) -> str: ) -> str:
"""Keep one unsupported source object visible without aborting its deck.""" """Keep one unsupported source object visible without aborting its deck."""
if node.xfrm.w <= 0 or node.xfrm.h <= 0: if node.xfrm.w <= 0 or node.xfrm.h <= 0:
@@ -500,7 +508,21 @@ def _fallback_node_svg(
f'y="{fmt_num(node.xfrm.y + min(18, node.xfrm.h / 2))}" ' f'y="{fmt_num(node.xfrm.y + min(18, node.xfrm.h / 2))}" '
f'font-size="12" fill="#991B1B">{label}</text>' f'font-size="12" fill="#991B1B">{label}</text>'
) )
return _wrap_shape_group(inner, node, ctx, top_level=top_level) extra_attrs = (
[
f'{_SOURCE_PROXY_ATTRIBUTE}="{_SOURCE_PROXY_KIND}"',
f'{_EXTERNAL_LINKED_IMAGE_PROXY_ATTRIBUTE}="true"',
]
if source_proxy
else None
)
return _wrap_shape_group(
inner,
node,
ctx,
top_level=top_level,
extra_attrs=extra_attrs,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -525,6 +547,20 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
asset_name_map=ctx.asset_name_map, asset_name_map=ctx.asset_name_map,
strict=ctx.strict, strict=ctx.strict,
) )
except LinkedImageResolutionError as exc:
if ctx.strict:
raise
ctx.diagnose(
"linked-image-proxy",
str(exc),
"retain the complete source object as a non-editable proxy",
)
return _fallback_node_svg(
node,
ctx,
top_level=top_level,
source_proxy=True,
)
except (ValueError, MediaResolutionError) as exc: except (ValueError, MediaResolutionError) as exc:
if ctx.strict: if ctx.strict:
raise raise
@@ -534,6 +570,18 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
"omit the image fill and retain shape geometry/text", "omit the image fill and retain shape geometry/text",
) )
else: else:
if blip_result.external_linked:
ctx.diagnose(
"linked-image-proxy",
"Externally linked image fills are source-backed",
"retain the complete source object as a non-editable proxy",
)
return _fallback_node_svg(
node,
ctx,
top_level=top_level,
source_proxy=True,
)
_diagnose_picture_result(ctx, blip_result) _diagnose_picture_result(ctx, blip_result)
if blip_result.svg: if blip_result.svg:
blip_image = _clip_blip_image(blip_result.svg, geom, ctx) blip_image = _clip_blip_image(blip_result.svg, geom, ctx)
@@ -1555,6 +1603,20 @@ def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool)
asset_name_map=ctx.asset_name_map, asset_name_map=ctx.asset_name_map,
strict=ctx.strict, strict=ctx.strict,
) )
except LinkedImageResolutionError as exc:
if ctx.strict:
raise
ctx.diagnose(
"linked-image-proxy",
str(exc),
"retain the complete source picture as a non-editable proxy",
)
return _fallback_node_svg(
node,
ctx,
top_level=top_level,
source_proxy=True,
)
except MediaResolutionError as exc: except MediaResolutionError as exc:
if ctx.strict: if ctx.strict:
raise raise
@@ -1581,6 +1643,13 @@ def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool)
clipped_svg = _clip_blip_image(result.svg, geom, ctx) clipped_svg = _clip_blip_image(result.svg, geom, ctx)
picture_attrs = {**_object_metadata(node, ctx), **effect_metadata} picture_attrs = {**_object_metadata(node, ctx), **effect_metadata}
group_attrs = _metadata_group_attrs(effect_metadata) group_attrs = _metadata_group_attrs(effect_metadata)
if result.external_linked:
group_attrs.append(
f'{_SOURCE_PROXY_ATTRIBUTE}="{_SOURCE_PROXY_KIND}"'
)
group_attrs.append(
f'{_EXTERNAL_LINKED_IMAGE_PROXY_ATTRIBUTE}="true"'
)
if effect.filter_id is not None: if effect.filter_id is not None:
filter_attr = f"url(#{effect.filter_id})" filter_attr = f"url(#{effect.filter_id})"
if ( if (
@@ -2051,6 +2120,7 @@ def _render_graphic_table(
result.native_payload["name"] = node.name result.native_payload["name"] = node.name
payload_metadata = _replacement_payload_metadata(result.native_payload) payload_metadata = _replacement_payload_metadata(result.native_payload)
replacement_attrs.append('data-pptx-replace-with="table"') replacement_attrs.append('data-pptx-replace-with="table"')
replacement_attrs.append('data-pptx-native-authority="json"')
elif result.native_status: elif result.native_status:
replacement_attrs.append( replacement_attrs.append(
'data-pptx-replacement-status="' 'data-pptx-replacement-status="'
@@ -2100,6 +2170,7 @@ def _render_graphic_chart(
) )
payload_metadata = _replacement_payload_metadata(payload) payload_metadata = _replacement_payload_metadata(payload)
replacement_attrs.append('data-pptx-replace-with="chart"') replacement_attrs.append('data-pptx-replace-with="chart"')
replacement_attrs.append('data-pptx-native-authority="json"')
elif result.native_status: elif result.native_status:
replacement_attrs.append( replacement_attrs.append(
'data-pptx-replacement-status="' 'data-pptx-replacement-status="'
@@ -37,12 +37,14 @@ Cell painting order:
from __future__ import annotations from __future__ import annotations
import copy import copy
import json
import math import math
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from pptx_effects import txbody_has_run_effects from pptx_effects import txbody_has_run_effects
from semantic_table import compact_semantic_table_payload
from .color_resolver import ColorPalette, find_color_elem, resolve_color from .color_resolver import ColorPalette, find_color_elem, resolve_color
from .emu_units import ( from .emu_units import (
@@ -632,27 +634,6 @@ def _strict_merge_span(value: str | None) -> int:
return span return span
def _canonical_merge_slave_is_empty(tc: ET.Element) -> bool:
tc_pr = tc.find("a:tcPr", NS)
if tc_pr is None or tc_pr.attrib or list(tc_pr):
return False
tx_body = tc.find("a:txBody", NS)
if tx_body is None:
return False
paragraph_count = 0
for child in tx_body:
name = child.tag.rsplit("}", 1)[-1]
if name in {"bodyPr", "lstStyle"}:
if child.attrib or list(child):
return False
continue
if name == "p" and not child.attrib and not list(child):
paragraph_count += 1
continue
return False
return paragraph_count > 0 and not (tx_body.text or "").strip()
def _canonical_native_merge_status( def _canonical_native_merge_status(
rows: list[ET.Element], rows: list[ET.Element],
col_count: int, col_count: int,
@@ -729,9 +710,6 @@ def _canonical_native_merge_status(
or v_merge != (row_idx > region.row) or v_merge != (row_idx > region.row)
): ):
return "unsupported-merge-topology" return "unsupported-merge-topology"
if not is_anchor and not _canonical_merge_slave_is_empty(tc):
return "unsupported-merge-topology"
return None return None
@@ -750,10 +728,14 @@ def _table_has_unsupported_style(tbl: ET.Element) -> bool:
for name in ("firstCol", "lastCol", "lastRow", "bandCol", "rtl") for name in ("firstCol", "lastCol", "lastRow", "bandCol", "rtl")
): ):
return True return True
return any( for child in tbl_pr:
child.tag.rsplit("}", 1)[-1] != "tableStyleId" name = child.tag.rsplit("}", 1)[-1]
for child in tbl_pr if name == "tableStyleId":
) continue
if name == "effectLst" and not child.attrib and not list(child):
continue
return True
return False
_DIRECT_BORDER_TAGS = { _DIRECT_BORDER_TAGS = {
@@ -761,37 +743,10 @@ _DIRECT_BORDER_TAGS = {
"lnR": "right", "lnR": "right",
"lnT": "top", "lnT": "top",
"lnB": "bottom", "lnB": "bottom",
"lnTlToBr": "diagonal_down",
"lnBlToTr": "diagonal_up",
} }
_DIRECT_BORDER_WIDTH_MAX = 20116800 _DIRECT_BORDER_WIDTH_MAX = 20116800
_OPAQUE_COLOR_MODIFIERS = {
"tint",
"shade",
"lumMod",
"lumOff",
"satMod",
"satOff",
}
def _validate_opaque_border_color(color_elem: ET.Element | None) -> None:
if color_elem is None:
raise ValueError("missing border color")
name = color_elem.tag.rsplit("}", 1)[-1]
if name not in {"srgbClr", "schemeClr"} or set(color_elem.attrib) != {"val"}:
raise ValueError("unsupported border color")
for modifier in color_elem:
modifier_name = modifier.tag.rsplit("}", 1)[-1]
if (
modifier_name not in _OPAQUE_COLOR_MODIFIERS
or set(modifier.attrib) != {"val"}
or list(modifier)
):
raise ValueError("unsupported border color modifier")
value = modifier.get("val", "")
if not value.isdigit() or not 0 <= int(value) <= 100000:
raise ValueError("invalid border color modifier")
def _direct_border_payload( def _direct_border_payload(
ln: ET.Element, ln: ET.Element,
palette: ColorPalette | None, palette: ColorPalette | None,
@@ -815,9 +770,21 @@ def _direct_border_payload(
children = list(ln) children = list(ln)
child_names = [child.tag.rsplit("}", 1)[-1] for child in children] child_names = [child.tag.rsplit("}", 1)[-1] for child in children]
if child_names == ["noFill"]: if child_names.count("noFill") == 1:
no_fill = children[0] no_fill = next(
if no_fill.attrib or list(no_fill): child for child in children
if child.tag.rsplit("}", 1)[-1] == "noFill"
)
if (
no_fill.attrib
or list(no_fill)
or any(
name not in {
"noFill", "prstDash", "round", "headEnd", "tailEnd",
}
for name in child_names
)
):
raise ValueError("invalid noFill border") raise ValueError("invalid noFill border")
return {"style": "none"} return {"style": "none"}
@@ -873,9 +840,8 @@ def _direct_border_payload(
if solid_fill.attrib or len(list(solid_fill)) != 1: if solid_fill.attrib or len(list(solid_fill)) != 1:
raise ValueError("invalid solid border fill") raise ValueError("invalid solid border fill")
color_elem = find_color_elem(solid_fill) color_elem = find_color_elem(solid_fill)
_validate_opaque_border_color(color_elem)
try: try:
color, alpha = resolve_color(color_elem, palette) color, alpha = resolve_color(color_elem, palette, strict=True)
except (TypeError, ValueError, OverflowError) as exc: except (TypeError, ValueError, OverflowError) as exc:
raise ValueError("invalid solid border color") from exc raise ValueError("invalid solid border color") from exc
if color is None or alpha != 1.0: if color is None or alpha != 1.0:
@@ -901,11 +867,18 @@ def _table_has_unsupported_direct_formatting(
return True return True
tc_pr = tc.find("a:tcPr", NS) tc_pr = tc.find("a:tcPr", NS)
if tc_pr is not None: if tc_pr is not None:
allowed_attrs = {"marL", "marR", "marT", "marB", "anchor"} allowed_attrs = {
"marL", "marR", "marT", "marB", "anchor",
"anchorCtr", "horzOverflow",
}
if any(name not in allowed_attrs for name in tc_pr.attrib): if any(name not in allowed_attrs for name in tc_pr.attrib):
return True return True
if tc_pr.get("anchor") not in {None, "t", "ctr", "b"}: if tc_pr.get("anchor") not in {None, "t", "ctr", "b"}:
return True return True
if tc_pr.get("anchorCtr") not in {None, "0", "1", "false", "true"}:
return True
if tc_pr.get("horzOverflow") not in {None, "clip", "overflow"}:
return True
if any( if any(
child.tag.rsplit("}", 1)[-1] child.tag.rsplit("}", 1)[-1]
not in {"solidFill", "noFill", *_DIRECT_BORDER_TAGS} not in {"solidFill", "noFill", *_DIRECT_BORDER_TAGS}
@@ -932,12 +905,18 @@ def _table_has_unsupported_direct_formatting(
return True return True
solid_fill = tc_pr.find("a:solidFill", NS) solid_fill = tc_pr.find("a:solidFill", NS)
if solid_fill is not None: if solid_fill is not None:
if solid_fill.find(".//a:alpha", NS) is not None: fill = resolve_fill(tc_pr, palette)
return True if (
if _cell_fill_hex(tc_pr, palette) is None: not fill.attrs
or not fill.attrs.get("fill", "").startswith("#")
or set(fill.attrs) - {"fill", "fill-opacity"}
):
return True return True
tx_body = tc.find("a:txBody", NS) tx_body = tc.find("a:txBody", NS)
if _text_body_has_unsupported_formatting(tx_body): if (
_text_body_has_unsupported_formatting(tx_body)
or _table_text_has_unsupported_outline(tx_body, palette)
):
return True return True
return False return False
@@ -1025,10 +1004,14 @@ def _text_body_has_unsupported_formatting(tx_body: ET.Element | None) -> bool:
p_pr = paragraph.find("a:pPr", NS) p_pr = paragraph.find("a:pPr", NS)
if p_pr is not None: if p_pr is not None:
alignment = p_pr.get("algn")
if alignment not in {None, "l", "ctr", "r", "just"}:
return True
p_pr_tags = [child.tag.rsplit("}", 1)[-1] for child in p_pr] p_pr_tags = [child.tag.rsplit("}", 1)[-1] for child in p_pr]
if p_pr_tags.count("defRPr") > 1 or p_pr_tags.count("buNone") > 1: if p_pr_tags.count("defRPr") > 1 or p_pr_tags.count("buNone") > 1:
return True return True
if any(tag.startswith("bu") and tag != "buNone" for tag in p_pr_tags): bullet_tags = [tag for tag in p_pr_tags if tag.startswith("bu")]
if bullet_tags and "buNone" not in bullet_tags:
return True return True
for run in paragraph.findall("a:r", NS): for run in paragraph.findall("a:r", NS):
@@ -1045,6 +1028,64 @@ def _text_body_has_unsupported_formatting(tx_body: ET.Element | None) -> bool:
allowed_text_attrs = {"{http://www.w3.org/XML/1998/namespace}space"} allowed_text_attrs = {"{http://www.w3.org/XML/1998/namespace}space"}
if any(name not in allowed_text_attrs for name in text_node.attrib): if any(name not in allowed_text_attrs for name in text_node.attrib):
return True return True
r_pr = run.find("a:rPr", NS)
if _table_run_props_have_unsupported_formatting(r_pr):
return True
end_r_pr = paragraph.find("a:endParaRPr", NS)
if _table_run_props_have_unsupported_formatting(end_r_pr):
return True
return False
def _table_run_props_have_unsupported_formatting(
run_props: ET.Element | None,
) -> bool:
"""Return whether one run uses semantics outside the normalized schema."""
if run_props is None:
return False
allowed_children = {
"effectLst", "latin", "ea", "cs", "sym", "ln", "solidFill",
}
for child in run_props:
name = child.tag.rsplit("}", 1)[-1]
if name not in allowed_children:
return True
if name == "effectLst" and (child.attrib or list(child)):
return True
return False
def _native_run_outline_payload(
line: ET.Element | None,
palette: ColorPalette | None,
) -> dict[str, Any] | None:
"""Normalize one visible text outline into the shared line schema."""
if line is None:
return None
if line.find("a:noFill", NS) is not None:
return None
raw_width = line.get("w")
if raw_width is None:
return None
if not raw_width.isdigit() or int(raw_width) == 0:
return None
return _direct_border_payload(line, palette)
def _table_text_has_unsupported_outline(
tx_body: ET.Element | None,
palette: ColorPalette | None,
) -> bool:
if tx_body is None:
return False
for run_props in tx_body.findall(".//a:rPr", NS):
line = run_props.find("a:ln", NS)
if line is None:
continue
try:
_native_run_outline_payload(line, palette)
except ValueError:
return True
return False return False
@@ -1196,7 +1237,7 @@ def _native_table_payload(
for row_cells in cells: for row_cells in cells:
row_payload: list[Any] = [] row_payload: list[Any] = []
for slot in row_cells: for slot in row_cells:
if slot is None or slot.is_dropped: if slot is None:
row_payload.append("") row_payload.append("")
continue continue
cell_payload = _native_cell_payload( cell_payload = _native_cell_payload(
@@ -1204,6 +1245,10 @@ def _native_table_payload(
palette, palette,
theme_fonts, theme_fonts,
) )
if slot.is_dropped:
cell_payload["merge_continuation"] = True
row_payload.append(cell_payload)
continue
if slot.row_span > 1: if slot.row_span > 1:
cell_payload["row_span"] = slot.row_span cell_payload["row_span"] = slot.row_span
if slot.col_span > 1: if slot.col_span > 1:
@@ -1211,7 +1256,7 @@ def _native_table_payload(
row_payload.append(cell_payload) row_payload.append(cell_payload)
rows_payload.append(row_payload) rows_payload.append(row_payload)
payload["rows"] = rows_payload payload["rows"] = rows_payload
return payload return compact_semantic_table_payload(payload)
def _native_cell_payload( def _native_cell_payload(
@@ -1234,9 +1279,11 @@ def _native_cell_payload(
else: else:
cell = {"text": _cell_plain_text(tx_body)} cell = {"text": _cell_plain_text(tx_body)}
fill = _cell_fill_hex(tc_pr, palette) fill, fill_opacity = _cell_fill_payload(tc_pr, palette)
if fill: if fill:
cell["fill"] = fill cell["fill"] = fill
if fill_opacity is not None:
cell["fill_opacity"] = fill_opacity
if rich_paragraphs is None: if rich_paragraphs is None:
color = _cell_text_color(tx_body, palette) color = _cell_text_color(tx_body, palette)
if color: if color:
@@ -1259,6 +1306,7 @@ def _native_cell_payload(
if borders: if borders:
cell["borders"] = borders cell["borders"] = borders
_copy_cell_margins(tc_pr, cell) _copy_cell_margins(tc_pr, cell)
_copy_cell_layout_options(tc_pr, cell)
return cell return cell
@@ -1283,7 +1331,7 @@ def _cell_paragraph_payloads(
text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS)) text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS))
p_pr = paragraph.find("a:pPr", NS) p_pr = paragraph.find("a:pPr", NS)
align = p_pr.get("algn") if p_pr is not None else None align = p_pr.get("algn") if p_pr is not None else None
if align in {"l", "ctr", "r"}: if align in {"l", "ctr", "r", "just"}:
payloads.append({"text": text, "align": align}) payloads.append({"text": text, "align": align})
else: else:
payloads.append(text) payloads.append(text)
@@ -1378,9 +1426,58 @@ def _native_run_payload(
language = _effective_run_attr(r_pr, default_r_pr, source) language = _effective_run_attr(r_pr, default_r_pr, source)
if language and language.strip(): if language and language.strip():
payload[target] = language.strip() payload[target] = language.strip()
raw_baseline = _effective_run_attr(r_pr, default_r_pr, "baseline")
if raw_baseline is not None:
try:
baseline = int(raw_baseline)
except ValueError:
baseline = 0
if baseline:
payload["baseline_percent"] = _round_payload_number(
baseline / 1000.0
)
outline = _native_run_outline_payload(
_effective_run_child(r_pr, default_r_pr, "ln"),
palette,
)
if outline is not None:
payload["outline"] = outline
return payload return payload
def _paragraph_line_spacing_percent(
p_pr: ET.Element | None,
) -> int | float | None:
if p_pr is None:
return None
spacing = p_pr.find("a:lnSpc/a:spcPct", NS)
raw = spacing.get("val") if spacing is not None else None
if raw is None or not raw.isdigit():
return None
percent = int(raw) / 1000.0
if percent == 100:
return None
return _round_payload_number(percent)
def _run_style_signature(run: dict[str, Any]) -> tuple[tuple[str, Any], ...]:
"""Return one hashable signature for deciding whether runs are required."""
return tuple(
sorted(
(
key,
json.dumps(value, sort_keys=True, separators=(",", ":"))
if isinstance(value, dict)
else value,
)
for key, value in run.items()
if key != "text"
)
)
def _cell_rich_paragraph_payloads( def _cell_rich_paragraph_payloads(
tx_body: ET.Element | None, tx_body: ET.Element | None,
palette: ColorPalette | None, palette: ColorPalette | None,
@@ -1390,25 +1487,31 @@ def _cell_rich_paragraph_payloads(
if tx_body is None or not _legacy_text_body_has_unsupported_formatting(tx_body): if tx_body is None or not _legacy_text_body_has_unsupported_formatting(tx_body):
return None return None
paragraphs: list[tuple[str | None, list[dict[str, Any]]]] = [] paragraphs: list[
tuple[str | None, int | float | None, list[dict[str, Any]]]
] = []
style_signatures: set[tuple[tuple[str, Any], ...]] = set() style_signatures: set[tuple[tuple[str, Any], ...]] = set()
needs_runs = False needs_runs = False
run_only_fields = { run_only_fields = {
"italic", "underline", "strike", "font_family", "lang", "alt_lang", "italic", "underline", "strike", "font_family", "lang", "alt_lang",
"baseline_percent", "outline",
} }
for paragraph in tx_body.findall("a:p", NS): for paragraph in tx_body.findall("a:p", NS):
p_pr = paragraph.find("a:pPr", NS) p_pr = paragraph.find("a:pPr", NS)
align = p_pr.get("algn") if p_pr is not None else None align = p_pr.get("algn") if p_pr is not None else None
if align not in {"l", "ctr", "r"}: if align not in {"l", "ctr", "r", "just"}:
align = None align = None
line_spacing_percent = _paragraph_line_spacing_percent(p_pr)
default_r_pr = p_pr.find("a:defRPr", NS) if p_pr is not None else None default_r_pr = p_pr.find("a:defRPr", NS) if p_pr is not None else None
runs = [ runs = [
_native_run_payload(run, default_r_pr, palette, theme_fonts) _native_run_payload(run, default_r_pr, palette, theme_fonts)
for run in paragraph.findall("a:r", NS) for run in paragraph.findall("a:r", NS)
] ]
paragraphs.append((align, runs)) paragraphs.append((align, line_spacing_percent, runs))
if line_spacing_percent is not None:
needs_runs = True
for run in runs: for run in runs:
style = tuple(sorted((key, value) for key, value in run.items() if key != "text")) style = _run_style_signature(run)
style_signatures.add(style) style_signatures.add(style)
if run_only_fields.intersection(run): if run_only_fields.intersection(run):
needs_runs = True needs_runs = True
@@ -1419,7 +1522,7 @@ def _cell_rich_paragraph_payloads(
return None return None
payloads: list[dict[str, Any]] = [] payloads: list[dict[str, Any]] = []
for align, runs in paragraphs: for align, line_spacing_percent, runs in paragraphs:
paragraph_payload: dict[str, Any] paragraph_payload: dict[str, Any]
if runs: if runs:
paragraph_payload = {"runs": runs} paragraph_payload = {"runs": runs}
@@ -1427,16 +1530,28 @@ def _cell_rich_paragraph_payloads(
paragraph_payload = {"text": ""} paragraph_payload = {"text": ""}
if align is not None: if align is not None:
paragraph_payload["align"] = align paragraph_payload["align"] = align
if line_spacing_percent is not None:
paragraph_payload["line_spacing_percent"] = line_spacing_percent
payloads.append(paragraph_payload) payloads.append(paragraph_payload)
return payloads return payloads
def _cell_fill_hex(tc_pr: ET.Element | None, palette: ColorPalette | None) -> str | None: def _cell_fill_payload(
tc_pr: ET.Element | None,
palette: ColorPalette | None,
) -> tuple[str | None, int | float | None]:
"""Return one normalized solid cell fill and optional opacity."""
fill = resolve_fill(tc_pr, palette) fill = resolve_fill(tc_pr, palette)
color = fill.attrs.get("fill") if fill.attrs else None color = fill.attrs.get("fill") if fill.attrs else None
if color and color.startswith("#"): if not color or not color.startswith("#"):
return color return None, None
return None opacity_raw = fill.attrs.get("fill-opacity")
opacity = (
_round_payload_number(float(opacity_raw))
if opacity_raw is not None
else None
)
return color, opacity
def _cell_text_color(tx_body: ET.Element | None, palette: ColorPalette | None) -> str | None: def _cell_text_color(tx_body: ET.Element | None, palette: ColorPalette | None) -> str | None:
@@ -1483,7 +1598,7 @@ def _cell_align(tx_body: ET.Element | None) -> str | None:
return None return None
p_pr = tx_body.find("a:p/a:pPr", NS) p_pr = tx_body.find("a:p/a:pPr", NS)
align = p_pr.get("algn") if p_pr is not None else None align = p_pr.get("algn") if p_pr is not None else None
if align in {"l", "ctr", "r"}: if align in {"l", "ctr", "r", "just"}:
return align return align
return None return None
@@ -1546,6 +1661,20 @@ def _copy_cell_margins(tc_pr: ET.Element | None, cell: dict[str, Any]) -> None:
cell[target] = _round_payload_number(emu_to_px(value)) cell[target] = _round_payload_number(emu_to_px(value))
def _copy_cell_layout_options(
tc_pr: ET.Element | None,
cell: dict[str, Any],
) -> None:
"""Copy native cell layout semantics not represented by SVG text."""
if tc_pr is None:
return
if tc_pr.get("anchorCtr") is not None:
cell["anchor_center"] = ooxml_bool(tc_pr.get("anchorCtr"))
horizontal_overflow = tc_pr.get("horzOverflow")
if horizontal_overflow is not None:
cell["horizontal_overflow"] = horizontal_overflow
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Cell text & borders # Cell text & borders
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -112,6 +112,7 @@ def import_slide_transition(
slide: SlideRef, slide: SlideRef,
*, *,
media_subdir: str, media_subdir: str,
resource_path_map: dict[str, str] | None = None,
) -> TransitionImport | None: ) -> TransitionImport | None:
"""Read one slide transition and resolve its optional WAV relationship.""" """Read one slide transition and resolve its optional WAV relationship."""
slide_xml = pkg.read_part_bytes(slide.part.path) slide_xml = pkg.read_part_bytes(slide.part.path)
@@ -127,15 +128,19 @@ def import_slide_transition(
media_files: dict[str, bytes] = {} media_files: dict[str, bytes] = {}
relationship_id = readback.summary.sound_relationship_id relationship_id = readback.summary.sound_relationship_id
if relationship_id is not None: if relationship_id is not None:
sound_path, sound_bytes = _resolve_transition_sound( source_part, sound_path, sound_bytes = _resolve_transition_sound(
pkg, pkg,
slide, slide,
relationship_id, relationship_id,
) )
media_files[sound_path] = sound_bytes mapped_path = (resource_path_map or {}).get(source_part)
config["sound"] = ( if mapped_path is not None:
PurePosixPath(media_subdir) / sound_path config["sound"] = mapped_path
).as_posix() else:
media_files[sound_path] = sound_bytes
config["sound"] = (
PurePosixPath(media_subdir) / sound_path
).as_posix()
return TransitionImport(config=config, media_files=media_files) return TransitionImport(config=config, media_files=media_files)
@@ -160,7 +165,7 @@ def _resolve_transition_sound(
pkg: OoxmlPackage, pkg: OoxmlPackage,
slide: SlideRef, slide: SlideRef,
relationship_id: str, relationship_id: str,
) -> tuple[str, bytes]: ) -> tuple[str, str, bytes]:
relationship = slide.part.rels.get(relationship_id) relationship = slide.part.rels.get(relationship_id)
if relationship is None: if relationship is None:
raise TransitionImportError( raise TransitionImportError(
@@ -189,7 +194,7 @@ def _resolve_transition_sound(
f"transition sound part is not a valid WAV file: {target}" f"transition sound part is not a valid WAV file: {target}"
) )
digest = hashlib.sha256(payload).hexdigest()[:16] digest = hashlib.sha256(payload).hexdigest()[:16]
return f"transition_sound_{digest}.wav", payload return target, f"transition_sound_{digest}.wav", payload
__all__ = [ __all__ = [
@@ -1232,7 +1232,10 @@ def _roman_number(value: int) -> str:
def _has_visible_text(paragraphs: list[TextParagraph]) -> bool: def _has_visible_text(paragraphs: list[TextParagraph]) -> bool:
for p in paragraphs: for p in paragraphs:
for r in p.runs: for r in p.runs:
if r.text.strip(): # Structured export writes U+200B only to keep a visually blank
# placeholder carrier alive in DrawingML. Restore that transport
# sentinel to an empty SVG carrier on re-import.
if r.text.replace("\u200b", "").strip():
return True return True
return False return False
@@ -3,8 +3,8 @@
PPT Master - PPTX Transition Core PPT Master - PPTX Transition Core
Provide one strict PowerPoint-native transition registry, a compatibility input Provide one strict PowerPoint-native transition registry, a compatibility input
map, and shared OOXML read/write helpers for generated slides, template-filled map, and shared OOXML read/write helpers for generated and source-preserving
PPTX files, and native PPTX enhancement. round-trip PPTX files.
See references/animations.md for the public workflow and See references/animations.md for the public workflow and
scripts/docs/pptx-transitions.md for the OOXML contract. scripts/docs/pptx-transitions.md for the OOXML contract.
@@ -525,7 +525,11 @@ _TRANSITION_EFFECT_OPTIONS: dict[str, dict[str, dict[str, Any]]] = {
), ),
}, },
"push": { "push": {
"direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS), "direction": _attribute_enum(
"right",
"dir",
{**_CARDINAL_DIRECTIONS, "left": "l"},
),
}, },
"wipe": { "wipe": {
"direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS), "direction": _attribute_enum("right", "dir", _CARDINAL_DIRECTIONS),
@@ -0,0 +1,978 @@
#!/usr/bin/env python3
"""
PPT Master - PPTX Semantic Workspace
Own the semantic on-disk paths and package-resource inventory shared by PPTX
import, template preparation, and source-preserving SVG round trips.
Usage:
Imported by pptx_to_svg.py, pptx_template_import.py, and svg_to_pptx.py.
Examples:
inventory = inventory_package_resources(package)
write_workspace_resources(workspace, inventory)
Dependencies:
None (only uses standard library)
"""
from __future__ import annotations
import hashlib
import io
import json
import posixpath
import re
import zipfile
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from xml.etree import ElementTree as ET
SOURCE_PPTX_PATH = Path("sources/source.pptx")
NATIVE_STRUCTURE_PATH = Path("analysis/native_structure.json")
ROUNDTRIP_MANIFEST_PATH = Path("analysis/roundtrip_manifest.json")
ROUNDTRIP_PAGE_PLAN_PATH = Path("page_plan.json")
TEMPLATE_MANIFEST_PATH = Path("analysis/manifest.json")
CONVERSION_REPORT_PATH = Path("validation/conversion-report.json")
AUTHORING_SVG_FLAT_DIR = Path("authoring-svg-flat")
AUTHORING_SVG_DIR = Path("authoring-svg")
ROUNDTRIP_SVG_ROOT = Path("analysis/roundtrip-svg")
ROUNDTRIP_LAYERED_SVG_DIR = ROUNDTRIP_SVG_ROOT / "layered"
ROUNDTRIP_FLAT_SVG_DIR = ROUNDTRIP_SVG_ROOT / "flat"
REMOVED_WORKSPACE_ENTRIES = (
Path("assets"),
Path("conversion-report.json"),
Path("manifest.json"),
Path("native_structure.json"),
Path("source_template.pptx"),
Path("svg_flat"),
)
IMAGE_EXTENSIONS = frozenset({
".avif",
".bmp",
".emf",
".gif",
".jpeg",
".jpg",
".png",
".svg",
".tif",
".tiff",
".webp",
".wmf",
})
VIDEO_EXTENSIONS = frozenset({
".avi",
".m4v",
".mkv",
".mov",
".mp4",
".mpeg",
".mpg",
".webm",
".wmv",
})
AUDIO_EXTENSIONS = frozenset({
".aac",
".aif",
".aiff",
".flac",
".m4a",
".mp3",
".oga",
".ogg",
".wav",
".wma",
})
_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
_DOC_REL_NS = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships"
)
_TRANSITION_TAG = (
"{http://schemas.openxmlformats.org/presentationml/2006/main}transition"
)
_REL_ATTR_PREFIX = f"{{{_DOC_REL_NS}}}"
_CONTENT_TYPES_NS = (
"http://schemas.openxmlformats.org/package/2006/content-types"
)
_FORMAT_BY_EXTENSION = {
".3mf": frozenset({"3mf"}),
".aac": frozenset({"aac"}),
".aif": frozenset({"aiff"}),
".aiff": frozenset({"aiff"}),
".avif": frozenset({"avif"}),
".avi": frozenset({"avi"}),
".bmp": frozenset({"bmp"}),
".doc": frozenset({"ole"}),
".docm": frozenset({"ooxml-docx"}),
".docx": frozenset({"ooxml-docx"}),
".dotm": frozenset({"ooxml-docx"}),
".dotx": frozenset({"ooxml-docx"}),
".emf": frozenset({"emf"}),
".eps": frozenset({"postscript"}),
".flac": frozenset({"flac"}),
".gif": frozenset({"gif"}),
".glb": frozenset({"glb"}),
".jpeg": frozenset({"jpeg"}),
".jpg": frozenset({"jpeg"}),
".ico": frozenset({"ico"}),
".m4a": frozenset({"iso-bmff"}),
".m4v": frozenset({"iso-bmff"}),
".mkv": frozenset({"ebml"}),
".mov": frozenset({"iso-bmff"}),
".mp3": frozenset({"mp3"}),
".mp4": frozenset({"iso-bmff"}),
".mpeg": frozenset({"mpeg"}),
".mpg": frozenset({"mpeg"}),
".oga": frozenset({"ogg"}),
".ogg": frozenset({"ogg"}),
".png": frozenset({"png"}),
".pdf": frozenset({"pdf"}),
".potm": frozenset({"ooxml-pptx"}),
".potx": frozenset({"ooxml-pptx"}),
".ppt": frozenset({"ole"}),
".pptm": frozenset({"ooxml-pptx"}),
".pptx": frozenset({"ooxml-pptx"}),
".svg": frozenset({"svg"}),
".tif": frozenset({"tiff"}),
".tiff": frozenset({"tiff"}),
".wav": frozenset({"wav"}),
".wdp": frozenset({"wdp"}),
".webm": frozenset({"ebml"}),
".webp": frozenset({"webp"}),
".wma": frozenset({"asf"}),
".wmf": frozenset({"wmf"}),
".wmv": frozenset({"asf"}),
".xls": frozenset({"ole"}),
".xlsb": frozenset({"ooxml-xlsx"}),
".xlsm": frozenset({"ooxml-xlsx"}),
".xlsx": frozenset({"ooxml-xlsx"}),
".xltm": frozenset({"ooxml-xlsx"}),
".xltx": frozenset({"ooxml-xlsx"}),
}
_FORMAT_MEDIA_KIND = {
"aac": "audio",
"aiff": "audio",
"asf": "media",
"avi": "video",
"avif": "image",
"bmp": "image",
"ebml": "video",
"emf": "image",
"flac": "audio",
"gif": "image",
"ico": "image",
"iso-bmff": "media",
"jpeg": "image",
"mp3": "audio",
"mpeg": "video",
"ogg": "media",
"png": "image",
"postscript": "image",
"svg": "image",
"tiff": "image",
"wav": "audio",
"wdp": "image",
"webp": "image",
"wmf": "image",
}
_FORMAT_BY_CONTENT_TYPE = {
"application/pdf": frozenset({"pdf"}),
"application/postscript": frozenset({"postscript"}),
"application/vnd.ms-3mfdocument": frozenset({"3mf"}),
"audio/aac": frozenset({"aac"}),
"audio/aiff": frozenset({"aiff"}),
"audio/flac": frozenset({"flac"}),
"audio/mpeg": frozenset({"mp3"}),
"audio/mp4": frozenset({"iso-bmff"}),
"audio/ogg": frozenset({"ogg"}),
"audio/wav": frozenset({"wav"}),
"audio/x-aiff": frozenset({"aiff"}),
"audio/x-ms-wma": frozenset({"asf"}),
"audio/x-wav": frozenset({"wav"}),
"image/avif": frozenset({"avif"}),
"image/bmp": frozenset({"bmp"}),
"image/gif": frozenset({"gif"}),
"image/jpeg": frozenset({"jpeg"}),
"image/png": frozenset({"png"}),
"image/svg+xml": frozenset({"svg"}),
"image/tiff": frozenset({"tiff"}),
"image/vnd.ms-photo": frozenset({"wdp"}),
"image/vnd.microsoft.icon": frozenset({"ico"}),
"image/webp": frozenset({"webp"}),
"image/x-eps": frozenset({"postscript"}),
"image/x-emf": frozenset({"emf"}),
"image/x-icon": frozenset({"ico"}),
"image/x-wmf": frozenset({"wmf"}),
"model/gltf-binary": frozenset({"glb"}),
"video/mp4": frozenset({"iso-bmff"}),
"video/mpeg": frozenset({"mpeg"}),
"video/quicktime": frozenset({"iso-bmff"}),
"video/webm": frozenset({"ebml"}),
"video/x-matroska": frozenset({"ebml"}),
"video/x-ms-wmv": frozenset({"asf"}),
"video/x-msvideo": frozenset({"avi"}),
}
def source_pptx_path(workspace: Path) -> Path:
"""Return the semantic preserved-source package path."""
return workspace / SOURCE_PPTX_PATH
def native_structure_path(workspace: Path) -> Path:
"""Return the semantic native-structure contract path."""
return workspace / NATIVE_STRUCTURE_PATH
def roundtrip_page_plan_path(workspace: Path) -> Path:
"""Return the optional deck-level round-trip page-plan path."""
return workspace / ROUNDTRIP_PAGE_PLAN_PATH
def template_manifest_path(workspace: Path) -> Path:
"""Return the semantic template-import manifest path."""
return workspace / TEMPLATE_MANIFEST_PATH
def conversion_report_path(workspace: Path) -> Path:
"""Return the semantic conversion-report path."""
return workspace / CONVERSION_REPORT_PATH
def reject_removed_workspace_layout(workspace: Path) -> None:
"""Reject mixed workspaces instead of guessing or migrating old paths."""
if not workspace.is_dir():
return
present = [
path.as_posix()
for path in REMOVED_WORKSPACE_ENTRIES
if (workspace / path).exists()
]
if present:
raise RuntimeError(
"Output workspace uses removed paths: "
+ ", ".join(present)
+ "; choose a clean directory and import again"
)
def _safe_basename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value.strip())
return cleaned.strip("._") or "resource"
def _sha256(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
def _sniff_zip_format(payload: bytes) -> str | None:
"""Return the semantic package family for one ZIP payload."""
try:
with zipfile.ZipFile(io.BytesIO(payload)) as package:
names = set(package.namelist())
except (OSError, zipfile.BadZipFile):
return None
if "xl/workbook.xml" in names or "xl/workbook.bin" in names:
return "ooxml-xlsx"
if "word/document.xml" in names:
return "ooxml-docx"
if "ppt/presentation.xml" in names:
return "ooxml-pptx"
if any(name.lower().endswith(".model") for name in names):
return "3mf"
return "zip"
def _sniff_resource_format(payload: bytes) -> str | None:
"""Identify common PPTX resource formats from their bytes."""
header = payload[:64]
if header.startswith(b"\x89PNG\r\n\x1a\n"):
return "png"
if header.startswith(b"\xff\xd8\xff"):
return "jpeg"
if header.startswith((b"GIF87a", b"GIF89a")):
return "gif"
if header.startswith(b"BM"):
return "bmp"
if header.startswith((b"II*\x00", b"MM\x00*", b"II+\x00", b"MM\x00+")):
return "tiff"
if header.startswith((b"II\xbc\x01", b"MM\x01\xbc")):
return "wdp"
if len(header) >= 44 and header[40:44] == b" EMF":
return "emf"
if header.startswith(b"\xd7\xcd\xc6\x9a"):
return "wmf"
if (
len(header) >= 6
and header[:2] in {b"\x01\x00", b"\x02\x00"}
and header[2:4] == b"\x09\x00"
):
return "wmf"
if header.startswith(b"\x00\x00\x01\x00"):
return "ico"
if header.startswith(b"%PDF-"):
return "pdf"
if header.startswith(b"%!PS-Adobe-"):
return "postscript"
if len(header) >= 12 and header[:4] in {b"RIFF", b"RF64"}:
form = header[8:12]
if form == b"WAVE":
return "wav"
if form == b"AVI ":
return "avi"
if form == b"WEBP":
return "webp"
if header.startswith(b"FORM") and header[8:12] in {b"AIFF", b"AIFC"}:
return "aiff"
if header.startswith(b"fLaC"):
return "flac"
if header.startswith(b"OggS"):
return "ogg"
if header.startswith(b"ID3") or (
len(header) >= 2
and header[0] == 0xFF
and header[1] & 0xE0 == 0xE0
and header[1] & 0x06 != 0
):
return "mp3"
if (
len(header) >= 2
and header[0] == 0xFF
and header[1] & 0xF6 == 0xF0
):
return "aac"
if header.startswith(b"\x30\x26\xb2\x75\x8e\x66\xcf\x11"):
return "asf"
if header.startswith(b"\x1aE\xdf\xa3"):
return "ebml"
if header.startswith(b"glTF"):
return "glb"
if len(header) >= 12 and header[4:8] == b"ftyp":
box_size = int.from_bytes(header[:4], "big")
brands = {
header[offset:offset + 4]
for offset in range(8, min(len(header), box_size or len(header)), 4)
}
if brands & {b"avif", b"avis"}:
return "avif"
return "iso-bmff"
if header.startswith((b"\x00\x00\x01\xba", b"\x00\x00\x01\xb3")):
return "mpeg"
if header.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
return "ole"
if header.startswith(b"PK"):
return _sniff_zip_format(payload)
try:
root = ET.fromstring(payload)
except ET.ParseError:
return None
if root.tag == "svg" or root.tag.endswith("}svg"):
return "svg"
return None
def _package_content_types(
package: zipfile.ZipFile,
) -> tuple[dict[str, str], dict[str, str]]:
"""Read Default and Override declarations from a PPTX package."""
try:
root = ET.fromstring(package.read("[Content_Types].xml"))
except (KeyError, ET.ParseError) as exc:
raise RuntimeError(
"Round-trip source PPTX has an invalid [Content_Types].xml"
) from exc
defaults = {
str(item.get("Extension", "")).lower(): str(item.get("ContentType", ""))
for item in root.findall(f"{{{_CONTENT_TYPES_NS}}}Default")
if item.get("Extension") and item.get("ContentType")
}
overrides = {
str(item.get("PartName", "")).lstrip("/"): str(
item.get("ContentType", "")
)
for item in root.findall(f"{{{_CONTENT_TYPES_NS}}}Override")
if item.get("PartName") and item.get("ContentType")
}
return defaults, overrides
def _expected_content_type_formats(content_type: str) -> frozenset[str]:
normalized = content_type.partition(";")[0].strip().lower()
known = _FORMAT_BY_CONTENT_TYPE.get(normalized)
if known is not None:
return known
if "spreadsheetml" in normalized:
return frozenset({"ooxml-xlsx"})
if "wordprocessingml" in normalized:
return frozenset({"ooxml-docx"})
if "presentationml" in normalized:
return frozenset({"ooxml-pptx"})
return frozenset()
def _validate_changed_resource_format(
*,
spec: WorkspaceResourceSpec,
payload: bytes,
source_payload: bytes,
content_type: str,
) -> None:
"""Refuse changed bytes that no longer fit their source package part."""
suffix = PurePosixPath(spec.package_part).suffix.lower()
actual_format = _sniff_resource_format(payload)
source_format = _sniff_resource_format(source_payload)
expected_by_extension = _FORMAT_BY_EXTENSION.get(suffix, frozenset())
expected_by_content_type = _expected_content_type_formats(content_type)
declared_media_kind = content_type.partition("/")[0].lower()
actual_media_kind = _FORMAT_MEDIA_KIND.get(actual_format or "")
has_declared_expectation = bool(
source_format
or expected_by_extension
or expected_by_content_type
or declared_media_kind in {"audio", "image", "video"}
)
mismatch = actual_format is None or not has_declared_expectation or (
actual_format is not None
and (
(source_format is not None and actual_format != source_format)
or (
bool(expected_by_extension)
and actual_format not in expected_by_extension
)
or (
bool(expected_by_content_type)
and actual_format not in expected_by_content_type
)
or (
declared_media_kind in {"audio", "image", "video"}
and actual_media_kind not in {declared_media_kind, "media"}
)
)
)
if not mismatch:
return
detected = actual_format or "unrecognized"
raise RuntimeError(
"Changed round-trip resource format does not match its source part: "
f"{spec.workspace_path.as_posix()} is {detected}, but "
f"{spec.package_part} uses extension {suffix or '<none>'} and "
f"Content-Type {content_type!r}"
)
def _source_part_for_relationships(rels_path: str) -> str | None:
if rels_path == "_rels/.rels":
return None
marker = "/_rels/"
if marker not in rels_path or not rels_path.endswith(".rels"):
return None
parent, filename = rels_path.split(marker, 1)
return f"{parent}/{filename[:-5]}"
def _resolve_relationship_target(source_part: str | None, target: str) -> str:
normalized = target.replace("\\", "/")
if normalized.startswith("/"):
return normalized.lstrip("/")
base_dir = posixpath.dirname(source_part or "")
return posixpath.normpath(posixpath.join(base_dir, normalized)).lstrip("/")
def _transition_relationship_ids(
package: zipfile.ZipFile,
source_part: str | None,
) -> set[str]:
if source_part is None or not source_part.startswith("ppt/slides/"):
return set()
try:
root = ET.fromstring(package.read(source_part))
except (KeyError, ET.ParseError):
return set()
ids: set[str] = set()
for transition in root.iter(_TRANSITION_TAG):
for node in transition.iter():
for name, value in node.attrib.items():
if name.startswith(_REL_ATTR_PREFIX) and value:
ids.add(value)
return ids
@dataclass(frozen=True)
class PackageResource:
"""One source package payload exposed through a semantic workspace path."""
package_part: str
kind: str
workspace_path: str
payload: bytes
relationship_types: tuple[str, ...] = ()
source_parts: tuple[str, ...] = ()
owner_parts: tuple[str, ...] = ()
@property
def sha256(self) -> str:
return _sha256(self.payload)
def manifest_row(self, *, materialized: bool = True) -> dict[str, object]:
"""Return the compact machine-readable inventory record."""
return {
"packagePart": self.package_part,
"kind": self.kind,
"workspacePath": self.workspace_path,
"sha256": self.sha256,
"bytes": len(self.payload),
"relationshipTypes": list(self.relationship_types),
"sourceParts": list(self.source_parts),
"ownerParts": list(self.owner_parts),
"materialized": materialized,
}
@dataclass(frozen=True)
class PackageResourceInventory:
"""Deterministic semantic projection of source package payloads."""
resources: tuple[PackageResource, ...] = ()
def path_map(self) -> dict[str, str]:
"""Map source package part names to workspace-relative paths."""
return {
resource.package_part: resource.workspace_path
for resource in self.resources
}
def image_name_map(self) -> dict[str, str]:
"""Map package image parts to basenames used by SVG hrefs."""
return {
resource.package_part: PurePosixPath(resource.workspace_path).name
for resource in self.resources
if resource.kind == "image"
}
def manifest(self, *, include_images: bool = True) -> dict[str, object]:
"""Return the versioned resource inventory payload."""
return {
"schema": "ppt-master.workspace-resources.v1",
"items": [
resource.manifest_row(
materialized=include_images or resource.kind != "image",
)
for resource in self.resources
],
}
@dataclass(frozen=True)
class WorkspaceResourceSpec:
"""One semantic resource mapped back to its source package part."""
package_part: str
kind: str
workspace_path: Path
materialized: bool
source_sha256: str
current_sha256: str | None
owner_parts: tuple[str, ...]
@property
def changed(self) -> bool:
"""Return whether materialized workspace bytes differ from import."""
return (
self.materialized
and self.current_sha256 is not None
and self.current_sha256 != self.source_sha256
)
def workspace_resource_specs(
workspace: Path,
manifest: dict[str, object],
) -> tuple[WorkspaceResourceSpec, ...]:
"""Validate and resolve the resource map used by round-trip export."""
resources = manifest.get("resources")
if not isinstance(resources, dict):
raise RuntimeError("Round-trip manifest resources must be an object")
if resources.get("schema") != "ppt-master.workspace-resources.v1":
raise RuntimeError(
"Unsupported round-trip resource schema: "
f"{resources.get('schema')!r}"
)
items = resources.get("items")
if not isinstance(items, list):
raise RuntimeError("Round-trip manifest resources.items must be an array")
workspace_root = workspace.resolve()
specs: list[WorkspaceResourceSpec] = []
changed_payloads: list[tuple[WorkspaceResourceSpec, bytes]] = []
seen_package_parts: set[str] = set()
for index, raw in enumerate(items):
context = f"round-trip resources.items[{index}]"
if not isinstance(raw, dict):
raise RuntimeError(f"{context} must be an object")
package_part = raw.get("packagePart")
kind = raw.get("kind")
workspace_path = raw.get("workspacePath")
materialized = raw.get("materialized")
source_sha256 = raw.get("sha256")
raw_owner_parts = raw.get("ownerParts")
if not isinstance(package_part, str) or not package_part:
raise RuntimeError(f"{context}.packagePart must be a non-empty string")
package_path = PurePosixPath(package_part)
if (
package_path.is_absolute()
or ".." in package_path.parts
or "\\" in package_part
or not any(
package_part.startswith(prefix)
for prefix in (
"ppt/media/",
"ppt/embeddings/",
"ppt/model3d/",
)
)
):
raise RuntimeError(
f"{context}.packagePart is outside the supported PPTX payload roots"
)
if package_part in seen_package_parts:
raise RuntimeError(f"{context} repeats package part {package_part!r}")
if not isinstance(kind, str) or not kind:
raise RuntimeError(f"{context}.kind must be a non-empty string")
if not isinstance(workspace_path, str) or not workspace_path:
raise RuntimeError(f"{context}.workspacePath must be a non-empty string")
relative = Path(workspace_path)
if (
relative.drive
or relative.anchor
or relative.is_absolute()
or ".." in relative.parts
):
raise RuntimeError(f"{context}.workspacePath must stay project-relative")
resolved = (workspace_root / relative).resolve()
try:
resolved.relative_to(workspace_root)
except ValueError as exc:
raise RuntimeError(
f"{context}.workspacePath resolves outside the project"
) from exc
if not isinstance(materialized, bool):
raise RuntimeError(f"{context}.materialized must be a boolean")
if materialized and not resolved.is_file():
raise RuntimeError(
f"Materialized round-trip resource is missing: {workspace_path}"
)
if (
not isinstance(source_sha256, str)
or re.fullmatch(r"[0-9a-f]{64}", source_sha256) is None
):
raise RuntimeError(f"{context}.sha256 must be a lowercase SHA-256")
if not isinstance(raw_owner_parts, list) or not all(
isinstance(value, str) and value
for value in raw_owner_parts
):
raise RuntimeError(f"{context}.ownerParts must be an array of parts")
payload = resolved.read_bytes() if materialized else None
current_sha256 = _sha256(payload) if payload is not None else None
spec = WorkspaceResourceSpec(
package_part=package_part,
kind=kind,
workspace_path=relative,
materialized=materialized,
source_sha256=source_sha256,
current_sha256=current_sha256,
owner_parts=tuple(raw_owner_parts),
)
specs.append(spec)
if payload is not None and spec.changed:
changed_payloads.append((spec, payload))
seen_package_parts.add(package_part)
if changed_payloads:
source_path = source_pptx_path(workspace_root)
if not source_path.is_file():
raise RuntimeError(
"Changed round-trip resources require the preserved source PPTX: "
f"{source_path}"
)
try:
with zipfile.ZipFile(source_path) as package:
defaults, overrides = _package_content_types(package)
names = set(package.namelist())
for spec, payload in changed_payloads:
if spec.package_part not in names:
raise RuntimeError(
"Round-trip source package part is missing: "
f"{spec.package_part}"
)
suffix = (
PurePosixPath(spec.package_part).suffix.lstrip(".").lower()
)
content_type = overrides.get(spec.package_part) or defaults.get(
suffix,
)
if not content_type:
raise RuntimeError(
"Round-trip source package part has no Content-Type: "
f"{spec.package_part}"
)
_validate_changed_resource_format(
spec=spec,
payload=payload,
source_payload=package.read(spec.package_part),
content_type=content_type,
)
except zipfile.BadZipFile as exc:
raise RuntimeError(
f"Round-trip source PPTX is not a valid ZIP package: {source_path}"
) from exc
return tuple(specs)
def _is_semantic_owner_part(package_part: str) -> bool:
return any(
package_part.startswith(prefix)
for prefix in (
"ppt/slides/slide",
"ppt/slideLayouts/slideLayout",
"ppt/slideMasters/slideMaster",
"ppt/notesSlides/notesSlide",
)
) and package_part.endswith(".xml")
def _resource_owner_parts(
package_part: str,
parents_by_target: dict[str, set[str]],
) -> tuple[str, ...]:
"""Resolve Slide/Layout/Master/Notes owners through relationship chains."""
owners: set[str] = set()
visited = {package_part}
pending = [package_part]
while pending:
current = pending.pop()
for parent in parents_by_target.get(current, set()):
if parent in visited:
continue
visited.add(parent)
if _is_semantic_owner_part(parent):
owners.add(parent)
else:
pending.append(parent)
return tuple(sorted(owners))
def _classify_resource(
package_part: str,
relationship_types: set[str],
*,
transition_only: bool,
) -> tuple[str, Path]:
suffix = PurePosixPath(package_part).suffix.lower()
if suffix in IMAGE_EXTENSIONS:
return "image", Path("images")
if (
suffix in VIDEO_EXTENSIONS
or any(rel_type.endswith("/video") for rel_type in relationship_types)
):
return "video", Path("video")
if suffix in AUDIO_EXTENSIONS:
if transition_only:
return "sound", Path("sounds")
return "audio", Path("audio")
if package_part.startswith("ppt/embeddings/"):
return "native-payload", Path("native-payloads/embeddings")
if package_part.startswith("ppt/model3d/"):
return "native-payload", Path("native-payloads/model3d")
return "native-payload", Path("native-payloads/media")
def inventory_package_resources(
package: zipfile.ZipFile,
) -> PackageResourceInventory:
"""Classify reusable and opaque PPTX payloads into semantic directories."""
references: dict[str, list[dict[str, object]]] = defaultdict(list)
parents_by_target: dict[str, set[str]] = defaultdict(set)
names = set(package.namelist())
for rels_path in sorted(name for name in names if name.endswith(".rels")):
source_part = _source_part_for_relationships(rels_path)
transition_ids = _transition_relationship_ids(package, source_part)
try:
root = ET.fromstring(package.read(rels_path))
except (KeyError, ET.ParseError):
continue
for relationship in root.findall(f"{{{_REL_NS}}}Relationship"):
if relationship.attrib.get("TargetMode") == "External":
continue
rel_id = relationship.attrib.get("Id", "")
rel_type = relationship.attrib.get("Type", "")
target = relationship.attrib.get("Target", "")
if not rel_id or not rel_type or not target:
continue
resolved = _resolve_relationship_target(source_part, target)
if source_part:
parents_by_target[resolved].add(source_part)
references[resolved].append({
"relationshipType": rel_type,
"sourcePart": source_part or "",
"transition": rel_id in transition_ids,
})
candidate_parts = sorted(
name
for name in names
if not name.endswith("/")
and (
name.startswith("ppt/media/")
or name.startswith("ppt/embeddings/")
or name.startswith("ppt/model3d/")
)
)
allocated: dict[tuple[str, str], list[tuple[str, str]]] = defaultdict(list)
resources: list[PackageResource] = []
for package_part in candidate_parts:
rows = references.get(package_part, [])
relationship_types = {
str(row["relationshipType"])
for row in rows
if row.get("relationshipType")
}
transition_flags = [bool(row.get("transition")) for row in rows]
transition_only = bool(transition_flags) and all(transition_flags)
kind, directory = _classify_resource(
package_part,
relationship_types,
transition_only=transition_only,
)
payload = package.read(package_part)
digest = _sha256(payload)
original_name = _safe_basename(PurePosixPath(package_part).name)
key = (directory.as_posix(), original_name.lower())
allocations = allocated[key]
existing_name = next(
(name for known_digest, name in allocations if known_digest == digest),
None,
)
if existing_name is None:
stem = Path(original_name).stem
suffix = Path(original_name).suffix
existing_name = (
original_name
if not allocations
else f"{stem}_{len(allocations) + 1}{suffix}"
)
allocations.append((digest, existing_name))
workspace_path = (directory / existing_name).as_posix()
resources.append(PackageResource(
package_part=package_part,
kind=kind,
workspace_path=workspace_path,
payload=payload,
relationship_types=tuple(sorted(relationship_types)),
source_parts=tuple(sorted({
str(row["sourcePart"])
for row in rows
if row.get("sourcePart")
})),
owner_parts=_resource_owner_parts(
package_part,
parents_by_target,
),
))
return PackageResourceInventory(resources=tuple(resources))
def write_workspace_resources(
workspace: Path,
inventory: PackageResourceInventory,
*,
include_images: bool = True,
) -> tuple[str, ...]:
"""Write the exact resource inventory without overwriting different bytes."""
written: list[str] = []
for resource in inventory.resources:
if resource.kind == "image" and not include_images:
continue
relative = Path(resource.workspace_path)
target = workspace / relative
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
if not target.is_file() or target.read_bytes() != resource.payload:
raise RuntimeError(
"Semantic resource path collides with different content: "
f"{relative}"
)
else:
target.write_bytes(resource.payload)
written.append(relative.as_posix())
return tuple(written)
def load_roundtrip_manifest(workspace: Path) -> dict[str, object] | None:
"""Load the semantic round-trip manifest when present."""
path = workspace / ROUNDTRIP_MANIFEST_PATH
if not path.is_file():
return None
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise RuntimeError(f"Cannot read round-trip manifest {path}: {exc}") from exc
if not isinstance(raw, dict):
raise RuntimeError(f"Round-trip manifest must be a JSON object: {path}")
return raw
def slide_animation_config_sha256(
config: dict[str, object],
slide_stem: str,
) -> str:
"""Hash global motion settings plus one slide's animation configuration."""
slides = config.get("slides")
slide_config = slides.get(slide_stem) if isinstance(slides, dict) else None
payload = {
"global": {
key: value
for key, value in config.items()
if key != "slides"
},
"slide": slide_config,
}
serialized = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return _sha256(serialized)
__all__ = [
"AUDIO_EXTENSIONS",
"CONVERSION_REPORT_PATH",
"REMOVED_WORKSPACE_ENTRIES",
"IMAGE_EXTENSIONS",
"NATIVE_STRUCTURE_PATH",
"PackageResource",
"PackageResourceInventory",
"ROUNDTRIP_MANIFEST_PATH",
"ROUNDTRIP_PAGE_PLAN_PATH",
"SOURCE_PPTX_PATH",
"TEMPLATE_MANIFEST_PATH",
"VIDEO_EXTENSIONS",
"WorkspaceResourceSpec",
"conversion_report_path",
"inventory_package_resources",
"load_roundtrip_manifest",
"native_structure_path",
"reject_removed_workspace_layout",
"roundtrip_page_plan_path",
"source_pptx_path",
"slide_animation_config_sha256",
"template_manifest_path",
"write_workspace_resources",
"workspace_resource_specs",
]
@@ -225,9 +225,9 @@ def _looks_like_image_path(raw: str) -> bool:
def parse_spec_lock_image_value(key: str, value: str) -> dict[str, str]: def parse_spec_lock_image_value(key: str, value: str) -> dict[str, str]:
"""Parse one image-lock row while preserving supported legacy rows. """Parse one image-lock row while preserving supported legacy rows.
Current rows use ``<path> | source=... | pattern=... | crop=...``. Legacy Current rows use ``<path> | source=... | crop=...`` and may retain the
rows remain readable, but any row that starts using named metadata must legacy ``pattern=...`` projection. Legacy rows remain readable, but any row
provide the complete current contract. that starts using named metadata must provide source and crop.
""" """
normalized_key = str(key).strip() normalized_key = str(key).strip()
normalized_value = str(value).strip() normalized_value = str(value).strip()
@@ -274,9 +274,10 @@ def parse_spec_lock_image_value(key: str, value: str) -> dict[str, str]:
raise ValueError(f"repeats metadata field {field!r}") raise ValueError(f"repeats metadata field {field!r}")
metadata[field] = raw.strip() metadata[field] = raw.strip()
expected_fields = {"source", "pattern", "crop"} allowed_fields = {"source", "pattern", "crop"}
unknown_fields = sorted(set(metadata) - expected_fields) required_fields = {"source", "crop"}
missing_fields = sorted(expected_fields - set(metadata)) unknown_fields = sorted(set(metadata) - allowed_fields)
missing_fields = sorted(required_fields - set(metadata))
if unsupported_parts: if unsupported_parts:
shown = ", ".join(repr(part) for part in unsupported_parts) shown = ", ".join(repr(part) for part in unsupported_parts)
raise ValueError(f"has unsupported metadata token(s) {shown}") raise ValueError(f"has unsupported metadata token(s) {shown}")
@@ -309,7 +310,7 @@ def parse_spec_lock_image_value(key: str, value: str) -> dict[str, str]:
if source not in _IMAGE_ACQUISITION_SOURCES: if source not in _IMAGE_ACQUISITION_SOURCES:
allowed = ", ".join(sorted(_IMAGE_ACQUISITION_SOURCES)) allowed = ", ".join(sorted(_IMAGE_ACQUISITION_SOURCES))
raise ValueError(f"source must be one of {allowed}, got {metadata['source']!r}") raise ValueError(f"source must be one of {allowed}, got {metadata['source']!r}")
if not metadata["pattern"]: if "pattern" in metadata and not metadata["pattern"]:
raise ValueError("pattern must be non-empty") raise ValueError("pattern must be non-empty")
crop = metadata["crop"].casefold() crop = metadata["crop"].casefold()
if crop not in _IMAGE_CROP_POLICIES: if crop not in _IMAGE_CROP_POLICIES:
@@ -319,7 +320,7 @@ def parse_spec_lock_image_value(key: str, value: str) -> dict[str, str]:
return { return {
"path": normalized_path, "path": normalized_path,
"source": source, "source": source,
"pattern": metadata["pattern"], "pattern": metadata.get("pattern", ""),
"crop": crop, "crop": crop,
"legacy": "false", "legacy": "false",
} }
@@ -333,8 +334,9 @@ def parse_spec_lock_artifact(
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Parse one execution lock and normalize supported legacy image rows. """Parse one execution lock and normalize supported legacy image rows.
New locks use ``- <key>: <path> | source=... | pattern=... | crop=...``. Current locks use ``- <key>: <path> | source=... | crop=...`` and may retain
Some versioned projects instead placed the image path before the colon. the legacy ``pattern=...`` projection. Some versioned projects instead
placed the image path before the colon.
Preserve those projects by projecting the key path back into the value so Preserve those projects by projecting the key path back into the value so
every consumer sees the same path-first image value. every consumer sees the same path-first image value.
""" """
@@ -374,7 +376,7 @@ def parse_spec_lock_artifact(
compatibility_warnings.append( compatibility_warnings.append(
f"{lock_path.name} images: normalized {len(compatibility_keys)} legacy " f"{lock_path.name} images: normalized {len(compatibility_keys)} legacy "
"path-as-key row(s); new locks should use '- <key>: <path> | " "path-as-key row(s); new locks should use '- <key>: <path> | "
"source=... | pattern=... | crop=...' " "source=... | crop=...' "
f"(found: {sample}{suffix})" f"(found: {sample}{suffix})"
) )
return normalized_sections return normalized_sections
@@ -407,11 +407,11 @@ def validate_project_structure(
# Check required files # Check required files
if not (project_path / 'README.md').exists(): if not (project_path / 'README.md').exists():
msg = "Missing required file: README.md" msg = "Missing README.md (optional project notes; nothing reads it)"
if use_helper and verbose: if use_helper and verbose:
msg += "\n" + ErrorHelper.format_error_message('missing_readme', msg += "\n" + ErrorHelper.format_error_message('missing_readme',
{'project_path': str(project_path)}) {'project_path': str(project_path)})
errors.append(msg) warnings.append(msg)
# Check design specification file # Check design specification file
has_spec = any((project_path / name).exists() for name in _DESIGN_SPEC_NAMES) has_spec = any((project_path / name).exists() for name in _DESIGN_SPEC_NAMES)
@@ -24,20 +24,20 @@
"skills/ppt-master/references/executor-visualization.md": 1250, "skills/ppt-master/references/executor-visualization.md": 1250,
"skills/ppt-master/references/executor-structure.md": 2250, "skills/ppt-master/references/executor-structure.md": 2250,
"skills/ppt-master/references/topology-assembly.md": 3750, "skills/ppt-master/references/topology-assembly.md": 3750,
"skills/ppt-master/references/executor-table.md": 1000, "skills/ppt-master/references/executor-table.md": 1250,
"skills/ppt-master/references/executor-image.md": 2000, "skills/ppt-master/references/executor-image.md": 2000,
"skills/ppt-master/references/executor-web-image.md": 750, "skills/ppt-master/references/executor-web-image.md": 750,
"skills/ppt-master/references/executor-notes.md": 1000, "skills/ppt-master/references/executor-notes.md": 1000,
"skills/ppt-master/references/shared-standards.md": 500, "skills/ppt-master/references/shared-standards.md": 500,
"skills/ppt-master/references/shared-standards-core.md": 13000, "skills/ppt-master/references/shared-standards-core.md": 13000,
"skills/ppt-master/references/svg-effects.md": 16000, "skills/ppt-master/references/svg-effects.md": 16000,
"skills/ppt-master/references/native-data-interface.md": 7500, "skills/ppt-master/references/native-data-interface.md": 7750,
"skills/ppt-master/references/pptx-structure-interface.md": 4750, "skills/ppt-master/references/pptx-structure-interface.md": 4750,
"skills/ppt-master/references/preset-shape-vocabulary.md": 2750, "skills/ppt-master/references/preset-shape-vocabulary.md": 2750,
"skills/ppt-master/references/strategist.md": 18000, "skills/ppt-master/references/strategist.md": 18000,
"skills/ppt-master/references/strategist-image.md": 3250, "skills/ppt-master/references/strategist-image.md": 3250,
"skills/ppt-master/references/strategist-template.md": 3000, "skills/ppt-master/references/strategist-template.md": 3000,
"skills/ppt-master/templates/design_spec_reference.md": 3750, "skills/ppt-master/templates/design_spec_reference.md": 4000,
"skills/ppt-master/templates/spec_lock_reference.md": 2750, "skills/ppt-master/templates/spec_lock_reference.md": 2750,
"skills/ppt-master/templates/charts/chart-vocabulary.md": 1250, "skills/ppt-master/templates/charts/chart-vocabulary.md": 1250,
"skills/ppt-master/templates/tables/table-vocabulary.md": 500, "skills/ppt-master/templates/tables/table-vocabulary.md": 500,
@@ -121,7 +121,7 @@
"max_tokens": 83000 "max_tokens": 83000
}, },
"route.create-template.layout": { "route.create-template.layout": {
"description": "Create Layout standard/fidelity path through Template_Designer, the strategy-triggered native-shape bundle, SVG core, and the structured PPTX interface; mirror excludes the authored bundle.", "description": "Create Layout standard/fidelity path through Template_Designer, the strategy-triggered native-shape bundle, SVG core, and the structured PPTX interface; mirror excludes the authored bundle. Ceiling raised after BUDGET_LOAD_SET reported 76053 tokens.",
"scope": "cumulative", "scope": "cumulative",
"include": [ "include": [
"bootstrap.routing" "bootstrap.routing"
@@ -138,27 +138,16 @@
"skills/ppt-master/templates/README.md", "skills/ppt-master/templates/README.md",
"skills/ppt-master/templates/layouts/README.md" "skills/ppt-master/templates/layouts/README.md"
], ],
"max_tokens": 76000 "max_tokens": 77000
}, },
"route.enhance-native-pptx": { "route.edit-native-pptx": {
"description": "Finished-PPTX native enhancement route.", "description": "Edit Native PPTX route: fill, edit, restructure, or enhance an existing deck through the source-preserving round-trip workspace.",
"scope": "cumulative", "scope": "cumulative",
"include": [ "include": [
"bootstrap.routing" "bootstrap.routing"
], ],
"files": [ "files": [
"skills/ppt-master/workflows/native-enhance-pptx.md" "skills/ppt-master/workflows/edit-native-pptx.md"
],
"max_tokens": 13000
},
"route.fill-native-pptx": {
"description": "Raw-PPTX native fill route.",
"scope": "cumulative",
"include": [
"bootstrap.routing"
],
"files": [
"skills/ppt-master/workflows/template-fill-pptx.md"
], ],
"max_tokens": 14000 "max_tokens": 14000
}, },
@@ -307,13 +296,15 @@
"max_tokens": 175000 "max_tokens": 175000
}, },
"route.generate.quick-generate.template-fusion": { "route.generate.quick-generate.template-fusion": {
"description": "Quick Generate with the maximum Brand, Style, Layout, and Deck exact-root fusion loaded for flat authoring. Ceiling raised after BUDGET_LOAD_SET reported 125689 tokens.", "description": "Quick Generate with the maximum Brand, Style, Layout, and Deck exact-root fusion; installed Layout/Deck ownership determines flat or structured authoring. Ceiling raised after BUDGET_LOAD_SET reported 125689 tokens.",
"scope": "cumulative", "scope": "cumulative",
"include": [ "include": [
"route.generate.quick-generate", "route.generate.quick-generate",
"stage.generate.template.fusion-brand-style-layout-deck" "stage.generate.template.fusion-brand-style-layout-deck"
], ],
"files": [], "files": [
"skills/ppt-master/references/pptx-structure-interface.md"
],
"max_tokens": 140000 "max_tokens": 140000
}, },
"route.generate.quick-generate.topic-research": { "route.generate.quick-generate.topic-research": {
@@ -626,11 +617,44 @@
"files": [], "files": [],
"max_tokens": 215000 "max_tokens": 215000
}, },
"route.enhance-native-pptx.audio": { "route.edit-native-pptx.edit": {
"description": "Enhance Native PPTX with the shared narration-audio stage.", "description": "Edit Native PPTX with the always-loaded shared standards for page editing.",
"scope": "cumulative", "scope": "cumulative",
"include": [ "include": [
"route.enhance-native-pptx", "route.edit-native-pptx"
],
"files": [
"skills/ppt-master/references/shared-standards-core.md"
],
"max_tokens": 26000
},
"route.edit-native-pptx.edit-effects": {
"description": "Edit Native PPTX page editing that authors new visual elements.",
"scope": "cumulative",
"include": [
"route.edit-native-pptx.edit"
],
"files": [
"skills/ppt-master/references/svg-effects.md"
],
"max_tokens": 42000
},
"route.edit-native-pptx.edit-native-data": {
"description": "Edit Native PPTX page editing that changes native chart or table data.",
"scope": "cumulative",
"include": [
"route.edit-native-pptx.edit"
],
"files": [
"skills/ppt-master/references/native-data-interface.md"
],
"max_tokens": 34000
},
"route.edit-native-pptx.audio": {
"description": "Edit Native PPTX with the shared narration-audio stage.",
"scope": "cumulative",
"include": [
"route.edit-native-pptx",
"stage.shared.generate-audio" "stage.shared.generate-audio"
], ],
"files": [], "files": [],
@@ -929,7 +953,7 @@
"max_tokens": 3000 "max_tokens": 3000
}, },
"stage.generate.customize-animations": { "stage.generate.customize-animations": {
"description": "Object-animation stage plus animation resolution rules.", "description": "Object-animation stage plus animation resolution rules. Ceiling raised after BUDGET_LOAD_SET reported 35742 tokens.",
"scope": "incremental", "scope": "incremental",
"files": [ "files": [
"skills/ppt-master/workflows/stages/customize-animations.md", "skills/ppt-master/workflows/stages/customize-animations.md",
@@ -938,7 +962,7 @@
"skills/ppt-master/scripts/docs/pptx-transitions.md", "skills/ppt-master/scripts/docs/pptx-transitions.md",
"skills/ppt-master/scripts/docs/svg-pipeline.md" "skills/ppt-master/scripts/docs/svg-pipeline.md"
], ],
"max_tokens": 35000 "max_tokens": 40000
}, },
"stage.generate.sound-selection": { "stage.generate.sound-selection": {
"description": "Complete post-motion sound identity vocabulary after a concrete auditory cue job exists.", "description": "Complete post-motion sound identity vocabulary after a concrete auditory cue job exists.",
@@ -1023,7 +1047,7 @@
"route.generate.planning-ai" "route.generate.planning-ai"
], ],
"files": [], "files": [],
"max_tokens": 120000 "max_tokens": 125000
}, },
"stage.generate.executor.chart": { "stage.generate.executor.chart": {
"description": "Conditional value-driven chart execution rules.", "description": "Conditional value-driven chart execution rules.",
@@ -1042,12 +1066,12 @@
"max_tokens": 1250 "max_tokens": 1250
}, },
"stage.generate.executor.table": { "stage.generate.executor.table": {
"description": "Conditional semantic cell-grid construction rules.", "description": "Conditional semantic cell-grid construction rules. Ceiling raised after the native projection step was added (BUDGET_FILE/BUDGET_LOAD_SET reported 1022 tokens).",
"scope": "incremental", "scope": "incremental",
"files": [ "files": [
"skills/ppt-master/references/executor-table.md" "skills/ppt-master/references/executor-table.md"
], ],
"max_tokens": 1000 "max_tokens": 1250
}, },
"stage.generate.strategist.template": { "stage.generate.strategist.template": {
"description": "Conditional Strategist module for an explicitly installed template workspace.", "description": "Conditional Strategist module for an explicitly installed template workspace.",
@@ -1058,12 +1082,12 @@
"max_tokens": 3000 "max_tokens": 3000
}, },
"stage.generate.executor.native-data": { "stage.generate.executor.native-data": {
"description": "Conditional preset-pattern and native chart/table metadata interface. Ceiling raised after BUDGET_FILE and BUDGET_LOAD_SET reported 6774 tokens.", "description": "Conditional preset-pattern and native chart/table metadata interface. Ceiling raised after BUDGET_FILE and BUDGET_LOAD_SET reported 6774 tokens. Ceiling raised again after the table-payload completeness and ChartEx title clauses were added (7573 tokens).",
"scope": "incremental", "scope": "incremental",
"files": [ "files": [
"skills/ppt-master/references/native-data-interface.md" "skills/ppt-master/references/native-data-interface.md"
], ],
"max_tokens": 7500 "max_tokens": 7750
}, },
"stage.generate.executor.formula": { "stage.generate.executor.formula": {
"description": "Conditional direct authoring contract for PowerPoint-native inline and block formulas.", "description": "Conditional direct authoring contract for PowerPoint-native inline and block formulas.",
@@ -1525,6 +1549,111 @@
"concern": "spec-lock-schema", "concern": "spec-lock-schema",
"from": "skills/ppt-master/templates/spec_lock_reference.md", "from": "skills/ppt-master/templates/spec_lock_reference.md",
"to": "skills/ppt-master/templates/schemas/spec_lock.schema.json" "to": "skills/ppt-master/templates/schemas/spec_lock.schema.json"
},
{
"concern": "create-template-workflow",
"from": "skills/ppt-master/workflows/routing.md",
"to": "skills/ppt-master/workflows/create-template.md"
},
{
"concern": "edit-native-pptx-workflow",
"from": "skills/ppt-master/workflows/routing.md",
"to": "skills/ppt-master/workflows/edit-native-pptx.md"
},
{
"concern": "image-to-pptx-profile",
"from": "skills/ppt-master/workflows/routing.md",
"to": "skills/ppt-master/workflows/profiles/image-to-pptx.md"
},
{
"concern": "beautify-profile",
"from": "skills/ppt-master/workflows/routing.md",
"to": "skills/ppt-master/workflows/profiles/beautify-pptx.md"
},
{
"concern": "create-brand-dispatch",
"from": "skills/ppt-master/workflows/create-template.md",
"to": "skills/ppt-master/workflows/create-template/create-brand.md"
},
{
"concern": "create-style-dispatch",
"from": "skills/ppt-master/workflows/create-template.md",
"to": "skills/ppt-master/workflows/create-template/create-style.md"
},
{
"concern": "create-layout-dispatch",
"from": "skills/ppt-master/workflows/create-template.md",
"to": "skills/ppt-master/workflows/create-template/create-layout.md"
},
{
"concern": "create-deck-dispatch",
"from": "skills/ppt-master/workflows/create-template.md",
"to": "skills/ppt-master/workflows/create-template/create-deck.md"
},
{
"concern": "quick-artifact-ownership",
"from": "skills/ppt-master/workflows/profiles/quick-generate.md",
"to": "skills/ppt-master/references/artifact-ownership.md"
},
{
"concern": "quick-svg-core-contract",
"from": "skills/ppt-master/workflows/profiles/quick-generate.md",
"to": "skills/ppt-master/references/shared-standards-core.md"
},
{
"concern": "quick-svg-effects-contract",
"from": "skills/ppt-master/workflows/profiles/quick-generate.md",
"to": "skills/ppt-master/references/svg-effects.md"
},
{
"concern": "quick-native-shape-contract",
"from": "skills/ppt-master/workflows/profiles/quick-generate.md",
"to": "skills/ppt-master/references/native-shape-authoring.md"
},
{
"concern": "quick-preset-vocabulary",
"from": "skills/ppt-master/workflows/profiles/quick-generate.md",
"to": "skills/ppt-master/references/preset-shape-vocabulary.md"
},
{
"concern": "quick-semantic-svg-contract",
"from": "skills/ppt-master/workflows/profiles/quick-generate.md",
"to": "skills/ppt-master/references/semantic-svg.md"
},
{
"concern": "quick-structure-routing",
"from": "skills/ppt-master/workflows/profiles/quick-generate.md",
"to": "skills/ppt-master/references/executor-structure.md"
},
{
"concern": "quick-topology-routing",
"from": "skills/ppt-master/workflows/profiles/quick-generate.md",
"to": "skills/ppt-master/references/topology-assembly.md"
},
{
"concern": "quick-structured-contract",
"from": "skills/ppt-master/workflows/profiles/quick-generate.md",
"to": "skills/ppt-master/references/pptx-structure-interface.md"
},
{
"concern": "quick-notes-routing",
"from": "skills/ppt-master/workflows/profiles/quick-generate.md",
"to": "skills/ppt-master/references/executor-notes.md"
},
{
"concern": "quick-chart-verification",
"from": "skills/ppt-master/workflows/profiles/quick-generate.md",
"to": "skills/ppt-master/workflows/stages/verify-charts.md"
},
{
"concern": "semantic-vocabulary-owner",
"from": "skills/ppt-master/references/shared-standards-core.md",
"to": "skills/ppt-master/references/semantic-svg.md"
},
{
"concern": "structured-interface-routing",
"from": "skills/ppt-master/references/shared-standards-core.md",
"to": "skills/ppt-master/references/pptx-structure-interface.md"
} }
], ],
"registries": [ "registries": [
@@ -1807,12 +1936,6 @@
"field": "page_charts", "field": "page_charts",
"owner_fingerprint": "0edfe64300c1", "owner_fingerprint": "0edfe64300c1",
"projections": [ "projections": [
{
"path": "skills/ppt-master/references/executor-structured.md",
"role": "compatibility",
"fingerprint": "a6ae3dfb6e6a",
"reason": "This site documents a legacy or omission compatibility boundary."
},
{ {
"path": "skills/ppt-master/references/executor-visualization.md", "path": "skills/ppt-master/references/executor-visualization.md",
"role": "compatibility", "role": "compatibility",
@@ -1864,13 +1987,13 @@
{ {
"path": "skills/ppt-master/references/pptx-structure-interface.md", "path": "skills/ppt-master/references/pptx-structure-interface.md",
"role": "reference", "role": "reference",
"fingerprint": "60f4c8b8d08a", "fingerprint": "2db6a34d15d7",
"reason": "This reference mirrors the owner field grammar or ownership boundary." "reason": "This reference mirrors the owner field grammar or ownership boundary."
}, },
{ {
"path": "skills/ppt-master/templates/spec_lock_reference.md", "path": "skills/ppt-master/templates/spec_lock_reference.md",
"role": "reference", "role": "reference",
"fingerprint": "c35477d35410", "fingerprint": "e5c9c162d0ff",
"reason": "This reference mirrors the owner field grammar or ownership boundary." "reason": "This reference mirrors the owner field grammar or ownership boundary."
}, },
{ {
@@ -1881,9 +2004,15 @@
}, },
{ {
"path": "skills/ppt-master/workflows/stages/apply-template-workspace.md", "path": "skills/ppt-master/workflows/stages/apply-template-workspace.md",
"role": "compatibility", "role": "consumer",
"fingerprint": "1c393e47c311", "fingerprint": "2a2c42538a76",
"reason": "This site documents a legacy or omission compatibility boundary." "reason": "This stage documents how each Generate runtime consumes the prototype mapping."
},
{
"path": "skills/ppt-master/references/shared-standards-core.md",
"role": "reference",
"fingerprint": "846de4145cbe",
"reason": "This reference distinguishes durable Default mappings from Quick active-context use."
} }
] ]
}, },
@@ -1894,7 +2023,7 @@
{ {
"path": "skills/ppt-master/references/artifact-ownership.md", "path": "skills/ppt-master/references/artifact-ownership.md",
"role": "reference", "role": "reference",
"fingerprint": "f91ef56378bb", "fingerprint": "e05b75f54145",
"reason": "This reference mirrors the owner field grammar or ownership boundary." "reason": "This reference mirrors the owner field grammar or ownership boundary."
}, },
{ {
@@ -1906,13 +2035,13 @@
{ {
"path": "skills/ppt-master/references/pptx-structure-interface.md", "path": "skills/ppt-master/references/pptx-structure-interface.md",
"role": "reference", "role": "reference",
"fingerprint": "b4596417aeec", "fingerprint": "3061c1488d1d",
"reason": "This reference mirrors the owner field grammar or ownership boundary." "reason": "This reference mirrors the owner field grammar or ownership boundary."
}, },
{ {
"path": "skills/ppt-master/references/shared-standards-core.md", "path": "skills/ppt-master/references/shared-standards-core.md",
"role": "reference", "role": "reference",
"fingerprint": "8afb713e37e8", "fingerprint": "e27448d68611",
"reason": "This reference mirrors the owner field grammar or ownership boundary." "reason": "This reference mirrors the owner field grammar or ownership boundary."
}, },
{ {
@@ -1963,12 +2092,6 @@
"field": "page_visualizations", "field": "page_visualizations",
"owner_fingerprint": "b3cdf822635a", "owner_fingerprint": "b3cdf822635a",
"projections": [ "projections": [
{
"path": "skills/ppt-master/references/executor-structured.md",
"role": "consumer",
"fingerprint": "105f5e4ce2ce",
"reason": "This consumer needs the field contract for deterministic execution."
},
{ {
"path": "skills/ppt-master/references/executor-visualization.md", "path": "skills/ppt-master/references/executor-visualization.md",
"role": "consumer", "role": "consumer",
@@ -2014,7 +2137,7 @@
{ {
"path": "skills/ppt-master/references/pptx-structure-interface.md", "path": "skills/ppt-master/references/pptx-structure-interface.md",
"role": "reference", "role": "reference",
"fingerprint": "6f93a0561460", "fingerprint": "f1339baac6db",
"reason": "This reference mirrors the owner field grammar or ownership boundary." "reason": "This reference mirrors the owner field grammar or ownership boundary."
}, },
{ {
@@ -2026,7 +2149,7 @@
{ {
"path": "skills/ppt-master/templates/spec_lock_reference.md", "path": "skills/ppt-master/templates/spec_lock_reference.md",
"role": "reference", "role": "reference",
"fingerprint": "2d6dba6c449e", "fingerprint": "0e01eedc2c4b",
"reason": "This reference mirrors the owner field grammar or ownership boundary." "reason": "This reference mirrors the owner field grammar or ownership boundary."
} }
] ]
@@ -2050,7 +2173,7 @@
{ {
"path": "skills/ppt-master/references/pptx-structure-interface.md", "path": "skills/ppt-master/references/pptx-structure-interface.md",
"role": "reference", "role": "reference",
"fingerprint": "92a978e5d229", "fingerprint": "d35af1a163ac",
"reason": "This reference mirrors the owner field grammar or ownership boundary." "reason": "This reference mirrors the owner field grammar or ownership boundary."
}, },
{ {
@@ -2080,7 +2203,7 @@
{ {
"path": "skills/ppt-master/references/strategist.md", "path": "skills/ppt-master/references/strategist.md",
"role": "producer", "role": "producer",
"fingerprint": "e267f1759749", "fingerprint": "46405fcad30f",
"reason": "This producer projects the owner field into the planning contract." "reason": "This producer projects the owner field into the planning contract."
}, },
{ {
@@ -14,8 +14,7 @@ in ``templates/README.md``; each kind's schema lives in its directory README:
Current workspaces keep ``design_spec.md`` and any SVG roster under Current workspaces keep ``design_spec.md`` and any SVG roster under
``<workspace>/templates/``. Assets live in optional ``images/`` / ``icons/`` ``<workspace>/templates/``. Assets live in optional ``images/`` / ``icons/``
directories. Explicitly generated review artifacts go to the optional, ignored directories. Explicitly generated review artifacts go to the optional, ignored
``exports/`` directory. Legacy flat roots remain readable for Brand/Layout/Deck; ``exports/`` directory. Every kind uses this nested workspace contract.
Style uses only the current nested one-file contract.
Index entry schemas (the JSON file is the single source of truth README Index entry schemas (the JSON file is the single source of truth README
files describe the kind and usage in prose but do **not** enumerate templates; files describe the kind and usage in prose but do **not** enumerate templates;
@@ -434,15 +433,13 @@ def _has_qualified_roster_spec(template_dir: Path) -> bool:
def _template_content_dir(template_root: Path) -> Path: def _template_content_dir(template_root: Path) -> Path:
"""Resolve the canonical source directory, with legacy-flat compatibility.""" """Resolve the only canonical template source directory."""
nested = template_root / "templates" nested = template_root / "templates"
if (nested / "design_spec.md").is_file() or _has_kind_qualified_spec(nested): if (nested / "design_spec.md").is_file() or _has_kind_qualified_spec(nested):
return nested return nested
if (template_root / "design_spec.md").is_file():
return template_root
raise SpecParseError( raise SpecParseError(
"missing templates/design_spec.md, templates/design_spec.<kind>.<id>.md, " "missing templates/design_spec.md or "
f"or legacy design_spec.md in {template_root}" f"templates/design_spec.<kind>.<id>.md in {template_root}"
) )
@@ -1161,12 +1158,7 @@ def _extract_entry(
"""Build the index entry + extras for a single template.""" """Build the index entry + extras for a single template."""
template_root = template_dir template_root = template_dir
template_dir = _template_content_dir(template_root) template_dir = _template_content_dir(template_root)
if kind == "style" and template_dir == template_root: if template_id is not None:
raise SpecParseError(
"Style workspaces require templates/design_spec.md; "
"legacy-flat design_spec.md is not supported"
)
if template_id is not None and template_dir != template_root:
exact_spec = template_dir / "design_spec.md" exact_spec = template_dir / "design_spec.md"
if not exact_spec.is_file(): if not exact_spec.is_file():
raise SpecParseError( raise SpecParseError(
@@ -1268,7 +1260,7 @@ def _extract_entry(
extras = OrderedDict( extras = OrderedDict(
pages=pages, pages=pages,
primary_color=str(primary_color), primary_color=str(primary_color),
page_prefix="templates/" if template_dir != template_root else "", page_prefix="templates/",
preview=( preview=(
f"exports/{resolved_template_id}_template_preview.pptx" f"exports/{resolved_template_id}_template_preview.pptx"
if ( if (
@@ -1364,10 +1356,7 @@ def _enumerate_ids(kind: str) -> list[str]:
return sorted( return sorted(
p.name for p in base.iterdir() p.name for p in base.iterdir()
if p.is_dir() if p.is_dir()
and ( and (p / "templates" / "design_spec.md").is_file()
(p / "templates" / "design_spec.md").is_file()
or (p / "design_spec.md").is_file()
)
) )
@@ -27,15 +27,19 @@ from xml.etree import ElementTree as ET
SVG_WORK_DIR_NAMES = frozenset({ SVG_WORK_DIR_NAMES = frozenset({
'authoring-svg',
'authoring-svg-flat',
'svg', 'svg',
'svg_output', 'svg_output',
'svg_final', 'svg_final',
'svg-flat', 'svg-flat',
'svg_flat',
}) })
SVG_FINAL_CANDIDATE_PREFIX = '.svg_final.candidate-' SVG_FINAL_CANDIDATE_PREFIX = '.svg_final.candidate-'
TEMPLATE_SOURCE_DIR_NAME = 'templates' TEMPLATE_SOURCE_DIR_NAME = 'templates'
TEMPLATE_SPEC_FILENAME = 'design_spec.md' TEMPLATE_SPEC_FILENAME = 'design_spec.md'
_TEMPLATE_QUALIFIED_SPEC_RE = re.compile(
r'design_spec\.(?:brand|style|layout|deck)\.[^/\\]+\.md'
)
_SVG_NAMESPACE = 'http://www.w3.org/2000/svg' _SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
_SVG_URL_REFERENCE_RE = re.compile(r'url\(\s*([^)]+?)\s*\)', re.IGNORECASE) _SVG_URL_REFERENCE_RE = re.compile(r'url\(\s*([^)]+?)\s*\)', re.IGNORECASE)
_SVG_CSS_URL_ATTRIBUTES = frozenset({ _SVG_CSS_URL_ATTRIBUTES = frozenset({
@@ -77,29 +81,27 @@ def project_root_for_svg_path(svg_path: Path) -> Path:
return base.parent return base.parent
if ( if (
base.name == TEMPLATE_SOURCE_DIR_NAME base.name == TEMPLATE_SOURCE_DIR_NAME
and (base / TEMPLATE_SPEC_FILENAME).is_file() and (
(base / TEMPLATE_SPEC_FILENAME).is_file()
or any(
path.is_file()
and _TEMPLATE_QUALIFIED_SPEC_RE.fullmatch(path.name)
for path in base.glob('design_spec.*.md')
)
)
): ):
return base.parent return base.parent
return base return base
def global_icons_dir() -> Path: def icon_dir_for_project(project_path: Path) -> Path:
"""Return the skill-level icon library directory.""" """Return the only valid icon root for a project."""
return Path(__file__).resolve().parent.parent / 'templates' / 'icons' return Path(project_path) / 'icons'
def icon_search_dirs_for_project(project_path: Path) -> tuple[Path, Path | None]: def icon_dir_for_svg(svg_path: Path) -> Path:
"""Return project-first icon dirs plus the global fallback when needed.""" """Return the project-local icon root for one SVG input."""
global_dir = global_icons_dir() return icon_dir_for_project(project_root_for_svg_path(svg_path))
project_icons_dir = Path(project_path) / 'icons'
if project_icons_dir.is_dir():
return project_icons_dir, global_dir
return global_dir, None
def icon_search_dirs_for_svg(svg_path: Path) -> tuple[Path, Path | None]:
"""Return icon dirs for an SVG file path or SVG directory path."""
return icon_search_dirs_for_project(project_root_for_svg_path(svg_path))
def _decode_svg_data_uri(raw: str) -> tuple[bytes | None, str | None]: def _decode_svg_data_uri(raw: str) -> tuple[bytes | None, str | None]:
@@ -242,47 +244,43 @@ def svg_data_uri_payload_error(raw: str) -> str | None:
return f'inline SVG data URI is not closed: {nested_error}' return f'inline SVG data URI is not closed: {nested_error}'
def external_image_reference_candidates(svg_dir: Path, href: str) -> list[Path]: def external_image_reference_path(
"""Return candidate paths for a non-data-URI SVG image href.""" svg_dir: Path,
href: str,
*,
project_root: Path | None = None,
) -> Path | None:
"""Resolve one exact SVG-relative image reference inside the project."""
parsed = urlsplit(href) parsed = urlsplit(href)
if parsed.scheme and parsed.scheme not in {'file'}: if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
return [] return None
decoded = unquote( decoded = unquote(parsed.path)
parsed.path if not decoded or Path(decoded).is_absolute():
if parsed.scheme return None
else href.split('?', 1)[0].split('#', 1)[0]
)
svg_dir = Path(svg_dir) svg_dir = Path(svg_dir)
project_root = project_root_for_svg_path(svg_dir).resolve() resolved_project_root = (
candidates = [ Path(project_root).resolve()
svg_dir / decoded, if project_root is not None
project_root / decoded, else project_root_for_svg_path(svg_dir).resolve()
project_root / 'images' / decoded, )
project_root / 'templates' / decoded, resolved = (svg_dir / decoded).resolve()
] try:
safe_candidates: list[Path] = [] resolved.relative_to(resolved_project_root)
for candidate in candidates: except ValueError:
resolved = candidate.resolve() return None
try: return resolved
resolved.relative_to(project_root)
except ValueError:
continue
if resolved not in safe_candidates:
safe_candidates.append(resolved)
return safe_candidates
def resolve_external_image_reference(svg_dir: Path, href: str) -> Path | None: def resolve_external_image_reference(
"""Resolve an SVG image href to an existing file, or return None.""" svg_dir: Path,
for candidate in external_image_reference_candidates(svg_dir, href): href: str,
if candidate.is_file(): *,
return candidate project_root: Path | None = None,
return None ) -> Path | None:
"""Resolve an exact SVG-relative image href, or return None."""
candidate = external_image_reference_path(
def unresolved_external_image_reference_path(svg_dir: Path, href: str) -> Path: svg_dir,
"""Return the first candidate path for diagnostics when resolution fails.""" href,
candidates = external_image_reference_candidates(svg_dir, href) project_root=project_root,
if candidates: )
return candidates[0].resolve() return candidate if candidate is not None and candidate.is_file() else None
return (Path(svg_dir) / href).resolve()
@@ -0,0 +1,522 @@
#!/usr/bin/env python3
"""Compact and expand the canonical semantic-table.v2 payload."""
from __future__ import annotations
import copy
import json
import re
from collections.abc import Iterable
from typing import Any
SEMANTIC_TABLE_SCHEMA = "ppt-master.semantic-table.v2"
_TOP_LEVEL_FIELDS = {
"schema",
"name",
"x",
"y",
"width",
"height",
"strict_grid",
"header_rows",
"column_widths",
"row_heights",
"style",
"defaults",
"cell_styles",
"columns",
"rows",
}
_TABLE_STYLE_FIELDS = {
"band_row",
"font_family",
"font_size",
"header_font_size",
"header_fill",
"header_text",
"body_fill",
"body_text",
"band_fill",
"border_color",
"border_width",
"padding",
"valign",
"lang",
"table_style_id",
}
_CELL_FORMAT_FIELDS = (
"fill",
"fill_opacity",
"color",
"font_size",
"bold",
"align",
"valign",
"borders",
"padding",
"padding_left",
"padding_right",
"padding_top",
"padding_bottom",
"border_color",
"border_width",
"lang",
"anchor_center",
"horizontal_overflow",
)
_CELL_FIELDS = set(_CELL_FORMAT_FIELDS) | {
"text",
"paragraphs",
"row_span",
"col_span",
"merge_continuation",
"cell_style",
}
_PARAGRAPH_DEFAULT_FIELDS = (
"align",
"line_spacing_percent",
)
_PARAGRAPH_FIELDS = set(_PARAGRAPH_DEFAULT_FIELDS) | {"text", "runs"}
_RUN_DEFAULT_FIELDS = (
"bold",
"italic",
"underline",
"strike",
"color",
"font_size",
"font_family",
"lang",
"alt_lang",
"baseline_percent",
"outline",
)
_RUN_FIELDS = set(_RUN_DEFAULT_FIELDS) | {"text"}
_DEFAULT_SECTIONS = {
"cell": set(_CELL_FORMAT_FIELDS),
"paragraph": set(_PARAGRAPH_DEFAULT_FIELDS),
"run": set(_RUN_DEFAULT_FIELDS),
}
_MERGED_OBJECT_FIELDS = {"padding"}
_STYLE_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
def _canonical_json(value: Any) -> str:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
def _payload_size(payload: dict[str, Any], defaults: dict[str, Any]) -> int:
return len(_canonical_json({"payload": payload, "defaults": defaults}))
def _require_object(value: Any, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise RuntimeError(f"Native PPTX table {label} must be an object")
return value
def _reject_unknown_fields(
value: dict[str, Any],
allowed: set[str],
label: str,
) -> None:
unknown = sorted(set(value) - allowed)
if unknown:
raise RuntimeError(
f"Native PPTX table {label} contains unsupported field(s): "
+ ", ".join(unknown)
)
def _format_layers(*layers: dict[str, Any]) -> dict[str, Any]:
result: dict[str, Any] = {}
for layer in layers:
for key, value in layer.items():
if (
key in _MERGED_OBJECT_FIELDS
and isinstance(value, dict)
and isinstance(result.get(key), dict)
):
merged = copy.deepcopy(result[key])
merged.update(copy.deepcopy(value))
result[key] = merged
else:
result[key] = copy.deepcopy(value)
return result
def _validated_defaults(payload: dict[str, Any]) -> dict[str, dict[str, Any]]:
raw_defaults = payload.get("defaults", {})
defaults = _require_object(raw_defaults, "defaults")
_reject_unknown_fields(defaults, set(_DEFAULT_SECTIONS), "defaults")
result: dict[str, dict[str, Any]] = {}
for section, allowed in _DEFAULT_SECTIONS.items():
raw_section = defaults.get(section, {})
section_data = _require_object(raw_section, f"defaults.{section}")
_reject_unknown_fields(
section_data,
allowed,
f"defaults.{section}",
)
result[section] = copy.deepcopy(section_data)
return result
def _validated_cell_styles(payload: dict[str, Any]) -> dict[str, dict[str, Any]]:
raw_styles = payload.get("cell_styles", {})
styles = _require_object(raw_styles, "cell_styles")
result: dict[str, dict[str, Any]] = {}
for name, raw_style in styles.items():
if not isinstance(name, str) or not _STYLE_NAME_RE.fullmatch(name):
raise RuntimeError(
"Native PPTX table cell style names must use lower-case kebab-case"
)
style = _require_object(raw_style, f"cell_styles.{name}")
_reject_unknown_fields(
style,
set(_CELL_FORMAT_FIELDS),
f"cell_styles.{name}",
)
result[name] = copy.deepcopy(style)
return result
def _expand_run(value: Any, run_defaults: dict[str, Any]) -> dict[str, Any]:
run = _require_object(value, "run")
_reject_unknown_fields(run, _RUN_FIELDS, "run")
return _format_layers(run_defaults, run)
def _expand_paragraph(
value: Any,
paragraph_defaults: dict[str, Any],
run_defaults: dict[str, Any],
) -> dict[str, Any]:
if isinstance(value, str):
paragraph = {"text": value}
else:
paragraph = _require_object(value, "paragraph")
_reject_unknown_fields(paragraph, _PARAGRAPH_FIELDS, "paragraph")
expanded = _format_layers(paragraph_defaults, paragraph)
if "runs" in expanded:
runs = expanded["runs"]
if not isinstance(runs, list):
raise RuntimeError("Native PPTX table paragraph runs must be a list")
expanded["runs"] = [_expand_run(run, run_defaults) for run in runs]
return expanded
def _expand_cell(
value: Any,
defaults: dict[str, dict[str, Any]],
cell_styles: dict[str, dict[str, Any]],
) -> dict[str, Any]:
if isinstance(value, dict):
cell = copy.deepcopy(value)
_reject_unknown_fields(cell, _CELL_FIELDS, "cell")
else:
cell = {"text": "" if value is None else str(value)}
style_name = cell.pop("cell_style", None)
if style_name is None:
style = {}
elif not isinstance(style_name, str) or style_name not in cell_styles:
raise RuntimeError(
f"Native PPTX table cell references unknown cell_style: {style_name!r}"
)
else:
style = cell_styles[style_name]
expanded = _format_layers(defaults["cell"], style, cell)
if "paragraphs" in expanded:
paragraphs = expanded["paragraphs"]
if not isinstance(paragraphs, list):
raise RuntimeError("Native PPTX table cell paragraphs must be a list")
expanded["paragraphs"] = [
_expand_paragraph(
paragraph,
defaults["paragraph"],
defaults["run"],
)
for paragraph in paragraphs
]
return expanded
def expand_semantic_table_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Expand semantic-table.v2 defaults and named styles into canonical cells."""
source = _require_object(payload, "payload")
if source.get("schema") != SEMANTIC_TABLE_SCHEMA:
raise RuntimeError(
"Native PPTX table metadata requires schema "
f"{SEMANTIC_TABLE_SCHEMA!r}"
)
_reject_unknown_fields(source, _TOP_LEVEL_FIELDS, "payload")
style = source.get("style", {})
if not isinstance(style, dict):
raise RuntimeError("Native PPTX table style must be an object")
_reject_unknown_fields(style, _TABLE_STYLE_FIELDS, "style")
defaults = _validated_defaults(source)
cell_styles = _validated_cell_styles(source)
expanded = {
key: copy.deepcopy(value)
for key, value in source.items()
if key not in {"schema", "defaults", "cell_styles", "columns", "rows"}
}
if "columns" in source:
columns = source["columns"]
if not isinstance(columns, list):
raise RuntimeError("Native PPTX table columns must be a list")
expanded["columns"] = [
_expand_cell(cell, defaults, cell_styles) for cell in columns
]
if "rows" in source:
rows = source["rows"]
if not isinstance(rows, list):
raise RuntimeError("Native PPTX table rows must be a list")
expanded_rows: list[list[dict[str, Any]]] = []
for row_index, row in enumerate(rows, start=1):
if not isinstance(row, list):
raise RuntimeError(
f"Native PPTX table row {row_index} must be a list"
)
expanded_rows.append(
[_expand_cell(cell, defaults, cell_styles) for cell in row]
)
expanded["rows"] = expanded_rows
return expanded
def _iter_cells(payload: dict[str, Any]) -> Iterable[dict[str, Any]]:
columns = payload.get("columns")
if isinstance(columns, list):
for cell in columns:
if isinstance(cell, dict):
yield cell
rows = payload.get("rows")
if isinstance(rows, list):
for row in rows:
if not isinstance(row, list):
continue
for cell in row:
if isinstance(cell, dict):
yield cell
def _iter_paragraphs(cells: Iterable[dict[str, Any]]) -> Iterable[dict[str, Any]]:
for cell in cells:
paragraphs = cell.get("paragraphs")
if not isinstance(paragraphs, list):
continue
for paragraph in paragraphs:
if isinstance(paragraph, dict):
yield paragraph
def _iter_runs(paragraphs: Iterable[dict[str, Any]]) -> Iterable[dict[str, Any]]:
for paragraph in paragraphs:
runs = paragraph.get("runs")
if not isinstance(runs, list):
continue
for run in runs:
if isinstance(run, dict):
yield run
def _most_common_value(items: list[dict[str, Any]], field: str) -> Any:
counts: dict[str, int] = {}
values: dict[str, Any] = {}
order: list[str] = []
for item in items:
signature = _canonical_json(item[field])
if signature not in counts:
counts[signature] = 0
values[signature] = item[field]
order.append(signature)
counts[signature] += 1
winner = max(order, key=lambda signature: counts[signature])
return copy.deepcopy(values[winner])
def _promote_defaults(
payload: dict[str, Any],
defaults: dict[str, dict[str, Any]],
section: str,
items: list[dict[str, Any]],
fields: tuple[str, ...],
) -> None:
if not items:
return
for field in fields:
if any(field not in item for item in items):
continue
value = _most_common_value(items, field)
before = _payload_size(payload, defaults)
matching = [item for item in items if item[field] == value]
defaults[section][field] = value
for item in matching:
del item[field]
after = _payload_size(payload, defaults)
if after < before:
continue
del defaults[section][field]
for item in matching:
item[field] = copy.deepcopy(value)
def _compact_plain_paragraphs(payload: dict[str, Any]) -> None:
for cell in _iter_cells(payload):
paragraphs = cell.get("paragraphs")
if not isinstance(paragraphs, list):
continue
for index, paragraph in enumerate(paragraphs):
if (
isinstance(paragraph, dict)
and set(paragraph) == {"text"}
and isinstance(paragraph["text"], str)
):
paragraphs[index] = paragraph["text"]
def _cell_style_signature(cell: dict[str, Any]) -> dict[str, Any]:
return {
field: copy.deepcopy(cell[field])
for field in _CELL_FORMAT_FIELDS
if field in cell
}
def _factor_cell_styles(
payload: dict[str, Any],
defaults: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
cells = list(_iter_cells(payload))
signatures: dict[str, dict[str, Any]] = {}
matching_cells: dict[str, list[dict[str, Any]]] = {}
order: list[str] = []
for cell in cells:
style = _cell_style_signature(cell)
if not style:
continue
signature = _canonical_json(style)
if signature not in signatures:
signatures[signature] = style
matching_cells[signature] = []
order.append(signature)
matching_cells[signature].append(cell)
cell_styles: dict[str, dict[str, Any]] = {}
for signature in order:
matched = matching_cells[signature]
if len(matched) < 2:
continue
name = f"cell-{len(cell_styles) + 1}"
before = len(
_canonical_json(
{"payload": payload, "defaults": defaults, "cell_styles": cell_styles}
)
)
style = signatures[signature]
cell_styles[name] = copy.deepcopy(style)
for cell in matched:
for field in style:
del cell[field]
cell["cell_style"] = name
after = len(
_canonical_json(
{"payload": payload, "defaults": defaults, "cell_styles": cell_styles}
)
)
if after < before:
continue
del cell_styles[name]
for cell in matched:
del cell["cell_style"]
cell.update(copy.deepcopy(style))
return cell_styles
def compact_semantic_table_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Return a deterministic, lossless semantic-table.v2 representation."""
source = _require_object(payload, "payload")
if "schema" in source:
working = expand_semantic_table_payload(source)
else:
working = copy.deepcopy(source)
defaults: dict[str, dict[str, Any]] = {
"cell": {},
"paragraph": {},
"run": {},
}
cells = list(_iter_cells(working))
total_cells = len(working.get("columns", [])) + sum(
len(row) for row in working.get("rows", []) if isinstance(row, list)
)
if cells and len(cells) == total_cells:
_promote_defaults(
working,
defaults,
"cell",
cells,
_CELL_FORMAT_FIELDS,
)
paragraphs = list(_iter_paragraphs(cells))
paragraph_count = sum(
len(cell["paragraphs"])
for cell in cells
if isinstance(cell.get("paragraphs"), list)
)
if paragraphs and len(paragraphs) == paragraph_count:
_promote_defaults(
working,
defaults,
"paragraph",
paragraphs,
_PARAGRAPH_DEFAULT_FIELDS,
)
runs = list(_iter_runs(paragraphs))
run_count = sum(
len(paragraph["runs"])
for paragraph in paragraphs
if isinstance(paragraph.get("runs"), list)
)
if runs and len(runs) == run_count:
_promote_defaults(
working,
defaults,
"run",
runs,
_RUN_DEFAULT_FIELDS,
)
_compact_plain_paragraphs(working)
cell_styles = _factor_cell_styles(working, defaults)
compact_defaults = {
section: values for section, values in defaults.items() if values
}
result: dict[str, Any] = {"schema": SEMANTIC_TABLE_SCHEMA}
for key, value in working.items():
if key not in {"columns", "rows"}:
result[key] = value
if compact_defaults:
result["defaults"] = compact_defaults
if cell_styles:
result["cell_styles"] = cell_styles
if "columns" in working:
result["columns"] = working["columns"]
if "rows" in working:
result["rows"] = working["rows"]
expand_semantic_table_payload(result)
return result
@@ -61,6 +61,8 @@ _BG_SAMPLE_BORDER = 2
_BG_SAMPLE_MAX_SIDE = 256 _BG_SAMPLE_MAX_SIDE = 256
_DEFAULT_FEATHER = 4 _DEFAULT_FEATHER = 4
_BOUNDARY_OPAQUE_ALPHA = 32 _BOUNDARY_OPAQUE_ALPHA = 32
_SHEET_DIAGNOSTIC_BORDER_RATIO = 0.01
_SHEET_DIAGNOSTIC_BUCKET_SIZE = 4
def _log(msg: str) -> None: def _log(msg: str) -> None:
@@ -176,6 +178,53 @@ def _sample_bg(cell: Image.Image, tolerance: int) -> tuple[int, int, int]:
) )
def _sample_sheet_border(
sheet: Image.Image,
) -> tuple[tuple[int, int, int], int]:
"""Return the dominant RGB cluster and its spread in the outer 1% ring."""
rgb = sheet.convert("RGB")
width, height = rgb.size
border_x = max(1, round(width * _SHEET_DIAGNOSTIC_BORDER_RATIO))
border_y = max(1, round(height * _SHEET_DIAGNOSTIC_BORDER_RATIO))
px = rgb.load()
pixels: list[tuple[int, int, int]] = []
for y in range(border_y):
pixels.extend(px[x, y] for x in range(width))
for y in range(max(border_y, height - border_y), height):
pixels.extend(px[x, y] for x in range(width))
for y in range(border_y, max(border_y, height - border_y)):
pixels.extend(px[x, y] for x in range(border_x))
pixels.extend(
px[x, y]
for x in range(max(border_x, width - border_x), width)
)
buckets = Counter(
tuple(channel // _SHEET_DIAGNOSTIC_BUCKET_SIZE for channel in pixel)
for pixel in pixels
)
dominant_bucket = buckets.most_common(1)[0][0]
dominant_pixels = [
pixel
for pixel in pixels
if tuple(
channel // _SHEET_DIAGNOSTIC_BUCKET_SIZE
for channel in pixel
) == dominant_bucket
]
dominant = tuple(
round(median(channel))
for channel in zip(*dominant_pixels)
)
channel_spreads = [
max(pixel[index] for pixel in dominant_pixels)
- min(pixel[index] for pixel in dominant_pixels)
for index in range(3)
]
return dominant, max(channel_spreads) # type: ignore[return-value]
def _max_channel_difference(cell: Image.Image, bg: tuple[int, int, int]) -> Image.Image: def _max_channel_difference(cell: Image.Image, bg: tuple[int, int, int]) -> Image.Image:
"""Return the maximum absolute RGB channel difference from the background.""" """Return the maximum absolute RGB channel difference from the background."""
diff = ImageChops.difference(cell.convert("RGB"), Image.new("RGB", cell.size, bg)) diff = ImageChops.difference(cell.convert("RGB"), Image.new("RGB", cell.size, bg))
@@ -363,7 +412,7 @@ def _keying_findings(
findings.append( findings.append(
f"{label}: {opaque}/{len(boundary)} boundary pixels stayed opaque " f"{label}: {opaque}/{len(boundary)} boundary pixels stayed opaque "
f"after --alpha " f"after --alpha "
f"(sampled background {hex_bg})" f"(key background {hex_bg})"
) )
if trim: if trim:
@@ -380,13 +429,18 @@ def _keying_findings(
if touched_edges: if touched_edges:
findings.append( findings.append(
f"{label}: content reaches the {'/'.join(touched_edges)} cell edge(s) " f"{label}: content reaches the {'/'.join(touched_edges)} cell edge(s) "
f"(sampled background {hex_bg})" f"(key background {hex_bg})"
) )
return findings return findings
def _log_keying_findings(findings: list[str]) -> None: def _log_keying_findings(
findings: list[str],
*,
sheet_border: tuple[tuple[int, int, int], int] | None = None,
tolerance: int,
) -> None:
"""Report incomplete flat-background keying.""" """Report incomplete flat-background keying."""
_log("\n[WARN] Alpha extraction is incomplete — the key field or cell") _log("\n[WARN] Alpha extraction is incomplete — the key field or cell")
_log(" isolation failed:") _log(" isolation failed:")
@@ -398,6 +452,18 @@ def _log_keying_findings(findings: list[str]) -> None:
"explicit") "explicit")
_log(" --bg <hex> and a larger --tolerance; use --inset when a drawn " _log(" --bg <hex> and a larger --tolerance; use --inset when a drawn "
"outer gutter is isolated from every element.") "outer gutter is isolated from every element.")
if sheet_border is not None:
dominant, drift = sheet_border
hex_bg = "#{:02X}{:02X}{:02X}".format(*dominant)
suggested_tolerance = max(tolerance, drift)
_log(
" Measured outer 1% border/gutter: "
f"dominant {hex_bg}; max channel spread {drift}."
)
_log(
" Suggested rerun: "
f"--bg {hex_bg} --tolerance {suggested_tolerance}"
)
def slice_sheet( def slice_sheet(
@@ -508,7 +574,12 @@ def slice_sheet(
idx += 1 idx += 1
if findings: if findings:
_log_keying_findings(findings) sheet_border = _sample_sheet_border(sheet) if strict_alpha else None
_log_keying_findings(
findings,
sheet_border=sheet_border,
tolerance=tolerance,
)
if strict_alpha: if strict_alpha:
raise ValueError( raise ValueError(
"strict alpha validation found incomplete background keying; " "strict alpha validation found incomplete background keying; "
@@ -45,7 +45,7 @@ if str(_SCRIPTS_DIR) not in sys.path:
from console_encoding import configure_utf8_stdio # noqa: E402 from console_encoding import configure_utf8_stdio # noqa: E402
from _batch import run_path_batch # noqa: E402 from _batch import run_path_batch # noqa: E402
from _conversion_profile import write_conversion_profile_best_effort # noqa: E402 from _conversion_profile import write_conversion_profile_best_effort # noqa: E402
from template_fill_pptx.diagram_read import ( # noqa: E402 from pptx_ooxml.diagram_read import ( # noqa: E402
read_smartart_diagrams, read_smartart_diagrams,
smartart_to_markdown, smartart_to_markdown,
) )
@@ -0,0 +1,346 @@
#!/usr/bin/env python3
"""
PPT Master - Stamp Native Fallback Baselines
Validate Chart/Table replacement payloads and bind SVG-authoritative markers
to their current visible fallback without reformatting the SVG document.
Usage:
python3 scripts/stamp_native_fallbacks.py "<svg-or-directory>" [--write]
Examples:
python3 scripts/stamp_native_fallbacks.py "projects/example/svg_output"
python3 scripts/stamp_native_fallbacks.py "projects/example/svg_output" --write
Dependencies:
None (only uses standard library and PPT Master sibling modules)
"""
from __future__ import annotations
import argparse
import os
import re
import stat
import sys
import tempfile
from pathlib import Path
from typing import Optional
from xml.etree import ElementTree as ET
from xml.parsers import expat
from console_encoding import configure_utf8_stdio
from pptx_shapes import NATIVE_FALLBACK_SHA256_ATTR
from svg_to_pptx.native_objects import (
native_json_is_authoritative,
native_replacement_kind,
stamp_native_fallback_baseline,
validate_native_object_marker,
)
from svg_to_pptx.native_objects.marker_status import native_marker_status_errors
_FALLBACK_ATTR_BYTES = NATIVE_FALLBACK_SHA256_ATTR.encode("ascii")
_FALLBACK_ATTR_RE = re.compile(
rb"(?P<prefix>\s" + re.escape(_FALLBACK_ATTR_BYTES) + rb"\s*=\s*)"
rb"(?P<quote>['\"])(?P<value>.*?)(?P=quote)",
re.DOTALL,
)
class NativeFallbackStampError(RuntimeError):
"""Raised when a fallback baseline cannot be validated or patched safely."""
def _local_name(tag: object) -> str:
return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""
def _svg_files(input_path: Path) -> list[Path]:
if input_path.is_file():
if input_path.suffix.lower() != ".svg":
raise NativeFallbackStampError(f"Input file is not SVG: {input_path}")
return [input_path]
if not input_path.is_dir():
raise NativeFallbackStampError(f"Input path does not exist: {input_path}")
files = sorted(
path
for path in input_path.glob("*.svg")
if path.is_file()
)
if not files:
raise NativeFallbackStampError(
f"Input directory contains no direct SVG files: {input_path}"
)
return files
def _marker_ancestors(
marker: ET.Element,
root: ET.Element,
parent_map: dict[ET.Element, ET.Element],
) -> tuple[ET.Element, ...]:
ancestors: list[ET.Element] = []
parent = parent_map.get(marker)
while parent is not None and parent is not root:
if _local_name(parent.tag) == "g":
ancestors.append(parent)
parent = parent_map.get(parent)
return tuple(reversed(ancestors))
def _start_tag_spans(source: bytes) -> list[tuple[int, int, str]]:
starts: list[tuple[int, str]] = []
parser = expat.ParserCreate()
def record_start(name: str, _attrs: dict[str, str]) -> None:
starts.append((parser.CurrentByteIndex, name.rsplit(":", 1)[-1]))
parser.StartElementHandler = record_start
try:
parser.Parse(source, True)
except expat.ExpatError as exc:
raise NativeFallbackStampError(f"Invalid SVG XML: {exc}") from exc
spans: list[tuple[int, int, str]] = []
for start, name in starts:
quote: int | None = None
cursor = start + 1
while cursor < len(source):
token = source[cursor]
if quote is not None:
if token == quote:
quote = None
elif token in {ord('"'), ord("'")}:
quote = token
elif token == ord(">"):
spans.append((start, cursor + 1, name))
break
cursor += 1
else:
raise NativeFallbackStampError(
f"Unterminated start tag at byte offset {start}"
)
return spans
def _patch_start_tag(tag: bytes, digest: str) -> bytes:
encoded_digest = digest.encode("ascii")
match = _FALLBACK_ATTR_RE.search(tag)
if match is not None:
return tag[:match.start("value")] + encoded_digest + tag[match.end("value"):]
close = re.search(rb"(?P<space>\s*)(?P<slash>/?)>$", tag)
if close is None:
raise NativeFallbackStampError("Unable to locate SVG start-tag terminator")
insertion = (
b" "
+ _FALLBACK_ATTR_BYTES
+ b'="'
+ encoded_digest
+ b'"'
)
return tag[:close.start()] + insertion + tag[close.start():]
def _patch_document(
source: bytes,
root: ET.Element,
digests: dict[ET.Element, str],
) -> tuple[bytes, int]:
elements = list(root.iter())
spans = _start_tag_spans(source)
if len(elements) != len(spans):
raise NativeFallbackStampError(
"Parsed SVG element roster does not match raw start-tag roster"
)
patches: list[tuple[int, int, bytes]] = []
for element, (start, end, raw_name) in zip(elements, spans):
if _local_name(element.tag) != raw_name:
raise NativeFallbackStampError(
"Parsed SVG element order does not match raw start-tag order"
)
digest = digests.get(element)
if digest is None:
continue
current_tag = source[start:end]
replacement = _patch_start_tag(current_tag, digest)
if replacement != current_tag:
patches.append((start, end, replacement))
patched = source
for start, end, replacement in reversed(patches):
patched = patched[:start] + replacement + patched[end:]
return patched, len(patches)
def _validated_svg_first_digests(
root: ET.Element,
path: Path,
) -> tuple[dict[ET.Element, str], int]:
parent_map = {
child: parent
for parent in root.iter()
for child in parent
}
status_errors: list[str] = []
for element in root.iter():
if _local_name(element.tag) == "metadata":
continue
marker_id = element.get("id") or element.get("data-name") or "<unnamed>"
status_errors.extend(
f"{marker_id}: {error}"
for error in native_marker_status_errors(element)
)
if status_errors:
raise NativeFallbackStampError(
f"{path.name}: invalid native marker status: "
+ "; ".join(status_errors)
)
digests: dict[ET.Element, str] = {}
json_authoritative = 0
for marker in root.iter():
kind = native_replacement_kind(marker)
if kind not in {"chart", "table"}:
continue
marker_id = marker.get("id") or marker.get("data-name") or "<unnamed>"
try:
validate_native_object_marker(
marker,
ancestors=_marker_ancestors(marker, root, parent_map),
)
except RuntimeError as exc:
raise NativeFallbackStampError(
f"{path.name}: invalid {kind} marker {marker_id}: {exc}"
) from exc
if native_json_is_authoritative(marker):
json_authoritative += 1
continue
digests[marker] = stamp_native_fallback_baseline(
marker,
document_root=root,
)
return digests, json_authoritative
def _atomic_write(path: Path, payload: bytes) -> None:
mode = stat.S_IMODE(path.stat().st_mode)
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as handle:
temporary_path = Path(handle.name)
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary_path, mode)
os.replace(temporary_path, path)
finally:
if temporary_path is not None and temporary_path.exists():
temporary_path.unlink()
def prepare_native_fallback_baselines(
source: bytes,
path: Path,
) -> tuple[bytes, int, int, int]:
"""Validate and plan fallback baselines for one in-memory SVG document."""
try:
root = ET.fromstring(source)
except ET.ParseError as exc:
raise NativeFallbackStampError(f"{path.name}: invalid SVG XML: {exc}") from exc
digests, json_authoritative = _validated_svg_first_digests(root, path)
patched, restamped = _patch_document(source, root, digests)
return patched, len(digests), json_authoritative, restamped
def _prepare_file(path: Path) -> tuple[bytes, int, int, bool]:
patched, svg_first, json_authoritative, restamped = (
prepare_native_fallback_baselines(path.read_bytes(), path)
)
return patched, svg_first, json_authoritative, restamped > 0
def stamp_file(path: Path, *, write: bool) -> tuple[int, int, bool]:
"""Validate and optionally stamp one SVG without reformatting it."""
patched, svg_authoritative, json_authoritative, changed = _prepare_file(path)
if write and changed:
_atomic_write(path, patched)
return svg_authoritative, json_authoritative, changed
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Validate native Chart/Table payloads and stamp current visible "
"fallback hashes on SVG-authoritative markers."
),
)
parser.add_argument(
"input",
type=Path,
help="One SVG file or a directory whose direct *.svg files are checked",
)
parser.add_argument(
"--write",
action="store_true",
help=(
"Atomically update SVG files. Omit for a read-only preview; use only "
"after visible fallback and embedded JSON were updated together."
),
)
return parser
def main(argv: Optional[list[str]] = None) -> int:
configure_utf8_stdio()
args = build_parser().parse_args(argv)
try:
files = _svg_files(args.input.resolve())
prepared = [
(path, *_prepare_file(path))
for path in files
]
if args.write:
for path, payload, _svg_first, _json_first, changed in prepared:
if changed:
_atomic_write(path, payload)
total_svg_first = 0
total_json_first = 0
changed_files = 0
for path, _payload, svg_first, json_first, changed in prepared:
total_svg_first += svg_first
total_json_first += json_first
changed_files += int(changed)
if args.write and changed:
action = "updated"
elif changed:
action = "would-update"
else:
action = "unchanged"
print(
f"{path}: {action}; SVG-first={svg_first}, JSON-first={json_first}"
)
except (NativeFallbackStampError, OSError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
mode = "write" if args.write else "read-only"
print(
f"Native fallback baselines: mode={mode}, files={len(files)}, "
f"changed={changed_files}, SVG-first={total_svg_first}, "
f"JSON-first={total_json_first}"
)
if changed_files and not args.write:
print("Re-run with --write to apply these validated baseline updates.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""Canonical compact SVG authoring contract.
The contract keeps authoring SVG as valid, readable XML. It removes only
provably redundant inherited declarations and safely canonicalizes page-space
metadata; it never encodes geometry or invents semantic grouping.
"""
from __future__ import annotations
import copy
from dataclasses import dataclass
from xml.etree import ElementTree as ET
from compact_svg_coordinates import (
CoordinateCompactionStats,
compact_svg_tree,
)
from compact_svg_styles import (
INHERITABLE_ATTRIBUTES,
StyleCompactionStats,
compact_svg_style_tree,
is_canonical_presentation_value,
)
_DEFINITION_SUBTREES = frozenset({
"clipPath",
"defs",
"filter",
"linearGradient",
"marker",
"mask",
"pattern",
"radialGradient",
"symbol",
})
_ROOT_PAINT_ATTRIBUTES = frozenset({
"clip-path",
"color",
"fill",
"fill-opacity",
"fill-rule",
"marker-end",
"marker-mid",
"marker-start",
"mask",
"opacity",
"paint-order",
"filter",
"stroke",
"stroke-dasharray",
"stroke-linecap",
"stroke-linejoin",
"stroke-opacity",
"stroke-width",
})
_SVG_NAMESPACE = "http://www.w3.org/2000/svg"
_COORDINATE_ATTRIBUTES = frozenset({
"data-pptx-bounds",
"data-pptx-frame",
"transform",
})
def _local_name(name: object) -> str:
return name.rsplit("}", 1)[-1] if isinstance(name, str) else ""
def _namespace(name: object) -> str | None:
if not isinstance(name, str) or not name.startswith("{"):
return None
return name[1:].split("}", 1)[0]
def _style_names(value: str | None) -> set[str]:
if not value:
return set()
names: set[str] = set()
for declaration in value.split(";"):
if ":" not in declaration:
continue
name, _raw_value = declaration.split(":", 1)
normalized = name.strip().lower()
if normalized:
names.add(normalized)
return names
def _style_value(value: str | None, name: str) -> str | None:
if not value:
return None
resolved: str | None = None
for declaration in value.split(";"):
if ":" not in declaration:
continue
raw_name, raw_value = declaration.split(":", 1)
if raw_name.strip().lower() == name:
resolved = raw_value.strip().lower()
return resolved
def _href_target(element: ET.Element) -> str | None:
for name, value in element.attrib.items():
if _local_name(name) == "href" and value.startswith("#"):
return value[1:]
return None
def _svg_namespace_errors(root: ET.Element) -> list[str]:
errors: list[str] = []
def visit(
element: ET.Element,
inside_metadata: bool,
*,
is_root: bool = False,
) -> None:
if not isinstance(element.tag, str):
return
local = _local_name(element.tag)
nested_metadata = inside_metadata or local == "metadata"
if (
not is_root
and not inside_metadata
and _namespace(element.tag) != _SVG_NAMESPACE
):
errors.append(
f"SVG element <{local}> exits the standard SVG namespace"
)
for child in element:
visit(child, nested_metadata)
visit(root, False, is_root=True)
return errors
def _attribute_change_examples(
before: ET.Element,
after: ET.Element,
*,
coordinate_changes: bool,
limit: int = 3,
) -> list[str]:
examples: list[str] = []
for index, (source, candidate) in enumerate(
zip(before.iter(), after.iter()),
start=1,
):
if not isinstance(source.tag, str):
continue
names = sorted(set(source.attrib) | set(candidate.attrib))
for name in names:
is_coordinate = name in _COORDINATE_ATTRIBUTES
if is_coordinate != coordinate_changes:
continue
old = source.get(name)
new = candidate.get(name)
if old == new:
continue
label = source.get("id") or f"{_local_name(source.tag)}[{index}]"
examples.append(
f"{label}:{name} {old!r} -> {new!r}"
)
if len(examples) == limit:
return examples
return examples
def _has_visible_text(root: ET.Element) -> bool:
elements_by_id = {
element_id: element
for element in root.iter()
if (element_id := element.get("id"))
}
def visit(
element: ET.Element,
*,
inside_definition: bool,
activated_reference: bool,
display_hidden: bool,
visibility: str,
use_stack: frozenset[str],
) -> bool:
local = _local_name(element.tag)
display = _style_value(element.get("style"), "display")
if display is None:
display = (element.get("display") or "").strip().lower()
nested_display_hidden = display_hidden or display == "none"
own_visibility = _style_value(element.get("style"), "visibility")
if own_visibility is None:
own_visibility = (element.get("visibility") or "").strip().lower()
nested_visibility = own_visibility or visibility
nested_definition = inside_definition or (
local in _DEFINITION_SUBTREES and not activated_reference
)
if (
local == "text"
and not nested_definition
and not nested_display_hidden
and nested_visibility not in {"hidden", "collapse"}
and "".join(element.itertext()).strip()
):
return True
if local == "use" and not nested_display_hidden:
target_id = _href_target(element)
target = elements_by_id.get(target_id or "")
if target is not None and target_id not in use_stack:
if visit(
target,
inside_definition=False,
activated_reference=True,
display_hidden=False,
visibility=nested_visibility,
use_stack=use_stack | {target_id},
):
return True
return any(
visit(
child,
inside_definition=nested_definition,
activated_reference=False,
display_hidden=nested_display_hidden,
visibility=nested_visibility,
use_stack=use_stack,
)
for child in element
)
return visit(
root,
inside_definition=False,
activated_reference=False,
display_hidden=False,
visibility="visible",
use_stack=frozenset(),
)
@dataclass
class AuthoringNormalizationStats:
"""Deterministic changes permitted before the first authoring write."""
coordinates: CoordinateCompactionStats
styles: StyleCompactionStats
@property
def changed_declarations(self) -> int:
return (
self.coordinates.changed_attributes
+ self.styles.changed_declarations
)
def as_dict(self) -> dict[str, dict[str, int] | int]:
return {
"coordinates": self.coordinates.as_dict(),
"styles": self.styles.as_dict(),
"changed_declarations": self.changed_declarations,
}
def normalize_compact_authoring_tree(
root: ET.Element,
*,
compact_native_frames: bool = True,
) -> AuthoringNormalizationStats:
"""Normalize one in-memory authoring tree without changing its semantics."""
if _local_name(root.tag) != "svg":
raise ValueError("Compact authoring requires an SVG root element")
coordinates = compact_svg_tree(
root,
compact_native_frames=compact_native_frames,
)
styles = compact_svg_style_tree(root)
return AuthoringNormalizationStats(
coordinates=coordinates,
styles=styles,
)
def canonical_authoring_errors(
root: ET.Element,
*,
compact_native_frames: bool = True,
) -> list[str]:
"""Return objective violations of the canonical compact authoring form."""
if _local_name(root.tag) != "svg":
return ["Canonical authoring requires an SVG root element"]
errors: list[str] = []
if _namespace(root.tag) != _SVG_NAMESPACE:
errors.append(
"Root SVG must use the standard http://www.w3.org/2000/svg "
"namespace"
)
errors.extend(_svg_namespace_errors(root))
style_elements = [
element for element in root.iter()
if _local_name(element.tag) == "style"
]
if style_elements:
errors.append(
"Embedded <style> blocks are not canonical authoring; write "
"supported values on the element or a meaningful ancestor"
)
class_elements = [
element for element in root.iter()
if element.get("class") is not None
]
if class_elements:
errors.append(
"class selectors are not canonical authoring; keep explicit "
"standard SVG presentation attributes"
)
unsafe_values = [
(element.get("id") or _local_name(element.tag), name, value)
for element in root.iter()
for name in INHERITABLE_ATTRIBUTES
if (value := element.get(name)) is not None
and not is_canonical_presentation_value(
value,
property_name=name,
)
]
if unsafe_values:
label, name, value = unsafe_values[0]
errors.append(
f"Element {label!r} has noncanonical {name}={value!r}; omit "
"CSS-wide inheritance or resolve dynamic CSS before authoring"
)
root_style_names = _style_names(root.get("style"))
root_paints = sorted(
name
for name in _ROOT_PAINT_ATTRIBUTES
if root.get(name) is not None or name in root_style_names
)
if root_paints:
errors.append(
"Root SVG cannot declare shared paint values: "
+ ", ".join(root_paints)
+ "; put color and line defaults on meaningful groups"
)
for element in root.iter():
if _local_name(element.tag) not in {"svg", "g"}:
continue
inherited_style_names = sorted(
_style_names(element.get("style"))
& set(INHERITABLE_ATTRIBUTES)
)
if inherited_style_names:
label = element.get("id") or _local_name(element.tag)
errors.append(
f"Container {label!r} writes inherited defaults in inline "
"style instead of presentation attributes: "
+ ", ".join(inherited_style_names)
)
if _has_visible_text(root) and not (root.get("font-family") or "").strip():
errors.append(
"Visible text requires one direct root font-family default; "
"semantic groups and text elements keep only real overrides"
)
candidate = copy.deepcopy(root)
stats = normalize_compact_authoring_tree(
candidate,
compact_native_frames=compact_native_frames,
)
if stats.styles.changed_declarations:
examples = _attribute_change_examples(
root,
candidate,
coordinate_changes=False,
)
errors.append(
"Authoring SVG contains noncanonical or redundant inherited "
f"style declarations ({stats.styles.changed_declarations} "
"change(s)); examples: "
+ "; ".join(examples)
)
if stats.coordinates.changed_attributes:
examples = _attribute_change_examples(
root,
candidate,
coordinate_changes=True,
)
errors.append(
"Authoring SVG contains safely compactable page-space metadata "
f"({stats.coordinates.changed_attributes} attribute(s)); examples: "
+ "; ".join(examples)
)
return errors
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
"""Shared, semantics-preserving SVG compatibility normalizations.
See scripts/docs/svg-pipeline.md for the dangerous compatibility export
boundary.
"""
from __future__ import annotations
from xml.etree import ElementTree as ET
_FILTER_TARGETS = frozenset({'circle', 'image', 'path', 'rect', 'text'})
_NON_VISUAL_TAGS = frozenset({'defs', 'desc', 'metadata', 'title'})
def _local_name(tag: object) -> str:
return tag.rsplit('}', 1)[-1] if isinstance(tag, str) else ''
def normalize_single_child_group_filters(
root: ET.Element,
) -> list[dict[str, str]]:
"""Move a group filter to its sole supported visual child.
A filter on a one-child, attribute-free group is visually equivalent to
the same filter on that child. Multi-child or otherwise styled groups are
left unchanged because lowering would alter effect compositing.
"""
normalizations: list[dict[str, str]] = []
for group in root.iter():
if _local_name(group.tag) != 'g':
continue
filter_value = group.get('filter')
if filter_value is None:
continue
if set(group.attrib) != {'filter'}:
continue
if group.text and group.text.strip():
continue
visual_children = [
child
for child in group
if isinstance(child.tag, str)
and _local_name(child.tag) not in _NON_VISUAL_TAGS
]
if len(visual_children) != 1:
continue
child = visual_children[0]
child_tag = _local_name(child.tag)
if child_tag not in _FILTER_TARGETS or child.get('filter') is not None:
continue
child.set('filter', filter_value)
del group.attrib['filter']
normalizations.append({
'action': 'lower-single-child-group-filter',
'target_tag': child_tag,
'filter': filter_value,
})
return normalizations
@@ -60,7 +60,7 @@ if str(_ROOT_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_ROOT_SCRIPTS_DIR)) sys.path.insert(0, str(_ROOT_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402 from console_encoding import configure_utf8_stdio # noqa: E402
from resource_paths import icon_search_dirs_for_project # noqa: E402 from resource_paths import icon_dir_for_project # noqa: E402
from slide_roster import discover_slide_svgs # noqa: E402 from slide_roster import discover_slide_svgs # noqa: E402
from server_common import ( # noqa: E402 from server_common import ( # noqa: E402
claim_lock as _claim_lock, claim_lock as _claim_lock,
@@ -166,11 +166,10 @@ def _inline_icons(
content: str, content: str,
icons_dir: Path, icons_dir: Path,
target_dir: Path, target_dir: Path,
fallback_dir: Optional[Path] = None,
) -> tuple[str, list[dict]]: ) -> tuple[str, list[dict]]:
"""Replace <use data-icon="..."/> with rendered <g> for browser preview. """Replace <use data-icon="..."/> with rendered <g> for browser preview.
Resolve icons from the project directory first, then the shared library. Resolve icons only from the prepared project directory.
Returns (rewritten_content, warnings). Each warning is Returns (rewritten_content, warnings). Each warning is
``{"icon": <name>, "reason": <str>}`` so the frontend can surface ``{"icon": <name>, "reason": <str>}`` so the frontend can surface
"icon X not found" to the user instead of silently dropping it. "icon X not found" to the user instead of silently dropping it.
@@ -189,7 +188,7 @@ def _inline_icons(
if not icon_name: if not icon_name:
warnings.append({'icon': '', 'reason': 'missing data-icon attribute'}) warnings.append({'icon': '', 'reason': 'missing data-icon attribute'})
continue continue
icon_path, _ = resolve_icon_path(icon_name, icons_dir, fallback_dir) icon_path, _ = resolve_icon_path(icon_name, icons_dir)
color = str(attrs.get('fill', '#000000')) color = str(attrs.get('fill', '#000000'))
elements, style, base_size = extract_paths_from_icon( elements, style, base_size = extract_paths_from_icon(
icon_path, icon_path,
@@ -436,8 +435,7 @@ def create_app(
project_path = Path(project_dir).resolve() project_path = Path(project_dir).resolve()
svg_dir = project_path / 'svg_output' svg_dir = project_path / 'svg_output'
images_dir = project_path / 'images' images_dir = project_path / 'images'
assets_dir = project_path / 'assets' icons_dir = icon_dir_for_project(project_path)
icons_dir, icons_fallback_dir = icon_search_dirs_for_project(project_path)
app = Flask(__name__, static_folder='static', static_url_path='/static') app = Flask(__name__, static_folder='static', static_url_path='/static')
app.config['PROJECT_PATH'] = project_path app.config['PROJECT_PATH'] = project_path
@@ -542,42 +540,6 @@ def create_app(
return jsonify({'error': 'not found'}), 404 return jsonify({'error': 'not found'}), 404
return send_from_directory(str(images_dir), filename) return send_from_directory(str(images_dir), filename)
@app.route('/assets/<path:filename>')
def serve_asset(filename: str):
"""Serve media extracted by pptx_to_svg.py as `../assets/*`."""
if not assets_dir.exists():
return jsonify({'error': 'assets directory not found'}), 404
target = (assets_dir / filename).resolve()
try:
target.relative_to(assets_dir.resolve())
except ValueError:
return jsonify({'error': 'invalid path'}), 400
if not target.exists() or not target.is_file():
return jsonify({'error': 'not found'}), 404
return send_from_directory(str(assets_dir), filename)
@app.route('/<path:filename>')
def serve_bare_asset(filename: str):
"""Resolve a template SVG's bare image href (e.g. `href="cover_bg.png"`).
Mirror templates copy hrefs verbatim, so a bare filename reaches the
browser as `/<filename>` (no `../images/` prefix). Resolve it against the
project's images/ then assets/. Every real route (`/api/*`, `/images/*`,
`/assets/*`, `/static/*`, `/`) is more specific and matches first; this
only catches the leftover bare references and 404s otherwise.
"""
for base in (images_dir, assets_dir):
if not base.exists():
continue
target = (base / filename).resolve()
try:
target.relative_to(base.resolve())
except ValueError:
continue
if target.exists() and target.is_file():
return send_from_directory(str(base), filename)
return jsonify({'error': 'not found'}), 404
@app.route('/api/slides') @app.route('/api/slides')
def get_slides(): def get_slides():
svg_dir = app.config['SVG_DIR'] svg_dir = app.config['SVG_DIR']
@@ -719,7 +681,6 @@ def create_app(
content, content,
icons_dir, icons_dir,
svg_file.parent, svg_file.parent,
icons_fallback_dir,
) )
if not pending_edits: if not pending_edits:
_cache_put( _cache_put(
@@ -4,17 +4,13 @@ SVG Icon Embedding Tool
Replaces icon placeholders in SVG files with actual icon code. Replaces icon placeholders in SVG files with actual icon code.
Placeholder syntax (new SVGs must include a library prefix): Placeholder syntax (every SVG must include the exact project-local namespace):
<use data-icon="chunk-filled/rocket" x="100" y="200" width="48" height="48" fill="#0076A8"/> <use data-icon="chunk-filled/rocket" x="100" y="200" width="48" height="48" fill="#0076A8"/>
<use data-icon="tabler-filled/home" x="100" y="200" width="48" height="48" fill="#0076A8"/> <use data-icon="tabler-filled/home" x="100" y="200" width="48" height="48" fill="#0076A8"/>
<use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#0076A8"/> <use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#0076A8"/>
<use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#0076A8" stroke-width="3"/> <use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#0076A8" stroke-width="3"/>
<use data-icon="imported/layered_slide_06_ill01"/> <use data-icon="imported/layered_slide_06_ill01"/>
Legacy compatibility accepted by the resolver:
<use data-icon="rocket" .../> -> chunk-filled/rocket
<use data-icon="chunk/rocket" .../> -> chunk-filled/rocket
Optional `stroke-width` (stroke-style libraries only e.g. tabler-outline): Optional `stroke-width` (stroke-style libraries only e.g. tabler-outline):
Default 2 (matches the source). Pass 1.5 for thin, 3 for bold. Default 2 (matches the source). Pass 1.5 for thin, 3 for bold.
Ignored on fill-style libraries. Ignored on fill-style libraries.
@@ -24,20 +20,22 @@ After replacement:
<path d="..."/> <path d="..."/>
</g> </g>
Icon libraries (subdirectories of templates/icons/): Project icon namespaces (subdirectories of <project>/icons/):
chunk-filled/ - 640+ fill icons, 16x16 viewBox (use prefix: chunk-filled/name; legacy 'chunk/' also accepted) chunk-filled/ - 640+ fill icons, 16x16 viewBox
tabler-filled/ - 1000+ fill icons, 24x24 viewBox (use prefix: tabler-filled/name) tabler-filled/ - 1000+ fill icons, 24x24 viewBox (use prefix: tabler-filled/name)
tabler-outline/ - 5000+ stroke icons, 24x24 viewBox (use prefix: tabler-outline/name) tabler-outline/ - 5000+ stroke icons, 24x24 viewBox (use prefix: tabler-outline/name)
phosphor-duotone/ - 1200+ duotone icons, 256x256 viewBox (single color + 0.2-opacity backplate) phosphor-duotone/ - 1200+ duotone icons, 256x256 viewBox (single color + 0.2-opacity backplate)
simple-icons/ - 3400+ brand logos, 24x24 viewBox (brand-inset library used alongside the chosen primary library, NOT as a standalone library for generic icons) simple-icons/ - 3400+ brand logos, 24x24 viewBox (brand-inset library used alongside the chosen primary library, NOT as a standalone library for generic icons)
imported/ - project-local extracted vector illustrations with data-icon-style="preserve-color"; preserve source colors and natural viewBox aspect ratio imported/ - extracted vector illustrations with data-icon-style="preserve-color"; preserve source colors and natural viewBox aspect ratio
Bundled icons must first be copied into the project with icon_sync.py. This
tool never reads templates/icons directly and never accepts a bare icon name.
Usage: Usage:
python3 scripts/svg_finalize/embed_icons.py <svg_file> [svg_file2] ... python3 scripts/svg_finalize/embed_icons.py <svg_file> [svg_file2] ...
python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg
Options: Options:
--icons-dir <path> Icon directory path (default: templates/icons/)
--dry-run Only show what would be replaced, without modifying files --dry-run Only show what would be replaced, without modifying files
--verbose Show detailed information --verbose Show detailed information
""" """
@@ -50,31 +48,30 @@ import sys
import argparse import argparse
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
from xml.etree import ElementTree as ET
_SCRIPTS_DIR = Path(__file__).resolve().parents[1] _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(_SCRIPTS_DIR) not in sys.path: if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR)) sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402 from console_encoding import configure_utf8_stdio # noqa: E402
from resource_paths import icon_dir_for_svg # noqa: E402
from svg_to_pptx.drawingml.utils import parse_project_geometry_length # noqa: E402 from svg_to_pptx.drawingml.utils import parse_project_geometry_length # noqa: E402
configure_utf8_stdio() configure_utf8_stdio()
# Default icon directory
DEFAULT_ICONS_DIR = Path(__file__).parent.parent.parent / 'templates' / 'icons'
# Icon base size per library # Icon base size per library
ICON_BASE_SIZES = { ICON_BASE_SIZES = {
'chunk-filled': 16, 'chunk-filled': 16,
'chunk': 16, # backward compat alias → chunk-filled/
'tabler-filled': 24, 'tabler-filled': 24,
'tabler-outline': 24, 'tabler-outline': 24,
'phosphor-duotone': 256, 'phosphor-duotone': 256,
'simple-icons': 24, 'simple-icons': 24,
'imported': 24,
} }
_ICON_LIBRARY_ALIASES = {'chunk': 'chunk-filled'} _ICON_IDENTIFIER_RE = re.compile(
r'(?P<library>[a-z0-9][a-z0-9-]*)/(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)'
)
DEFAULT_ICON_BASE_SIZE = 24 DEFAULT_ICON_BASE_SIZE = 24
BaseGeometry = float | tuple[float, float, float, float] BaseGeometry = float | tuple[float, float, float, float]
@@ -112,7 +109,7 @@ def _format_number(value: object) -> str:
def _base_geometry(base_size: BaseGeometry) -> tuple[float, float, float, float]: def _base_geometry(base_size: BaseGeometry) -> tuple[float, float, float, float]:
"""Normalize legacy square icon size and full viewBox geometry.""" """Normalize scalar icon size and full viewBox geometry."""
if isinstance(base_size, tuple): if isinstance(base_size, tuple):
return base_size return base_size
return 0.0, 0.0, float(base_size), float(base_size) return 0.0, 0.0, float(base_size), float(base_size)
@@ -169,22 +166,24 @@ def _extract_shape_elements(content: str, color: str) -> list[str]:
return elements return elements
def _resolve_in_dir(icon_name: str, icons_dir: Path) -> tuple[Path, float]: def _split_icon_identifier(icon_name: str) -> tuple[str, str]:
"""Resolve `icon_name` against a single icons dir (no fallback).""" """Parse one complete canonical ``library/name`` identifier."""
if '/' in icon_name: match = _ICON_IDENTIFIER_RE.fullmatch(icon_name)
lib, name = icon_name.split('/', 1) if match is None:
lib = _ICON_LIBRARY_ALIASES.get(lib, lib) # resolve aliases raise ValueError(
icon_path = icons_dir / lib / f'{name}.svg' "data-icon must be a complete project-local library/name identifier: "
base_size = ICON_BASE_SIZES.get(lib, 24) f"{icon_name!r}"
else: )
# Backward compatibility: un-prefixed names fall back to legacy chunk-filled/ library return match.group('library'), match.group('name')
icon_path = icons_dir / 'chunk-filled' / f'{icon_name}.svg'
base_size = 16
if not icon_path.exists():
icon_path = icons_dir / f'{icon_name}.svg' # legacy flat layout
base_size = 16
return icon_path, base_size
def _resolve_in_dir(icon_name: str, icons_dir: Path) -> tuple[Path, float]:
"""Resolve one canonical identifier against exactly one icon root."""
library, name = _split_icon_identifier(icon_name)
return (
icons_dir / library / f'{name}.svg',
ICON_BASE_SIZES.get(library, DEFAULT_ICON_BASE_SIZE),
)
def _casefold_icon_name_in_dir(icon_name: str, icons_dir: Path) -> str | None: def _casefold_icon_name_in_dir(icon_name: str, icons_dir: Path) -> str | None:
@@ -192,24 +191,11 @@ def _casefold_icon_name_in_dir(icon_name: str, icons_dir: Path) -> str | None:
if not icons_dir.is_dir(): if not icons_dir.is_dir():
return None return None
search_dirs: list[Path] = [] try:
expected_name = icon_name requested_lib, expected_name = _split_icon_identifier(icon_name)
if '/' in icon_name: except ValueError:
raw_lib, expected_name = icon_name.split('/', 1) return None
requested_lib = _ICON_LIBRARY_ALIASES.get(raw_lib.casefold(), raw_lib) search_dirs = [icons_dir / requested_lib]
library_dir = icons_dir / requested_lib
if not library_dir.is_dir():
library_dir = next(
(
path for path in icons_dir.iterdir()
if path.is_dir()
and path.name.casefold() == requested_lib.casefold()
),
library_dir,
)
search_dirs.append(library_dir)
else:
search_dirs.extend((icons_dir / 'chunk-filled', icons_dir))
expected_filename = f'{expected_name}.svg'.casefold() expected_filename = f'{expected_name}.svg'.casefold()
for search_dir in search_dirs: for search_dir in search_dirs:
@@ -231,31 +217,21 @@ def _casefold_icon_name_in_dir(icon_name: str, icons_dir: Path) -> str | None:
def suggest_icon_name( def suggest_icon_name(
icon_name: str, icon_name: str,
icons_dir: Path, icons_dir: Path,
fallback_dir: Path | None = None,
) -> str | None: ) -> str | None:
"""Suggest an exact project-first icon identifier without auto-correcting it.""" """Suggest exact casing inside the declared project-local namespace."""
suggestion = _casefold_icon_name_in_dir(icon_name, icons_dir) return _casefold_icon_name_in_dir(icon_name, icons_dir)
if suggestion is None and fallback_dir is not None:
suggestion = _casefold_icon_name_in_dir(icon_name, fallback_dir)
return suggestion
def resolve_icon_path(icon_name: str, icons_dir: Path, fallback_dir: Path | None = None) -> tuple[Path, float]: def resolve_icon_path(icon_name: str, icons_dir: Path) -> tuple[Path, float]:
""" """Resolve one complete identifier only under the supplied icon root."""
Resolve icon name to file path and base size, e.g. "chunk-filled/home"
icons_dir/chunk-filled/home.svg. "chunk/" is a backward-compat alias; an
un-prefixed name falls back to chunk-filled/ then a legacy flat layout.
Resolution is project-first: if the icon is absent under ``icons_dir`` and a
``fallback_dir`` (the global library) is given, the fallback's path is
returned instead. Returns (path, base_size); the path may not exist when
neither dir has the icon.
"""
icon_path, base_size = _resolve_in_dir(icon_name, icons_dir) icon_path, base_size = _resolve_in_dir(icon_name, icons_dir)
if fallback_dir is not None and not icon_path.exists(): resolved_root = icons_dir.resolve()
fb_path, fb_size = _resolve_in_dir(icon_name, fallback_dir) try:
if fb_path.exists(): icon_path.resolve().relative_to(resolved_root)
return fb_path, fb_size except ValueError as exc:
raise ValueError(
f"data-icon escapes the project-local icon root: {icon_name!r}"
) from exc
return icon_path, base_size return icon_path, base_size
@@ -473,7 +449,12 @@ def generate_icon_group(attrs: dict[str, str | float], elements: list[str], styl
</g>''' </g>'''
def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, verbose: bool = False, fallback_dir: Path | None = None) -> int: def process_svg_file(
svg_path: Path,
icons_dir: Path,
dry_run: bool = False,
verbose: bool = False,
) -> int:
""" """
Process a single SVG file, replacing all icon placeholders. Process a single SVG file, replacing all icon placeholders.
@@ -487,8 +468,7 @@ def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, ver
Number of icons replaced Number of icons replaced
""" """
if not svg_path.exists(): if not svg_path.exists():
print(f"[ERROR] File not found: {svg_path}") raise FileNotFoundError(f"SVG file not found: {svg_path}")
return 0
content = svg_path.read_text(encoding='utf-8') content = svg_path.read_text(encoding='utf-8')
@@ -512,20 +492,24 @@ def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, ver
icon_name = attrs.get('icon') icon_name = attrs.get('icon')
if not icon_name: if not icon_name:
continue raise ValueError(
f'{svg_path.name}: icon placeholder has an empty data-icon value'
)
icon_path, _ = resolve_icon_path(str(icon_name), icons_dir, fallback_dir) try:
icon_path, _ = resolve_icon_path(str(icon_name), icons_dir)
except ValueError as exc:
raise ValueError(f'{svg_path.name}: {exc}') from exc
if not icon_path.exists(): if not icon_path.exists():
suggestion = suggest_icon_name(str(icon_name), icons_dir, fallback_dir) suggestion = suggest_icon_name(str(icon_name), icons_dir)
hint = ( hint = (
f"; identifiers are case-sensitive; use '{suggestion}'" f"; identifiers are case-sensitive; use '{suggestion}'"
if suggestion else "" if suggestion else ""
) )
print( raise FileNotFoundError(
f"[WARN] Icon not found: {icon_name}{hint} " f'{svg_path.name}: project-local icon not found: '
f"(in {svg_path.name})" f'{icon_name}{hint}'
) )
continue
elements, style, base_size = extract_paths_from_icon( elements, style, base_size = extract_paths_from_icon(
icon_path, icon_path,
@@ -533,11 +517,9 @@ def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, ver
) )
color = resolve_icon_color(attrs, style) color = resolve_icon_color(attrs, style)
if not elements: if not elements:
print( raise ValueError(
f"[WARN] Icon has no embeddable shapes: {icon_name} " f'{svg_path.name}: icon has no embeddable shapes: {icon_name}'
f"(in {svg_path.name})"
) )
continue
replacement = generate_icon_group(attrs, elements, style, base_size) replacement = generate_icon_group(attrs, elements, style, base_size)
@@ -557,7 +539,7 @@ def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, ver
return replaced_count return replaced_count
def main() -> None: def main() -> int:
"""Run the CLI entry point.""" """Run the CLI entry point."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='Replace icon placeholders in SVG files with actual icon code', description='Replace icon placeholders in SVG files with actual icon code',
@@ -567,13 +549,10 @@ Examples:
python3 scripts/svg_finalize/embed_icons.py svg_output/01_cover.svg python3 scripts/svg_finalize/embed_icons.py svg_output/01_cover.svg
python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg
python3 scripts/svg_finalize/embed_icons.py --dry-run svg_output/*.svg python3 scripts/svg_finalize/embed_icons.py --dry-run svg_output/*.svg
python3 scripts/svg_finalize/embed_icons.py --icons-dir my_icons/ output.svg
''' '''
) )
parser.add_argument('files', nargs='+', help='SVG files to process') parser.add_argument('files', nargs='+', help='SVG files to process')
parser.add_argument('--icons-dir', type=Path, default=DEFAULT_ICONS_DIR,
help=f'Icon directory path (default: {DEFAULT_ICONS_DIR})')
parser.add_argument('--dry-run', action='store_true', parser.add_argument('--dry-run', action='store_true',
help='Only show what would be replaced, without modifying files') help='Only show what would be replaced, without modifying files')
parser.add_argument('--verbose', '-v', action='store_true', parser.add_argument('--verbose', '-v', action='store_true',
@@ -581,12 +560,6 @@ Examples:
args = parser.parse_args() args = parser.parse_args()
# Validate icon directory
if not args.icons_dir.exists():
print(f"[ERROR] Icon directory not found: {args.icons_dir}")
sys.exit(1)
print(f"[DIR] Icon directory: {args.icons_dir}")
if args.dry_run: if args.dry_run:
print("[PREVIEW] Preview mode (no files will be modified)") print("[PREVIEW] Preview mode (no files will be modified)")
print() print()
@@ -594,18 +567,28 @@ Examples:
total_replaced = 0 total_replaced = 0
total_files = 0 total_files = 0
for file_pattern in args.files: try:
svg_path = Path(file_pattern) for file_pattern in args.files:
if svg_path.exists(): svg_path = Path(file_pattern)
count = process_svg_file(svg_path, args.icons_dir, args.dry_run, args.verbose) icons_dir = icon_dir_for_svg(svg_path)
count = process_svg_file(
svg_path,
icons_dir,
args.dry_run,
args.verbose,
)
total_replaced += count total_replaced += count
if count > 0: if count > 0:
total_files += 1 total_files += 1
except (OSError, ValueError) as exc:
print(f'[ERROR] {exc}', file=sys.stderr)
return 1
print() print()
print(f"[Summary] Total: {total_files} file(s), {total_replaced} icon(s)" + print(f"[Summary] Total: {total_files} file(s), {total_replaced} icon(s)" +
(" (preview)" if args.dry_run else " replaced")) (" (preview)" if args.dry_run else " replaced"))
return 0
if __name__ == '__main__': if __name__ == '__main__':
main() raise SystemExit(main())
@@ -15,6 +15,7 @@ configure_utf8_stdio()
SVG_NS = "http://www.w3.org/2000/svg" SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
NSMAP = {"svg": SVG_NS} NSMAP = {"svg": SVG_NS}
# Ensure pretty element names without ns0 prefix on write # Ensure pretty element names without ns0 prefix on write
@@ -277,6 +278,24 @@ def _build_paragraph_child_view(
return view, synthetic_first return view, synthetic_first
def _is_svg_tag(el: ET.Element, name: str) -> bool:
"""Return whether one element has the requested SVG namespace tag."""
return el.tag == f"{{{SVG_NS}}}{name}"
def _is_new_line_tspan(tspan: ET.Element) -> bool:
"""Return whether one direct tspan starts a positioned visual line."""
t_dy_attr = get_attr(tspan, "dy")
t_y_attr = get_attr(tspan, "y")
t_x_attr = get_attr(tspan, "x")
dy_val = parse_first_number(t_dy_attr) if t_dy_attr is not None else None
return (
t_y_attr is not None
or (dy_val is not None and dy_val != 0)
or t_x_attr is not None
)
def _get_font_size_px(elem: ET.Element) -> float | None: def _get_font_size_px(elem: ET.Element) -> float | None:
"""Read font-size from an attribute or inline style.""" """Read font-size from an attribute or inline style."""
size = parse_first_number(get_attr(elem, "font-size")) size = parse_first_number(get_attr(elem, "font-size"))
@@ -436,6 +455,19 @@ def _classify_paragraph_block(
return base, extras, break_kinds, line_groups, synthetic_first return base, extras, break_kinds, line_groups, synthetic_first
def classify_paragraph_block(
text_el: ET.Element,
preserve_line_breaks: bool = False,
) -> tuple[float, list[float], list[str], list[list[ET.Element]], ET.Element | None] | None:
"""Classify one paragraph block with the shared synthetic-first logic."""
return _classify_paragraph_block(
text_el,
_is_svg_tag,
_is_new_line_tspan,
preserve_line_breaks,
)
def _emit_mergeable_paragraph( def _emit_mergeable_paragraph(
text_el: ET.Element, text_el: ET.Element,
base_dy: float, base_dy: float,
@@ -539,30 +571,11 @@ def flatten_text_with_tspans(
parent_map = {c: p for p in root.iter() for c in p} parent_map = {c: p for p in root.iter() for c in p}
changed = False changed = False
def is_svg_tag(el: ET.Element, name: str) -> bool:
return el.tag == f"{{{SVG_NS}}}{name}"
def is_new_line_tspan(tspan: ET.Element) -> bool:
"""Determine whether a tspan represents a new line (has its own y or non-zero dy)."""
t_dy_attr = get_attr(tspan, "dy")
t_y_attr = get_attr(tspan, "y")
t_x_attr = get_attr(tspan, "x")
dy_val = parse_first_number(t_dy_attr) if t_dy_attr is not None else None
# Has its own y attribute, or has non-zero dy, or has its own x attribute (indicating a new line)
if t_y_attr is not None:
return True
if dy_val is not None and dy_val != 0:
return True
# If tspan has an x attribute and there are preceding sibling tspans, treat it as a new line
if t_x_attr is not None:
return True
return False
# Collect candidates first to avoid modifying while iterating # Collect candidates first to avoid modifying while iterating
candidates = [] candidates = []
for el in root.iter(): for el in root.iter():
if is_svg_tag(el, "text"): if _is_svg_tag(el, "text"):
has_tspan_child = any(is_svg_tag(c, "tspan") for c in list(el)) has_tspan_child = any(_is_svg_tag(c, "tspan") for c in list(el))
if has_tspan_child: if has_tspan_child:
candidates.append(el) candidates.append(el)
@@ -574,9 +587,9 @@ def flatten_text_with_tspans(
# First check whether any tspan needs flattening (dy != 0 or has its own y attribute) # First check whether any tspan needs flattening (dy != 0 or has its own y attribute)
needs_flatten = False needs_flatten = False
for child in list(text_el): for child in list(text_el):
if not is_svg_tag(child, "tspan"): if not _is_svg_tag(child, "tspan"):
continue continue
if is_new_line_tspan(child): if _is_new_line_tspan(child):
needs_flatten = True needs_flatten = True
break break
@@ -588,11 +601,9 @@ def flatten_text_with_tspans(
# <text>. The downstream converter either preserves visual breaks or # <text>. The downstream converter either preserves visual breaks or
# reflows them. Split mode promotes each positioned line to <text>. # reflows them. Split mode promotes each positioned line to <text>.
if merge_paragraphs: if merge_paragraphs:
paragraph = _classify_paragraph_block( paragraph = classify_paragraph_block(
text_el, text_el,
is_svg_tag, preserve_line_breaks=preserve_line_breaks,
is_new_line_tspan,
preserve_line_breaks,
) )
if paragraph is not None: if paragraph is not None:
base_dy, extras, break_kinds, line_groups, synthetic_first = paragraph base_dy, extras, break_kinds, line_groups, synthetic_first = paragraph
@@ -618,13 +629,13 @@ def flatten_text_with_tspans(
current_line_lead_text = text_el.text or None current_line_lead_text = text_el.text or None
for idx, child in enumerate(list(text_el)): for idx, child in enumerate(list(text_el)):
if not is_svg_tag(child, "tspan"): if not _is_svg_tag(child, "tspan"):
continue continue
content = collect_text_content(child) content = collect_text_content(child)
# Check whether this tspan starts a new line # Check whether this tspan starts a new line
if is_new_line_tspan(child): if _is_new_line_tspan(child):
# Save previously accumulated same-line tspans first # Save previously accumulated same-line tspans first
if current_line_tspans or _has_non_xml_whitespace( if current_line_tspans or _has_non_xml_whitespace(
current_line_lead_text current_line_lead_text
@@ -827,6 +838,8 @@ def process_svg_file(
os.makedirs(os.path.dirname(dst_path), exist_ok=True) os.makedirs(os.path.dirname(dst_path), exist_ok=True)
# Write out XML without XML declaration to mimic input style # Write out XML without XML declaration to mimic input style
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS)
tree.write(dst_path, encoding="utf-8", xml_declaration=False, method="xml") tree.write(dst_path, encoding="utf-8", xml_declaration=False, method="xml")
return changed return changed
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,49 @@ def _first_page_target(target: str) -> str:
return str(svg_files[0]) if svg_files else target return str(svg_files[0]) if svg_files else target
def _page_target(target: str, page: str) -> str:
"""Resolve one requested page while keeping it inside ``svg_output/``."""
target_path = Path(target).resolve()
svg_root = (
target_path
if target_path.is_dir() and target_path.name == "svg_output"
else target_path / "svg_output"
)
if not svg_root.is_dir():
raise ValueError(
"--stage page requires a project or svg_output directory target"
)
svg_root = svg_root.resolve()
requested = Path(page)
if requested.is_absolute():
candidates = [requested]
else:
candidates = [svg_root / requested]
if requested.parts and requested.parts[0] == "svg_output":
candidates.append(svg_root.parent / requested)
candidates.append(Path.cwd() / requested)
inside_candidates: list[Path] = []
for candidate in candidates:
resolved = candidate.resolve()
try:
resolved.relative_to(svg_root)
except ValueError:
continue
if resolved not in inside_candidates:
inside_candidates.append(resolved)
if resolved.is_file() and resolved.suffix.casefold() == ".svg":
return str(resolved)
if not inside_candidates:
raise ValueError("--page must resolve to a path under svg_output/")
candidate = inside_candidates[0]
if candidate.suffix.casefold() != ".svg":
raise ValueError(f"--page must name an SVG file: {page}")
raise ValueError(f"--page SVG does not exist: {page}")
def _default_json_report_path( def _default_json_report_path(
checker: SVGQualityChecker, checker: SVGQualityChecker,
target: str, target: str,
@@ -40,11 +83,11 @@ def _default_json_report_path(
"""Choose a stage-specific report path without overwriting the final gate.""" """Choose a stage-specific report path without overwriting the final gate."""
target_path = Path(target) target_path = Path(target)
project_path = checker._resolve_project_path(target_path) project_path = checker._resolve_project_path(target_path)
report_name = ( report_name = {
"svg_quality_report.json" "final": "svg_quality_report.json",
if stage == "final" "first-page": "svg_quality_first_page_report.json",
else "svg_quality_first_page_report.json" "page": "svg_quality_page_report.json",
) }[stage]
if ( if (
(project_path / "svg_output").is_dir() (project_path / "svg_output").is_dir()
or (project_path / "design_spec.md").is_file() or (project_path / "design_spec.md").is_file()
@@ -60,6 +103,7 @@ def print_usage() -> None:
print("Usage:") print("Usage:")
print(" python3 scripts/svg_quality_checker.py <svg_file>") print(" python3 scripts/svg_quality_checker.py <svg_file>")
print(" python3 scripts/svg_quality_checker.py <directory>") print(" python3 scripts/svg_quality_checker.py <directory>")
print(" python3 scripts/svg_quality_checker.py <roundtrip-workspace> --roundtrip")
print(" python3 scripts/svg_quality_checker.py <workspace>/templates --template-mode") print(" python3 scripts/svg_quality_checker.py <workspace>/templates --template-mode")
print(" python3 scripts/svg_quality_checker.py --all projects") print(" python3 scripts/svg_quality_checker.py --all projects")
print("\nExamples:") print("\nExamples:")
@@ -70,15 +114,26 @@ def print_usage() -> None:
print(" python3 scripts/svg_quality_checker.py templates/decks/中国电信/templates --template-mode") print(" python3 scripts/svg_quality_checker.py templates/decks/中国电信/templates --template-mode")
print("\nOptions:") print("\nOptions:")
print(" --format <ppt169|ppt43|...> Expected canvas format") print(" --format <ppt169|ppt43|...> Expected canvas format")
print(" --stage <first-page|final> first-page checks only the first authored SVG") print(" --stage <first-page|page|final>")
print(" with a partial structure roster; final (default)") print(" first-page checks only the first authored SVG;")
print(" requires the complete declared page roster.") print(" page checks only --page with the same partial")
print(" structure rules; final (default) requires the")
print(" complete declared page roster.")
print(" --page <basename|path> Required with --stage page; must resolve under")
print(" the target project's svg_output/ directory.")
print(" --json Write a machine-readable quality report") print(" --json Write a machine-readable quality report")
print(" --json-output <path> Override the JSON report path") print(" --json-output <path> Override the JSON report path")
print(" --export Write a plain-text quality report") print(" --export Write a plain-text quality report")
print(" --output <path> Override the plain-text report path") print(" --output <path> Override the plain-text report path")
print(" --quick-generate Validate lockless flat Quick Generate SVGs;") print(" --quick-generate Validate lockless Quick SVGs; infer flat or")
print(" structured output from the complete roster;")
print(" ignore design_spec.md and spec_lock.md.") print(" ignore design_spec.md and spec_lock.md.")
print(" --roundtrip Validate edited-text capacity on the resolved")
print(" authoring-svg-flat/ output roster; uses")
print(" page_plan.json when present and skips")
print(" generated-project/template-only contracts.")
print(" --canonical-authoring Require compact authoring syntax as written;")
print(" the checker never rewrites source SVG.")
print(" --template-mode Validate a template workspace's templates/ directory:") print(" --template-mode Validate a template workspace's templates/ directory:")
print(" Brand/Style validate their portable workspace contracts;") print(" Brand/Style validate their portable workspace contracts;")
print(" Layout/Deck glob *.svg directly, skip spec_lock checks,") print(" Layout/Deck glob *.svg directly, skip spec_lock checks,")
@@ -109,17 +164,27 @@ def main() -> None:
template_mode = "--template-mode" in sys.argv template_mode = "--template-mode" in sys.argv
quick_generate = "--quick-generate" in sys.argv quick_generate = "--quick-generate" in sys.argv
canonical_authoring = "--canonical-authoring" in sys.argv
roundtrip = "--roundtrip" in sys.argv
if template_mode and quick_generate: if template_mode and quick_generate:
print("[ERROR] --template-mode cannot be combined with --quick-generate") print("[ERROR] --template-mode cannot be combined with --quick-generate")
sys.exit(1) sys.exit(1)
if roundtrip and (template_mode or quick_generate or canonical_authoring):
print(
"[ERROR] --roundtrip cannot be combined with --template-mode, "
"--quick-generate, or --canonical-authoring"
)
sys.exit(1)
checker = SVGQualityChecker( checker = SVGQualityChecker(
template_mode=template_mode, template_mode=template_mode,
quick_generate=quick_generate, quick_generate=quick_generate,
canonical_authoring=canonical_authoring,
) )
target = sys.argv[1] target = sys.argv[1]
expected_format = None expected_format = None
stage = "final" stage = "final"
page = None
if "--format" in sys.argv: if "--format" in sys.argv:
idx = sys.argv.index("--format") idx = sys.argv.index("--format")
@@ -127,20 +192,41 @@ def main() -> None:
expected_format = sys.argv[idx + 1] expected_format = sys.argv[idx + 1]
if "--stage" in sys.argv: if "--stage" in sys.argv:
idx = sys.argv.index("--stage") idx = sys.argv.index("--stage")
if idx + 1 >= len(sys.argv): if idx + 1 >= len(sys.argv) or sys.argv[idx + 1].startswith("--"):
print("[ERROR] --stage requires first-page or final") print("[ERROR] --stage requires first-page, page, or final")
sys.exit(1) sys.exit(1)
stage = sys.argv[idx + 1] stage = sys.argv[idx + 1]
if stage not in {"first-page", "final"}: if stage not in {"first-page", "page", "final"}:
print(f"[ERROR] Unsupported quality-check stage: {stage}") print(f"[ERROR] Unsupported quality-check stage: {stage}")
sys.exit(1) sys.exit(1)
if "--page" in sys.argv:
idx = sys.argv.index("--page")
if idx + 1 >= len(sys.argv) or sys.argv[idx + 1].startswith("--"):
print("[ERROR] --page requires a basename or path under svg_output/")
sys.exit(1)
page = sys.argv[idx + 1]
if stage == "page" and page is None:
print("[ERROR] --stage page requires --page <basename or path under svg_output/>")
sys.exit(1)
if stage != "page" and page is not None:
print("[ERROR] --page is supported only with --stage page")
sys.exit(1)
if roundtrip and any(
option in sys.argv
for option in ("--format", "--stage", "--page")
):
print("[ERROR] --roundtrip does not support --format, --stage, or --page")
sys.exit(1)
if target == "--all": if target == "--all":
if roundtrip:
print("[ERROR] --roundtrip does not support --all")
sys.exit(1)
if quick_generate: if quick_generate:
print("[ERROR] --quick-generate does not support --all") print("[ERROR] --quick-generate does not support --all")
sys.exit(1) sys.exit(1)
if stage != "final": if stage != "final":
print("[ERROR] --stage first-page does not support --all") print(f"[ERROR] --stage {stage} does not support --all")
sys.exit(1) sys.exit(1)
base_dir = sys.argv[2] if len(sys.argv) > 2 else "projects" base_dir = sys.argv[2] if len(sys.argv) > 2 else "projects"
from project_utils import find_all_projects from project_utils import find_all_projects
@@ -153,10 +239,22 @@ def main() -> None:
print("=" * 80) print("=" * 80)
checker.check_directory(str(project)) checker.check_directory(str(project))
else: else:
check_target = _first_page_target(target) if stage == "first-page" else target if roundtrip:
checker.check_directory(check_target, expected_format) checker.check_roundtrip_workspace(target)
elif stage == "first-page":
check_target = _first_page_target(target)
elif stage == "page":
try:
check_target = _page_target(target, page or "")
except ValueError as exc:
print(f"[ERROR] {exc}")
sys.exit(1)
else:
check_target = target
if not roundtrip:
checker.check_directory(check_target, expected_format)
if stage == "final" and Path(target).is_dir(): if not roundtrip and stage == "final" and Path(target).is_dir():
if checker._has_incomplete_page_roster: if checker._has_incomplete_page_roster:
print( print(
"[TIP] This final-stage run found an incomplete page roster. " "[TIP] This final-stage run found an incomplete page roster. "
@@ -7,10 +7,12 @@ Implementation lives in ``svg_quality/``.
Usage: Usage:
python3 scripts/svg_quality_checker.py <svg_file> python3 scripts/svg_quality_checker.py <svg_file>
python3 scripts/svg_quality_checker.py <directory> python3 scripts/svg_quality_checker.py <directory>
python3 scripts/svg_quality_checker.py <roundtrip_workspace> --roundtrip
python3 scripts/svg_quality_checker.py --all projects python3 scripts/svg_quality_checker.py --all projects
Examples: Examples:
python3 scripts/svg_quality_checker.py projects/demo --stage final --json python3 scripts/svg_quality_checker.py projects/demo --stage final --json
python3 scripts/svg_quality_checker.py /path/to/import --roundtrip
Dependencies: Dependencies:
Same as svg_quality.cli. Same as svg_quality.cli.
@@ -4,6 +4,14 @@
Delegates to the svg_to_pptx package. ``-s final`` remains a native-export Delegates to the svg_to_pptx package. ``-s final`` remains a native-export
diagnostic override; the standard pipeline reads ``svg_output/``: diagnostic override; the standard pipeline reads ``svg_output/``:
python3 scripts/svg_to_pptx.py <project_path> -s final python3 scripts/svg_to_pptx.py <project_path> -s final
An imported flat authoring bundle rehydrates unchanged source objects before
the source-preserving export:
python3 scripts/svg_to_pptx.py <project_path> --roundtrip
An explicit compatibility export may normalize the default ``svg_output/`` or
another project-relative source selected with ``-s`` before strict flat
conversion. It does not provide source-object restoration.
""" """
import sys import sys

Some files were not shown because too many files have changed in this diff Show More